Favivon - Correção

This commit is contained in:
2026-05-30 19:59:39 -03:00
parent 76ddaa815d
commit d7dfd221f0
32859 changed files with 5459654 additions and 404 deletions
+748
View File
@@ -0,0 +1,748 @@
import AbrController from './controller/abr-controller';
import AudioStreamController from './controller/audio-stream-controller';
import AudioTrackController from './controller/audio-track-controller';
import BufferController from './controller/buffer-controller';
import CapLevelController from './controller/cap-level-controller';
import CMCDController from './controller/cmcd-controller';
import ContentSteeringController from './controller/content-steering-controller';
import EMEController from './controller/eme-controller';
import ErrorController from './controller/error-controller';
import FPSController from './controller/fps-controller';
import InterstitialsController from './controller/interstitials-controller';
import { SubtitleStreamController } from './controller/subtitle-stream-controller';
import SubtitleTrackController from './controller/subtitle-track-controller';
import { TimelineController } from './controller/timeline-controller';
import Cues from './utils/cues';
import FetchLoader, { fetchSupported } from './utils/fetch-loader';
import { requestMediaKeySystemAccess } from './utils/mediakeys-helper';
import { stringify } from './utils/safe-json-stringify';
import XhrLoader from './utils/xhr-loader';
import type { MediaKeySessionContext } from './controller/eme-controller';
import type Hls from './hls';
import type {
FragmentLoaderContext,
Loader,
LoaderContext,
LoaderResponse,
PlaylistLoaderContext,
} from './types/loader';
import type {
AudioSelectionOption,
SubtitleSelectionOption,
VideoSelectionOption,
} from './types/media-playlist';
import type { CuesInterface } from './utils/cues';
import type { ILogger } from './utils/logger';
import type { KeySystems, MediaKeyFunc } from './utils/mediakeys-helper';
export type ABRControllerConfig = {
abrEwmaFastLive: number;
abrEwmaSlowLive: number;
abrEwmaFastVoD: number;
abrEwmaSlowVoD: number;
/**
* Default bandwidth estimate in bits/s prior to collecting fragment bandwidth samples
*/
abrEwmaDefaultEstimate: number;
abrEwmaDefaultEstimateMax: number;
abrBandWidthFactor: number;
abrBandWidthUpFactor: number;
abrMaxWithRealBitrate: boolean;
maxStarvationDelay: number;
maxLoadingDelay: number;
};
export type BufferControllerConfig = {
appendErrorMaxRetry: number;
backBufferLength: number;
frontBufferFlushThreshold: number;
liveDurationInfinity: boolean;
/**
* @deprecated use backBufferLength
*/
liveBackBufferLength: number | null;
};
export type CapLevelControllerConfig = {
capLevelToPlayerSize: boolean;
};
export type CMCDControllerConfig = {
sessionId?: string;
contentId?: string;
useHeaders?: boolean;
includeKeys?: string[];
};
export type DRMSystemOptions = {
audioRobustness?: string;
videoRobustness?: string;
audioEncryptionScheme?: string | null;
videoEncryptionScheme?: string | null;
persistentState?: MediaKeysRequirement;
distinctiveIdentifier?: MediaKeysRequirement;
sessionTypes?: string[];
sessionType?: string;
};
export type DRMSystemConfiguration = {
licenseUrl: string;
serverCertificateUrl?: string;
generateRequest?: (
this: Hls,
initDataType: string,
initData: ArrayBuffer | null,
keyContext: MediaKeySessionContext,
) =>
| { initDataType: string; initData: ArrayBuffer | null }
| undefined
| never;
};
export type DRMSystemsConfiguration = Partial<
Record<KeySystems, DRMSystemConfiguration>
>;
export type EMEControllerConfig = {
licenseXhrSetup?: (
this: Hls,
xhr: XMLHttpRequest,
url: string,
keyContext: MediaKeySessionContext,
licenseChallenge: Uint8Array,
) => void | Uint8Array | Promise<Uint8Array | void>;
licenseResponseCallback?: (
this: Hls,
xhr: XMLHttpRequest,
url: string,
keyContext: MediaKeySessionContext,
) => ArrayBuffer;
emeEnabled: boolean;
widevineLicenseUrl?: string;
drmSystems: DRMSystemsConfiguration | undefined;
drmSystemOptions: DRMSystemOptions | undefined;
requestMediaKeySystemAccessFunc: MediaKeyFunc | null;
requireKeySystemAccessOnStart: boolean;
};
export interface FragmentLoaderConstructor {
new (confg: HlsConfig): Loader<FragmentLoaderContext>;
}
/**
* @deprecated use fragLoadPolicy.default
*/
export type FragmentLoaderConfig = {
fragLoadingTimeOut: number;
fragLoadingMaxRetry: number;
fragLoadingRetryDelay: number;
fragLoadingMaxRetryTimeout: number;
};
export type FPSControllerConfig = {
capLevelOnFPSDrop: boolean;
fpsDroppedMonitoringPeriod: number;
fpsDroppedMonitoringThreshold: number;
};
export type LevelControllerConfig = {
startLevel?: number;
};
export type MP4RemuxerConfig = {
stretchShortVideoTrack: boolean;
maxAudioFramesDrift: number;
};
export interface PlaylistLoaderConstructor {
new (confg: HlsConfig): Loader<PlaylistLoaderContext>;
}
/**
* @deprecated use manifestLoadPolicy.default and playlistLoadPolicy.default
*/
export type PlaylistLoaderConfig = {
manifestLoadingTimeOut: number;
manifestLoadingMaxRetry: number;
manifestLoadingRetryDelay: number;
manifestLoadingMaxRetryTimeout: number;
levelLoadingTimeOut: number;
levelLoadingMaxRetry: number;
levelLoadingRetryDelay: number;
levelLoadingMaxRetryTimeout: number;
};
export type HlsLoadPolicies = {
fragLoadPolicy: LoadPolicy;
keyLoadPolicy: LoadPolicy;
certLoadPolicy: LoadPolicy;
playlistLoadPolicy: LoadPolicy;
manifestLoadPolicy: LoadPolicy;
steeringManifestLoadPolicy: LoadPolicy;
interstitialAssetListLoadPolicy: LoadPolicy;
};
export type LoadPolicy = {
default: LoaderConfig;
};
export type LoaderConfig = {
maxTimeToFirstByteMs: number; // Max time to first byte
maxLoadTimeMs: number; // Max time for load completion
timeoutRetry: RetryConfig | null;
errorRetry: RetryConfig | null;
};
export type RetryConfig = {
maxNumRetry: number; // Maximum number of retries
retryDelayMs: number; // Retry delay = 2^retryCount * retryDelayMs (exponential) or retryCount * retryDelayMs (linear)
maxRetryDelayMs: number; // Maximum delay between retries
backoff?: 'exponential' | 'linear'; // used to determine retry backoff duration (see retryDelayMs)
shouldRetry?: (
retryConfig: RetryConfig | null | undefined,
retryCount: number,
isTimeout: boolean,
loaderResponse: LoaderResponse | undefined,
retry: boolean,
) => boolean;
};
export type StreamControllerConfig = {
autoStartLoad: boolean;
startPosition: number;
defaultAudioCodec?: string;
initialLiveManifestSize: number;
maxBufferLength: number;
maxBufferSize: number;
maxBufferHole: number;
maxFragLookUpTolerance: number;
maxMaxBufferLength: number;
startFragPrefetch: boolean;
testBandwidth: boolean;
liveSyncMode?: 'edge' | 'buffered';
startOnSegmentBoundary: boolean;
};
export type GapControllerConfig = {
detectStallWithCurrentTimeMs: number;
highBufferWatchdogPeriod: number;
nudgeOffset: number;
nudgeMaxRetry: number;
nudgeOnVideoHole: boolean;
};
export type SelectionPreferences = {
videoPreference?: VideoSelectionOption;
audioPreference?: AudioSelectionOption;
subtitlePreference?: SubtitleSelectionOption;
};
export type LatencyControllerConfig = {
liveSyncDurationCount: number;
liveMaxLatencyDurationCount: number;
liveSyncDuration?: number;
liveMaxLatencyDuration?: number;
maxLiveSyncPlaybackRate: number;
liveSyncOnStallIncrease: number;
};
export type MetadataControllerConfig = {
enableDateRangeMetadataCues: boolean;
enableEmsgMetadataCues: boolean;
enableEmsgKLVMetadata: boolean;
enableID3MetadataCues: boolean;
};
export type TimelineControllerConfig = {
cueHandler: CuesInterface;
enableWebVTT: boolean;
enableIMSC1: boolean;
enableCEA708Captions: boolean;
captionsTextTrack1Label: string;
captionsTextTrack1LanguageCode: string;
captionsTextTrack2Label: string;
captionsTextTrack2LanguageCode: string;
captionsTextTrack3Label: string;
captionsTextTrack3LanguageCode: string;
captionsTextTrack4Label: string;
captionsTextTrack4LanguageCode: string;
renderTextTracksNatively: boolean;
};
export type TSDemuxerConfig = {
forceKeyFrameOnDiscontinuity: boolean;
};
export type HlsConfig = {
debug: boolean | ILogger;
enableWorker: boolean;
workerPath: null | string;
enableSoftwareAES: boolean;
minAutoBitrate: number;
ignoreDevicePixelRatio: boolean;
maxDevicePixelRatio: number;
preferManagedMediaSource: boolean;
preserveManualLevelOnError: boolean;
timelineOffset?: number;
ignorePlaylistParsingErrors: boolean;
loader: { new (confg: HlsConfig): Loader<LoaderContext> };
fLoader?: FragmentLoaderConstructor;
pLoader?: PlaylistLoaderConstructor;
fetchSetup?: (
context: LoaderContext,
initParams: any,
) => Promise<Request> | Request;
xhrSetup?: (xhr: XMLHttpRequest, url: string) => Promise<void> | void;
// Alt Audio
audioStreamController?: typeof AudioStreamController;
audioTrackController?: typeof AudioTrackController;
// Subtitle
subtitleStreamController?: typeof SubtitleStreamController;
subtitleTrackController?: typeof SubtitleTrackController;
timelineController?: typeof TimelineController;
// EME
emeController?: typeof EMEController;
// CMCD
cmcd?: CMCDControllerConfig;
cmcdController?: typeof CMCDController;
// Content Steering
contentSteeringController?: typeof ContentSteeringController;
// Interstitial Controller (setting to null disables Interstitials parsing and playback)
interstitialsController?: typeof InterstitialsController;
// Option to disable internal playback handling of Interstitials (set to false to disable Interstitials playback without disabling parsing and schedule events)
enableInterstitialPlayback: boolean;
// Option to disable appending Interstitials inline on same timeline and MediaSource as Primary media
interstitialAppendInPlace: boolean;
// How many seconds past the end of a live playlist to preload Interstitial assets
interstitialLiveLookAhead: number;
// An optional `Hls` instance ID prefixed to debug logs
assetPlayerId?: string;
// MediaCapabilies API for level, track, and switch filtering
useMediaCapabilities: boolean;
abrController: typeof AbrController;
bufferController: typeof BufferController;
capLevelController: typeof CapLevelController;
errorController: typeof ErrorController;
fpsController: typeof FPSController;
progressive: boolean;
lowLatencyMode: boolean;
primarySessionId?: string;
} & ABRControllerConfig &
BufferControllerConfig &
CapLevelControllerConfig &
EMEControllerConfig &
FPSControllerConfig &
GapControllerConfig &
LevelControllerConfig &
MP4RemuxerConfig &
StreamControllerConfig &
SelectionPreferences &
LatencyControllerConfig &
MetadataControllerConfig &
TimelineControllerConfig &
TSDemuxerConfig &
HlsLoadPolicies &
FragmentLoaderConfig &
PlaylistLoaderConfig;
const defaultLoadPolicy: LoaderConfig = {
maxTimeToFirstByteMs: 8000,
maxLoadTimeMs: 20000,
timeoutRetry: null,
errorRetry: null,
};
/**
* @ignore
* If possible, keep hlsDefaultConfig shallow
* It is cloned whenever a new Hls instance is created, by keeping the config
* shallow the properties are cloned, and we don't end up manipulating the default
*/
export const hlsDefaultConfig: HlsConfig = {
autoStartLoad: true, // used by stream-controller
startPosition: -1, // used by stream-controller
defaultAudioCodec: undefined, // used by stream-controller
debug: false, // used by logger
capLevelOnFPSDrop: false, // used by fps-controller
capLevelToPlayerSize: false, // used by cap-level-controller
ignoreDevicePixelRatio: false, // used by cap-level-controller
maxDevicePixelRatio: Number.POSITIVE_INFINITY, // used by cap-level-controller
preferManagedMediaSource: true,
initialLiveManifestSize: 1, // used by stream-controller
maxBufferLength: 30, // used by stream-controller
backBufferLength: Infinity, // used by buffer-controller
frontBufferFlushThreshold: Infinity,
startOnSegmentBoundary: false, // used by stream-controller
maxBufferSize: 60 * 1000 * 1000, // used by stream-controller
maxFragLookUpTolerance: 0.25, // used by stream-controller
maxBufferHole: 0.1, // used by stream-controller and gap-controller
detectStallWithCurrentTimeMs: 1250, // used by gap-controller
highBufferWatchdogPeriod: 2, // used by gap-controller
nudgeOffset: 0.1, // used by gap-controller
nudgeMaxRetry: 3, // used by gap-controller
nudgeOnVideoHole: true, // used by gap-controller
liveSyncMode: 'edge', // used by stream-controller
liveSyncDurationCount: 3, // used by latency-controller
liveSyncOnStallIncrease: 1, // used by latency-controller
liveMaxLatencyDurationCount: Infinity, // used by latency-controller
liveSyncDuration: undefined, // used by latency-controller
liveMaxLatencyDuration: undefined, // used by latency-controller
maxLiveSyncPlaybackRate: 1, // used by latency-controller
liveDurationInfinity: false, // used by buffer-controller
/**
* @deprecated use backBufferLength
*/
liveBackBufferLength: null, // used by buffer-controller
maxMaxBufferLength: 600, // used by stream-controller
enableWorker: true, // used by transmuxer
workerPath: null, // used by transmuxer
enableSoftwareAES: true, // used by decrypter
startLevel: undefined, // used by level-controller
startFragPrefetch: false, // used by stream-controller
fpsDroppedMonitoringPeriod: 5000, // used by fps-controller
fpsDroppedMonitoringThreshold: 0.2, // used by fps-controller
appendErrorMaxRetry: 3, // used by buffer-controller
ignorePlaylistParsingErrors: false,
loader: XhrLoader,
// loader: FetchLoader,
fLoader: undefined, // used by fragment-loader
pLoader: undefined, // used by playlist-loader
xhrSetup: undefined, // used by xhr-loader
licenseXhrSetup: undefined, // used by eme-controller
licenseResponseCallback: undefined, // used by eme-controller
abrController: AbrController,
bufferController: BufferController,
capLevelController: CapLevelController,
errorController: ErrorController,
fpsController: FPSController,
stretchShortVideoTrack: false, // used by mp4-remuxer
maxAudioFramesDrift: 1, // used by mp4-remuxer
forceKeyFrameOnDiscontinuity: true, // used by ts-demuxer
abrEwmaFastLive: 3, // used by abr-controller
abrEwmaSlowLive: 9, // used by abr-controller
abrEwmaFastVoD: 3, // used by abr-controller
abrEwmaSlowVoD: 9, // used by abr-controller
abrEwmaDefaultEstimate: 5e5, // 500 kbps // used by abr-controller
abrEwmaDefaultEstimateMax: 5e6, // 5 mbps
abrBandWidthFactor: 0.95, // used by abr-controller
abrBandWidthUpFactor: 0.7, // used by abr-controller
abrMaxWithRealBitrate: false, // used by abr-controller
maxStarvationDelay: 4, // used by abr-controller
maxLoadingDelay: 4, // used by abr-controller
minAutoBitrate: 0, // used by hls
emeEnabled: false, // used by eme-controller
widevineLicenseUrl: undefined, // used by eme-controller
drmSystems: {}, // used by eme-controller
drmSystemOptions: {}, // used by eme-controller
requestMediaKeySystemAccessFunc: __USE_EME_DRM__
? requestMediaKeySystemAccess
: null, // used by eme-controller
requireKeySystemAccessOnStart: false, // used by eme-controller
testBandwidth: true,
progressive: false,
lowLatencyMode: true,
cmcd: undefined,
enableDateRangeMetadataCues: true,
enableEmsgMetadataCues: true,
enableEmsgKLVMetadata: false,
enableID3MetadataCues: true,
enableInterstitialPlayback: __USE_INTERSTITIALS__,
interstitialAppendInPlace: true,
interstitialLiveLookAhead: 10,
useMediaCapabilities: __USE_MEDIA_CAPABILITIES__,
preserveManualLevelOnError: false,
certLoadPolicy: {
default: defaultLoadPolicy,
},
keyLoadPolicy: {
default: {
maxTimeToFirstByteMs: 8000,
maxLoadTimeMs: 20000,
timeoutRetry: {
maxNumRetry: 1,
retryDelayMs: 1000,
maxRetryDelayMs: 20000,
backoff: 'linear',
},
errorRetry: {
maxNumRetry: 8,
retryDelayMs: 1000,
maxRetryDelayMs: 20000,
backoff: 'linear',
},
},
},
manifestLoadPolicy: {
default: {
maxTimeToFirstByteMs: Infinity,
maxLoadTimeMs: 20000,
timeoutRetry: {
maxNumRetry: 2,
retryDelayMs: 0,
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 1,
retryDelayMs: 1000,
maxRetryDelayMs: 8000,
},
},
},
playlistLoadPolicy: {
default: {
maxTimeToFirstByteMs: 10000,
maxLoadTimeMs: 20000,
timeoutRetry: {
maxNumRetry: 2,
retryDelayMs: 0,
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 2,
retryDelayMs: 1000,
maxRetryDelayMs: 8000,
},
},
},
fragLoadPolicy: {
default: {
maxTimeToFirstByteMs: 10000,
maxLoadTimeMs: 120000,
timeoutRetry: {
maxNumRetry: 4,
retryDelayMs: 0,
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 6,
retryDelayMs: 1000,
maxRetryDelayMs: 8000,
},
},
},
steeringManifestLoadPolicy: {
default: __USE_CONTENT_STEERING__
? {
maxTimeToFirstByteMs: 10000,
maxLoadTimeMs: 20000,
timeoutRetry: {
maxNumRetry: 2,
retryDelayMs: 0,
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 1,
retryDelayMs: 1000,
maxRetryDelayMs: 8000,
},
}
: defaultLoadPolicy,
},
interstitialAssetListLoadPolicy: {
default: __USE_INTERSTITIALS__
? {
maxTimeToFirstByteMs: 10000,
maxLoadTimeMs: 30000,
timeoutRetry: {
maxNumRetry: 0,
retryDelayMs: 0,
maxRetryDelayMs: 0,
},
errorRetry: {
maxNumRetry: 0,
retryDelayMs: 1000,
maxRetryDelayMs: 8000,
},
}
: defaultLoadPolicy,
},
// These default settings are deprecated in favor of the above policies
// and are maintained for backwards compatibility
manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 1,
manifestLoadingRetryDelay: 1000,
manifestLoadingMaxRetryTimeout: 64000,
levelLoadingTimeOut: 10000,
levelLoadingMaxRetry: 4,
levelLoadingRetryDelay: 1000,
levelLoadingMaxRetryTimeout: 64000,
fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 6,
fragLoadingRetryDelay: 1000,
fragLoadingMaxRetryTimeout: 64000,
// Dynamic Modules
...timelineConfig(),
subtitleStreamController: __USE_SUBTITLES__
? SubtitleStreamController
: undefined,
subtitleTrackController: __USE_SUBTITLES__
? SubtitleTrackController
: undefined,
timelineController: __USE_SUBTITLES__ ? TimelineController : undefined,
audioStreamController: __USE_ALT_AUDIO__ ? AudioStreamController : undefined,
audioTrackController: __USE_ALT_AUDIO__ ? AudioTrackController : undefined,
emeController: __USE_EME_DRM__ ? EMEController : undefined,
cmcdController: __USE_CMCD__ ? CMCDController : undefined,
contentSteeringController: __USE_CONTENT_STEERING__
? ContentSteeringController
: undefined,
interstitialsController: __USE_INTERSTITIALS__
? InterstitialsController
: undefined,
};
function timelineConfig(): TimelineControllerConfig {
return {
cueHandler: Cues, // used by timeline-controller
enableWebVTT: __USE_SUBTITLES__, // used by timeline-controller
enableIMSC1: __USE_SUBTITLES__, // used by timeline-controller
enableCEA708Captions: __USE_SUBTITLES__, // used by timeline-controller
captionsTextTrack1Label: 'English', // used by timeline-controller
captionsTextTrack1LanguageCode: 'en', // used by timeline-controller
captionsTextTrack2Label: 'Spanish', // used by timeline-controller
captionsTextTrack2LanguageCode: 'es', // used by timeline-controller
captionsTextTrack3Label: 'Unknown CC', // used by timeline-controller
captionsTextTrack3LanguageCode: '', // used by timeline-controller
captionsTextTrack4Label: 'Unknown CC', // used by timeline-controller
captionsTextTrack4LanguageCode: '', // used by timeline-controller
renderTextTracksNatively: true,
};
}
/**
* @ignore
*/
export function mergeConfig(
defaultConfig: HlsConfig,
userConfig: Partial<HlsConfig>,
logger: ILogger,
): HlsConfig {
if (
(userConfig.liveSyncDurationCount ||
userConfig.liveMaxLatencyDurationCount) &&
(userConfig.liveSyncDuration || userConfig.liveMaxLatencyDuration)
) {
throw new Error(
"Illegal hls.js config: don't mix up liveSyncDurationCount/liveMaxLatencyDurationCount and liveSyncDuration/liveMaxLatencyDuration",
);
}
if (
userConfig.liveMaxLatencyDurationCount !== undefined &&
(userConfig.liveSyncDurationCount === undefined ||
userConfig.liveMaxLatencyDurationCount <=
userConfig.liveSyncDurationCount)
) {
throw new Error(
'Illegal hls.js config: "liveMaxLatencyDurationCount" must be greater than "liveSyncDurationCount"',
);
}
if (
userConfig.liveMaxLatencyDuration !== undefined &&
(userConfig.liveSyncDuration === undefined ||
userConfig.liveMaxLatencyDuration <= userConfig.liveSyncDuration)
) {
throw new Error(
'Illegal hls.js config: "liveMaxLatencyDuration" must be greater than "liveSyncDuration"',
);
}
const defaultsCopy = deepCpy(defaultConfig);
// Backwards compatibility with deprecated config values
const deprecatedSettingTypes = ['manifest', 'level', 'frag'];
const deprecatedSettings = [
'TimeOut',
'MaxRetry',
'RetryDelay',
'MaxRetryTimeout',
];
deprecatedSettingTypes.forEach((type) => {
const policyName = `${type === 'level' ? 'playlist' : type}LoadPolicy`;
const policyNotSet = userConfig[policyName] === undefined;
const report: string[] = [];
deprecatedSettings.forEach((setting) => {
const deprecatedSetting = `${type}Loading${setting}`;
const value = userConfig[deprecatedSetting];
if (value !== undefined && policyNotSet) {
report.push(deprecatedSetting);
const settings: LoaderConfig = defaultsCopy[policyName].default;
userConfig[policyName] = { default: settings };
switch (setting) {
case 'TimeOut':
settings.maxLoadTimeMs = value;
settings.maxTimeToFirstByteMs = value;
break;
case 'MaxRetry':
settings.errorRetry!.maxNumRetry = value;
settings.timeoutRetry!.maxNumRetry = value;
break;
case 'RetryDelay':
settings.errorRetry!.retryDelayMs = value;
settings.timeoutRetry!.retryDelayMs = value;
break;
case 'MaxRetryTimeout':
settings.errorRetry!.maxRetryDelayMs = value;
settings.timeoutRetry!.maxRetryDelayMs = value;
break;
}
}
});
if (report.length) {
logger.warn(
`hls.js config: "${report.join(
'", "',
)}" setting(s) are deprecated, use "${policyName}": ${stringify(
userConfig[policyName],
)}`,
);
}
});
return {
...defaultsCopy,
...userConfig,
};
}
function deepCpy(obj: any): any {
if (obj && typeof obj === 'object') {
if (Array.isArray(obj)) {
return obj.map(deepCpy);
}
return Object.keys(obj).reduce((result, key) => {
result[key] = deepCpy(obj[key]);
return result;
}, {});
}
return obj;
}
/**
* @ignore
*/
export function enableStreamingMode(config: HlsConfig, logger: ILogger) {
const currentLoader = config.loader;
if (currentLoader !== FetchLoader && currentLoader !== XhrLoader) {
// If a developer has configured their own loader, respect that choice
logger.log(
'[config]: Custom loader detected, cannot enable progressive streaming',
);
config.progressive = false;
} else {
const canStreamProgressively = fetchSupported();
if (canStreamProgressively) {
config.loader = FetchLoader;
config.progressive = true;
config.enableSoftwareAES = true;
logger.log('[config]: Progressive streaming enabled, using FetchLoader');
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+438
View File
@@ -0,0 +1,438 @@
import BasePlaylistController from './base-playlist-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { PlaylistContextType } from '../types/loader';
import { mediaAttributesIdentical } from '../utils/media-option-attributes';
import {
audioMatchPredicate,
findClosestLevelWithAudioGroup,
findMatchingOption,
matchesOption,
useAlternateAudio,
} from '../utils/rendition-helper';
import type Hls from '../hls';
import type {
AudioTrackLoadedData,
AudioTracksUpdatedData,
ErrorData,
LevelLoadingData,
LevelSwitchingData,
ManifestParsedData,
} from '../types/events';
import type { HlsUrlParameters } from '../types/level';
import type {
AudioSelectionOption,
MediaPlaylist,
} from '../types/media-playlist';
class AudioTrackController extends BasePlaylistController {
private tracks: MediaPlaylist[] = [];
private groupIds: (string | undefined)[] | null = null;
private tracksInGroup: MediaPlaylist[] = [];
private trackId: number = -1;
private currentTrack: MediaPlaylist | null = null;
private selectDefaultTrack: boolean = true;
constructor(hls: Hls) {
super(hls, 'audio-track-controller');
this.registerListeners();
}
private registerListeners() {
const { hls } = this;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
hls.on(Events.AUDIO_TRACK_LOADED, this.onAudioTrackLoaded, this);
hls.on(Events.ERROR, this.onError, this);
}
private unregisterListeners() {
const { hls } = this;
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
hls.off(Events.AUDIO_TRACK_LOADED, this.onAudioTrackLoaded, this);
hls.off(Events.ERROR, this.onError, this);
}
public destroy() {
this.unregisterListeners();
this.tracks.length = 0;
this.tracksInGroup.length = 0;
this.currentTrack = null;
super.destroy();
}
protected onManifestLoading(): void {
this.tracks = [];
this.tracksInGroup = [];
this.groupIds = null;
this.currentTrack = null;
this.trackId = -1;
this.selectDefaultTrack = true;
}
protected onManifestParsed(
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
): void {
this.tracks = data.audioTracks || [];
}
protected onAudioTrackLoaded(
event: Events.AUDIO_TRACK_LOADED,
data: AudioTrackLoadedData,
): void {
const { id, groupId, details } = data;
const trackInActiveGroup = this.tracksInGroup[id];
if (!trackInActiveGroup || trackInActiveGroup.groupId !== groupId) {
this.warn(
`Audio track with id:${id} and group:${groupId} not found in active group ${trackInActiveGroup?.groupId}`,
);
return;
}
const curDetails = trackInActiveGroup.details;
trackInActiveGroup.details = data.details;
this.log(
`Audio track ${id} "${trackInActiveGroup.name}" lang:${trackInActiveGroup.lang} group:${groupId} loaded [${details.startSN}-${details.endSN}]`,
);
if (id === this.trackId) {
this.playlistLoaded(id, data, curDetails);
}
}
protected onLevelLoading(
event: Events.LEVEL_LOADING,
data: LevelLoadingData,
): void {
this.switchLevel(data.level);
}
protected onLevelSwitching(
event: Events.LEVEL_SWITCHING,
data: LevelSwitchingData,
): void {
this.switchLevel(data.level);
}
private switchLevel(levelIndex: number) {
const levelInfo = this.hls.levels[levelIndex];
if (!levelInfo) {
return;
}
const audioGroups = levelInfo.audioGroups || null;
const currentGroups = this.groupIds;
let currentTrack = this.currentTrack;
if (
!audioGroups ||
currentGroups?.length !== audioGroups?.length ||
audioGroups?.some((groupId) => currentGroups?.indexOf(groupId) === -1)
) {
this.groupIds = audioGroups;
this.trackId = -1;
this.currentTrack = null;
const audioTracks = this.tracks.filter(
(track): boolean =>
!audioGroups || audioGroups.indexOf(track.groupId) !== -1,
);
if (audioTracks.length) {
// Disable selectDefaultTrack if there are no default tracks
if (
this.selectDefaultTrack &&
!audioTracks.some((track) => track.default)
) {
this.selectDefaultTrack = false;
}
// track.id should match hls.audioTracks index
audioTracks.forEach((track, i) => {
track.id = i;
});
} else if (!currentTrack && !this.tracksInGroup.length) {
// Do not dispatch AUDIO_TRACKS_UPDATED when there were and are no tracks
return;
}
this.tracksInGroup = audioTracks;
// Find preferred track
const audioPreference = this.hls.config.audioPreference;
if (!currentTrack && audioPreference) {
const groupIndex = findMatchingOption(
audioPreference,
audioTracks,
audioMatchPredicate,
);
if (groupIndex > -1) {
currentTrack = audioTracks[groupIndex];
} else {
const allIndex = findMatchingOption(audioPreference, this.tracks);
currentTrack = this.tracks[allIndex];
}
}
// Select initial track
let trackId = this.findTrackId(currentTrack);
if (trackId === -1 && currentTrack) {
trackId = this.findTrackId(null);
}
// Dispatch events and load track if needed
const audioTracksUpdated: AudioTracksUpdatedData = { audioTracks };
this.log(
`Updating audio tracks, ${
audioTracks.length
} track(s) found in group(s): ${audioGroups?.join(',')}`,
);
this.hls.trigger(Events.AUDIO_TRACKS_UPDATED, audioTracksUpdated);
const selectedTrackId = this.trackId;
if (trackId !== -1 && selectedTrackId === -1) {
this.setAudioTrack(trackId);
} else if (audioTracks.length && selectedTrackId === -1) {
const error = new Error(
`No audio track selected for current audio group-ID(s): ${this.groupIds?.join(
',',
)} track count: ${audioTracks.length}`,
);
this.warn(error.message);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.AUDIO_TRACK_LOAD_ERROR,
fatal: true,
error,
});
}
}
}
protected onError(event: Events.ERROR, data: ErrorData): void {
if (data.fatal || !data.context) {
return;
}
if (
data.context.type === PlaylistContextType.AUDIO_TRACK &&
data.context.id === this.trackId &&
(!this.groupIds || this.groupIds.indexOf(data.context.groupId) !== -1)
) {
this.checkRetry(data);
}
}
get allAudioTracks(): MediaPlaylist[] {
return this.tracks;
}
get audioTracks(): MediaPlaylist[] {
return this.tracksInGroup;
}
get audioTrack(): number {
return this.trackId;
}
set audioTrack(newId: number) {
// If audio track is selected from API then don't choose from the manifest default track
this.selectDefaultTrack = false;
this.setAudioTrack(newId);
}
public setAudioOption(
audioOption: MediaPlaylist | AudioSelectionOption | undefined,
): MediaPlaylist | null {
const hls = this.hls;
hls.config.audioPreference = audioOption;
if (audioOption) {
const allAudioTracks = this.allAudioTracks;
this.selectDefaultTrack = false;
if (allAudioTracks.length) {
// First see if current option matches (no switch op)
const currentTrack = this.currentTrack;
if (
currentTrack &&
matchesOption(audioOption, currentTrack, audioMatchPredicate)
) {
return currentTrack;
}
// Find option in available tracks (tracksInGroup)
const groupIndex = findMatchingOption(
audioOption,
this.tracksInGroup,
audioMatchPredicate,
);
if (groupIndex > -1) {
const track = this.tracksInGroup[groupIndex];
this.setAudioTrack(groupIndex);
return track;
} else if (currentTrack) {
// Find option in nearest level audio group
let searchIndex = hls.loadLevel;
if (searchIndex === -1) {
searchIndex = hls.firstAutoLevel;
}
const switchIndex = findClosestLevelWithAudioGroup(
audioOption,
hls.levels,
allAudioTracks,
searchIndex,
audioMatchPredicate,
);
if (switchIndex === -1) {
// could not find matching variant
return null;
}
// and switch level to acheive the audio group switch
hls.nextLoadLevel = switchIndex;
}
if (audioOption.channels || audioOption.audioCodec) {
// Could not find a match with codec / channels predicate
// Find a match without channels or codec
const withoutCodecAndChannelsMatch = findMatchingOption(
audioOption,
allAudioTracks,
);
if (withoutCodecAndChannelsMatch > -1) {
return allAudioTracks[withoutCodecAndChannelsMatch];
}
}
}
}
return null;
}
private setAudioTrack(newId: number): void {
const tracks = this.tracksInGroup;
// check if level idx is valid
if (newId < 0 || newId >= tracks.length) {
this.warn(`Invalid audio track id: ${newId}`);
return;
}
this.selectDefaultTrack = false;
const lastTrack = this.currentTrack;
const track = tracks[newId];
const trackLoaded = track.details && !track.details.live;
if (newId === this.trackId && track === lastTrack && trackLoaded) {
return;
}
this.log(
`Switching to audio-track ${newId} "${track.name}" lang:${track.lang} group:${track.groupId} channels:${track.channels}`,
);
this.trackId = newId;
this.currentTrack = track;
this.hls.trigger(Events.AUDIO_TRACK_SWITCHING, { ...track });
// Do not reload track unless live
if (trackLoaded) {
return;
}
const hlsUrlParameters = this.switchParams(
track.url,
lastTrack?.details,
track.details,
);
this.loadPlaylist(hlsUrlParameters);
}
private findTrackId(currentTrack: MediaPlaylist | null): number {
const audioTracks = this.tracksInGroup;
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
if (this.selectDefaultTrack && !track.default) {
continue;
}
if (
!currentTrack ||
matchesOption(currentTrack, track, audioMatchPredicate)
) {
return i;
}
}
if (currentTrack) {
const { name, lang, assocLang, characteristics, audioCodec, channels } =
currentTrack;
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
if (
matchesOption(
{ name, lang, assocLang, characteristics, audioCodec, channels },
track,
audioMatchPredicate,
)
) {
return i;
}
}
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
if (
mediaAttributesIdentical(currentTrack.attrs, track.attrs, [
'LANGUAGE',
'ASSOC-LANGUAGE',
'CHARACTERISTICS',
])
) {
return i;
}
}
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
if (
mediaAttributesIdentical(currentTrack.attrs, track.attrs, [
'LANGUAGE',
])
) {
return i;
}
}
}
return -1;
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
super.loadPlaylist();
const audioTrack = this.currentTrack;
if (!this.shouldLoadPlaylist(audioTrack)) {
return;
}
// Do not load audio rendition with URI matching main variant URI
if (useAlternateAudio(audioTrack.url, this.hls)) {
this.scheduleLoading(audioTrack, hlsUrlParameters);
}
}
protected loadingPlaylist(
audioTrack: MediaPlaylist,
hlsUrlParameters: HlsUrlParameters | undefined,
) {
super.loadingPlaylist(audioTrack, hlsUrlParameters);
const id = audioTrack.id;
const groupId = audioTrack.groupId as string;
const url = this.getUrlWithDirectives(audioTrack.url, hlsUrlParameters);
const details = audioTrack.details;
const age = details?.age;
this.log(
`Loading audio-track ${id} "${audioTrack.name}" lang:${audioTrack.lang} group:${groupId}${
hlsUrlParameters?.msn !== undefined
? ' at sn ' + hlsUrlParameters.msn + ' part ' + hlsUrlParameters.part
: ''
}${age && details.live ? ' age ' + age.toFixed(1) + (details.type ? ' ' + details.type || '' : '') : ''} ${url}`,
);
this.hls.trigger(Events.AUDIO_TRACK_LOADING, {
url,
id,
groupId,
deliveryDirectives: hlsUrlParameters || null,
track: audioTrack,
});
}
}
export default AudioTrackController;
+414
View File
@@ -0,0 +1,414 @@
import { NetworkErrorAction } from './error-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import {
getSkipValue,
HlsSkip,
HlsUrlParameters,
type Level,
} from '../types/level';
import { getRetryDelay, isTimeoutError } from '../utils/error-helper';
import { computeReloadInterval, mergeDetails } from '../utils/level-helper';
import { Logger } from '../utils/logger';
import type Hls from '../hls';
import type { LevelDetails } from '../loader/level-details';
import type { NetworkComponentAPI } from '../types/component-api';
import type { ErrorData } from '../types/events';
import type {
AudioTrackLoadedData,
LevelLoadedData,
TrackLoadedData,
} from '../types/events';
import type { MediaPlaylist } from '../types/media-playlist';
export default class BasePlaylistController
extends Logger
implements NetworkComponentAPI
{
protected hls: Hls;
protected canLoad: boolean = false;
private timer: number = -1;
constructor(hls: Hls, logPrefix: string) {
super(logPrefix, hls.logger);
this.hls = hls;
}
public destroy() {
this.clearTimer();
// @ts-ignore
this.hls = this.log = this.warn = null;
}
private clearTimer() {
if (this.timer !== -1) {
self.clearTimeout(this.timer);
this.timer = -1;
}
}
public startLoad() {
this.canLoad = true;
this.loadPlaylist();
}
public stopLoad() {
this.canLoad = false;
this.clearTimer();
}
protected switchParams(
playlistUri: string,
previous: LevelDetails | undefined,
current: LevelDetails | undefined,
): HlsUrlParameters | undefined {
const renditionReports = previous?.renditionReports;
if (renditionReports) {
let foundIndex = -1;
for (let i = 0; i < renditionReports.length; i++) {
const attr = renditionReports[i];
let uri: string;
try {
uri = new self.URL(attr.URI, previous.url).href;
} catch (error) {
this.warn(
`Could not construct new URL for Rendition Report: ${error}`,
);
uri = attr.URI || '';
}
// Use exact match. Otherwise, the last partial match, if any, will be used
// (Playlist URI includes a query string that the Rendition Report does not)
if (uri === playlistUri) {
foundIndex = i;
break;
} else if (uri === playlistUri.substring(0, uri.length)) {
foundIndex = i;
}
}
if (foundIndex !== -1) {
const attr = renditionReports[foundIndex];
const msn = parseInt(attr['LAST-MSN']) || previous.lastPartSn;
let part = parseInt(attr['LAST-PART']) || previous.lastPartIndex;
if (this.hls.config.lowLatencyMode) {
const currentGoal = Math.min(
previous.age - previous.partTarget,
previous.targetduration,
);
if (part >= 0 && currentGoal > previous.partTarget) {
part += 1;
}
}
const skip = current && getSkipValue(current);
return new HlsUrlParameters(msn, part >= 0 ? part : undefined, skip);
}
}
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters) {
// Loading is handled by the subclasses
this.clearTimer();
}
protected loadingPlaylist(
playlist: Level | MediaPlaylist,
hlsUrlParameters?: HlsUrlParameters,
) {
// Loading is handled by the subclasses
this.clearTimer();
}
protected shouldLoadPlaylist(
playlist: Level | MediaPlaylist | null | undefined,
): playlist is Level | MediaPlaylist {
return (
this.canLoad &&
!!playlist &&
!!playlist.url &&
(!playlist.details || playlist.details.live)
);
}
protected getUrlWithDirectives(
uri: string,
hlsUrlParameters: HlsUrlParameters | undefined,
): string {
if (hlsUrlParameters) {
try {
return hlsUrlParameters.addDirectives(uri);
} catch (error) {
this.warn(
`Could not construct new URL with HLS Delivery Directives: ${error}`,
);
}
}
return uri;
}
protected playlistLoaded(
index: number,
data: LevelLoadedData | AudioTrackLoadedData | TrackLoadedData,
previousDetails?: LevelDetails,
) {
const { details, stats } = data;
// Set last updated date-time
const now = self.performance.now();
const elapsed = stats.loading.first
? Math.max(0, now - stats.loading.first)
: 0;
details.advancedDateTime = Date.now() - elapsed;
// shift fragment starts with timelineOffset
const timelineOffset = this.hls.config.timelineOffset;
if (timelineOffset !== details.appliedTimelineOffset) {
const offset = Math.max(timelineOffset || 0, 0);
details.appliedTimelineOffset = offset;
details.fragments.forEach((frag) => {
frag.setStart(frag.playlistOffset + offset);
});
}
// if current playlist is a live playlist, arm a timer to reload it
if (details.live || previousDetails?.live) {
const levelOrTrack = 'levelInfo' in data ? data.levelInfo : data.track;
details.reloaded(previousDetails);
// Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments
if (previousDetails && details.fragments.length > 0) {
mergeDetails(previousDetails, details, this);
const error = details.playlistParsingError;
if (error) {
this.warn(error);
const hls = this.hls;
if (!hls.config.ignorePlaylistParsingErrors) {
const { networkDetails } = data;
hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.LEVEL_PARSING_ERROR,
fatal: false,
url: details.url,
error,
reason: error.message,
level: (data as any).level || undefined,
parent: details.fragments[0]?.type,
networkDetails,
stats,
});
return;
}
details.playlistParsingError = null;
}
}
if (details.requestScheduled === -1) {
details.requestScheduled = stats.loading.start;
}
const bufferInfo = this.hls.mainForwardBufferInfo;
const position = bufferInfo ? bufferInfo.end - bufferInfo.len : 0;
const distanceToLiveEdgeMs = (details.edge - position) * 1000;
const reloadInterval = computeReloadInterval(
details,
distanceToLiveEdgeMs,
);
if (details.requestScheduled + reloadInterval < now) {
details.requestScheduled = now;
} else {
details.requestScheduled += reloadInterval;
}
this.log(
`live playlist ${index} ${
details.advanced
? 'REFRESHED ' + details.lastPartSn + '-' + details.lastPartIndex
: details.updated
? 'UPDATED'
: 'MISSED'
}`,
);
if (!this.canLoad || !details.live) {
return;
}
let deliveryDirectives: HlsUrlParameters | undefined;
let msn: number | undefined = undefined;
let part: number | undefined = undefined;
if (details.canBlockReload && details.endSN && details.advanced) {
// Load level with LL-HLS delivery directives
const lowLatencyMode = this.hls.config.lowLatencyMode;
const lastPartSn = details.lastPartSn;
const endSn = details.endSN;
const lastPartIndex = details.lastPartIndex;
const hasParts = lastPartIndex !== -1;
const atLastPartOfSegment = lastPartSn === endSn;
if (hasParts) {
// When low latency mode is disabled, request the last part of the next segment
if (atLastPartOfSegment) {
msn = endSn + 1;
part = lowLatencyMode ? 0 : lastPartIndex;
} else {
msn = lastPartSn;
part = lowLatencyMode ? lastPartIndex + 1 : details.maxPartIndex;
}
} else {
msn = endSn + 1;
}
// Low-Latency CDN Tune-in: "age" header and time since load indicates we're behind by more than one part
// Update directives to obtain the Playlist that has the estimated additional duration of media
const lastAdvanced = details.age;
const cdnAge = lastAdvanced + details.ageHeader;
let currentGoal = Math.min(
cdnAge - details.partTarget,
details.targetduration * 1.5,
);
if (currentGoal > 0) {
if (cdnAge > details.targetduration * 3) {
// Omit segment and part directives when the last response was more than 3 target durations ago,
this.log(
`Playlist last advanced ${lastAdvanced.toFixed(
2,
)}s ago. Omitting segment and part directives.`,
);
msn = undefined;
part = undefined;
} else if (
previousDetails?.tuneInGoal &&
cdnAge - details.partTarget > previousDetails.tuneInGoal
) {
// If we attempted to get the next or latest playlist update, but currentGoal increased,
// then we either can't catchup, or the "age" header cannot be trusted.
this.warn(
`CDN Tune-in goal increased from: ${previousDetails.tuneInGoal} to: ${currentGoal} with playlist age: ${details.age}`,
);
currentGoal = 0;
} else {
const segments = Math.floor(currentGoal / details.targetduration);
msn += segments;
if (part !== undefined) {
const parts = Math.round(
(currentGoal % details.targetduration) / details.partTarget,
);
part += parts;
}
this.log(
`CDN Tune-in age: ${
details.ageHeader
}s last advanced ${lastAdvanced.toFixed(
2,
)}s goal: ${currentGoal} skip sn ${segments} to part ${part}`,
);
}
details.tuneInGoal = currentGoal;
}
deliveryDirectives = this.getDeliveryDirectives(
details,
data.deliveryDirectives,
msn,
part,
);
if (lowLatencyMode || !atLastPartOfSegment) {
details.requestScheduled = now;
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
} else if (details.canBlockReload || details.canSkipUntil) {
deliveryDirectives = this.getDeliveryDirectives(
details,
data.deliveryDirectives,
msn,
part,
);
}
if (deliveryDirectives && msn !== undefined && details.canBlockReload) {
details.requestScheduled =
stats.loading.first +
Math.max(reloadInterval - elapsed * 2, reloadInterval / 2);
}
this.scheduleLoading(levelOrTrack, deliveryDirectives, details);
} else {
this.clearTimer();
}
}
protected scheduleLoading(
levelOrTrack: Level | MediaPlaylist,
deliveryDirectives?: HlsUrlParameters,
updatedDetails?: LevelDetails,
) {
const details = updatedDetails || levelOrTrack.details;
if (!details) {
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
const now = self.performance.now();
const requestScheduled = details.requestScheduled;
if (now >= requestScheduled) {
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
const estimatedTimeUntilUpdate = requestScheduled - now;
this.log(
`reload live playlist ${levelOrTrack.name || levelOrTrack.bitrate + 'bps'} in ${Math.round(
estimatedTimeUntilUpdate,
)} ms`,
);
this.clearTimer();
this.timer = self.setTimeout(
() => this.loadingPlaylist(levelOrTrack, deliveryDirectives),
estimatedTimeUntilUpdate,
);
}
private getDeliveryDirectives(
details: LevelDetails,
previousDeliveryDirectives: HlsUrlParameters | null,
msn?: number,
part?: number,
): HlsUrlParameters {
let skip = getSkipValue(details);
if (previousDeliveryDirectives?.skip && details.deltaUpdateFailed) {
msn = previousDeliveryDirectives.msn;
part = previousDeliveryDirectives.part;
skip = HlsSkip.No;
}
return new HlsUrlParameters(msn, part, skip);
}
protected checkRetry(errorEvent: ErrorData): boolean {
const errorDetails = errorEvent.details;
const isTimeout = isTimeoutError(errorEvent);
const errorAction = errorEvent.errorAction;
const { action, retryCount = 0, retryConfig } = errorAction || {};
const retry =
!!errorAction &&
!!retryConfig &&
(action === NetworkErrorAction.RetryRequest ||
(!errorAction.resolved &&
action === NetworkErrorAction.SendAlternateToPenaltyBox));
if (retry) {
if (retryCount >= retryConfig.maxNumRetry) {
return false;
}
if (isTimeout && errorEvent.context?.deliveryDirectives) {
// The LL-HLS request already timed out so retry immediately
this.warn(
`Retrying playlist loading ${retryCount + 1}/${
retryConfig.maxNumRetry
} after "${errorDetails}" without delivery-directives`,
);
this.loadPlaylist();
} else {
const delay = getRetryDelay(retryConfig, retryCount);
// Schedule level/track reload
this.clearTimer();
this.timer = self.setTimeout(() => this.loadPlaylist(), delay);
this.warn(
`Retrying playlist loading ${retryCount + 1}/${
retryConfig.maxNumRetry
} after "${errorDetails}" in ${delay}ms`,
);
}
// `levelRetry = true` used to inform other controllers that a retry is happening
errorEvent.levelRetry = true;
errorAction.resolved = true;
}
return retry;
}
}
File diff suppressed because it is too large Load Diff
+1880
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
import type {
BufferOperation,
BufferOperationQueues,
SourceBufferName,
SourceBufferTrackSet,
} from '../types/buffer';
export default class BufferOperationQueue {
private tracks: SourceBufferTrackSet | null;
private queues: BufferOperationQueues | null = {
video: [],
audio: [],
audiovideo: [],
};
constructor(sourceBufferReference: SourceBufferTrackSet) {
this.tracks = sourceBufferReference;
}
public destroy() {
this.tracks = this.queues = null;
}
public append(
operation: BufferOperation,
type: SourceBufferName,
pending?: boolean | undefined,
) {
if (this.queues === null || this.tracks === null) {
return;
}
const queue = this.queues[type];
queue.push(operation);
if (queue.length === 1 && !pending) {
this.executeNext(type);
}
}
public appendBlocker(type: SourceBufferName): Promise<void> {
return new Promise((resolve) => {
const operation: BufferOperation = {
label: 'async-blocker',
execute: resolve,
onStart: () => {},
onComplete: () => {},
onError: () => {},
};
this.append(operation, type);
});
}
public prependBlocker(type: SourceBufferName): Promise<void> {
return new Promise((resolve) => {
if (this.queues) {
const operation: BufferOperation = {
label: 'async-blocker-prepend',
execute: resolve,
onStart: () => {},
onComplete: () => {},
onError: () => {},
};
this.queues[type].unshift(operation);
}
});
}
public removeBlockers() {
if (this.queues === null) {
return;
}
[this.queues.video, this.queues.audio, this.queues.audiovideo].forEach(
(queue) => {
const label = queue[0]?.label;
if (label === 'async-blocker' || label === 'async-blocker-prepend') {
queue[0].execute();
queue.splice(0, 1);
}
},
);
}
public unblockAudio(op: BufferOperation) {
if (this.queues === null) {
return;
}
const queue = this.queues.audio;
if (queue[0] === op) {
this.shiftAndExecuteNext('audio');
}
}
public executeNext(type: SourceBufferName) {
if (this.queues === null || this.tracks === null) {
return;
}
const queue = this.queues[type];
if (queue.length) {
const operation: BufferOperation = queue[0];
try {
// Operations are expected to result in an 'updateend' event being fired. If not, the queue will lock. Operations
// which do not end with this event must call _onSBUpdateEnd manually
operation.execute();
} catch (error) {
operation.onError(error);
if (this.queues === null || this.tracks === null) {
return;
}
// Only shift the current operation off, otherwise the updateend handler will do this for us
const sb = this.tracks[type]?.buffer;
if (!sb?.updating) {
this.shiftAndExecuteNext(type);
}
}
}
}
public shiftAndExecuteNext(type: SourceBufferName) {
if (this.queues === null) {
return;
}
this.queues[type].shift();
this.executeNext(type);
}
public current(type: SourceBufferName): BufferOperation | null {
return this.queues?.[type][0] || null;
}
public toString(): string {
const { queues, tracks } = this;
if (queues === null || tracks === null) {
return `<destroyed>`;
}
return `
${this.list('video')}
${this.list('audio')}
${this.list('audiovideo')}}`;
}
public list(type: SourceBufferName): string {
return this.queues?.[type] || this.tracks?.[type]
? `${type}: (${this.listSbInfo(type)}) ${this.listOps(type)}`
: '';
}
private listSbInfo(type: SourceBufferName): string {
const track = this.tracks?.[type];
const sb = track?.buffer;
if (!sb) {
return 'none';
}
return `SourceBuffer${sb.updating ? ' updating' : ''}${track.ended ? ' ended' : ''}${track.ending ? ' ending' : ''}`;
}
private listOps(type: SourceBufferName): string {
return this.queues?.[type].map((op) => op.label).join(', ') || '';
}
}
+320
View File
@@ -0,0 +1,320 @@
/*
* cap stream level to media size dimension controller
*/
import { Events } from '../events';
import type StreamController from './stream-controller';
import type Hls from '../hls';
import type { ComponentAPI } from '../types/component-api';
import type {
BufferCodecsData,
FPSDropLevelCappingData,
LevelsUpdatedData,
ManifestParsedData,
MediaAttachingData,
} from '../types/events';
import type { Level } from '../types/level';
type RestrictedLevel = { width: number; height: number; bitrate: number };
class CapLevelController implements ComponentAPI {
private hls: Hls;
private autoLevelCapping: number;
private firstLevel: number;
private media: HTMLVideoElement | null;
private restrictedLevels: RestrictedLevel[];
private timer: number | undefined;
private clientRect: { width: number; height: number } | null;
private streamController?: StreamController;
constructor(hls: Hls) {
this.hls = hls;
this.autoLevelCapping = Number.POSITIVE_INFINITY;
this.firstLevel = -1;
this.media = null;
this.restrictedLevels = [];
this.timer = undefined;
this.clientRect = null;
this.registerListeners();
}
public setStreamController(streamController: StreamController) {
this.streamController = streamController;
}
public destroy() {
if (this.hls) {
this.unregisterListener();
}
if (this.timer) {
this.stopCapping();
}
this.media = null;
this.clientRect = null;
// @ts-ignore
this.hls = this.streamController = null;
}
protected registerListeners() {
const { hls } = this;
hls.on(Events.FPS_DROP_LEVEL_CAPPING, this.onFpsDropLevelCapping, this);
hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.on(Events.BUFFER_CODECS, this.onBufferCodecs, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}
protected unregisterListener() {
const { hls } = this;
hls.off(Events.FPS_DROP_LEVEL_CAPPING, this.onFpsDropLevelCapping, this);
hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.off(Events.BUFFER_CODECS, this.onBufferCodecs, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}
protected onFpsDropLevelCapping(
event: Events.FPS_DROP_LEVEL_CAPPING,
data: FPSDropLevelCappingData,
) {
// Don't add a restricted level more than once
const level = this.hls.levels[data.droppedLevel];
if (this.isLevelAllowed(level)) {
this.restrictedLevels.push({
bitrate: level.bitrate,
height: level.height,
width: level.width,
});
}
}
protected onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
) {
this.media = data.media instanceof HTMLVideoElement ? data.media : null;
this.clientRect = null;
if (this.timer && this.hls.levels.length) {
this.detectPlayerSize();
}
}
protected onManifestParsed(
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
) {
const hls = this.hls;
this.restrictedLevels = [];
this.firstLevel = data.firstLevel;
if (hls.config.capLevelToPlayerSize && data.video) {
// Start capping immediately if the manifest has signaled video codecs
this.startCapping();
}
}
private onLevelsUpdated(
event: Events.LEVELS_UPDATED,
data: LevelsUpdatedData,
) {
if (this.timer && Number.isFinite(this.autoLevelCapping)) {
this.detectPlayerSize();
}
}
// Only activate capping when playing a video stream; otherwise, multi-bitrate audio-only streams will be restricted
// to the first level
protected onBufferCodecs(
event: Events.BUFFER_CODECS,
data: BufferCodecsData,
) {
const hls = this.hls;
if (hls.config.capLevelToPlayerSize && data.video) {
// If the manifest did not signal a video codec capping has been deferred until we're certain video is present
this.startCapping();
}
}
protected onMediaDetaching() {
this.stopCapping();
this.media = null;
}
detectPlayerSize() {
if (this.media) {
if (this.mediaHeight <= 0 || this.mediaWidth <= 0) {
this.clientRect = null;
return;
}
const levels = this.hls.levels;
if (levels.length) {
const hls = this.hls;
const maxLevel = this.getMaxLevel(levels.length - 1);
if (maxLevel !== this.autoLevelCapping) {
hls.logger.log(
`Setting autoLevelCapping to ${maxLevel}: ${levels[maxLevel].height}p@${levels[maxLevel].bitrate} for media ${this.mediaWidth}x${this.mediaHeight}`,
);
}
hls.autoLevelCapping = maxLevel;
if (
hls.autoLevelEnabled &&
hls.autoLevelCapping > this.autoLevelCapping &&
this.streamController
) {
// if auto level capping has a higher value for the previous one, flush the buffer using nextLevelSwitch
// usually happen when the user go to the fullscreen mode.
this.streamController.nextLevelSwitch();
}
this.autoLevelCapping = hls.autoLevelCapping;
}
}
}
/*
* returns level should be the one with the dimensions equal or greater than the media (player) dimensions (so the video will be downscaled)
*/
getMaxLevel(capLevelIndex: number): number {
const levels = this.hls.levels;
if (!levels.length) {
return -1;
}
const validLevels = levels.filter(
(level, index) => this.isLevelAllowed(level) && index <= capLevelIndex,
);
this.clientRect = null;
return CapLevelController.getMaxLevelByMediaSize(
validLevels,
this.mediaWidth,
this.mediaHeight,
);
}
startCapping() {
if (this.timer) {
// Don't reset capping if started twice; this can happen if the manifest signals a video codec
return;
}
this.autoLevelCapping = Number.POSITIVE_INFINITY;
self.clearInterval(this.timer);
this.timer = self.setInterval(this.detectPlayerSize.bind(this), 1000);
this.detectPlayerSize();
}
stopCapping() {
this.restrictedLevels = [];
this.firstLevel = -1;
this.autoLevelCapping = Number.POSITIVE_INFINITY;
if (this.timer) {
self.clearInterval(this.timer);
this.timer = undefined;
}
}
getDimensions(): { width: number; height: number } {
if (this.clientRect) {
return this.clientRect;
}
const media = this.media;
const boundsRect = {
width: 0,
height: 0,
};
if (media) {
const clientRect = media.getBoundingClientRect();
boundsRect.width = clientRect.width;
boundsRect.height = clientRect.height;
if (!boundsRect.width && !boundsRect.height) {
// When the media element has no width or height (equivalent to not being in the DOM),
// then use its width and height attributes (media.width, media.height)
boundsRect.width =
clientRect.right - clientRect.left || media.width || 0;
boundsRect.height =
clientRect.bottom - clientRect.top || media.height || 0;
}
}
this.clientRect = boundsRect;
return boundsRect;
}
get mediaWidth(): number {
return this.getDimensions().width * this.contentScaleFactor;
}
get mediaHeight(): number {
return this.getDimensions().height * this.contentScaleFactor;
}
get contentScaleFactor(): number {
let pixelRatio = 1;
if (!this.hls.config.ignoreDevicePixelRatio) {
try {
pixelRatio = self.devicePixelRatio;
} catch (e) {
/* no-op */
}
}
return Math.min(pixelRatio, this.hls.config.maxDevicePixelRatio);
}
private isLevelAllowed(level: Level): boolean {
const restrictedLevels = this.restrictedLevels;
return !restrictedLevels.some((restrictedLevel) => {
return (
level.bitrate === restrictedLevel.bitrate &&
level.width === restrictedLevel.width &&
level.height === restrictedLevel.height
);
});
}
static getMaxLevelByMediaSize(
levels: Array<Level>,
width: number,
height: number,
): number {
if (!levels?.length) {
return -1;
}
// Levels can have the same dimensions but differing bandwidths - since levels are ordered, we can look to the next
// to determine whether we've chosen the greatest bandwidth for the media's dimensions
const atGreatestBandwidth = (
curLevel: Level,
nextLevel: Level | undefined,
) => {
if (!nextLevel) {
return true;
}
return (
curLevel.width !== nextLevel.width ||
curLevel.height !== nextLevel.height
);
};
// If we run through the loop without breaking, the media's dimensions are greater than every level, so default to
// the max level
let maxLevelIndex = levels.length - 1;
// Prevent changes in aspect-ratio from causing capping to toggle back and forth
const squareSize = Math.max(width, height);
for (let i = 0; i < levels.length; i += 1) {
const level = levels[i];
if (
(level.width >= squareSize || level.height >= squareSize) &&
atGreatestBandwidth(level, levels[i + 1])
) {
maxLevelIndex = i;
break;
}
}
return maxLevelIndex;
}
}
export default CapLevelController;
+422
View File
@@ -0,0 +1,422 @@
import { CmcdObjectType } from '@svta/common-media-library/cmcd/CmcdObjectType';
import { CmcdStreamingFormat } from '@svta/common-media-library/cmcd/CmcdStreamingFormat';
import { appendCmcdHeaders } from '@svta/common-media-library/cmcd/appendCmcdHeaders';
import { appendCmcdQuery } from '@svta/common-media-library/cmcd/appendCmcdQuery';
import { Events } from '../events';
import { BufferHelper } from '../utils/buffer-helper';
import type {
FragmentLoaderConstructor,
HlsConfig,
PlaylistLoaderConstructor,
} from '../config';
import type Hls from '../hls';
import type { Fragment, Part } from '../loader/fragment';
import type { ExtendedSourceBuffer } from '../types/buffer';
import type { ComponentAPI } from '../types/component-api';
import type { BufferCreatedData, MediaAttachedData } from '../types/events';
import type {
FragmentLoaderContext,
Loader,
LoaderCallbacks,
LoaderConfiguration,
LoaderContext,
PlaylistLoaderContext,
} from '../types/loader';
import type { Cmcd } from '@svta/common-media-library/cmcd/Cmcd';
import type { CmcdEncodeOptions } from '@svta/common-media-library/cmcd/CmcdEncodeOptions';
/**
* Controller to deal with Common Media Client Data (CMCD)
* @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf
*/
export default class CMCDController implements ComponentAPI {
private hls: Hls;
private config: HlsConfig;
private media?: HTMLMediaElement;
private sid?: string;
private cid?: string;
private useHeaders: boolean = false;
private includeKeys?: string[];
private initialized: boolean = false;
private starved: boolean = false;
private buffering: boolean = true;
private audioBuffer?: ExtendedSourceBuffer;
private videoBuffer?: ExtendedSourceBuffer;
constructor(hls: Hls) {
this.hls = hls;
const config = (this.config = hls.config);
const { cmcd } = config;
if (cmcd != null) {
config.pLoader = this.createPlaylistLoader();
config.fLoader = this.createFragmentLoader();
this.sid = cmcd.sessionId || hls.sessionId;
this.cid = cmcd.contentId;
this.useHeaders = cmcd.useHeaders === true;
this.includeKeys = cmcd.includeKeys;
this.registerListeners();
}
}
private registerListeners() {
const hls = this.hls;
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this);
hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this);
}
private unregisterListeners() {
const hls = this.hls;
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this);
hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this);
}
destroy() {
this.unregisterListeners();
this.onMediaDetached();
// @ts-ignore
this.hls = this.config = this.audioBuffer = this.videoBuffer = null;
// @ts-ignore
this.onWaiting = this.onPlaying = this.media = null;
}
private onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData,
) {
this.media = data.media;
this.media.addEventListener('waiting', this.onWaiting);
this.media.addEventListener('playing', this.onPlaying);
}
private onMediaDetached() {
if (!this.media) {
return;
}
this.media.removeEventListener('waiting', this.onWaiting);
this.media.removeEventListener('playing', this.onPlaying);
// @ts-ignore
this.media = null;
}
private onBufferCreated(
event: Events.BUFFER_CREATED,
data: BufferCreatedData,
) {
this.audioBuffer = data.tracks.audio?.buffer;
this.videoBuffer = data.tracks.video?.buffer;
}
private onWaiting = () => {
if (this.initialized) {
this.starved = true;
}
this.buffering = true;
};
private onPlaying = () => {
if (!this.initialized) {
this.initialized = true;
}
this.buffering = false;
};
/**
* Create baseline CMCD data
*/
private createData(): Cmcd {
return {
v: 1,
sf: CmcdStreamingFormat.HLS,
sid: this.sid,
cid: this.cid,
pr: this.media?.playbackRate,
mtp: this.hls.bandwidthEstimate / 1000,
};
}
/**
* Apply CMCD data to a request.
*/
private apply(context: LoaderContext, data: Cmcd = {}) {
// apply baseline data
Object.assign(data, this.createData());
const isVideo =
data.ot === CmcdObjectType.INIT ||
data.ot === CmcdObjectType.VIDEO ||
data.ot === CmcdObjectType.MUXED;
if (this.starved && isVideo) {
data.bs = true;
data.su = true;
this.starved = false;
}
if (data.su == null) {
data.su = this.buffering;
}
// TODO: Implement rtp, nrr, dl
const { includeKeys } = this;
if (includeKeys) {
data = Object.keys(data).reduce((acc, key) => {
includeKeys.includes(key) && (acc[key] = data[key]);
return acc;
}, {});
}
const options: CmcdEncodeOptions = { baseUrl: context.url };
if (this.useHeaders) {
if (!context.headers) {
context.headers = {};
}
appendCmcdHeaders(context.headers, data, options);
} else {
context.url = appendCmcdQuery(context.url, data, options);
}
}
/**
* Apply CMCD data to a manifest request.
*/
private applyPlaylistData = (context: PlaylistLoaderContext) => {
try {
this.apply(context, {
ot: CmcdObjectType.MANIFEST,
su: !this.initialized,
});
} catch (error) {
this.hls.logger.warn('Could not generate manifest CMCD data.', error);
}
};
/**
* Apply CMCD data to a segment request
*/
private applyFragmentData = (context: FragmentLoaderContext) => {
try {
const { frag, part } = context;
const level = this.hls.levels[frag.level];
const ot = this.getObjectType(frag);
const data: Cmcd = { d: (part || frag).duration * 1000, ot };
if (
ot === CmcdObjectType.VIDEO ||
ot === CmcdObjectType.AUDIO ||
ot == CmcdObjectType.MUXED
) {
data.br = level.bitrate / 1000;
data.tb = this.getTopBandwidth(ot) / 1000;
data.bl = this.getBufferLength(ot);
}
const next = part ? this.getNextPart(part) : this.getNextFrag(frag);
if (next?.url && next.url !== frag.url) {
data.nor = next.url;
}
this.apply(context, data);
} catch (error) {
this.hls.logger.warn('Could not generate segment CMCD data.', error);
}
};
private getNextFrag(fragment: Fragment): Fragment | undefined {
const levelDetails = this.hls.levels[fragment.level]?.details;
if (levelDetails) {
const index = (fragment.sn as number) - levelDetails.startSN;
return levelDetails.fragments[index + 1];
}
return undefined;
}
private getNextPart(part: Part): Part | undefined {
const { index, fragment } = part;
const partList = this.hls.levels[fragment.level]?.details?.partList;
if (partList) {
const { sn } = fragment;
for (let i = partList.length - 1; i >= 0; i--) {
const p = partList[i];
if (p.index === index && p.fragment.sn === sn) {
return partList[i + 1];
}
}
}
return undefined;
}
/**
* The CMCD object type.
*/
private getObjectType(fragment: Fragment): CmcdObjectType | undefined {
const { type } = fragment;
if (type === 'subtitle') {
return CmcdObjectType.TIMED_TEXT;
}
if (fragment.sn === 'initSegment') {
return CmcdObjectType.INIT;
}
if (type === 'audio') {
return CmcdObjectType.AUDIO;
}
if (type === 'main') {
if (!this.hls.audioTracks.length) {
return CmcdObjectType.MUXED;
}
return CmcdObjectType.VIDEO;
}
return undefined;
}
/**
* Get the highest bitrate.
*/
private getTopBandwidth(type: CmcdObjectType) {
let bitrate: number = 0;
let levels;
const hls = this.hls;
if (type === CmcdObjectType.AUDIO) {
levels = hls.audioTracks;
} else {
const max = hls.maxAutoLevel;
const len = max > -1 ? max + 1 : hls.levels.length;
levels = hls.levels.slice(0, len);
}
levels.forEach((level) => {
if (level.bitrate > bitrate) {
bitrate = level.bitrate;
}
});
return bitrate > 0 ? bitrate : NaN;
}
/**
* Get the buffer length for a media type in milliseconds
*/
private getBufferLength(type: CmcdObjectType) {
const media = this.media;
const buffer =
type === CmcdObjectType.AUDIO ? this.audioBuffer : this.videoBuffer;
if (!buffer || !media) {
return NaN;
}
const info = BufferHelper.bufferInfo(
buffer,
media.currentTime,
this.config.maxBufferHole,
);
return info.len * 1000;
}
/**
* Create a playlist loader
*/
private createPlaylistLoader(): PlaylistLoaderConstructor | undefined {
const { pLoader } = this.config;
const apply = this.applyPlaylistData;
const Ctor = pLoader || (this.config.loader as PlaylistLoaderConstructor);
return class CmcdPlaylistLoader {
private loader: Loader<PlaylistLoaderContext>;
constructor(config: HlsConfig) {
this.loader = new Ctor(config);
}
get stats() {
return this.loader.stats;
}
get context() {
return this.loader.context;
}
destroy() {
this.loader.destroy();
}
abort() {
this.loader.abort();
}
load(
context: PlaylistLoaderContext,
config: LoaderConfiguration,
callbacks: LoaderCallbacks<PlaylistLoaderContext>,
) {
apply(context);
this.loader.load(context, config, callbacks);
}
};
}
/**
* Create a playlist loader
*/
private createFragmentLoader(): FragmentLoaderConstructor | undefined {
const { fLoader } = this.config;
const apply = this.applyFragmentData;
const Ctor = fLoader || (this.config.loader as FragmentLoaderConstructor);
return class CmcdFragmentLoader {
private loader: Loader<FragmentLoaderContext>;
constructor(config: HlsConfig) {
this.loader = new Ctor(config);
}
get stats() {
return this.loader.stats;
}
get context() {
return this.loader.context;
}
destroy() {
this.loader.destroy();
}
abort() {
this.loader.abort();
}
load(
context: FragmentLoaderContext,
config: LoaderConfiguration,
callbacks: LoaderCallbacks<FragmentLoaderContext>,
) {
apply(context);
this.loader.load(context, config, callbacks);
}
};
}
}
+621
View File
@@ -0,0 +1,621 @@
import { ErrorActionFlags, NetworkErrorAction } from './error-controller';
import { ErrorDetails } from '../errors';
import { Events } from '../events';
import { Level } from '../types/level';
import {
type Loader,
type LoaderCallbacks,
type LoaderConfiguration,
type LoaderContext,
type LoaderResponse,
type LoaderStats,
PlaylistContextType,
} from '../types/loader';
import { AttrList } from '../utils/attr-list';
import { reassignFragmentLevelIndexes } from '../utils/level-helper';
import { Logger } from '../utils/logger';
import { stringify } from '../utils/safe-json-stringify';
import type { RetryConfig } from '../config';
import type Hls from '../hls';
import type { NetworkComponentAPI } from '../types/component-api';
import type {
ErrorData,
ManifestLoadedData,
ManifestParsedData,
SteeringManifestLoadedData,
} from '../types/events';
import type { MediaAttributes, MediaPlaylist } from '../types/media-playlist';
export type SteeringManifest = {
VERSION: 1;
TTL: number;
'RELOAD-URI'?: string;
'PATHWAY-PRIORITY': string[];
'PATHWAY-CLONES'?: PathwayClone[];
};
export type PathwayClone = {
'BASE-ID': string;
ID: string;
'URI-REPLACEMENT': UriReplacement;
};
export type UriReplacement = {
HOST?: string;
PARAMS?: { [queryParameter: string]: string };
'PER-VARIANT-URIS'?: { [stableVariantId: string]: string };
'PER-RENDITION-URIS'?: { [stableRenditionId: string]: string };
};
const PATHWAY_PENALTY_DURATION_MS = 300000;
export default class ContentSteeringController
extends Logger
implements NetworkComponentAPI
{
private readonly hls: Hls;
private loader: Loader<LoaderContext> | null = null;
private uri: string | null = null;
private pathwayId: string = '.';
private _pathwayPriority: string[] | null = null;
private timeToLoad: number = 300;
private reloadTimer: number = -1;
private updated: number = 0;
private started: boolean = false;
private enabled: boolean = true;
private levels: Level[] | null = null;
private audioTracks: MediaPlaylist[] | null = null;
private subtitleTracks: MediaPlaylist[] | null = null;
private penalizedPathways: { [pathwayId: string]: number } = {};
constructor(hls: Hls) {
super('content-steering', hls.logger);
this.hls = hls;
this.registerListeners();
}
private registerListeners() {
const hls = this.hls;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.on(Events.ERROR, this.onError, this);
}
private unregisterListeners() {
const hls = this.hls;
if (!hls) {
return;
}
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.off(Events.ERROR, this.onError, this);
}
pathways() {
return (this.levels || []).reduce((pathways, level) => {
if (pathways.indexOf(level.pathwayId) === -1) {
pathways.push(level.pathwayId);
}
return pathways;
}, [] as string[]);
}
get pathwayPriority(): string[] | null {
return this._pathwayPriority;
}
set pathwayPriority(pathwayPriority: string[]) {
this.updatePathwayPriority(pathwayPriority);
}
startLoad() {
this.started = true;
this.clearTimeout();
if (this.enabled && this.uri) {
if (this.updated) {
const ttl = this.timeToLoad * 1000 - (performance.now() - this.updated);
if (ttl > 0) {
this.scheduleRefresh(this.uri, ttl);
return;
}
}
this.loadSteeringManifest(this.uri);
}
}
stopLoad() {
this.started = false;
if (this.loader) {
this.loader.destroy();
this.loader = null;
}
this.clearTimeout();
}
clearTimeout() {
if (this.reloadTimer !== -1) {
self.clearTimeout(this.reloadTimer);
this.reloadTimer = -1;
}
}
destroy() {
this.unregisterListeners();
this.stopLoad();
// @ts-ignore
this.hls = null;
this.levels = this.audioTracks = this.subtitleTracks = null;
}
removeLevel(levelToRemove: Level) {
const levels = this.levels;
if (levels) {
this.levels = levels.filter((level) => level !== levelToRemove);
}
}
private onManifestLoading() {
this.stopLoad();
this.enabled = true;
this.timeToLoad = 300;
this.updated = 0;
this.uri = null;
this.pathwayId = '.';
this.levels = this.audioTracks = this.subtitleTracks = null;
}
private onManifestLoaded(
event: Events.MANIFEST_LOADED,
data: ManifestLoadedData,
) {
const { contentSteering } = data;
if (contentSteering === null) {
return;
}
this.pathwayId = contentSteering.pathwayId;
this.uri = contentSteering.uri;
if (this.started) {
this.startLoad();
}
}
private onManifestParsed(
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
) {
this.audioTracks = data.audioTracks;
this.subtitleTracks = data.subtitleTracks;
}
private onError(event: Events.ERROR, data: ErrorData) {
const { errorAction } = data;
if (
errorAction?.action === NetworkErrorAction.SendAlternateToPenaltyBox &&
errorAction.flags === ErrorActionFlags.MoveAllAlternatesMatchingHost
) {
const levels = this.levels;
let pathwayPriority = this._pathwayPriority;
let errorPathway = this.pathwayId;
if (data.context) {
const { groupId, pathwayId, type } = data.context;
if (groupId && levels) {
errorPathway = this.getPathwayForGroupId(groupId, type, errorPathway);
} else if (pathwayId) {
errorPathway = pathwayId;
}
}
if (!(errorPathway in this.penalizedPathways)) {
this.penalizedPathways[errorPathway] = performance.now();
}
if (!pathwayPriority && levels) {
// If PATHWAY-PRIORITY was not provided, list pathways for error handling
pathwayPriority = this.pathways();
}
if (pathwayPriority && pathwayPriority.length > 1) {
this.updatePathwayPriority(pathwayPriority);
errorAction.resolved = this.pathwayId !== errorPathway;
}
if (data.details === ErrorDetails.BUFFER_APPEND_ERROR && !data.fatal) {
// Error will become fatal in buffer-controller when reaching `appendErrorMaxRetry`
// Stream-controllers are expected to reduce buffer length even if this is not deemed a QuotaExceededError
errorAction.resolved = true;
} else if (!errorAction.resolved) {
this.warn(
`Could not resolve ${data.details} ("${
data.error.message
}") with content-steering for Pathway: ${errorPathway} levels: ${
levels ? levels.length : levels
} priorities: ${stringify(
pathwayPriority,
)} penalized: ${stringify(this.penalizedPathways)}`,
);
}
}
}
public filterParsedLevels(levels: Level[]): Level[] {
// Filter levels to only include those that are in the initial pathway
this.levels = levels;
let pathwayLevels = this.getLevelsForPathway(this.pathwayId);
if (pathwayLevels.length === 0) {
const pathwayId = levels[0].pathwayId;
this.log(
`No levels found in Pathway ${this.pathwayId}. Setting initial Pathway to "${pathwayId}"`,
);
pathwayLevels = this.getLevelsForPathway(pathwayId);
this.pathwayId = pathwayId;
}
if (pathwayLevels.length !== levels.length) {
this.log(
`Found ${pathwayLevels.length}/${levels.length} levels in Pathway "${this.pathwayId}"`,
);
}
return pathwayLevels;
}
private getLevelsForPathway(pathwayId: string): Level[] {
if (this.levels === null) {
return [];
}
return this.levels.filter((level) => pathwayId === level.pathwayId);
}
private updatePathwayPriority(pathwayPriority: string[]) {
this._pathwayPriority = pathwayPriority;
let levels: Level[] | undefined;
// Evaluate if we should remove the pathway from the penalized list
const penalizedPathways = this.penalizedPathways;
const now = performance.now();
Object.keys(penalizedPathways).forEach((pathwayId) => {
if (now - penalizedPathways[pathwayId] > PATHWAY_PENALTY_DURATION_MS) {
delete penalizedPathways[pathwayId];
}
});
for (let i = 0; i < pathwayPriority.length; i++) {
const pathwayId = pathwayPriority[i];
if (pathwayId in penalizedPathways) {
continue;
}
if (pathwayId === this.pathwayId) {
return;
}
const selectedIndex = this.hls.nextLoadLevel;
const selectedLevel: Level = this.hls.levels[selectedIndex];
levels = this.getLevelsForPathway(pathwayId);
if (levels.length > 0) {
this.log(`Setting Pathway to "${pathwayId}"`);
this.pathwayId = pathwayId;
reassignFragmentLevelIndexes(levels);
this.hls.trigger(Events.LEVELS_UPDATED, { levels });
// Set LevelController's level to trigger LEVEL_SWITCHING which loads playlist if needed
const levelAfterChange = this.hls.levels[selectedIndex];
if (selectedLevel && levelAfterChange && this.levels) {
if (
levelAfterChange.attrs['STABLE-VARIANT-ID'] !==
selectedLevel.attrs['STABLE-VARIANT-ID'] &&
levelAfterChange.bitrate !== selectedLevel.bitrate
) {
this.log(
`Unstable Pathways change from bitrate ${selectedLevel.bitrate} to ${levelAfterChange.bitrate}`,
);
}
this.hls.nextLoadLevel = selectedIndex;
}
break;
}
}
}
private getPathwayForGroupId(
groupId: string,
type: PlaylistContextType,
defaultPathway: string,
): string {
const levels = this.getLevelsForPathway(defaultPathway).concat(
this.levels || [],
);
for (let i = 0; i < levels.length; i++) {
if (
(type === PlaylistContextType.AUDIO_TRACK &&
levels[i].hasAudioGroup(groupId)) ||
(type === PlaylistContextType.SUBTITLE_TRACK &&
levels[i].hasSubtitleGroup(groupId))
) {
return levels[i].pathwayId;
}
}
return defaultPathway;
}
private clonePathways(pathwayClones: PathwayClone[]) {
const levels = this.levels;
if (!levels) {
return;
}
const audioGroupCloneMap: Record<string, string> = {};
const subtitleGroupCloneMap: Record<string, string> = {};
pathwayClones.forEach((pathwayClone) => {
const {
ID: cloneId,
'BASE-ID': baseId,
'URI-REPLACEMENT': uriReplacement,
} = pathwayClone;
if (levels.some((level) => level.pathwayId === cloneId)) {
return;
}
const clonedVariants = this.getLevelsForPathway(baseId).map(
(baseLevel) => {
const attributes = new AttrList(baseLevel.attrs);
attributes['PATHWAY-ID'] = cloneId;
const clonedAudioGroupId: string | undefined =
attributes.AUDIO && `${attributes.AUDIO}_clone_${cloneId}`;
const clonedSubtitleGroupId: string | undefined =
attributes.SUBTITLES && `${attributes.SUBTITLES}_clone_${cloneId}`;
if (clonedAudioGroupId) {
audioGroupCloneMap[attributes.AUDIO] = clonedAudioGroupId;
attributes.AUDIO = clonedAudioGroupId;
}
if (clonedSubtitleGroupId) {
subtitleGroupCloneMap[attributes.SUBTITLES] = clonedSubtitleGroupId;
attributes.SUBTITLES = clonedSubtitleGroupId;
}
const url = performUriReplacement(
baseLevel.uri,
attributes['STABLE-VARIANT-ID'],
'PER-VARIANT-URIS',
uriReplacement,
);
const clonedLevel = new Level({
attrs: attributes,
audioCodec: baseLevel.audioCodec,
bitrate: baseLevel.bitrate,
height: baseLevel.height,
name: baseLevel.name,
url,
videoCodec: baseLevel.videoCodec,
width: baseLevel.width,
});
if (baseLevel.audioGroups) {
for (let i = 1; i < baseLevel.audioGroups.length; i++) {
clonedLevel.addGroupId(
'audio',
`${baseLevel.audioGroups[i]}_clone_${cloneId}`,
);
}
}
if (baseLevel.subtitleGroups) {
for (let i = 1; i < baseLevel.subtitleGroups.length; i++) {
clonedLevel.addGroupId(
'text',
`${baseLevel.subtitleGroups[i]}_clone_${cloneId}`,
);
}
}
return clonedLevel;
},
);
levels.push(...clonedVariants);
cloneRenditionGroups(
this.audioTracks,
audioGroupCloneMap,
uriReplacement,
cloneId,
);
cloneRenditionGroups(
this.subtitleTracks,
subtitleGroupCloneMap,
uriReplacement,
cloneId,
);
});
}
private loadSteeringManifest(uri: string) {
const config = this.hls.config;
const Loader = config.loader;
if (this.loader) {
this.loader.destroy();
}
this.loader = new Loader(config) as Loader<LoaderContext>;
let url: URL;
try {
url = new self.URL(uri);
} catch (error) {
this.enabled = false;
this.log(`Failed to parse Steering Manifest URI: ${uri}`);
return;
}
if (url.protocol !== 'data:') {
const throughput =
(this.hls.bandwidthEstimate || config.abrEwmaDefaultEstimate) | 0;
url.searchParams.set('_HLS_pathway', this.pathwayId);
url.searchParams.set('_HLS_throughput', '' + throughput);
}
const context: LoaderContext = {
responseType: 'json',
url: url.href,
};
const loadPolicy = config.steeringManifestLoadPolicy.default;
const legacyRetryCompatibility: RetryConfig | Record<string, void> =
loadPolicy.errorRetry || loadPolicy.timeoutRetry || {};
const loaderConfig: LoaderConfiguration = {
loadPolicy,
timeout: loadPolicy.maxLoadTimeMs,
maxRetry: legacyRetryCompatibility.maxNumRetry || 0,
retryDelay: legacyRetryCompatibility.retryDelayMs || 0,
maxRetryDelay: legacyRetryCompatibility.maxRetryDelayMs || 0,
};
const callbacks: LoaderCallbacks<LoaderContext> = {
onSuccess: (
response: LoaderResponse,
stats: LoaderStats,
context: LoaderContext,
networkDetails: any,
) => {
this.log(`Loaded steering manifest: "${url}"`);
const steeringData = response.data as SteeringManifest;
if (steeringData?.VERSION !== 1) {
this.log(`Steering VERSION ${steeringData.VERSION} not supported!`);
return;
}
this.updated = performance.now();
this.timeToLoad = steeringData.TTL;
const {
'RELOAD-URI': reloadUri,
'PATHWAY-CLONES': pathwayClones,
'PATHWAY-PRIORITY': pathwayPriority,
} = steeringData;
if (reloadUri) {
try {
this.uri = new self.URL(reloadUri, url).href;
} catch (error) {
this.enabled = false;
this.log(
`Failed to parse Steering Manifest RELOAD-URI: ${reloadUri}`,
);
return;
}
}
this.scheduleRefresh(this.uri || context.url);
if (pathwayClones) {
this.clonePathways(pathwayClones);
}
const loadedSteeringData: SteeringManifestLoadedData = {
steeringManifest: steeringData,
url: url.toString(),
};
this.hls.trigger(Events.STEERING_MANIFEST_LOADED, loadedSteeringData);
if (pathwayPriority) {
this.updatePathwayPriority(pathwayPriority);
}
},
onError: (
error: { code: number; text: string },
context: LoaderContext,
networkDetails: any,
stats: LoaderStats,
) => {
this.log(
`Error loading steering manifest: ${error.code} ${error.text} (${context.url})`,
);
this.stopLoad();
if (error.code === 410) {
this.enabled = false;
this.log(`Steering manifest ${context.url} no longer available`);
return;
}
let ttl = this.timeToLoad * 1000;
if (error.code === 429) {
const loader = this.loader;
if (typeof loader?.getResponseHeader === 'function') {
const retryAfter = loader.getResponseHeader('Retry-After');
if (retryAfter) {
ttl = parseFloat(retryAfter) * 1000;
}
}
this.log(`Steering manifest ${context.url} rate limited`);
return;
}
this.scheduleRefresh(this.uri || context.url, ttl);
},
onTimeout: (
stats: LoaderStats,
context: LoaderContext,
networkDetails: any,
) => {
this.log(`Timeout loading steering manifest (${context.url})`);
this.scheduleRefresh(this.uri || context.url);
},
};
this.log(`Requesting steering manifest: ${url}`);
this.loader.load(context, loaderConfig, callbacks);
}
private scheduleRefresh(uri: string, ttlMs: number = this.timeToLoad * 1000) {
this.clearTimeout();
this.reloadTimer = self.setTimeout(() => {
const media = this.hls?.media;
if (media && !media.ended) {
this.loadSteeringManifest(uri);
return;
}
this.scheduleRefresh(uri, this.timeToLoad * 1000);
}, ttlMs);
}
}
function cloneRenditionGroups(
tracks: MediaPlaylist[] | null,
groupCloneMap: Record<string, string>,
uriReplacement: UriReplacement,
cloneId: string,
) {
if (!tracks) {
return;
}
Object.keys(groupCloneMap).forEach((audioGroupId) => {
const clonedTracks = tracks
.filter((track) => track.groupId === audioGroupId)
.map((track) => {
const clonedTrack = Object.assign({}, track);
clonedTrack.details = undefined;
clonedTrack.attrs = new AttrList(clonedTrack.attrs) as MediaAttributes;
clonedTrack.url = clonedTrack.attrs.URI = performUriReplacement(
track.url,
track.attrs['STABLE-RENDITION-ID'],
'PER-RENDITION-URIS',
uriReplacement,
);
clonedTrack.groupId = clonedTrack.attrs['GROUP-ID'] =
groupCloneMap[audioGroupId];
clonedTrack.attrs['PATHWAY-ID'] = cloneId;
return clonedTrack;
});
tracks.push(...clonedTracks);
});
}
function performUriReplacement(
uri: string,
stableId: string | undefined,
perOptionKey: 'PER-VARIANT-URIS' | 'PER-RENDITION-URIS',
uriReplacement: UriReplacement,
): string {
const {
HOST: host,
PARAMS: params,
[perOptionKey]: perOptionUris,
} = uriReplacement;
let perVariantUri;
if (stableId) {
perVariantUri = perOptionUris?.[stableId];
if (perVariantUri) {
uri = perVariantUri;
}
}
const url = new self.URL(uri);
if (host && !perVariantUri) {
url.host = host;
}
if (params) {
Object.keys(params)
.sort()
.forEach((key) => {
if (key) {
url.searchParams.set(key, params[key]);
}
});
}
return url.href;
}
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
import { findFragmentByPTS } from './fragment-finders';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { HdcpLevels } from '../types/level';
import { PlaylistContextType, PlaylistLevelType } from '../types/loader';
import { getCodecsForMimeType } from '../utils/codecs';
import {
getRetryConfig,
isKeyError,
isTimeoutError,
isUnusableKeyError,
shouldRetry,
} from '../utils/error-helper';
import { arrayToHex } from '../utils/hex';
import { Logger } from '../utils/logger';
import type { RetryConfig } from '../config';
import type { LevelKey } from '../hls';
import type Hls from '../hls';
import type { Fragment, MediaFragment } from '../loader/fragment';
import type { NetworkComponentAPI } from '../types/component-api';
import type { ErrorData } from '../types/events';
import type { HdcpLevel, Level } from '../types/level';
export const enum NetworkErrorAction {
DoNothing = 0,
SendEndCallback = 1, // Reserved for future use
SendAlternateToPenaltyBox = 2,
RemoveAlternatePermanently = 3, // Reserved for future use
InsertDiscontinuity = 4, // Reserved for future use
RetryRequest = 5,
}
export const enum ErrorActionFlags {
None = 0,
MoveAllAlternatesMatchingHost = 1,
MoveAllAlternatesMatchingHDCP = 2,
MoveAllAlternatesMatchingKey = 4,
SwitchToSDR = 8,
}
export type IErrorAction = {
action: NetworkErrorAction;
flags: ErrorActionFlags;
retryCount?: number;
retryConfig?: RetryConfig;
hdcpLevel?: HdcpLevel;
nextAutoLevel?: number;
resolved?: boolean;
};
export default class ErrorController
extends Logger
implements NetworkComponentAPI
{
private readonly hls: Hls;
private playlistError: number = 0;
constructor(hls: Hls) {
super('error-controller', hls.logger);
this.hls = hls;
this.registerListeners();
}
private registerListeners() {
const hls = this.hls;
hls.on(Events.ERROR, this.onError, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
}
private unregisterListeners() {
const hls = this.hls;
if (!hls) {
return;
}
hls.off(Events.ERROR, this.onError, this);
hls.off(Events.ERROR, this.onErrorOut, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
}
destroy() {
this.unregisterListeners();
// @ts-ignore
this.hls = null;
}
startLoad(startPosition: number): void {}
stopLoad(): void {
this.playlistError = 0;
}
private getVariantLevelIndex(frag: Fragment | undefined): number {
if (frag?.type === PlaylistLevelType.MAIN) {
return frag.level;
}
return this.getVariantIndex();
}
private getVariantIndex(): number {
const hls = this.hls;
const currentLevel = hls.currentLevel;
if (hls.loadLevelObj?.details || currentLevel === -1) {
return hls.loadLevel;
}
return currentLevel;
}
private variantHasKey(
level: Level | undefined,
keyInError: LevelKey,
): boolean {
if (level) {
if (level.details?.hasKey(keyInError)) {
return true;
}
const audioGroupsIds = level.audioGroups;
if (audioGroupsIds) {
const audioTracks = this.hls.allAudioTracks.filter(
(track) => audioGroupsIds.indexOf(track.groupId) >= 0,
);
return audioTracks.some((track) => track.details?.hasKey(keyInError));
}
}
return false;
}
private onManifestLoading() {
this.playlistError = 0;
}
private onLevelUpdated() {
this.playlistError = 0;
}
private onError(event: Events.ERROR, data: ErrorData) {
if (data.fatal) {
return;
}
const hls = this.hls;
const context = data.context;
switch (data.details) {
case ErrorDetails.FRAG_LOAD_ERROR:
case ErrorDetails.FRAG_LOAD_TIMEOUT:
case ErrorDetails.KEY_LOAD_ERROR:
case ErrorDetails.KEY_LOAD_TIMEOUT:
data.errorAction = this.getFragRetryOrSwitchAction(data);
return;
case ErrorDetails.FRAG_PARSING_ERROR:
// ignore empty segment errors marked as gap
if (data.frag?.gap) {
data.errorAction = createDoNothingErrorAction();
return;
}
// falls through
case ErrorDetails.FRAG_GAP:
case ErrorDetails.FRAG_DECRYPT_ERROR: {
// Switch level if possible, otherwise allow retry count to reach max error retries
data.errorAction = this.getFragRetryOrSwitchAction(data);
data.errorAction.action = NetworkErrorAction.SendAlternateToPenaltyBox;
return;
}
case ErrorDetails.LEVEL_EMPTY_ERROR:
case ErrorDetails.LEVEL_PARSING_ERROR:
{
// Only retry when empty and live
const levelIndex =
data.parent === PlaylistLevelType.MAIN
? (data.level as number)
: hls.loadLevel;
if (
data.details === ErrorDetails.LEVEL_EMPTY_ERROR &&
!!data.context?.levelDetails?.live
) {
data.errorAction = this.getPlaylistRetryOrSwitchAction(
data,
levelIndex,
);
} else {
// Escalate to fatal if not retrying or switching
data.levelRetry = false;
data.errorAction = this.getLevelSwitchAction(data, levelIndex);
}
}
return;
case ErrorDetails.LEVEL_LOAD_ERROR:
case ErrorDetails.LEVEL_LOAD_TIMEOUT:
if (typeof context?.level === 'number') {
data.errorAction = this.getPlaylistRetryOrSwitchAction(
data,
context.level,
);
}
return;
case ErrorDetails.AUDIO_TRACK_LOAD_ERROR:
case ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT:
case ErrorDetails.SUBTITLE_LOAD_ERROR:
case ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT:
if (context) {
const level = hls.loadLevelObj;
if (
level &&
((context.type === PlaylistContextType.AUDIO_TRACK &&
level.hasAudioGroup(context.groupId)) ||
(context.type === PlaylistContextType.SUBTITLE_TRACK &&
level.hasSubtitleGroup(context.groupId)))
) {
// Perform Pathway switch or Redundant failover if possible for fastest recovery
// otherwise allow playlist retry count to reach max error retries
data.errorAction = this.getPlaylistRetryOrSwitchAction(
data,
hls.loadLevel,
);
data.errorAction.action =
NetworkErrorAction.SendAlternateToPenaltyBox;
data.errorAction.flags =
ErrorActionFlags.MoveAllAlternatesMatchingHost;
return;
}
}
return;
case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED:
{
data.errorAction = {
action: NetworkErrorAction.SendAlternateToPenaltyBox,
flags: ErrorActionFlags.MoveAllAlternatesMatchingHDCP,
};
}
return;
case ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED:
case ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR:
case ErrorDetails.KEY_SYSTEM_NO_SESSION:
{
data.errorAction = {
action: NetworkErrorAction.SendAlternateToPenaltyBox,
flags: ErrorActionFlags.MoveAllAlternatesMatchingKey,
};
}
return;
case ErrorDetails.BUFFER_ADD_CODEC_ERROR:
case ErrorDetails.REMUX_ALLOC_ERROR:
case ErrorDetails.BUFFER_APPEND_ERROR:
// Buffer-controller can set errorAction when append errors can be ignored or resolved locally
if (!data.errorAction) {
data.errorAction = this.getLevelSwitchAction(
data,
data.level ?? hls.loadLevel,
);
}
return;
case ErrorDetails.INTERNAL_EXCEPTION:
case ErrorDetails.BUFFER_APPENDING_ERROR:
case ErrorDetails.BUFFER_FULL_ERROR:
case ErrorDetails.LEVEL_SWITCH_ERROR:
case ErrorDetails.BUFFER_STALLED_ERROR:
case ErrorDetails.BUFFER_SEEK_OVER_HOLE:
case ErrorDetails.BUFFER_NUDGE_ON_STALL:
data.errorAction = createDoNothingErrorAction();
return;
}
if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) {
// Do not retry level. Should be fatal if ErrorDetails.KEY_SYSTEM_<ERROR> not handled with early return above.
data.levelRetry = false;
data.errorAction = createDoNothingErrorAction();
}
}
private getPlaylistRetryOrSwitchAction(
data: ErrorData,
levelIndex: number | null | undefined,
): IErrorAction {
const hls = this.hls;
const retryConfig = getRetryConfig(hls.config.playlistLoadPolicy, data);
const retryCount = this.playlistError++;
const retry = shouldRetry(
retryConfig,
retryCount,
isTimeoutError(data),
data.response,
);
if (retry) {
return {
action: NetworkErrorAction.RetryRequest,
flags: ErrorActionFlags.None,
retryConfig,
retryCount,
};
}
const errorAction = this.getLevelSwitchAction(data, levelIndex);
if (retryConfig) {
errorAction.retryConfig = retryConfig;
errorAction.retryCount = retryCount;
}
return errorAction;
}
private getFragRetryOrSwitchAction(data: ErrorData): IErrorAction {
const hls = this.hls;
// Share fragment error count accross media options (main, audio, subs)
// This allows for level based rendition switching when media option assets fail
const variantLevelIndex = this.getVariantLevelIndex(data.frag);
const level = hls.levels[variantLevelIndex];
const { fragLoadPolicy, keyLoadPolicy } = hls.config;
const retryConfig = getRetryConfig(
isKeyError(data) ? keyLoadPolicy : fragLoadPolicy,
data,
);
const fragmentErrors = hls.levels.reduce(
(acc, level) => acc + level.fragmentError,
0,
);
// Switch levels when out of retried or level index out of bounds
if (level) {
if (data.details !== ErrorDetails.FRAG_GAP) {
level.fragmentError++;
}
if (!isUnusableKeyError(data)) {
const retry = shouldRetry(
retryConfig,
fragmentErrors,
isTimeoutError(data),
data.response,
);
if (retry) {
return {
action: NetworkErrorAction.RetryRequest,
flags: ErrorActionFlags.None,
retryConfig,
retryCount: fragmentErrors,
};
}
}
}
// Reach max retry count, or Missing level reference
// Switch to valid index
const errorAction = this.getLevelSwitchAction(data, variantLevelIndex);
// Add retry details to allow skipping of FRAG_PARSING_ERROR
if (retryConfig) {
errorAction.retryConfig = retryConfig;
errorAction.retryCount = fragmentErrors;
}
return errorAction;
}
private getLevelSwitchAction(
data: ErrorData,
levelIndex: number | null | undefined,
): IErrorAction {
const hls = this.hls;
if (levelIndex === null || levelIndex === undefined) {
levelIndex = hls.loadLevel;
}
const level = this.hls.levels[levelIndex];
if (level) {
const errorDetails = data.details;
level.loadError++;
if (errorDetails === ErrorDetails.BUFFER_APPEND_ERROR) {
level.fragmentError++;
}
// Search for next level to retry
let nextLevel = -1;
const { levels, loadLevel, minAutoLevel, maxAutoLevel } = hls;
if (!hls.autoLevelEnabled && !hls.config.preserveManualLevelOnError) {
hls.loadLevel = -1;
}
const fragErrorType = data.frag?.type;
// Find alternate audio codec if available on audio codec error
const isAudioCodecError =
(fragErrorType === PlaylistLevelType.AUDIO &&
errorDetails === ErrorDetails.FRAG_PARSING_ERROR) ||
(data.sourceBufferName === 'audio' &&
(errorDetails === ErrorDetails.BUFFER_ADD_CODEC_ERROR ||
errorDetails === ErrorDetails.BUFFER_APPEND_ERROR));
const findAudioCodecAlternate =
isAudioCodecError &&
levels.some(({ audioCodec }) => level.audioCodec !== audioCodec);
// Find alternate video codec if available on video codec error
const isVideoCodecError =
data.sourceBufferName === 'video' &&
(errorDetails === ErrorDetails.BUFFER_ADD_CODEC_ERROR ||
errorDetails === ErrorDetails.BUFFER_APPEND_ERROR);
const findVideoCodecAlternate =
isVideoCodecError &&
levels.some(
({ codecSet, audioCodec }) =>
level.codecSet !== codecSet && level.audioCodec === audioCodec,
);
const { type: playlistErrorType, groupId: playlistErrorGroupId } =
data.context ?? {};
for (let i = levels.length; i--; ) {
const candidate = (i + loadLevel) % levels.length;
if (
candidate !== loadLevel &&
candidate >= minAutoLevel &&
candidate <= maxAutoLevel &&
levels[candidate].loadError === 0
) {
const levelCandidate = levels[candidate];
// Skip level switch if GAP tag is found in next level at same position
if (
errorDetails === ErrorDetails.FRAG_GAP &&
fragErrorType === PlaylistLevelType.MAIN &&
data.frag
) {
const levelDetails = levels[candidate].details;
if (levelDetails) {
const fragCandidate = findFragmentByPTS(
data.frag as MediaFragment,
levelDetails.fragments,
data.frag.start,
);
if (fragCandidate?.gap) {
continue;
}
}
} else if (
(playlistErrorType === PlaylistContextType.AUDIO_TRACK &&
levelCandidate.hasAudioGroup(playlistErrorGroupId)) ||
(playlistErrorType === PlaylistContextType.SUBTITLE_TRACK &&
levelCandidate.hasSubtitleGroup(playlistErrorGroupId))
) {
// For audio/subs playlist errors find another group ID or fallthrough to redundant fail-over
continue;
} else if (
(fragErrorType === PlaylistLevelType.AUDIO &&
level.audioGroups?.some((groupId) =>
levelCandidate.hasAudioGroup(groupId),
)) ||
(fragErrorType === PlaylistLevelType.SUBTITLE &&
level.subtitleGroups?.some((groupId) =>
levelCandidate.hasSubtitleGroup(groupId),
)) ||
(findAudioCodecAlternate &&
level.audioCodec === levelCandidate.audioCodec) ||
(findVideoCodecAlternate &&
level.codecSet === levelCandidate.codecSet) ||
(!findAudioCodecAlternate &&
level.codecSet !== levelCandidate.codecSet)
) {
// For video/audio/subs frag errors find another group ID or fallthrough to redundant fail-over
continue;
}
nextLevel = candidate;
break;
}
}
if (nextLevel > -1 && hls.loadLevel !== nextLevel) {
data.levelRetry = true;
this.playlistError = 0;
return {
action: NetworkErrorAction.SendAlternateToPenaltyBox,
flags: ErrorActionFlags.None,
nextAutoLevel: nextLevel,
};
}
}
// No levels to switch / Manual level selection / Level not found
// Resolve with Pathway switch, Redundant fail-over, or stay on lowest Level
return {
action: NetworkErrorAction.SendAlternateToPenaltyBox,
flags: ErrorActionFlags.MoveAllAlternatesMatchingHost,
};
}
public onErrorOut(event: Events.ERROR, data: ErrorData) {
switch (data.errorAction?.action) {
case NetworkErrorAction.DoNothing:
break;
case NetworkErrorAction.SendAlternateToPenaltyBox:
this.sendAlternateToPenaltyBox(data);
if (
!data.errorAction.resolved &&
data.details !== ErrorDetails.FRAG_GAP
) {
data.fatal = true;
} else if (/MediaSource readyState: ended/.test(data.error.message)) {
this.warn(
`MediaSource ended after "${data.sourceBufferName}" sourceBuffer append error. Attempting to recover from media error.`,
);
this.hls.recoverMediaError();
}
break;
case NetworkErrorAction.RetryRequest:
// handled by stream and playlist/level controllers
break;
}
if (data.fatal) {
this.hls.stopLoad();
return;
}
}
private sendAlternateToPenaltyBox(data: ErrorData) {
const hls = this.hls;
const errorAction = data.errorAction;
if (!errorAction) {
return;
}
const { flags } = errorAction;
const nextAutoLevel = errorAction.nextAutoLevel;
switch (flags) {
case ErrorActionFlags.None:
this.switchLevel(data, nextAutoLevel);
break;
case ErrorActionFlags.MoveAllAlternatesMatchingHDCP: {
const levelIndex = this.getVariantLevelIndex(data.frag);
const level = hls.levels[levelIndex];
const restrictedHdcpLevel = (level as Level | undefined)?.attrs[
'HDCP-LEVEL'
];
errorAction.hdcpLevel = restrictedHdcpLevel;
if (restrictedHdcpLevel === 'NONE') {
this.warn(`HDCP policy resticted output with HDCP-LEVEL=NONE`);
} else if (restrictedHdcpLevel) {
hls.maxHdcpLevel =
HdcpLevels[HdcpLevels.indexOf(restrictedHdcpLevel) - 1];
errorAction.resolved = true;
this.warn(
`Restricting playback to HDCP-LEVEL of "${hls.maxHdcpLevel}" or lower`,
);
break;
}
// Fallthrough when no HDCP-LEVEL attribute is found
}
// eslint-disable-next-line no-fallthrough
case ErrorActionFlags.MoveAllAlternatesMatchingKey: {
const levelKey = data.decryptdata;
if (levelKey) {
// Penalize all levels with key
const levels = this.hls.levels;
const levelCountWithError = levels.length;
for (let i = levelCountWithError; i--; ) {
if (this.variantHasKey(levels[i], levelKey)) {
this.log(
`Banned key found in level ${i} (${levels[i].bitrate}bps) or audio group "${levels[i].audioGroups?.join(',')}" (${data.frag?.type} fragment) ${arrayToHex(levelKey.keyId || [])}`,
);
levels[i].fragmentError++;
levels[i].loadError++;
this.log(`Removing level ${i} with key error (${data.error})`);
this.hls.removeLevel(i);
}
}
const frag = data.frag;
if (this.hls.levels.length < levelCountWithError) {
errorAction.resolved = true;
} else if (frag && frag.type !== PlaylistLevelType.MAIN) {
// Ignore key error for audio track with unmatched key (main session error)
const fragLevelKey = frag.decryptdata;
if (fragLevelKey && !levelKey.matches(fragLevelKey)) {
errorAction.resolved = true;
}
}
}
break;
}
}
// If not resolved by previous actions try to switch to next level
if (!errorAction.resolved) {
this.switchLevel(data, nextAutoLevel);
}
}
private switchLevel(data: ErrorData, levelIndex: number | undefined) {
if (levelIndex !== undefined && data.errorAction) {
this.warn(`switching to level ${levelIndex} after ${data.details}`);
this.hls.nextAutoLevel = levelIndex;
data.errorAction.resolved = true;
// Stream controller is responsible for this but won't switch on false start
this.hls.nextLoadLevel = this.hls.nextAutoLevel;
if (
data.details === ErrorDetails.BUFFER_ADD_CODEC_ERROR &&
data.mimeType &&
data.sourceBufferName !== 'audiovideo'
) {
const codec = getCodecsForMimeType(data.mimeType);
const levels = this.hls.levels;
for (let i = levels.length; i--; ) {
if (levels[i][`${data.sourceBufferName}Codec`] === codec) {
this.log(
`Removing level ${i} for ${data.details} ("${codec}" not supported)`,
);
this.hls.removeLevel(i);
}
}
}
}
}
}
export function createDoNothingErrorAction(resolved?: boolean): IErrorAction {
const errorAction: IErrorAction = {
action: NetworkErrorAction.DoNothing,
flags: ErrorActionFlags.None,
};
if (resolved) {
errorAction.resolved = true;
}
return errorAction;
}
+146
View File
@@ -0,0 +1,146 @@
import { Events } from '../events';
import type StreamController from './stream-controller';
import type Hls from '../hls';
import type { ComponentAPI } from '../types/component-api';
import type { MediaAttachingData } from '../types/events';
class FPSController implements ComponentAPI {
private hls: Hls;
private isVideoPlaybackQualityAvailable: boolean = false;
private timer?: number;
private media: HTMLVideoElement | null = null;
private lastTime: any;
private lastDroppedFrames: number = 0;
private lastDecodedFrames: number = 0;
// stream controller must be provided as a dependency!
private streamController!: StreamController;
constructor(hls: Hls) {
this.hls = hls;
this.registerListeners();
}
public setStreamController(streamController: StreamController) {
this.streamController = streamController;
}
protected registerListeners() {
this.hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
this.hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}
protected unregisterListeners() {
this.hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
this.hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}
destroy() {
if (this.timer) {
clearInterval(this.timer);
}
this.unregisterListeners();
this.isVideoPlaybackQualityAvailable = false;
this.media = null;
}
protected onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
) {
const config = this.hls.config;
if (config.capLevelOnFPSDrop) {
const media =
data.media instanceof self.HTMLVideoElement ? data.media : null;
this.media = media;
if (media && typeof media.getVideoPlaybackQuality === 'function') {
this.isVideoPlaybackQualityAvailable = true;
}
self.clearInterval(this.timer);
this.timer = self.setInterval(
this.checkFPSInterval.bind(this),
config.fpsDroppedMonitoringPeriod,
);
}
}
private onMediaDetaching() {
this.media = null;
}
checkFPS(
video: HTMLVideoElement,
decodedFrames: number,
droppedFrames: number,
) {
const currentTime = performance.now();
if (decodedFrames) {
if (this.lastTime) {
const currentPeriod = currentTime - this.lastTime;
const currentDropped = droppedFrames - this.lastDroppedFrames;
const currentDecoded = decodedFrames - this.lastDecodedFrames;
const droppedFPS = (1000 * currentDropped) / currentPeriod;
const hls = this.hls;
hls.trigger(Events.FPS_DROP, {
currentDropped: currentDropped,
currentDecoded: currentDecoded,
totalDroppedFrames: droppedFrames,
});
if (droppedFPS > 0) {
// hls.logger.log('checkFPS : droppedFPS/decodedFPS:' + droppedFPS/(1000 * currentDecoded / currentPeriod));
if (
currentDropped >
hls.config.fpsDroppedMonitoringThreshold * currentDecoded
) {
let currentLevel = hls.currentLevel;
hls.logger.warn(
'drop FPS ratio greater than max allowed value for currentLevel: ' +
currentLevel,
);
if (
currentLevel > 0 &&
(hls.autoLevelCapping === -1 ||
hls.autoLevelCapping >= currentLevel)
) {
currentLevel = currentLevel - 1;
hls.trigger(Events.FPS_DROP_LEVEL_CAPPING, {
level: currentLevel,
droppedLevel: hls.currentLevel,
});
hls.autoLevelCapping = currentLevel;
this.streamController.nextLevelSwitch();
}
}
}
}
this.lastTime = currentTime;
this.lastDroppedFrames = droppedFrames;
this.lastDecodedFrames = decodedFrames;
}
}
checkFPSInterval() {
const video = this.media;
if (video) {
if (this.isVideoPlaybackQualityAvailable) {
const videoPlaybackQuality = video.getVideoPlaybackQuality();
this.checkFPS(
video,
videoPlaybackQuality.totalVideoFrames,
videoPlaybackQuality.droppedVideoFrames,
);
} else {
// HTMLVideoElement doesn't include the webkit types
this.checkFPS(
video,
(video as any).webkitDecodedFrameCount as number,
(video as any).webkitDroppedFrameCount as number,
);
}
}
}
}
export default FPSController;
+257
View File
@@ -0,0 +1,257 @@
import BinarySearch from '../utils/binary-search';
import type { Fragment, MediaFragment } from '../loader/fragment';
import type { LevelDetails } from '../loader/level-details';
/**
* Returns first fragment whose endPdt value exceeds the given PDT, or null.
* @param fragments - The array of candidate fragments
* @param PDTValue - The PDT value which must be exceeded
* @param maxFragLookUpTolerance - The amount of time that a fragment's start/end can be within in order to be considered contiguous
*/
export function findFragmentByPDT(
fragments: MediaFragment[],
PDTValue: number | null,
maxFragLookUpTolerance: number,
): MediaFragment | null {
if (
PDTValue === null ||
!Array.isArray(fragments) ||
!fragments.length ||
!Number.isFinite(PDTValue)
) {
return null;
}
// if less than start
const startPDT = fragments[0].programDateTime;
if (PDTValue < (startPDT || 0)) {
return null;
}
const endPDT = fragments[fragments.length - 1].endProgramDateTime;
if (PDTValue >= (endPDT || 0)) {
return null;
}
for (let seg = 0; seg < fragments.length; ++seg) {
const frag = fragments[seg];
if (pdtWithinToleranceTest(PDTValue, maxFragLookUpTolerance, frag)) {
return frag;
}
}
return null;
}
/**
* Finds a fragment based on the SN of the previous fragment; or based on the needs of the current buffer.
* This method compensates for small buffer gaps by applying a tolerance to the start of any candidate fragment, thus
* breaking any traps which would cause the same fragment to be continuously selected within a small range.
* @param fragPrevious - The last frag successfully appended
* @param fragments - The array of candidate fragments
* @param bufferEnd - The end of the contiguous buffered range the playhead is currently within
* @param maxFragLookUpTolerance - The amount of time that a fragment's start/end can be within in order to be considered contiguous
* @returns a matching fragment or null
*/
export function findFragmentByPTS(
fragPrevious: MediaFragment | null,
fragments: MediaFragment[],
bufferEnd: number = 0,
maxFragLookUpTolerance: number = 0,
nextFragLookupTolerance: number = 0.005,
): MediaFragment | null {
let fragNext: MediaFragment | null = null;
if (fragPrevious) {
fragNext = fragments[1 + fragPrevious.sn - fragments[0].sn] || null;
// check for buffer-end rounding error
const bufferEdgeError = (fragPrevious.endDTS as number) - bufferEnd;
if (bufferEdgeError > 0 && bufferEdgeError < 0.0000015) {
bufferEnd += 0.0000015;
}
if (
fragNext &&
fragPrevious.level !== fragNext.level &&
fragNext.end <= fragPrevious.end
) {
fragNext = fragments[2 + fragPrevious.sn - fragments[0].sn] || null;
}
} else if (bufferEnd === 0 && fragments[0].start === 0) {
fragNext = fragments[0];
}
// Prefer the next fragment if it's within tolerance
if (
fragNext &&
(((!fragPrevious || fragPrevious.level === fragNext.level) &&
fragmentWithinToleranceTest(
bufferEnd,
maxFragLookUpTolerance,
fragNext,
) === 0) ||
fragmentWithinFastStartSwitch(
fragNext,
fragPrevious,
Math.min(nextFragLookupTolerance, maxFragLookUpTolerance),
))
) {
return fragNext;
}
// We might be seeking past the tolerance so find the best match
const foundFragment = BinarySearch.search(
fragments,
fragmentWithinToleranceTest.bind(null, bufferEnd, maxFragLookUpTolerance),
);
if (foundFragment && (foundFragment !== fragPrevious || !fragNext)) {
return foundFragment;
}
// If no match was found return the next fragment after fragPrevious, or null
return fragNext;
}
function fragmentWithinFastStartSwitch(
fragNext: Fragment,
fragPrevious: Fragment | null,
nextFragLookupTolerance: number,
): boolean {
if (
fragPrevious &&
fragPrevious.start === 0 &&
fragPrevious.level < fragNext.level &&
(fragPrevious.endPTS || 0) > 0
) {
const firstDuration = fragPrevious.tagList.reduce((duration, tag) => {
if (tag[0] === 'INF') {
duration += parseFloat(tag[1]);
}
return duration;
}, nextFragLookupTolerance);
return fragNext.start <= firstDuration;
}
return false;
}
/**
* The test function used by the findFragmentBySn's BinarySearch to look for the best match to the current buffer conditions.
* @param candidate - The fragment to test
* @param bufferEnd - The end of the current buffered range the playhead is currently within
* @param maxFragLookUpTolerance - The amount of time that a fragment's start can be within in order to be considered contiguous
* @returns 0 if it matches, 1 if too low, -1 if too high
*/
export function fragmentWithinToleranceTest(
bufferEnd = 0,
maxFragLookUpTolerance = 0,
candidate: MediaFragment,
) {
// eagerly accept an accurate match (no tolerance)
if (
candidate.start <= bufferEnd &&
candidate.start + candidate.duration > bufferEnd
) {
return 0;
}
// offset should be within fragment boundary - config.maxFragLookUpTolerance
// this is to cope with situations like
// bufferEnd = 9.991
// frag[Ø] : [0,10]
// frag[1] : [10,20]
// bufferEnd is within frag[0] range ... although what we are expecting is to return frag[1] here
// frag start frag start+duration
// |-----------------------------|
// <---> <--->
// ...--------><-----------------------------><---------....
// previous frag matching fragment next frag
// return -1 return 0 return 1
// logger.log(`level/sn/start/end/bufEnd:${level}/${candidate.sn}/${candidate.start}/${(candidate.start+candidate.duration)}/${bufferEnd}`);
// Set the lookup tolerance to be small enough to detect the current segment - ensures we don't skip over very small segments
const candidateLookupTolerance = Math.min(
maxFragLookUpTolerance,
candidate.duration + (candidate.deltaPTS ? candidate.deltaPTS : 0),
);
if (
candidate.start + candidate.duration - candidateLookupTolerance <=
bufferEnd
) {
return 1;
} else if (
candidate.start - candidateLookupTolerance > bufferEnd &&
candidate.start
) {
// if maxFragLookUpTolerance will have negative value then don't return -1 for first element
return -1;
}
return 0;
}
/**
* The test function used by the findFragmentByPdt's BinarySearch to look for the best match to the current buffer conditions.
* This function tests the candidate's program date time values, as represented in Unix time
* @param candidate - The fragment to test
* @param pdtBufferEnd - The Unix time representing the end of the current buffered range
* @param maxFragLookUpTolerance - The amount of time that a fragment's start can be within in order to be considered contiguous
* @returns true if contiguous, false otherwise
*/
export function pdtWithinToleranceTest(
pdtBufferEnd: number,
maxFragLookUpTolerance: number,
candidate: MediaFragment,
): boolean {
const candidateLookupTolerance =
Math.min(
maxFragLookUpTolerance,
candidate.duration + (candidate.deltaPTS ? candidate.deltaPTS : 0),
) * 1000;
// endProgramDateTime can be null, default to zero
const endProgramDateTime = candidate.endProgramDateTime || 0;
return endProgramDateTime - candidateLookupTolerance > pdtBufferEnd;
}
export function findFragWithCC(
fragments: MediaFragment[],
cc: number,
): MediaFragment | null {
return BinarySearch.search(fragments, (candidate) => {
if (candidate.cc < cc) {
return 1;
} else if (candidate.cc > cc) {
return -1;
} else {
return 0;
}
});
}
export function findNearestWithCC(
details: LevelDetails | undefined,
cc: number,
pos: number,
): MediaFragment | null {
if (details) {
if (details.startCC <= cc && details.endCC >= cc) {
let fragments = details.fragments;
const { fragmentHint } = details;
if (fragmentHint) {
fragments = fragments.concat(fragmentHint);
}
let closest: MediaFragment | undefined;
BinarySearch.search(fragments, (candidate) => {
if (candidate.cc < cc) {
return 1;
}
if (candidate.cc > cc) {
return -1;
}
closest = candidate;
if (candidate.end <= pos) {
return 1;
}
if (candidate.start > pos) {
return -1;
}
return 0;
});
return closest || null;
}
}
return null;
}
+567
View File
@@ -0,0 +1,567 @@
import { Events } from '../events';
import type Hls from '../hls';
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type { SourceBufferName } from '../types/buffer';
import type { ComponentAPI } from '../types/component-api';
import type {
BufferAppendedData,
FragBufferedData,
FragLoadedData,
} from '../types/events';
import type {
FragmentBufferedRange,
FragmentEntity,
FragmentTimeRange,
} from '../types/fragment-tracker';
import type { PlaylistLevelType } from '../types/loader';
export const enum FragmentState {
NOT_LOADED = 'NOT_LOADED',
APPENDING = 'APPENDING',
PARTIAL = 'PARTIAL',
OK = 'OK',
}
export class FragmentTracker implements ComponentAPI {
private activePartLists: { [key in PlaylistLevelType]?: Part[] } =
Object.create(null);
private endListFragments: { [key in PlaylistLevelType]?: FragmentEntity } =
Object.create(null);
private fragments: Partial<Record<string, FragmentEntity>> =
Object.create(null);
private timeRanges:
| {
[key in SourceBufferName]?: TimeRanges;
}
| null = Object.create(null);
private bufferPadding: number = 0.2;
private hls: Hls | null;
private hasGaps: boolean = false;
constructor(hls: Hls) {
this.hls = hls;
this._registerListeners();
}
private _registerListeners() {
const { hls } = this;
if (hls) {
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this);
hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.on(Events.FRAG_LOADED, this.onFragLoaded, this);
}
}
private _unregisterListeners() {
const { hls } = this;
if (hls) {
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this);
hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.off(Events.FRAG_LOADED, this.onFragLoaded, this);
}
}
public destroy() {
this._unregisterListeners();
// @ts-ignore
this.hls =
// @ts-ignore
this.fragments =
// @ts-ignore
this.activePartLists =
// @ts-ignore
this.endListFragments =
this.timeRanges =
null;
}
/**
* Return a Fragment or Part with an appended range that matches the position and levelType
* Otherwise, return null
*/
public getAppendedFrag(
position: number,
levelType: PlaylistLevelType,
): MediaFragment | Part | null {
const activeParts = this.activePartLists[levelType];
if (activeParts) {
for (let i = activeParts.length; i--; ) {
const activePart = activeParts[i];
if (!activePart as any) {
break;
}
if (
activePart.start <= position &&
position <= activePart.end &&
activePart.loaded
) {
return activePart;
}
}
}
return this.getBufferedFrag(position, levelType);
}
/**
* Return a buffered Fragment that matches the position and levelType.
* A buffered Fragment is one whose loading, parsing and appending is done (completed or "partial" meaning aborted).
* If not found any Fragment, return null
*/
public getBufferedFrag(
position: number,
levelType: PlaylistLevelType,
): MediaFragment | null {
return this.getFragAtPos(position, levelType, true);
}
public getFragAtPos(
position: number,
levelType: PlaylistLevelType,
buffered?: boolean,
): MediaFragment | null {
const { fragments } = this;
const keys = Object.keys(fragments);
for (let i = keys.length; i--; ) {
const fragmentEntity = fragments[keys[i]];
if (
fragmentEntity?.body.type === levelType &&
(!buffered || fragmentEntity.buffered)
) {
const frag = fragmentEntity.body;
if (frag.start <= position && position <= frag.end) {
return frag;
}
}
}
return null;
}
/**
* Partial fragments effected by coded frame eviction will be removed
* The browser will unload parts of the buffer to free up memory for new buffer data
* Fragments will need to be reloaded when the buffer is freed up, removing partial fragments will allow them to reload(since there might be parts that are still playable)
*/
public detectEvictedFragments(
elementaryStream: SourceBufferName,
timeRange: TimeRanges,
playlistType: PlaylistLevelType,
appendedPart?: Part | null,
removeAppending?: boolean,
) {
if (this.timeRanges) {
this.timeRanges[elementaryStream] = timeRange;
}
// Check if any flagged fragments have been unloaded
// excluding anything newer than appendedPartSn
const appendedPartSn = appendedPart?.fragment.sn || -1;
Object.keys(this.fragments).forEach((key) => {
const fragmentEntity = this.fragments[key];
if (!fragmentEntity) {
return;
}
if (appendedPartSn >= fragmentEntity.body.sn) {
return;
}
if (
!fragmentEntity.buffered &&
(!fragmentEntity.loaded || removeAppending)
) {
if (fragmentEntity.body.type === playlistType) {
this.removeFragment(fragmentEntity.body);
}
return;
}
const esData = fragmentEntity.range[elementaryStream];
if (!esData) {
return;
}
if (esData.time.length === 0) {
this.removeFragment(fragmentEntity.body);
return;
}
esData.time.some((time: FragmentTimeRange) => {
const isNotBuffered = !this.isTimeBuffered(
time.startPTS,
time.endPTS,
timeRange,
);
if (isNotBuffered) {
// Unregister partial fragment as it needs to load again to be reused
this.removeFragment(fragmentEntity.body);
}
return isNotBuffered;
});
});
}
/**
* Checks if the fragment passed in is loaded in the buffer properly
* Partially loaded fragments will be registered as a partial fragment
*/
public detectPartialFragments(data: FragBufferedData) {
const timeRanges = this.timeRanges;
if (!timeRanges || data.frag.sn === 'initSegment') {
return;
}
const frag = data.frag as MediaFragment;
const fragKey = getFragmentKey(frag);
const fragmentEntity = this.fragments[fragKey];
if (!fragmentEntity || (fragmentEntity.buffered && frag.gap)) {
return;
}
const isFragHint = !frag.relurl;
Object.keys(timeRanges).forEach((elementaryStream: SourceBufferName) => {
const streamInfo = frag.elementaryStreams[elementaryStream];
if (!streamInfo) {
return;
}
const timeRange = timeRanges[elementaryStream] as TimeRanges;
const partial = isFragHint || streamInfo.partial === true;
fragmentEntity.range[elementaryStream] = this.getBufferedTimes(
frag,
data.part,
partial,
timeRange,
);
});
fragmentEntity.loaded = null;
if (Object.keys(fragmentEntity.range).length) {
this.bufferedEnd(fragmentEntity, frag);
if (!isPartial(fragmentEntity)) {
// Remove older fragment parts from lookup after frag is tracked as buffered
this.removeParts(frag.sn - 1, frag.type);
}
} else {
// remove fragment if nothing was appended
this.removeFragment(fragmentEntity.body);
}
}
private bufferedEnd(fragmentEntity: FragmentEntity, frag: MediaFragment) {
fragmentEntity.buffered = true;
const endList = (fragmentEntity.body.endList =
frag.endList || fragmentEntity.body.endList);
if (endList) {
this.endListFragments[fragmentEntity.body.type] = fragmentEntity;
}
}
private removeParts(snToKeep: number, levelType: PlaylistLevelType) {
const activeParts = this.activePartLists[levelType];
if (!activeParts) {
return;
}
this.activePartLists[levelType] = filterParts(
activeParts,
(part) => part.fragment.sn >= snToKeep,
);
}
public fragBuffered(frag: MediaFragment, force?: true) {
const fragKey = getFragmentKey(frag);
let fragmentEntity = this.fragments[fragKey];
if (!fragmentEntity && force) {
fragmentEntity = this.fragments[fragKey] = {
body: frag,
appendedPTS: null,
loaded: null,
buffered: false,
range: Object.create(null),
};
if (frag.gap) {
this.hasGaps = true;
}
}
if (fragmentEntity) {
fragmentEntity.loaded = null;
this.bufferedEnd(fragmentEntity, frag);
}
}
private getBufferedTimes(
fragment: Fragment,
part: Part | null,
partial: boolean,
timeRange: TimeRanges,
): FragmentBufferedRange {
const buffered: FragmentBufferedRange = {
time: [],
partial,
};
const startPTS = fragment.start;
const endPTS = fragment.end;
const minEndPTS = fragment.minEndPTS || endPTS;
const maxStartPTS = fragment.maxStartPTS || startPTS;
for (let i = 0; i < timeRange.length; i++) {
const startTime = timeRange.start(i) - this.bufferPadding;
const endTime = timeRange.end(i) + this.bufferPadding;
if (maxStartPTS >= startTime && minEndPTS <= endTime) {
// Fragment is entirely contained in buffer
// No need to check the other timeRange times since it's completely playable
buffered.time.push({
startPTS: Math.max(startPTS, timeRange.start(i)),
endPTS: Math.min(endPTS, timeRange.end(i)),
});
break;
} else if (startPTS < endTime && endPTS > startTime) {
const start = Math.max(startPTS, timeRange.start(i));
const end = Math.min(endPTS, timeRange.end(i));
if (end > start) {
buffered.partial = true;
// Check for intersection with buffer
// Get playable sections of the fragment
buffered.time.push({
startPTS: start,
endPTS: end,
});
}
} else if (endPTS <= startTime) {
// No need to check the rest of the timeRange as it is in order
break;
}
}
return buffered;
}
/**
* Gets the partial fragment for a certain time
*/
public getPartialFragment(time: number): MediaFragment | null {
let bestFragment: Fragment | null = null;
let timePadding: number;
let startTime: number;
let endTime: number;
let bestOverlap: number = 0;
const { bufferPadding, fragments } = this;
Object.keys(fragments).forEach((key) => {
const fragmentEntity = fragments[key];
if (!fragmentEntity) {
return;
}
if (isPartial(fragmentEntity)) {
startTime = fragmentEntity.body.start - bufferPadding;
endTime = fragmentEntity.body.end + bufferPadding;
if (time >= startTime && time <= endTime) {
// Use the fragment that has the most padding from start and end time
timePadding = Math.min(time - startTime, endTime - time);
if (bestOverlap <= timePadding) {
bestFragment = fragmentEntity.body;
bestOverlap = timePadding;
}
}
}
});
return bestFragment;
}
public isEndListAppended(type: PlaylistLevelType): boolean {
const lastFragmentEntity = this.endListFragments[type];
return (
lastFragmentEntity !== undefined &&
(lastFragmentEntity.buffered || isPartial(lastFragmentEntity))
);
}
public getState(fragment: Fragment): FragmentState {
const fragKey = getFragmentKey(fragment);
const fragmentEntity = this.fragments[fragKey];
if (fragmentEntity) {
if (!fragmentEntity.buffered) {
return FragmentState.APPENDING;
} else if (isPartial(fragmentEntity)) {
return FragmentState.PARTIAL;
} else {
return FragmentState.OK;
}
}
return FragmentState.NOT_LOADED;
}
private isTimeBuffered(
startPTS: number,
endPTS: number,
timeRange: TimeRanges,
): boolean {
let startTime;
let endTime;
for (let i = 0; i < timeRange.length; i++) {
startTime = timeRange.start(i) - this.bufferPadding;
endTime = timeRange.end(i) + this.bufferPadding;
if (startPTS >= startTime && endPTS <= endTime) {
return true;
}
if (endPTS <= startTime) {
// No need to check the rest of the timeRange as it is in order
return false;
}
}
return false;
}
private onManifestLoading() {
this.removeAllFragments();
}
private onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
// don't track initsegment (for which sn is not a number)
// don't track frags used for bitrateTest, they're irrelevant.
if (data.frag.sn === 'initSegment' || data.frag.bitrateTest) {
return;
}
const frag = data.frag as MediaFragment;
// Fragment entity `loaded` FragLoadedData is null when loading parts
const loaded = data.part ? null : data;
const fragKey = getFragmentKey(frag);
this.fragments[fragKey] = {
body: frag,
appendedPTS: null,
loaded,
buffered: false,
range: Object.create(null),
};
}
private onBufferAppended(
event: Events.BUFFER_APPENDED,
data: BufferAppendedData,
) {
const { frag, part, timeRanges, type } = data;
if (frag.sn === 'initSegment') {
return;
}
const playlistType = frag.type;
if (part) {
let activeParts = this.activePartLists[playlistType];
if (!activeParts) {
this.activePartLists[playlistType] = activeParts = [];
}
activeParts.push(part);
}
// Store the latest timeRanges loaded in the buffer
this.timeRanges = timeRanges;
const timeRange = timeRanges[type] as TimeRanges;
this.detectEvictedFragments(type, timeRange, playlistType, part);
}
private onFragBuffered(event: Events.FRAG_BUFFERED, data: FragBufferedData) {
this.detectPartialFragments(data);
}
private hasFragment(fragment: Fragment): boolean {
const fragKey = getFragmentKey(fragment);
return !!this.fragments[fragKey];
}
public hasFragments(type?: PlaylistLevelType): boolean {
const { fragments } = this;
const keys = Object.keys(fragments);
if (!type) {
return keys.length > 0;
}
for (let i = keys.length; i--; ) {
const fragmentEntity = fragments[keys[i]];
if (fragmentEntity?.body.type === type) {
return true;
}
}
return false;
}
public hasParts(type: PlaylistLevelType): boolean {
return !!this.activePartLists[type]?.length;
}
public removeFragmentsInRange(
start: number,
end: number,
playlistType: PlaylistLevelType,
withGapOnly?: boolean,
unbufferedOnly?: boolean,
) {
if (withGapOnly && !this.hasGaps) {
return;
}
Object.keys(this.fragments).forEach((key) => {
const fragmentEntity = this.fragments[key];
if (!fragmentEntity) {
return;
}
const frag = fragmentEntity.body;
if (frag.type !== playlistType || (withGapOnly && !frag.gap)) {
return;
}
if (
frag.start < end &&
frag.end > start &&
(fragmentEntity.buffered || unbufferedOnly)
) {
this.removeFragment(frag);
}
});
}
public removeFragment(fragment: Fragment) {
const fragKey = getFragmentKey(fragment);
fragment.clearElementaryStreamInfo();
const activeParts = this.activePartLists[fragment.type];
if (activeParts) {
const snToRemove = fragment.sn;
this.activePartLists[fragment.type] = filterParts(
activeParts,
(part) => part.fragment.sn !== snToRemove,
);
}
delete this.fragments[fragKey];
if (fragment.endList) {
delete this.endListFragments[fragment.type];
}
}
public removeAllFragments() {
this.fragments = Object.create(null);
this.endListFragments = Object.create(null);
this.activePartLists = Object.create(null);
this.hasGaps = false;
const partlist = this.hls?.latestLevelDetails?.partList;
if (partlist) {
partlist.forEach((part) => part.clearElementaryStreamInfo());
}
}
}
function isPartial(fragmentEntity: FragmentEntity): boolean {
return (
fragmentEntity.buffered &&
!!(
fragmentEntity.body.gap ||
fragmentEntity.range.video?.partial ||
fragmentEntity.range.audio?.partial ||
fragmentEntity.range.audiovideo?.partial
)
);
}
function getFragmentKey(fragment: Fragment): string {
return `${fragment.type}_${fragment.level}_${fragment.sn}`;
}
function filterParts(partList: Part[], predicate: (part: Part) => boolean) {
return partList.filter((part) => {
const keep = predicate(part);
if (!keep) {
part.clearElementaryStreamInfo();
}
return keep;
});
}
+715
View File
@@ -0,0 +1,715 @@
import { State } from './base-stream-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import TaskLoop from '../task-loop';
import { PlaylistLevelType } from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
import {
addEventListener,
removeEventListener,
} from '../utils/event-listener-helper';
import { stringify } from '../utils/safe-json-stringify';
import type { InFlightData } from './base-stream-controller';
import type { InFlightFragments } from '../hls';
import type Hls from '../hls';
import type { FragmentTracker } from './fragment-tracker';
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type { SourceBufferName } from '../types/buffer';
import type {
BufferAppendedData,
MediaAttachedData,
MediaDetachingData,
} from '../types/events';
import type { ErrorData } from '../types/events';
import type { BufferInfo } from '../utils/buffer-helper';
export const MAX_START_GAP_JUMP = 2.0;
export const SKIP_BUFFER_HOLE_STEP_SECONDS = 0.1;
export const SKIP_BUFFER_RANGE_START = 0.05;
const TICK_INTERVAL = 100;
export default class GapController extends TaskLoop {
private hls: Hls | null;
private fragmentTracker: FragmentTracker | null;
private media: HTMLMediaElement | null = null;
private mediaSource?: MediaSource;
private nudgeRetry: number = 0;
private stallReported: boolean = false;
private stalled: number | null = null;
private moved: boolean = false;
private seeking: boolean = false;
private buffered: Partial<Record<SourceBufferName, TimeRanges>> = {};
private lastCurrentTime: number = 0;
public ended: number = 0;
public waiting: number = 0;
constructor(hls: Hls, fragmentTracker: FragmentTracker) {
super('gap-controller', hls.logger);
this.hls = hls;
this.fragmentTracker = fragmentTracker;
this.registerListeners();
}
private registerListeners() {
const { hls } = this;
if (hls) {
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this);
}
}
private unregisterListeners() {
const { hls } = this;
if (hls) {
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this);
}
}
public destroy() {
super.destroy();
this.unregisterListeners();
this.media = this.hls = this.fragmentTracker = null;
this.mediaSource = undefined;
}
private onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData,
) {
this.setInterval(TICK_INTERVAL);
this.mediaSource = data.mediaSource;
const media = (this.media = data.media);
addEventListener(media, 'playing', this.onMediaPlaying);
addEventListener(media, 'waiting', this.onMediaWaiting);
addEventListener(media, 'ended', this.onMediaEnded);
}
private onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
this.clearInterval();
const { media } = this;
if (media) {
removeEventListener(media, 'playing', this.onMediaPlaying);
removeEventListener(media, 'waiting', this.onMediaWaiting);
removeEventListener(media, 'ended', this.onMediaEnded);
this.media = null;
}
this.mediaSource = undefined;
}
private onBufferAppended(
event: Events.BUFFER_APPENDED,
data: BufferAppendedData,
) {
this.buffered = data.timeRanges;
}
private onMediaPlaying = () => {
this.ended = 0;
this.waiting = 0;
};
private onMediaWaiting = () => {
if (this.media?.seeking) {
return;
}
this.waiting = self.performance.now();
this.tick();
};
private onMediaEnded = () => {
if (this.hls) {
// ended is set when triggering MEDIA_ENDED so that we do not trigger it again on stall or on tick with media.ended
this.ended = this.media?.currentTime || 1;
this.hls.trigger(Events.MEDIA_ENDED, {
stalled: false,
});
}
};
public get hasBuffered(): boolean {
return Object.keys(this.buffered).length > 0;
}
public tick() {
if (!this.media?.readyState || !this.hasBuffered) {
return;
}
const currentTime = this.media.currentTime;
this.poll(currentTime, this.lastCurrentTime);
this.lastCurrentTime = currentTime;
}
/**
* Checks if the playhead is stuck within a gap, and if so, attempts to free it.
* A gap is an unbuffered range between two buffered ranges (or the start and the first buffered range).
*
* @param lastCurrentTime - Previously read playhead position
*/
public poll(currentTime: number, lastCurrentTime: number) {
const config = this.hls?.config;
if (!config) {
return;
}
const media = this.media;
if (!media) {
return;
}
const { seeking } = media;
const seeked = this.seeking && !seeking;
const beginSeek = !this.seeking && seeking;
const pausedEndedOrHalted =
(media.paused && !seeking) || media.ended || media.playbackRate === 0;
this.seeking = seeking;
// The playhead is moving, no-op
if (currentTime !== lastCurrentTime) {
if (lastCurrentTime) {
this.ended = 0;
}
this.moved = true;
if (!seeking) {
this.nudgeRetry = 0;
// When crossing between buffered video time ranges, but not audio, flush pipeline with seek (Chrome)
if (
config.nudgeOnVideoHole &&
!pausedEndedOrHalted &&
currentTime > lastCurrentTime
) {
this.nudgeOnVideoHole(currentTime, lastCurrentTime);
}
}
if (this.waiting === 0) {
this.stallResolved(currentTime);
}
return;
}
// Clear stalled state when beginning or finishing seeking so that we don't report stalls coming out of a seek
if (beginSeek || seeked) {
if (seeked) {
this.stallResolved(currentTime);
}
return;
}
// The playhead should not be moving
if (pausedEndedOrHalted) {
this.nudgeRetry = 0;
this.stallResolved(currentTime);
// Fire MEDIA_ENDED to workaround event not being dispatched by browser
if (!this.ended && media.ended && this.hls) {
this.ended = currentTime || 1;
this.hls.trigger(Events.MEDIA_ENDED, {
stalled: false,
});
}
return;
}
if (!BufferHelper.getBuffered(media).length) {
this.nudgeRetry = 0;
return;
}
// Resolve stalls at buffer holes using the main buffer, whose ranges are the intersections of the A/V sourcebuffers
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
const nextStart = bufferInfo.nextStart || 0;
const fragmentTracker = this.fragmentTracker;
if (seeking && fragmentTracker && this.hls) {
// Is there a fragment loading/parsing/appending before currentTime?
const inFlightDependency = getInFlightDependency(
this.hls.inFlightFragments,
currentTime,
);
// Waiting for seeking in a buffered range to complete
const hasEnoughBuffer = bufferInfo.len > MAX_START_GAP_JUMP;
// Next buffered range is too far ahead to jump to while still seeking
const noBufferHole =
!nextStart ||
inFlightDependency ||
(nextStart - currentTime > MAX_START_GAP_JUMP &&
!fragmentTracker.getPartialFragment(currentTime));
if (hasEnoughBuffer || noBufferHole) {
return;
}
// Reset moved state when seeking to a point in or before a gap/hole
this.moved = false;
}
// Skip start gaps if we haven't played, but the last poll detected the start of a stall
// The addition poll gives the browser a chance to jump the gap for us
const levelDetails = this.hls?.latestLevelDetails;
if (!this.moved && this.stalled !== null && fragmentTracker) {
// There is no playable buffer (seeked, waiting for buffer)
const isBuffered = bufferInfo.len > 0;
if (!isBuffered && !nextStart) {
return;
}
// Jump start gaps within jump threshold
const startJump =
Math.max(nextStart, bufferInfo.start || 0) - currentTime;
// When joining a live stream with audio tracks, account for live playlist window sliding by allowing
// a larger jump over start gaps caused by the audio-stream-controller buffering a start fragment
// that begins over 1 target duration after the video start position.
const isLive = !!levelDetails?.live;
const maxStartGapJump = isLive
? levelDetails!.targetduration * 2
: MAX_START_GAP_JUMP;
const appended = appendedFragAtPosition(currentTime, fragmentTracker);
if (startJump > 0 && (startJump <= maxStartGapJump || appended)) {
if (!media.paused) {
this._trySkipBufferHole(appended);
}
return;
}
}
// Start tracking stall time
const detectStallWithCurrentTimeMs = config.detectStallWithCurrentTimeMs;
const tnow = self.performance.now();
const tWaiting = this.waiting;
let stalled = this.stalled;
if (stalled === null) {
// Use time of recent "waiting" event
if (tWaiting > 0 && tnow - tWaiting < detectStallWithCurrentTimeMs) {
stalled = this.stalled = tWaiting;
} else {
this.stalled = tnow;
return;
}
}
const stalledDuration = tnow - stalled;
if (
!seeking &&
(stalledDuration >= detectStallWithCurrentTimeMs || tWaiting) &&
this.hls
) {
// Dispatch MEDIA_ENDED when media.ended/ended event is not signalled at end of stream
if (
this.mediaSource?.readyState === 'ended' &&
!levelDetails?.live &&
Math.abs(currentTime - (levelDetails?.edge || 0)) < 1
) {
if (this.ended) {
return;
}
this.ended = currentTime || 1;
this.hls.trigger(Events.MEDIA_ENDED, {
stalled: true,
});
return;
}
// Report stalling after trying to fix
this._reportStall(bufferInfo);
if (!this.media || (!this.hls as any)) {
return;
}
}
const bufferedWithHoles = BufferHelper.bufferInfo(
media,
currentTime,
config.maxBufferHole,
);
this._tryFixBufferStall(bufferedWithHoles, stalledDuration, currentTime);
}
private stallResolved(currentTime: number) {
const stalled = this.stalled;
if (stalled && this.hls) {
this.stalled = null;
// The playhead is now moving, but was previously stalled
if (this.stallReported) {
const stalledDuration = self.performance.now() - stalled;
this.log(
`playback not stuck anymore @${currentTime}, after ${Math.round(
stalledDuration,
)}ms`,
);
this.stallReported = false;
this.waiting = 0;
this.hls.trigger(Events.STALL_RESOLVED, {});
}
}
}
private nudgeOnVideoHole(currentTime: number, lastCurrentTime: number) {
// Chrome will play one second past a hole in video buffered time ranges without rendering any video from the subsequent range and then stall as long as audio is buffered:
// https://github.com/video-dev/hls.js/issues/5631
// https://issues.chromium.org/issues/40280613#comment10
// Detect the potential for this situation and proactively seek to flush the video pipeline once the playhead passes the start of the video hole.
// When there are audio and video buffers and currentTime is past the end of the first video buffered range...
const videoSourceBuffered = this.buffered.video;
if (
this.hls &&
this.media &&
this.fragmentTracker &&
this.buffered.audio?.length &&
videoSourceBuffered &&
videoSourceBuffered.length > 1 &&
currentTime > videoSourceBuffered.end(0)
) {
// and audio is buffered at the playhead
const audioBufferInfo = BufferHelper.bufferedInfo(
BufferHelper.timeRangesToArray(this.buffered.audio),
currentTime,
0,
);
if (audioBufferInfo.len > 1 && lastCurrentTime >= audioBufferInfo.start) {
const videoTimes = BufferHelper.timeRangesToArray(videoSourceBuffered);
const lastBufferedIndex = BufferHelper.bufferedInfo(
videoTimes,
lastCurrentTime,
0,
).bufferedIndex;
// nudge when crossing into another video buffered range (hole).
if (
lastBufferedIndex > -1 &&
lastBufferedIndex < videoTimes.length - 1
) {
const bufferedIndex = BufferHelper.bufferedInfo(
videoTimes,
currentTime,
0,
).bufferedIndex;
const holeStart = videoTimes[lastBufferedIndex].end;
const holeEnd = videoTimes[lastBufferedIndex + 1].start;
if (
(bufferedIndex === -1 || bufferedIndex > lastBufferedIndex) &&
holeEnd - holeStart < 1 && // `maxBufferHole` may be too small and setting it to 0 should not disable this feature
currentTime - holeStart < 2
) {
const error = new Error(
`nudging playhead to flush pipeline after video hole. currentTime: ${currentTime} hole: ${holeStart} -> ${holeEnd} buffered index: ${bufferedIndex}`,
);
this.warn(error.message);
// Magic number to flush the pipeline without interuption to audio playback:
this.media.currentTime += 0.000001;
let frag: MediaFragment | Part | null | undefined =
appendedFragAtPosition(currentTime, this.fragmentTracker);
if (frag && 'fragment' in frag) {
frag = frag.fragment;
} else if (!frag) {
frag = undefined;
}
const bufferInfo = BufferHelper.bufferInfo(
this.media,
currentTime,
0,
);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
error,
reason: error.message,
frag,
buffer: bufferInfo.len,
bufferInfo,
});
}
}
}
}
}
/**
* Detects and attempts to fix known buffer stalling issues.
* @param bufferInfo - The properties of the current buffer.
* @param stalledDurationMs - The amount of time Hls.js has been stalling for.
* @private
*/
private _tryFixBufferStall(
bufferInfo: BufferInfo,
stalledDurationMs: number,
currentTime: number,
) {
const { fragmentTracker, media } = this;
const config = this.hls?.config;
if (!media || !fragmentTracker || !config) {
return;
}
const levelDetails = this.hls?.latestLevelDetails;
const appended = appendedFragAtPosition(currentTime, fragmentTracker);
if (
appended ||
(levelDetails?.live && currentTime < levelDetails.fragmentStart)
) {
// Try to skip over the buffer hole caused by a partial fragment
// This method isn't limited by the size of the gap between buffered ranges
const targetTime = this._trySkipBufferHole(appended);
// we return here in this case, meaning
// the branch below only executes when we haven't seeked to a new position
if (targetTime || !this.media) {
return;
}
}
// if we haven't had to skip over a buffer hole of a partial fragment
// we may just have to "nudge" the playlist as the browser decoding/rendering engine
// needs to cross some sort of threshold covering all source-buffers content
// to start playing properly.
const bufferedRanges = bufferInfo.buffered;
const adjacentTraversal = this.adjacentTraversal(bufferInfo, currentTime);
if (
((bufferedRanges &&
bufferedRanges.length > 1 &&
bufferInfo.len > config.maxBufferHole) ||
(bufferInfo.nextStart &&
(bufferInfo.nextStart - currentTime < config.maxBufferHole ||
adjacentTraversal))) &&
(stalledDurationMs > config.highBufferWatchdogPeriod * 1000 ||
this.waiting)
) {
this.warn('Trying to nudge playhead over buffer-hole');
// Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds
// We only try to jump the hole if it's under the configured size
this._tryNudgeBuffer(bufferInfo);
}
}
private adjacentTraversal(bufferInfo: BufferInfo, currentTime: number) {
const fragmentTracker = this.fragmentTracker;
const nextStart = bufferInfo.nextStart;
if (fragmentTracker && nextStart) {
const current = fragmentTracker.getFragAtPos(
currentTime,
PlaylistLevelType.MAIN,
);
const next = fragmentTracker.getFragAtPos(
nextStart,
PlaylistLevelType.MAIN,
);
if (current && next) {
return next.sn - current.sn < 2;
}
}
return false;
}
/**
* Triggers a BUFFER_STALLED_ERROR event, but only once per stall period.
* @param bufferLen - The playhead distance from the end of the current buffer segment.
* @private
*/
private _reportStall(bufferInfo: BufferInfo) {
const { hls, media, stallReported, stalled } = this;
if (!stallReported && stalled !== null && media && hls) {
// Report stalled error once
this.stallReported = true;
const error = new Error(
`Playback stalling at @${
media.currentTime
} due to low buffer (${stringify(bufferInfo)})`,
);
this.warn(error.message);
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_STALLED_ERROR,
fatal: false,
error,
buffer: bufferInfo.len,
bufferInfo,
stalled: { start: stalled },
});
}
}
/**
* Attempts to fix buffer stalls by jumping over known gaps caused by partial fragments
* @param appended - The fragment or part found at the current time (where playback is stalling).
* @private
*/
private _trySkipBufferHole(appended: MediaFragment | Part | null): number {
const { fragmentTracker, media } = this;
const config = this.hls?.config;
if (!media || !fragmentTracker || !config) {
return 0;
}
// Check if currentTime is between unbuffered regions of partial fragments
const currentTime = media.currentTime;
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
const startTime =
currentTime < bufferInfo.start ? bufferInfo.start : bufferInfo.nextStart;
if (startTime && this.hls) {
const bufferStarved = bufferInfo.len <= config.maxBufferHole;
const waiting =
bufferInfo.len > 0 && bufferInfo.len < 1 && media.readyState < 3;
const gapLength = startTime - currentTime;
if (gapLength > 0 && (bufferStarved || waiting)) {
// Only allow large gaps to be skipped if it is a start gap, or all fragments in skip range are partial
if (gapLength > config.maxBufferHole) {
let startGap = false;
if (currentTime === 0) {
const startFrag = fragmentTracker.getAppendedFrag(
0,
PlaylistLevelType.MAIN,
);
if (startFrag && startTime < startFrag.end) {
startGap = true;
}
}
if (!startGap && appended) {
// Do not seek when selected variant playlist is unloaded
if (!this.hls.loadLevelObj?.details) {
return 0;
}
// Do not seek when required fragments are inflight or appending
const inFlightDependency = getInFlightDependency(
this.hls.inFlightFragments,
startTime,
);
if (inFlightDependency) {
return 0;
}
// Do not seek if we can't walk tracked fragments to end of gap
let moreToLoad = false;
let pos = appended.end;
while (pos < startTime) {
const provisioned = appendedFragAtPosition(pos, fragmentTracker);
if (provisioned) {
pos += provisioned.duration;
} else {
moreToLoad = true;
break;
}
}
if (moreToLoad) {
return 0;
}
}
}
const targetTime = Math.max(
startTime + SKIP_BUFFER_RANGE_START,
currentTime + SKIP_BUFFER_HOLE_STEP_SECONDS,
);
this.warn(
`skipping hole, adjusting currentTime from ${currentTime} to ${targetTime}`,
);
this.moved = true;
media.currentTime = targetTime;
if (!appended?.gap) {
const error = new Error(
`fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`,
);
const errorData: ErrorData = {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
error,
reason: error.message,
buffer: bufferInfo.len,
bufferInfo,
};
if (appended) {
if ('fragment' in appended) {
errorData.part = appended;
} else {
errorData.frag = appended;
}
}
this.hls.trigger(Events.ERROR, errorData);
}
return targetTime;
}
}
return 0;
}
/**
* Attempts to fix buffer stalls by advancing the mediaElement's current time by a small amount.
* @private
*/
private _tryNudgeBuffer(bufferInfo: BufferInfo) {
const { hls, media, nudgeRetry } = this;
const config = hls?.config;
if (!media || !config) {
return 0;
}
const currentTime = media.currentTime;
this.nudgeRetry++;
if (nudgeRetry < config.nudgeMaxRetry) {
const targetTime = currentTime + (nudgeRetry + 1) * config.nudgeOffset;
// playback stalled in buffered area ... let's nudge currentTime to try to overcome this
const error = new Error(
`Nudging 'currentTime' from ${currentTime} to ${targetTime}`,
);
this.warn(error.message);
media.currentTime = targetTime;
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
error,
fatal: false,
buffer: bufferInfo.len,
bufferInfo,
});
} else {
const error = new Error(
`Playhead still not moving while enough data buffered @${currentTime} after ${config.nudgeMaxRetry} nudges`,
);
this.error(error.message);
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_STALLED_ERROR,
error,
fatal: true,
buffer: bufferInfo.len,
bufferInfo,
});
}
}
}
function getInFlightDependency(
inFlightFragments: InFlightFragments,
currentTime: number,
): Fragment | null {
const main = inFlight(inFlightFragments.main);
if (main && main.start <= currentTime) {
return main;
}
const audio = inFlight(inFlightFragments.audio);
if (audio && audio.start <= currentTime) {
return audio;
}
return null;
}
function inFlight(inFlightData: InFlightData | undefined): Fragment | null {
if (!inFlightData) {
return null;
}
switch (inFlightData.state) {
case State.IDLE:
case State.STOPPED:
case State.ENDED:
case State.ERROR:
return null;
}
return inFlightData.frag;
}
function appendedFragAtPosition(pos: number, fragmentTracker: FragmentTracker) {
return (
fragmentTracker.getAppendedFrag(pos, PlaylistLevelType.MAIN) ||
fragmentTracker.getPartialFragment(pos)
);
}
+516
View File
@@ -0,0 +1,516 @@
import { getId3Frames } from '@svta/common-media-library/id3/getId3Frames';
import { isId3TimestampFrame } from '@svta/common-media-library/id3/isId3TimestampFrame';
import { Events } from '../events';
import {
isDateRangeCueAttribute,
isSCTE35Attribute,
} from '../loader/date-range';
import { MetadataSchema } from '../types/demuxer';
import { hexToArrayBuffer } from '../utils/hex';
import { stringify } from '../utils/safe-json-stringify';
import {
clearCurrentCues,
removeCuesInRange,
sendAddTrackEvent,
} from '../utils/texttrack-utils';
import type { MediaFragment } from '../hls';
import type Hls from '../hls';
import type { DateRange } from '../loader/date-range';
import type { LevelDetails } from '../loader/level-details';
import type { ComponentAPI } from '../types/component-api';
import type {
BufferFlushingData,
FragParsingMetadataData,
LevelPTSUpdatedData,
LevelUpdatedData,
MediaAttachingData,
MediaDetachingData,
} from '../types/events';
declare global {
interface Window {
WebKitDataCue: VTTCue | void;
}
}
const MIN_CUE_DURATION = 0.25;
function getCueClass(): typeof VTTCue | typeof TextTrackCue | undefined {
if (typeof self === 'undefined') return undefined;
return (self.VTTCue as typeof VTTCue | undefined) || self.TextTrackCue;
}
function createCueWithDataFields(
Cue: typeof VTTCue | typeof TextTrackCue,
startTime: number,
endTime: number,
data: Object,
type?: string,
): VTTCue | TextTrackCue | undefined {
let cue = new Cue(startTime, endTime, '');
try {
(cue as any).value = data;
if (type) {
(cue as any).type = type;
}
} catch (e) {
cue = new Cue(
startTime,
endTime,
stringify(type ? { type, ...data } : data),
);
}
return cue;
}
// VTTCue latest draft allows an infinite duration, fallback
// to MAX_VALUE if necessary
const MAX_CUE_ENDTIME = (() => {
const Cue = getCueClass();
try {
Cue && new Cue(0, Number.POSITIVE_INFINITY, '');
} catch (e) {
return Number.MAX_VALUE;
}
return Number.POSITIVE_INFINITY;
})();
class ID3TrackController implements ComponentAPI {
private hls: Hls | null;
private id3Track: TextTrack | null = null;
private media: HTMLMediaElement | null = null;
private dateRangeCuesAppended: Record<
string,
| {
cues: Record<string, VTTCue | TextTrackCue | undefined>;
dateRange: DateRange;
durationKnown: boolean;
}
| undefined
> = {};
private removeCues: boolean = true;
private assetCue?: VTTCue | TextTrackCue;
constructor(hls) {
this.hls = hls;
this._registerListeners();
}
public destroy() {
this._unregisterListeners();
this.id3Track = null;
this.media = null;
this.dateRangeCuesAppended = {};
// @ts-ignore
this.hls = this.onEventCueEnter = null;
}
private _registerListeners() {
const { hls } = this;
if (hls) {
hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this);
hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
hls.on(Events.LEVEL_PTS_UPDATED, this.onLevelPtsUpdated, this);
}
}
private _unregisterListeners() {
const { hls } = this;
if (hls) {
hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this);
hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
hls.off(Events.LEVEL_PTS_UPDATED, this.onLevelPtsUpdated, this);
}
}
private onEventCueEnter = () => {
if (!this.hls) {
return;
}
this.hls.trigger(Events.EVENT_CUE_ENTER, {});
};
// Add ID3 metatadata text track.
private onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
): void {
this.media = data.media;
if (data.overrides?.cueRemoval === false) {
this.removeCues = false;
}
}
private onMediaAttached() {
const details = this.hls?.latestLevelDetails;
if (details) {
this.updateDateRangeCues(details);
}
}
private onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
this.media = null;
const transferringMedia = !!data.transferMedia;
if (transferringMedia) {
return;
}
if (this.id3Track) {
if (this.removeCues) {
clearCurrentCues(this.id3Track, this.onEventCueEnter);
}
this.id3Track = null;
}
this.dateRangeCuesAppended = {};
}
private onManifestLoading() {
this.dateRangeCuesAppended = {};
}
private createTrack(media: HTMLMediaElement): TextTrack {
const track = this.getID3Track(media.textTracks) as TextTrack;
track.mode = 'hidden';
return track;
}
private getID3Track(textTracks: TextTrackList): TextTrack | void {
if (!this.media) {
return;
}
for (let i = 0; i < textTracks.length; i++) {
const textTrack: TextTrack = textTracks[i];
if (textTrack.kind === 'metadata' && textTrack.label === 'id3') {
// send 'addtrack' when reusing the textTrack for metadata,
// same as what we do for captions
sendAddTrackEvent(textTrack, this.media);
return textTrack;
}
}
return this.media.addTextTrack('metadata', 'id3');
}
private onFragParsingMetadata(
event: Events.FRAG_PARSING_METADATA,
data: FragParsingMetadataData,
) {
if (!this.media || !this.hls) {
return;
}
const { enableEmsgMetadataCues, enableID3MetadataCues } = this.hls.config;
if (!enableEmsgMetadataCues && !enableID3MetadataCues) {
return;
}
const { samples } = data;
// create track dynamically
if (!this.id3Track) {
this.id3Track = this.createTrack(this.media);
}
const Cue = getCueClass();
if (!Cue) {
return;
}
for (let i = 0; i < samples.length; i++) {
const type = samples[i].type;
if (
(type === MetadataSchema.emsg && !enableEmsgMetadataCues) ||
!enableID3MetadataCues
) {
continue;
}
const frames = getId3Frames(samples[i].data);
const startTime = samples[i].pts;
let endTime: number = startTime + samples[i].duration;
if (endTime > MAX_CUE_ENDTIME) {
endTime = MAX_CUE_ENDTIME;
}
const timeDiff = endTime - startTime;
if (timeDiff <= 0) {
endTime = startTime + MIN_CUE_DURATION;
}
for (let j = 0; j < frames.length; j++) {
const frame = frames[j];
// Safari doesn't put the timestamp frame in the TextTrack
if (!isId3TimestampFrame(frame)) {
// add a bounds to any unbounded cues
this.updateId3CueEnds(startTime, type);
const cue = createCueWithDataFields(
Cue,
startTime,
endTime,
frame,
type,
);
if (cue) {
this.id3Track.addCue(cue);
}
}
}
}
}
private updateId3CueEnds(startTime: number, type: MetadataSchema) {
const cues = this.id3Track?.cues;
if (cues) {
for (let i = cues.length; i--; ) {
const cue = cues[i] as any;
if (
cue.type === type &&
cue.startTime < startTime &&
cue.endTime === MAX_CUE_ENDTIME
) {
cue.endTime = startTime;
}
}
}
}
private onBufferFlushing(
event: Events.BUFFER_FLUSHING,
{ startOffset, endOffset, type }: BufferFlushingData,
) {
const { id3Track, hls } = this;
if (!hls) {
return;
}
const {
config: { enableEmsgMetadataCues, enableID3MetadataCues },
} = hls;
if (id3Track && (enableEmsgMetadataCues || enableID3MetadataCues)) {
let predicate;
if (type === 'audio') {
predicate = (cue) =>
(cue as any).type === MetadataSchema.audioId3 &&
enableID3MetadataCues;
} else if (type === 'video') {
predicate = (cue) =>
(cue as any).type === MetadataSchema.emsg && enableEmsgMetadataCues;
} else {
predicate = (cue) =>
((cue as any).type === MetadataSchema.audioId3 &&
enableID3MetadataCues) ||
((cue as any).type === MetadataSchema.emsg && enableEmsgMetadataCues);
}
removeCuesInRange(id3Track, startOffset, endOffset, predicate);
}
}
private onLevelUpdated(
event: Events.LEVEL_UPDATED,
{ details }: LevelUpdatedData,
) {
this.updateDateRangeCues(details, true);
}
private onLevelPtsUpdated(
event: Events.LEVEL_PTS_UPDATED,
data: LevelPTSUpdatedData,
) {
if (Math.abs(data.drift) > 0.01) {
this.updateDateRangeCues(data.details);
}
}
private updateDateRangeCues(details: LevelDetails, removeOldCues?: true) {
if (!this.hls || !this.media) {
return;
}
const {
assetPlayerId,
timelineOffset,
enableDateRangeMetadataCues,
interstitialsController,
} = this.hls.config;
if (!enableDateRangeMetadataCues) {
return;
}
const Cue = getCueClass();
if (
__USE_INTERSTITIALS__ &&
assetPlayerId &&
timelineOffset &&
!interstitialsController
) {
const { fragmentStart, fragmentEnd } = details;
let cue = this.assetCue;
if (cue) {
cue.startTime = fragmentStart;
cue.endTime = fragmentEnd;
} else if (Cue) {
cue = this.assetCue = createCueWithDataFields(
Cue,
fragmentStart,
fragmentEnd,
{ assetPlayerId: this.hls.config.assetPlayerId },
'hlsjs.interstitial.asset',
);
if (cue) {
cue.id = assetPlayerId;
this.id3Track ||= this.createTrack(this.media);
this.id3Track.addCue(cue);
cue.addEventListener('enter', this.onEventCueEnter);
}
}
}
if (!details.hasProgramDateTime) {
return;
}
const { id3Track } = this;
const { dateRanges } = details;
const ids = Object.keys(dateRanges);
let dateRangeCuesAppended = this.dateRangeCuesAppended;
// Remove cues from track not found in details.dateRanges
if (id3Track && removeOldCues) {
if (id3Track.cues?.length) {
const idsToRemove = Object.keys(dateRangeCuesAppended).filter(
(id) => !ids.includes(id),
);
for (let i = idsToRemove.length; i--; ) {
const id = idsToRemove[i];
const cues = dateRangeCuesAppended[id]?.cues;
delete dateRangeCuesAppended[id];
if (cues) {
Object.keys(cues).forEach((key) => {
const cue = cues[key];
if (cue) {
cue.removeEventListener('enter', this.onEventCueEnter);
try {
id3Track.removeCue(cue);
} catch (e) {
/* no-op */
}
}
});
}
}
} else {
dateRangeCuesAppended = this.dateRangeCuesAppended = {};
}
}
// Exit if the playlist does not have Date Ranges or does not have Program Date Time
const lastFragment = details.fragments[details.fragments.length - 1] as
| MediaFragment
| undefined;
if (ids.length === 0 || !Number.isFinite(lastFragment?.programDateTime)) {
return;
}
this.id3Track ||= this.createTrack(this.media);
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const dateRange = dateRanges[id]!;
const startTime = dateRange.startTime;
// Process DateRanges to determine end-time (known DURATION, END-DATE, or END-ON-NEXT)
const appendedDateRangeCues = dateRangeCuesAppended[id];
const cues = appendedDateRangeCues?.cues || {};
let durationKnown = appendedDateRangeCues?.durationKnown || false;
let endTime = MAX_CUE_ENDTIME;
const { duration, endDate } = dateRange;
if (endDate && duration !== null) {
endTime = startTime + duration;
durationKnown = true;
} else if (dateRange.endOnNext && !durationKnown) {
const nextDateRangeWithSameClass = ids.reduce(
(candidateDateRange: DateRange | null, id) => {
if (id !== dateRange.id) {
const otherDateRange = dateRanges[id]!;
if (
otherDateRange.class === dateRange.class &&
otherDateRange.startDate > dateRange.startDate &&
(!candidateDateRange ||
dateRange.startDate < candidateDateRange.startDate)
) {
return otherDateRange;
}
}
return candidateDateRange;
},
null,
);
if (nextDateRangeWithSameClass) {
endTime = nextDateRangeWithSameClass.startTime;
durationKnown = true;
}
}
// Create TextTrack Cues for each MetadataGroup Item (select DateRange attribute)
// This is to emulate Safari HLS playback handling of DateRange tags
const attributes = Object.keys(dateRange.attr);
for (let j = 0; j < attributes.length; j++) {
const key = attributes[j];
if (!isDateRangeCueAttribute(key)) {
continue;
}
const cue = cues[key];
if (cue) {
if (durationKnown && !appendedDateRangeCues?.durationKnown) {
cue.endTime = endTime;
} else if (Math.abs(cue.startTime - startTime) > 0.01) {
cue.startTime = startTime;
cue.endTime = endTime;
}
} else if (Cue) {
let data = dateRange.attr[key];
if (isSCTE35Attribute(key)) {
data = hexToArrayBuffer(data);
}
const payload: any = { key, data };
const cue = createCueWithDataFields(
Cue,
startTime,
endTime,
payload,
MetadataSchema.dateRange,
);
if (cue) {
cue.id = id;
this.id3Track.addCue(cue);
cues[key] = cue;
if (__USE_INTERSTITIALS__ && interstitialsController) {
if (key === 'X-ASSET-LIST' || key === 'X-ASSET-URL') {
cue.addEventListener('enter', this.onEventCueEnter);
}
}
}
}
}
// Keep track of processed DateRanges by ID for updating cues with new DateRange tag attributes
dateRangeCuesAppended[id] = {
cues,
dateRange,
durationKnown,
};
}
}
}
export default ID3TrackController;
+302
View File
@@ -0,0 +1,302 @@
import { Events, type HlsListeners } from '../events';
import {
eventAssetToString,
getInterstitialUrl,
type InterstitialAssetId,
type InterstitialAssetItem,
type InterstitialEvent,
type InterstitialId,
} from '../loader/interstitial-event';
import { BufferHelper } from '../utils/buffer-helper';
import type { HlsConfig } from '../config';
import type { InterstitialScheduleEventItem } from '../controller/interstitials-schedule';
import type Hls from '../hls';
import type { BufferCodecsData, MediaAttachingData } from '../types/events';
export interface InterstitialPlayer {
bufferedEnd: number;
currentTime: number;
duration: number;
assetPlayers: (HlsAssetPlayer | null)[];
playingIndex: number;
scheduleItem: InterstitialScheduleEventItem | null;
}
export type HlsAssetPlayerConfig = Partial<HlsConfig> &
Required<Pick<HlsConfig, 'assetPlayerId' | 'primarySessionId'>>;
export class HlsAssetPlayer {
public hls: Hls | null;
public interstitial: InterstitialEvent;
public readonly assetItem: InterstitialAssetItem;
public tracks: Partial<BufferCodecsData> | null = null;
private hasDetails: boolean = false;
private mediaAttached: HTMLMediaElement | null = null;
private _currentTime?: number;
private _bufferedEosTime?: number;
constructor(
HlsPlayerClass: typeof Hls,
userConfig: HlsAssetPlayerConfig,
interstitial: InterstitialEvent,
assetItem: InterstitialAssetItem,
) {
const hls = (this.hls = new HlsPlayerClass(userConfig));
this.interstitial = interstitial;
this.assetItem = assetItem;
const detailsLoaded = () => {
this.hasDetails = true;
};
hls.once(Events.LEVEL_LOADED, detailsLoaded);
hls.once(Events.AUDIO_TRACK_LOADED, detailsLoaded);
hls.once(Events.SUBTITLE_TRACK_LOADED, detailsLoaded);
hls.on(Events.MEDIA_ATTACHING, (name, { media }) => {
this.removeMediaListeners();
this.mediaAttached = media;
const event = this.interstitial;
if (event.playoutLimit) {
media.addEventListener('timeupdate', this.checkPlayout);
if (this.appendInPlace) {
hls.on(Events.BUFFER_APPENDED, () => {
const bufferedEnd = this.bufferedEnd;
if (this.reachedPlayout(bufferedEnd)) {
this._bufferedEosTime = bufferedEnd;
hls.trigger(Events.BUFFERED_TO_END, undefined);
}
});
}
}
});
}
get appendInPlace(): boolean {
return this.interstitial.appendInPlace;
}
loadSource() {
const hls = this.hls;
if (!hls) {
return;
}
if (!hls.url) {
let uri: string = this.assetItem.uri;
try {
uri = getInterstitialUrl(uri, hls.config.primarySessionId || '').href;
} catch (error) {
// Ignore error parsing ASSET_URI or adding _HLS_primary_id to it. The
// issue should surface as an INTERSTITIAL_ASSET_ERROR loading the asset.
}
hls.loadSource(uri);
} else if (hls.levels.length && !(hls as any).started) {
hls.startLoad(-1, true);
}
}
bufferedInPlaceToEnd(media?: HTMLMediaElement | null) {
if (!this.appendInPlace) {
return false;
}
if (this.hls?.bufferedToEnd) {
return true;
}
if (!media) {
return false;
}
const duration = Math.min(this._bufferedEosTime || Infinity, this.duration);
const start = this.timelineOffset;
const bufferInfo = BufferHelper.bufferInfo(media, start, 0);
const bufferedEnd = this.getAssetTime(bufferInfo.end);
return bufferedEnd >= duration - 0.02;
}
private checkPlayout = () => {
if (this.reachedPlayout(this.currentTime) && this.hls) {
this.hls.trigger(Events.PLAYOUT_LIMIT_REACHED, {});
}
};
private reachedPlayout(time: number): boolean {
const interstitial = this.interstitial;
const playoutLimit = interstitial.playoutLimit;
return this.startOffset + time >= playoutLimit;
}
get destroyed(): boolean {
return !this.hls?.userConfig;
}
get assetId(): InterstitialAssetId {
return this.assetItem.identifier;
}
get interstitialId(): InterstitialId {
return this.assetItem.parentIdentifier;
}
get media(): HTMLMediaElement | null {
return this.hls?.media || null;
}
get bufferedEnd(): number {
const media = this.media || this.mediaAttached;
if (!media) {
if (this._bufferedEosTime) {
return this._bufferedEosTime;
}
return this.currentTime;
}
const bufferInfo = BufferHelper.bufferInfo(media, media.currentTime, 0.001);
return this.getAssetTime(bufferInfo.end);
}
get currentTime(): number {
const media = this.media || this.mediaAttached;
if (!media) {
return this._currentTime || 0;
}
return this.getAssetTime(media.currentTime);
}
get duration(): number {
const duration = this.assetItem.duration;
if (!duration) {
return 0;
}
const playoutLimit = this.interstitial.playoutLimit;
if (playoutLimit) {
const assetPlayout = playoutLimit - this.startOffset;
if (assetPlayout > 0 && assetPlayout < duration) {
return assetPlayout;
}
}
return duration;
}
get remaining(): number {
const duration = this.duration;
if (!duration) {
return 0;
}
return Math.max(0, duration - this.currentTime);
}
get startOffset(): number {
return this.assetItem.startOffset;
}
get timelineOffset(): number {
return this.hls?.config.timelineOffset || 0;
}
set timelineOffset(value: number) {
const timelineOffset = this.timelineOffset;
if (value !== timelineOffset) {
const diff = value - timelineOffset;
if (Math.abs(diff) > 1 / 90000 && this.hls) {
if (this.hasDetails) {
throw new Error(
`Cannot set timelineOffset after playlists are loaded`,
);
}
this.hls.config.timelineOffset = value;
}
}
}
private getAssetTime(time: number): number {
const timelineOffset = this.timelineOffset;
const duration = this.duration;
return Math.min(Math.max(0, time - timelineOffset), duration);
}
private removeMediaListeners() {
const media = this.mediaAttached;
if (media) {
this._currentTime = media.currentTime;
this.bufferSnapShot();
media.removeEventListener('timeupdate', this.checkPlayout);
}
}
private bufferSnapShot() {
if (this.mediaAttached) {
if (this.hls?.bufferedToEnd) {
this._bufferedEosTime = this.bufferedEnd;
}
}
}
destroy() {
this.removeMediaListeners();
if (this.hls) {
this.hls.destroy();
}
this.hls = null;
// @ts-ignore
this.tracks = this.mediaAttached = this.checkPlayout = null;
}
attachMedia(data: HTMLMediaElement | MediaAttachingData) {
this.loadSource();
this.hls?.attachMedia(data);
}
detachMedia() {
this.removeMediaListeners();
this.mediaAttached = null;
this.hls?.detachMedia();
}
resumeBuffering() {
this.hls?.resumeBuffering();
}
pauseBuffering() {
this.hls?.pauseBuffering();
}
transferMedia() {
this.bufferSnapShot();
return this.hls?.transferMedia() || null;
}
resetDetails() {
const hls = this.hls;
if (hls && this.hasDetails) {
hls.stopLoad();
const deleteDetails = (obj) => delete obj.details;
hls.levels.forEach(deleteDetails);
hls.allAudioTracks.forEach(deleteDetails);
hls.allSubtitleTracks.forEach(deleteDetails);
this.hasDetails = false;
}
}
on<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener: HlsListeners[E],
context?: Context,
) {
this.hls?.on(event, listener);
}
once<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener: HlsListeners[E],
context?: Context,
) {
this.hls?.once(event, listener);
}
off<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener: HlsListeners[E],
context?: Context,
) {
this.hls?.off(event, listener);
}
toString(): string {
return `HlsAssetPlayer: ${eventAssetToString(this.assetItem)} ${this.hls?.sessionId} ${this.appendInPlace ? 'append-in-place' : ''}`;
}
}
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
import { findFragmentByPTS } from './fragment-finders';
import {
ALIGNED_END_THRESHOLD_SECONDS,
type BaseData,
InterstitialEvent,
type InterstitialId,
TimelineOccupancy,
} from '../loader/interstitial-event';
import { Logger } from '../utils/logger';
import type { DateRange } from '../loader/date-range';
import type { MediaSelection } from '../types/media-playlist';
import type { ILogger } from '../utils/logger';
const ABUTTING_THRESHOLD_SECONDS = 0.033;
export type InterstitialScheduleEventItem = {
event: InterstitialEvent;
start: number;
end: number;
playout: {
start: number;
end: number;
};
integrated: {
start: number;
end: number;
};
};
export type InterstitialSchedulePrimaryItem = {
nextEvent: InterstitialEvent | null;
previousEvent: InterstitialEvent | null;
event?: undefined;
start: number;
end: number;
playout: {
start: number;
end: number;
};
integrated: {
start: number;
end: number;
};
};
export type InterstitialScheduleItem =
| InterstitialScheduleEventItem
| InterstitialSchedulePrimaryItem;
export type InterstitialScheduleDurations = {
primary: number;
playout: number;
integrated: number;
};
export type TimelineType = 'primary' | 'playout' | 'integrated';
type ScheduleUpdateCallback = (
removed: InterstitialEvent[],
previousItems: InterstitialScheduleItem[] | null,
) => void;
export class InterstitialsSchedule extends Logger {
private onScheduleUpdate: ScheduleUpdateCallback;
private eventMap: Record<string, InterstitialEvent | undefined> = {};
public events: InterstitialEvent[] | null = null;
public items: InterstitialScheduleItem[] | null = null;
public durations: InterstitialScheduleDurations = {
primary: 0,
playout: 0,
integrated: 0,
};
constructor(onScheduleUpdate: ScheduleUpdateCallback, logger: ILogger) {
super('interstitials-sched', logger);
this.onScheduleUpdate = onScheduleUpdate;
}
public destroy() {
this.reset();
// @ts-ignore
this.onScheduleUpdate = null;
}
public reset() {
this.eventMap = {};
this.setDurations(0, 0, 0);
if (this.events) {
this.events.forEach((interstitial) => interstitial.reset());
}
this.events = this.items = null;
}
public resetErrorsInRange(start: number, end: number): number {
if (this.events) {
return this.events.reduce((count, interstitial) => {
if (
start <= interstitial.startOffset &&
end > interstitial.startOffset
) {
delete interstitial.error;
return count + 1;
}
return count;
}, 0);
}
return 0;
}
get duration(): number {
const items = this.items;
return items ? items[items.length - 1].end : 0;
}
get length(): number {
return this.items ? this.items.length : 0;
}
public getEvent(
identifier: InterstitialId | undefined,
): InterstitialEvent | null {
return identifier ? this.eventMap[identifier] || null : null;
}
public hasEvent(identifier: InterstitialId): boolean {
return identifier in this.eventMap;
}
public findItemIndex(item: InterstitialScheduleItem, time?: number): number {
if (item.event) {
// Find Event Item
return this.findEventIndex(item.event.identifier);
}
// Find Primary Item
let index = -1;
if (item.nextEvent) {
index = this.findEventIndex(item.nextEvent.identifier) - 1;
} else if (item.previousEvent) {
index = this.findEventIndex(item.previousEvent.identifier) + 1;
}
const items = this.items;
if (items) {
if (!items[index]) {
if (time === undefined) {
time = item.start;
}
index = this.findItemIndexAtTime(time);
}
// Only return index of a Primary Item
while (index >= 0 && items[index]?.event) {
// If index found is an interstitial it is not a valid result as it should have been matched up top
// decrement until result is negative (not found) or a primary segment
index--;
}
}
return index;
}
public findItemIndexAtTime(
timelinePos: number,
timelineType?: TimelineType,
): number {
const items = this.items;
if (items) {
for (let i = 0; i < items.length; i++) {
let timeRange: { start: number; end: number } = items[i];
if (timelineType && timelineType !== 'primary') {
timeRange = timeRange[timelineType];
}
if (
timelinePos === timeRange.start ||
(timelinePos > timeRange.start && timelinePos < timeRange.end)
) {
return i;
}
}
}
return -1;
}
public findJumpRestrictedIndex(startIndex: number, endIndex: number): number {
const items = this.items;
if (items) {
for (let i = startIndex; i <= endIndex; i++) {
if (!items[i]) {
break;
}
const event = items[i].event;
if (event?.restrictions.jump && !event.appendInPlace) {
return i;
}
}
}
return -1;
}
public findEventIndex(identifier: InterstitialId): number {
const items = this.items;
if (items) {
for (let i = items.length; i--; ) {
if (items[i].event?.identifier === identifier) {
return i;
}
}
}
return -1;
}
public findAssetIndex(event: InterstitialEvent, timelinePos: number): number {
const assetList = event.assetList;
const length = assetList.length;
if (length > 1) {
for (let i = 0; i < length; i++) {
const asset = assetList[i];
if (!asset.error) {
const timelineStart = asset.timelineStart;
if (
timelinePos === timelineStart ||
(timelinePos > timelineStart &&
(timelinePos < timelineStart + (asset.duration || 0) ||
i === length - 1))
) {
return i;
}
}
}
}
return 0;
}
public get assetIdAtEnd(): string | null {
const interstitialAtEnd = this.items?.[this.length - 1]?.event;
if (interstitialAtEnd) {
const assetList = interstitialAtEnd.assetList;
const assetAtEnd = assetList[assetList.length - 1];
if (assetAtEnd) {
return assetAtEnd.identifier;
}
}
return null;
}
public parseInterstitialDateRanges(
mediaSelection: MediaSelection,
enableAppendInPlace: boolean,
) {
const details = mediaSelection.main.details!;
const { dateRanges } = details;
const previousInterstitialEvents = this.events;
const interstitialEvents = this.parseDateRanges(
dateRanges,
{
url: details.url,
},
enableAppendInPlace,
);
const ids = Object.keys(dateRanges);
const removedInterstitials = previousInterstitialEvents
? previousInterstitialEvents.filter(
(event) => !ids.includes(event.identifier),
)
: [];
if (interstitialEvents.length) {
// pre-rolls, post-rolls, and events with the same start time are played in playlist tag order
// all other events are ordered by start time
interstitialEvents.sort((a, b) => {
const aPre = a.cue.pre;
const aPost = a.cue.post;
const bPre = b.cue.pre;
const bPost = b.cue.post;
if (aPre && !bPre) {
return -1;
}
if (bPre && !aPre) {
return 1;
}
if (aPost && !bPost) {
return 1;
}
if (bPost && !aPost) {
return -1;
}
if (!aPre && !bPre && !aPost && !bPost) {
const startA = a.startTime;
const startB = b.startTime;
if (startA !== startB) {
return startA - startB;
}
}
return a.dateRange.tagOrder - b.dateRange.tagOrder;
});
}
this.events = interstitialEvents;
// Clear removed DateRanges from buffered list (kills playback of active Interstitials)
removedInterstitials.forEach((interstitial) => {
this.removeEvent(interstitial);
});
this.updateSchedule(mediaSelection, removedInterstitials);
}
public updateSchedule(
mediaSelection: MediaSelection,
removedInterstitials: InterstitialEvent[] = [],
forceUpdate: boolean = false,
) {
const events = this.events || [];
if (events.length || removedInterstitials.length || this.length < 2) {
const currentItems = this.items;
const updatedItems = this.parseSchedule(events, mediaSelection);
const updated =
forceUpdate ||
removedInterstitials.length ||
currentItems?.length !== updatedItems.length ||
updatedItems.some((item, i) => {
return (
Math.abs(item.playout.start - currentItems[i].playout.start) >
0.005 ||
Math.abs(item.playout.end - currentItems[i].playout.end) > 0.005
);
});
if (updated) {
this.items = updatedItems;
// call interstitials-controller onScheduleUpdated()
this.onScheduleUpdate(removedInterstitials, currentItems);
}
}
}
private parseDateRanges(
dateRanges: Record<string, DateRange | undefined>,
baseData: BaseData,
enableAppendInPlace: boolean,
): InterstitialEvent[] {
const interstitialEvents: InterstitialEvent[] = [];
const ids = Object.keys(dateRanges);
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const dateRange = dateRanges[id]!;
if (dateRange.isInterstitial) {
let interstitial = this.eventMap[id];
if (interstitial) {
// Update InterstitialEvent already parsed and mapped
// This retains already loaded duration and loaded asset list info
interstitial.setDateRange(dateRange);
} else {
interstitial = new InterstitialEvent(dateRange, baseData);
this.eventMap[id] = interstitial;
if (enableAppendInPlace === false) {
interstitial.appendInPlace = enableAppendInPlace;
}
}
interstitialEvents.push(interstitial);
}
}
return interstitialEvents;
}
private parseSchedule(
interstitialEvents: InterstitialEvent[],
mediaSelection: MediaSelection,
): InterstitialScheduleItem[] {
const schedule: InterstitialScheduleItem[] = [];
const details = mediaSelection.main.details!;
const primaryDuration = details.live ? Infinity : details.edge;
let playoutDuration = 0;
// Filter events that have errored from the schedule (Primary fallback)
interstitialEvents = interstitialEvents.filter(
(event) => !event.error && !(event.cue.once && event.hasPlayed),
);
if (interstitialEvents.length) {
// Update Schedule
this.resolveOffsets(interstitialEvents, mediaSelection);
// Populate Schedule with Interstitial Event and Primary Segment Items
let primaryPosition = 0;
let integratedTime = 0;
interstitialEvents.forEach((interstitial, i) => {
const preroll = interstitial.cue.pre;
const postroll = interstitial.cue.post;
const previousEvent =
(interstitialEvents[i - 1] as InterstitialEvent | undefined) || null;
const appendInPlace = interstitial.appendInPlace;
const eventStart = postroll
? primaryDuration
: interstitial.startOffset;
const interstitialDuration = interstitial.duration;
const timelineDuration =
interstitial.timelineOccupancy === TimelineOccupancy.Range
? interstitialDuration
: 0;
const resumptionOffset = interstitial.resumptionOffset;
const inSameStartTimeSequence = previousEvent?.startTime === eventStart;
const start = eventStart + interstitial.cumulativeDuration;
let end = appendInPlace
? start + interstitialDuration
: eventStart + resumptionOffset;
if (preroll || (!postroll && eventStart <= 0)) {
// preroll or in-progress midroll
const integratedStart = integratedTime;
integratedTime += timelineDuration;
interstitial.timelineStart = start;
const playoutStart = playoutDuration;
playoutDuration += interstitialDuration;
schedule.push({
event: interstitial,
start,
end,
playout: {
start: playoutStart,
end: playoutDuration,
},
integrated: {
start: integratedStart,
end: integratedTime,
},
});
} else if (eventStart <= primaryDuration) {
if (!inSameStartTimeSequence) {
const segmentDuration = eventStart - primaryPosition;
// Do not schedule a primary segment if interstitials are abutting by less than ABUTTING_THRESHOLD_SECONDS
if (segmentDuration > ABUTTING_THRESHOLD_SECONDS) {
// primary segment
const timelineStart = primaryPosition;
const integratedStart = integratedTime;
integratedTime += segmentDuration;
const playoutStart = playoutDuration;
playoutDuration += segmentDuration;
const primarySegment = {
previousEvent: interstitialEvents[i - 1] || null,
nextEvent: interstitial,
start: timelineStart,
end: timelineStart + segmentDuration,
playout: {
start: playoutStart,
end: playoutDuration,
},
integrated: {
start: integratedStart,
end: integratedTime,
},
};
schedule.push(primarySegment);
} else if (segmentDuration > 0 && previousEvent) {
// Add previous event `resumeTime` (based on duration or resumeOffset) so that it ends aligned with this one
previousEvent.cumulativeDuration += segmentDuration;
schedule[schedule.length - 1].end = eventStart;
}
}
// midroll / postroll
if (postroll) {
end = start;
}
interstitial.timelineStart = start;
const integratedStart = integratedTime;
integratedTime += timelineDuration;
const playoutStart = playoutDuration;
playoutDuration += interstitialDuration;
schedule.push({
event: interstitial,
start,
end,
playout: {
start: playoutStart,
end: playoutDuration,
},
integrated: {
start: integratedStart,
end: integratedTime,
},
});
} else {
// Interstitial starts after end of primary VOD - not included in schedule
return;
}
const resumeTime = interstitial.resumeTime;
if (postroll || resumeTime > primaryDuration) {
primaryPosition = primaryDuration;
} else {
primaryPosition = resumeTime;
}
});
if (primaryPosition < primaryDuration) {
// last primary segment
const timelineStart = primaryPosition;
const integratedStart = integratedTime;
const segmentDuration = primaryDuration - primaryPosition;
integratedTime += segmentDuration;
const playoutStart = playoutDuration;
playoutDuration += segmentDuration;
schedule.push({
previousEvent: schedule[schedule.length - 1]?.event || null,
nextEvent: null,
start: primaryPosition,
end: timelineStart + segmentDuration,
playout: {
start: playoutStart,
end: playoutDuration,
},
integrated: {
start: integratedStart,
end: integratedTime,
},
});
}
this.setDurations(primaryDuration, playoutDuration, integratedTime);
} else {
// no interstials - schedule is one primary segment
const start = 0;
schedule.push({
previousEvent: null,
nextEvent: null,
start,
end: primaryDuration,
playout: {
start,
end: primaryDuration,
},
integrated: {
start,
end: primaryDuration,
},
});
this.setDurations(primaryDuration, primaryDuration, primaryDuration);
}
return schedule;
}
private setDurations(primary: number, playout: number, integrated: number) {
this.durations = {
primary,
playout,
integrated,
};
}
private resolveOffsets(
interstitialEvents: InterstitialEvent[],
mediaSelection: MediaSelection,
) {
const details = mediaSelection.main.details!;
const primaryDuration = details.live ? Infinity : details.edge;
// First resolve cumulative resumption offsets for Interstitials that start at the same DateTime
let cumulativeDuration = 0;
let lastScheduledStart = -1;
interstitialEvents.forEach((interstitial, i) => {
const preroll = interstitial.cue.pre;
const postroll = interstitial.cue.post;
const eventStart = preroll
? 0
: postroll
? primaryDuration
: interstitial.startTime;
this.updateAssetDurations(interstitial);
// X-RESUME-OFFSET values of interstitials scheduled at the same time are cumulative
const inSameStartTimeSequence = lastScheduledStart === eventStart;
if (inSameStartTimeSequence) {
interstitial.cumulativeDuration = cumulativeDuration;
} else {
cumulativeDuration = 0;
lastScheduledStart = eventStart;
}
if (!postroll && interstitial.snapOptions.in) {
// FIXME: Include audio playlist in snapping
interstitial.resumeAnchor =
findFragmentByPTS(
null,
details.fragments,
interstitial.startOffset + interstitial.resumptionOffset,
0,
0,
) || undefined;
}
// Check if primary fragments align with resumption offset and disable appendInPlace if they do not
if (interstitial.appendInPlace && !interstitial.appendInPlaceStarted) {
const alignedSegmentStart = this.primaryCanResumeInPlaceAt(
interstitial,
mediaSelection,
);
if (!alignedSegmentStart) {
interstitial.appendInPlace = false;
}
}
if (!interstitial.appendInPlace && i + 1 < interstitialEvents.length) {
// abutting Interstitials must use the same MediaSource strategy, this applies to all whether or not they are back to back:
const timeBetween =
interstitialEvents[i + 1].startTime -
interstitialEvents[i].resumeTime;
if (timeBetween < ABUTTING_THRESHOLD_SECONDS) {
interstitialEvents[i + 1].appendInPlace = false;
if (interstitialEvents[i + 1].appendInPlace) {
this.warn(
`Could not change append strategy for abutting event ${interstitial}`,
);
}
}
}
// Update cumulativeDuration for next abutting interstitial with the same start date
const resumeOffset = Number.isFinite(interstitial.resumeOffset)
? interstitial.resumeOffset
: interstitial.duration;
cumulativeDuration += resumeOffset;
});
}
private primaryCanResumeInPlaceAt(
interstitial: InterstitialEvent,
mediaSelection: MediaSelection,
): boolean {
const resumeTime = interstitial.resumeTime;
const resumesInPlaceAt =
interstitial.startTime + interstitial.resumptionOffset;
if (
Math.abs(resumeTime - resumesInPlaceAt) > ALIGNED_END_THRESHOLD_SECONDS
) {
this.log(
`"${interstitial.identifier}" resumption ${resumeTime} not aligned with estimated timeline end ${resumesInPlaceAt}`,
);
return false;
}
const playlists = Object.keys(mediaSelection);
return !playlists.some((playlistType) => {
const details = mediaSelection[playlistType].details;
const playlistEnd = details.edge;
if (resumeTime >= playlistEnd) {
// Live playback - resumption segments are not yet available
this.log(
`"${interstitial.identifier}" resumption ${resumeTime} past ${playlistType} playlist end ${playlistEnd}`,
);
// Assume alignment is possible (or reset can take place)
return false;
}
const startFragment = findFragmentByPTS(
null,
details.fragments,
resumeTime,
);
if (!startFragment) {
this.log(
`"${interstitial.identifier}" resumption ${resumeTime} does not align with any fragments in ${playlistType} playlist (${details.fragStart}-${details.fragmentEnd})`,
);
return true;
}
const allowance = playlistType === 'audio' ? 0.175 : 0;
const alignedWithSegment =
Math.abs(startFragment.start - resumeTime) <
ALIGNED_END_THRESHOLD_SECONDS + allowance ||
Math.abs(startFragment.end - resumeTime) <
ALIGNED_END_THRESHOLD_SECONDS + allowance;
if (!alignedWithSegment) {
this.log(
`"${interstitial.identifier}" resumption ${resumeTime} not aligned with ${playlistType} fragment bounds (${startFragment.start}-${startFragment.end} sn: ${startFragment.sn} cc: ${startFragment.cc})`,
);
return true;
}
return false;
});
}
private updateAssetDurations(interstitial: InterstitialEvent) {
if (!interstitial.assetListLoaded) {
return;
}
const eventStart = interstitial.timelineStart;
let sumDuration = 0;
let hasUnknownDuration = false;
let hasErrors = false;
for (let i = 0; i < interstitial.assetList.length; i++) {
const asset = interstitial.assetList[i];
const timelineStart = eventStart + sumDuration;
asset.startOffset = sumDuration;
asset.timelineStart = timelineStart;
hasUnknownDuration ||= asset.duration === null;
hasErrors ||= !!asset.error;
const duration = asset.error ? 0 : (asset.duration as number) || 0;
sumDuration += duration;
}
// Use the sum of known durations when it is greater than the stated duration
if (hasUnknownDuration && !hasErrors) {
interstitial.duration = Math.max(sumDuration, interstitial.duration);
} else {
interstitial.duration = sumDuration;
}
}
private removeEvent(interstitial: InterstitialEvent) {
interstitial.reset();
delete this.eventMap[interstitial.identifier];
}
}
export function segmentToString(segment: InterstitialScheduleItem): string {
return `[${segment.event ? '"' + segment.event.identifier + '"' : 'primary'}: ${segment.start.toFixed(2)}-${segment.end.toFixed(2)}]`;
}
+294
View File
@@ -0,0 +1,294 @@
import { ErrorDetails } from '../errors';
import { Events } from '../events';
import type { HlsConfig } from '../config';
import type Hls from '../hls';
import type { LevelDetails } from '../loader/level-details';
import type { ComponentAPI } from '../types/component-api';
import type {
ErrorData,
LevelUpdatedData,
MediaAttachingData,
} from '../types/events';
export default class LatencyController implements ComponentAPI {
private hls: Hls | null;
private readonly config: HlsConfig;
private media: HTMLMediaElement | null = null;
private currentTime: number = 0;
private stallCount: number = 0;
private _latency: number | null = null;
private _targetLatencyUpdated = false;
constructor(hls: Hls) {
this.hls = hls;
this.config = hls.config;
this.registerListeners();
}
private get levelDetails(): LevelDetails | null {
return this.hls?.latestLevelDetails || null;
}
get latency(): number {
return this._latency || 0;
}
get maxLatency(): number {
const { config } = this;
if (config.liveMaxLatencyDuration !== undefined) {
return config.liveMaxLatencyDuration;
}
const levelDetails = this.levelDetails;
return levelDetails
? config.liveMaxLatencyDurationCount * levelDetails.targetduration
: 0;
}
get targetLatency(): number | null {
const levelDetails = this.levelDetails;
if (levelDetails === null || this.hls === null) {
return null;
}
const { holdBack, partHoldBack, targetduration } = levelDetails;
const { liveSyncDuration, liveSyncDurationCount, lowLatencyMode } =
this.config;
const userConfig = this.hls.userConfig;
let targetLatency = lowLatencyMode ? partHoldBack || holdBack : holdBack;
if (
this._targetLatencyUpdated ||
userConfig.liveSyncDuration ||
userConfig.liveSyncDurationCount ||
targetLatency === 0
) {
targetLatency =
liveSyncDuration !== undefined
? liveSyncDuration
: liveSyncDurationCount * targetduration;
}
const maxLiveSyncOnStallIncrease = targetduration;
return (
targetLatency +
Math.min(
this.stallCount * this.config.liveSyncOnStallIncrease,
maxLiveSyncOnStallIncrease,
)
);
}
set targetLatency(latency: number) {
this.stallCount = 0;
this.config.liveSyncDuration = latency;
this._targetLatencyUpdated = true;
}
get liveSyncPosition(): number | null {
const liveEdge = this.estimateLiveEdge();
const targetLatency = this.targetLatency;
if (liveEdge === null || targetLatency === null) {
return null;
}
const levelDetails = this.levelDetails;
if (levelDetails === null) {
return null;
}
const edge = levelDetails.edge;
const syncPosition = liveEdge - targetLatency - this.edgeStalled;
const min = edge - levelDetails.totalduration;
const max =
edge -
((this.config.lowLatencyMode && levelDetails.partTarget) ||
levelDetails.targetduration);
return Math.min(Math.max(min, syncPosition), max);
}
get drift(): number {
const levelDetails = this.levelDetails;
if (levelDetails === null) {
return 1;
}
return levelDetails.drift;
}
get edgeStalled(): number {
const levelDetails = this.levelDetails;
if (levelDetails === null) {
return 0;
}
const maxLevelUpdateAge =
((this.config.lowLatencyMode && levelDetails.partTarget) ||
levelDetails.targetduration) * 3;
return Math.max(levelDetails.age - maxLevelUpdateAge, 0);
}
private get forwardBufferLength(): number {
const { media } = this;
const levelDetails = this.levelDetails;
if (!media || !levelDetails) {
return 0;
}
const bufferedRanges = media.buffered.length;
return (
(bufferedRanges
? media.buffered.end(bufferedRanges - 1)
: levelDetails.edge) - this.currentTime
);
}
public destroy(): void {
this.unregisterListeners();
this.onMediaDetaching();
this.hls = null;
}
private registerListeners() {
const { hls } = this;
if (!hls) {
return;
}
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
hls.on(Events.ERROR, this.onError, this);
}
private unregisterListeners() {
const { hls } = this;
if (!hls) {
return;
}
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
hls.off(Events.ERROR, this.onError, this);
}
private onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachingData,
) {
this.media = data.media;
this.media.addEventListener('timeupdate', this.onTimeupdate);
}
private onMediaDetaching() {
if (this.media) {
this.media.removeEventListener('timeupdate', this.onTimeupdate);
this.media = null;
}
}
private onManifestLoading() {
this._latency = null;
this.stallCount = 0;
}
private onLevelUpdated(
event: Events.LEVEL_UPDATED,
{ details }: LevelUpdatedData,
) {
if (details.advanced) {
this.onTimeupdate();
}
if (!details.live && this.media) {
this.media.removeEventListener('timeupdate', this.onTimeupdate);
}
}
private onError(event: Events.ERROR, data: ErrorData) {
if (data.details !== ErrorDetails.BUFFER_STALLED_ERROR) {
return;
}
this.stallCount++;
if (this.hls && this.levelDetails?.live) {
this.hls.logger.warn(
'[latency-controller]: Stall detected, adjusting target latency',
);
}
}
private onTimeupdate = () => {
const { media } = this;
const levelDetails = this.levelDetails;
if (!media || !levelDetails) {
return;
}
this.currentTime = media.currentTime;
const latency = this.computeLatency();
if (latency === null) {
return;
}
this._latency = latency;
// Adapt playbackRate to meet target latency in low-latency mode
const { lowLatencyMode, maxLiveSyncPlaybackRate } = this.config;
if (
!lowLatencyMode ||
maxLiveSyncPlaybackRate === 1 ||
!levelDetails.live
) {
return;
}
const targetLatency = this.targetLatency;
if (targetLatency === null) {
return;
}
const distanceFromTarget = latency - targetLatency;
// Only adjust playbackRate when within one target duration of targetLatency
// and more than one second from under-buffering.
// Playback further than one target duration from target can be considered DVR playback.
const liveMinLatencyDuration = Math.min(
this.maxLatency,
targetLatency + levelDetails.targetduration,
);
const inLiveRange = distanceFromTarget < liveMinLatencyDuration;
if (
inLiveRange &&
distanceFromTarget > 0.05 &&
this.forwardBufferLength > 1
) {
const max = Math.min(2, Math.max(1.0, maxLiveSyncPlaybackRate));
const rate =
Math.round(
(2 / (1 + Math.exp(-0.75 * distanceFromTarget - this.edgeStalled))) *
20,
) / 20;
const playbackRate = Math.min(max, Math.max(1, rate));
this.changeMediaPlaybackRate(media, playbackRate);
} else if (media.playbackRate !== 1 && media.playbackRate !== 0) {
this.changeMediaPlaybackRate(media, 1);
}
};
private changeMediaPlaybackRate(
media: HTMLMediaElement,
playbackRate: number,
) {
if (media.playbackRate === playbackRate) {
return;
}
this.hls?.logger.debug(
`[latency-controller]: latency=${this.latency.toFixed(3)}, targetLatency=${this.targetLatency?.toFixed(3)}, forwardBufferLength=${this.forwardBufferLength.toFixed(3)}: adjusting playback rate from ${media.playbackRate} to ${playbackRate}`,
);
media.playbackRate = playbackRate;
}
private estimateLiveEdge(): number | null {
const levelDetails = this.levelDetails;
if (levelDetails === null) {
return null;
}
return levelDetails.edge + levelDetails.age;
}
private computeLatency(): number | null {
const liveEdge = this.estimateLiveEdge();
if (liveEdge === null) {
return null;
}
return liveEdge - this.currentTime;
}
}
+753
View File
@@ -0,0 +1,753 @@
import BasePlaylistController from './base-playlist-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { isVideoRange, Level, VideoRangeValues } from '../types/level';
import { PlaylistContextType, PlaylistLevelType } from '../types/loader';
import {
areCodecsMediaSourceSupported,
codecsSetSelectionPreferenceValue,
convertAVC1ToAVCOTI,
getCodecCompatibleName,
videoCodecPreferenceValue,
} from '../utils/codecs';
import { reassignFragmentLevelIndexes } from '../utils/level-helper';
import { getUnsupportedResult } from '../utils/mediacapabilities-helper';
import { stringify } from '../utils/safe-json-stringify';
import type ContentSteeringController from './content-steering-controller';
import type Hls from '../hls';
import type {
ErrorData,
FragBufferedData,
LevelLoadedData,
LevelsUpdatedData,
LevelSwitchingData,
ManifestLoadedData,
ManifestLoadingData,
ManifestParsedData,
} from '../types/events';
import type { HlsUrlParameters, LevelParsed } from '../types/level';
import type { MediaPlaylist } from '../types/media-playlist';
export default class LevelController extends BasePlaylistController {
private _levels: Level[] = [];
private _firstLevel: number = -1;
private _maxAutoLevel: number = -1;
private _startLevel?: number;
private currentLevel: Level | null = null;
private currentLevelIndex: number = -1;
private manualLevelIndex: number = -1;
private steering: ContentSteeringController | null;
public onParsedComplete!: Function;
constructor(
hls: Hls,
contentSteeringController: ContentSteeringController | null,
) {
super(hls, 'level-controller');
this.steering = contentSteeringController;
this._registerListeners();
}
private _registerListeners() {
const { hls } = this;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.on(Events.LEVEL_LOADED, this.onLevelLoaded, this);
hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.on(Events.ERROR, this.onError, this);
}
private _unregisterListeners() {
const { hls } = this;
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.off(Events.LEVEL_LOADED, this.onLevelLoaded, this);
hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.off(Events.ERROR, this.onError, this);
}
public destroy() {
this._unregisterListeners();
this.steering = null;
this.resetLevels();
super.destroy();
}
public stopLoad(): void {
const levels = this._levels;
// clean up live level details to force reload them, and reset load errors
levels.forEach((level) => {
level.loadError = 0;
level.fragmentError = 0;
});
super.stopLoad();
}
private resetLevels() {
this._startLevel = undefined;
this.manualLevelIndex = -1;
this.currentLevelIndex = -1;
this.currentLevel = null;
this._levels = [];
this._maxAutoLevel = -1;
}
private onManifestLoading(
event: Events.MANIFEST_LOADING,
data: ManifestLoadingData,
) {
this.resetLevels();
}
protected onManifestLoaded(
event: Events.MANIFEST_LOADED,
data: ManifestLoadedData,
) {
const preferManagedMediaSource = this.hls.config.preferManagedMediaSource;
const levels: Level[] = [];
const redundantSet: { [key: string]: Level } = {};
const generatePathwaySet: { [key: string]: number } = {};
let resolutionFound = false;
let videoCodecFound = false;
let audioCodecFound = false;
data.levels.forEach((levelParsed: LevelParsed) => {
const attributes = levelParsed.attrs;
let { audioCodec, videoCodec } = levelParsed;
if (audioCodec) {
// Returns empty and set to undefined for 'mp4a.40.34' with fallback to 'audio/mpeg' SourceBuffer
levelParsed.audioCodec = audioCodec =
getCodecCompatibleName(audioCodec, preferManagedMediaSource) ||
undefined;
}
if (videoCodec) {
videoCodec = levelParsed.videoCodec = convertAVC1ToAVCOTI(videoCodec);
}
// only keep levels with supported audio/video codecs
const { width, height, unknownCodecs } = levelParsed;
const unknownUnsupportedCodecCount = unknownCodecs?.length || 0;
resolutionFound ||= !!(width && height);
videoCodecFound ||= !!videoCodec;
audioCodecFound ||= !!audioCodec;
if (
unknownUnsupportedCodecCount ||
(audioCodec && !this.isAudioSupported(audioCodec)) ||
(videoCodec && !this.isVideoSupported(videoCodec))
) {
this.log(`Some or all CODECS not supported "${attributes.CODECS}"`);
return;
}
const {
CODECS,
'FRAME-RATE': FRAMERATE,
'HDCP-LEVEL': HDCP,
'PATHWAY-ID': PATHWAY,
RESOLUTION,
'VIDEO-RANGE': VIDEO_RANGE,
} = attributes;
const contentSteeringPrefix = `${PATHWAY || '.'}-`;
const levelKey = `${contentSteeringPrefix}${levelParsed.bitrate}-${RESOLUTION}-${FRAMERATE}-${CODECS}-${VIDEO_RANGE}-${HDCP}`;
if (!redundantSet[levelKey]) {
const level = this.createLevel(levelParsed);
redundantSet[levelKey] = level;
generatePathwaySet[levelKey] = 1;
levels.push(level);
} else if (
redundantSet[levelKey].uri !== levelParsed.url &&
!levelParsed.attrs['PATHWAY-ID']
) {
// Assign Pathway IDs to Redundant Streams (default Pathways is ".". Redundant Streams "..", "...", and so on.)
// Content Steering controller to handles Pathway fallback on error
const pathwayCount = (generatePathwaySet[levelKey] += 1);
levelParsed.attrs['PATHWAY-ID'] = new Array(pathwayCount + 1).join('.');
const level = this.createLevel(levelParsed);
redundantSet[levelKey] = level;
levels.push(level);
} else {
redundantSet[levelKey].addGroupId('audio', attributes.AUDIO);
redundantSet[levelKey].addGroupId('text', attributes.SUBTITLES);
}
});
this.filterAndSortMediaOptions(
levels,
data,
resolutionFound,
videoCodecFound,
audioCodecFound,
);
}
private createLevel(levelParsed: LevelParsed): Level {
const level = new Level(levelParsed);
const supplemental = levelParsed.supplemental;
if (
supplemental?.videoCodec &&
!this.isVideoSupported(supplemental.videoCodec)
) {
const error = new Error(
`SUPPLEMENTAL-CODECS not supported "${supplemental.videoCodec}"`,
);
this.log(error.message);
level.supportedResult = getUnsupportedResult(error, []);
}
return level;
}
private isAudioSupported(codec: string): boolean {
return areCodecsMediaSourceSupported(
codec,
'audio',
this.hls.config.preferManagedMediaSource,
);
}
private isVideoSupported(codec: string): boolean {
return areCodecsMediaSourceSupported(
codec,
'video',
this.hls.config.preferManagedMediaSource,
);
}
private filterAndSortMediaOptions(
filteredLevels: Level[],
data: ManifestLoadedData,
resolutionFound: boolean,
videoCodecFound: boolean,
audioCodecFound: boolean,
) {
let audioTracks: MediaPlaylist[] = [];
let subtitleTracks: MediaPlaylist[] = [];
let levels = filteredLevels;
const statsParsing = data.stats?.parsing || {};
// remove audio-only and invalid video-range levels if we also have levels with video codecs or RESOLUTION signalled
if ((resolutionFound || videoCodecFound) && audioCodecFound) {
levels = levels.filter(
({ videoCodec, videoRange, width, height }) =>
(!!videoCodec || !!(width && height)) && isVideoRange(videoRange),
);
}
if (levels.length === 0) {
// Dispatch error after MANIFEST_LOADED is done propagating
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.resolve().then(() => {
if (this.hls) {
let message = 'no level with compatible codecs found in manifest';
let reason = message;
if (data.levels.length) {
reason = `one or more CODECS in variant not supported: ${stringify(
data.levels
.map((level) => level.attrs.CODECS)
.filter(
(value, index, array) => array.indexOf(value) === index,
),
)}`;
this.warn(reason);
message += ` (${reason})`;
}
const error = new Error(message);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
fatal: true,
url: data.url,
error,
reason,
});
}
});
statsParsing.end = performance.now();
return;
}
if (data.audioTracks) {
audioTracks = data.audioTracks.filter(
(track) => !track.audioCodec || this.isAudioSupported(track.audioCodec),
);
// Assign ids after filtering as array indices by group-id
assignTrackIdsByGroup(audioTracks);
}
if (data.subtitles) {
subtitleTracks = data.subtitles;
assignTrackIdsByGroup(subtitleTracks);
}
// start bitrate is the first bitrate of the manifest
const unsortedLevels = levels.slice(0);
// sort levels from lowest to highest
levels.sort((a, b) => {
if (a.attrs['HDCP-LEVEL'] !== b.attrs['HDCP-LEVEL']) {
return (a.attrs['HDCP-LEVEL'] || '') > (b.attrs['HDCP-LEVEL'] || '')
? 1
: -1;
}
// sort on height before bitrate for cap-level-controller
if (resolutionFound && a.height !== b.height) {
return a.height - b.height;
}
if (a.frameRate !== b.frameRate) {
return a.frameRate - b.frameRate;
}
if (a.videoRange !== b.videoRange) {
return (
VideoRangeValues.indexOf(a.videoRange) -
VideoRangeValues.indexOf(b.videoRange)
);
}
if (a.videoCodec !== b.videoCodec) {
const valueA = videoCodecPreferenceValue(a.videoCodec);
const valueB = videoCodecPreferenceValue(b.videoCodec);
if (valueA !== valueB) {
return valueB - valueA;
}
}
if (a.uri === b.uri && a.codecSet !== b.codecSet) {
const valueA = codecsSetSelectionPreferenceValue(a.codecSet);
const valueB = codecsSetSelectionPreferenceValue(b.codecSet);
if (valueA !== valueB) {
return valueB - valueA;
}
}
if (a.averageBitrate !== b.averageBitrate) {
return a.averageBitrate - b.averageBitrate;
}
return 0;
});
let firstLevelInPlaylist = unsortedLevels[0];
if (this.steering) {
levels = this.steering.filterParsedLevels(levels);
if (levels.length !== unsortedLevels.length) {
for (let i = 0; i < unsortedLevels.length; i++) {
if (unsortedLevels[i].pathwayId === levels[0].pathwayId) {
firstLevelInPlaylist = unsortedLevels[i];
break;
}
}
}
}
this._levels = levels;
// find index of first level in sorted levels
for (let i = 0; i < levels.length; i++) {
if (levels[i] === firstLevelInPlaylist) {
this._firstLevel = i;
const firstLevelBitrate = firstLevelInPlaylist.bitrate;
const bandwidthEstimate = this.hls.bandwidthEstimate;
this.log(
`manifest loaded, ${levels.length} level(s) found, first bitrate: ${firstLevelBitrate}`,
);
// Update default bwe to first variant bitrate as long it has not been configured or set
if (this.hls.userConfig?.abrEwmaDefaultEstimate === undefined) {
const startingBwEstimate = Math.min(
firstLevelBitrate,
this.hls.config.abrEwmaDefaultEstimateMax,
);
if (
startingBwEstimate > bandwidthEstimate &&
bandwidthEstimate === this.hls.abrEwmaDefaultEstimate
) {
this.hls.bandwidthEstimate = startingBwEstimate;
}
}
break;
}
}
// Audio is only alternate if manifest include a URI along with the audio group tag,
// and this is not an audio-only stream where levels contain audio-only
const audioOnly = audioCodecFound && !videoCodecFound;
const config = this.hls.config;
const altAudioEnabled = !!(
config.audioStreamController && config.audioTrackController
);
const edata: ManifestParsedData = {
levels,
audioTracks,
subtitleTracks,
sessionData: data.sessionData,
sessionKeys: data.sessionKeys,
firstLevel: this._firstLevel,
stats: data.stats,
audio: audioCodecFound,
video: videoCodecFound,
altAudio:
altAudioEnabled && !audioOnly && audioTracks.some((t) => !!t.url),
};
statsParsing.end = performance.now();
this.hls.trigger(Events.MANIFEST_PARSED, edata);
}
get levels(): Level[] | null {
if (this._levels.length === 0) {
return null;
}
return this._levels;
}
get loadLevelObj(): Level | null {
return this.currentLevel;
}
get level(): number {
return this.currentLevelIndex;
}
set level(newLevel: number) {
const levels = this._levels;
if (levels.length === 0) {
return;
}
// check if level idx is valid
if (newLevel < 0 || newLevel >= levels.length) {
// invalid level id given, trigger error
const error = new Error('invalid level idx');
const fatal = newLevel < 0;
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.OTHER_ERROR,
details: ErrorDetails.LEVEL_SWITCH_ERROR,
level: newLevel,
fatal,
error,
reason: error.message,
});
if (fatal) {
return;
}
newLevel = Math.min(newLevel, levels.length - 1);
}
const lastLevelIndex = this.currentLevelIndex;
const lastLevel = this.currentLevel;
const lastPathwayId = lastLevel ? lastLevel.attrs['PATHWAY-ID'] : undefined;
const level = levels[newLevel];
const pathwayId = level.attrs['PATHWAY-ID'];
this.currentLevelIndex = newLevel;
this.currentLevel = level;
if (
lastLevelIndex === newLevel &&
lastLevel &&
lastPathwayId === pathwayId
) {
return;
}
this.log(
`Switching to level ${newLevel} (${
level.height ? level.height + 'p ' : ''
}${level.videoRange ? level.videoRange + ' ' : ''}${
level.codecSet ? level.codecSet + ' ' : ''
}@${level.bitrate})${
pathwayId ? ' with Pathway ' + pathwayId : ''
} from level ${lastLevelIndex}${
lastPathwayId ? ' with Pathway ' + lastPathwayId : ''
}`,
);
const levelSwitchingData: LevelSwitchingData = {
level: newLevel,
attrs: level.attrs,
details: level.details,
bitrate: level.bitrate,
averageBitrate: level.averageBitrate,
maxBitrate: level.maxBitrate,
realBitrate: level.realBitrate,
width: level.width,
height: level.height,
codecSet: level.codecSet,
audioCodec: level.audioCodec,
videoCodec: level.videoCodec,
audioGroups: level.audioGroups,
subtitleGroups: level.subtitleGroups,
loaded: level.loaded,
loadError: level.loadError,
fragmentError: level.fragmentError,
name: level.name,
id: level.id,
uri: level.uri,
url: level.url,
urlId: 0,
audioGroupIds: level.audioGroupIds,
textGroupIds: level.textGroupIds,
};
this.hls.trigger(Events.LEVEL_SWITCHING, levelSwitchingData);
// check if we need to load playlist for this level
const levelDetails = level.details;
if (!levelDetails || levelDetails.live) {
// level not retrieved yet, or live playlist we need to (re)load it
const hlsUrlParameters = this.switchParams(
level.uri,
lastLevel?.details,
levelDetails,
);
this.loadPlaylist(hlsUrlParameters);
}
}
get manualLevel(): number {
return this.manualLevelIndex;
}
set manualLevel(newLevel) {
this.manualLevelIndex = newLevel;
if (this._startLevel === undefined) {
this._startLevel = newLevel;
}
if (newLevel !== -1) {
this.level = newLevel;
}
}
get firstLevel(): number {
return this._firstLevel;
}
set firstLevel(newLevel) {
this._firstLevel = newLevel;
}
get startLevel(): number {
// Setting hls.startLevel (this._startLevel) overrides config.startLevel
if (this._startLevel === undefined) {
const configStartLevel = this.hls.config.startLevel;
if (configStartLevel !== undefined) {
return configStartLevel;
}
return this.hls.firstAutoLevel;
}
return this._startLevel;
}
set startLevel(newLevel: number) {
this._startLevel = newLevel;
}
get pathways(): string[] {
if (this.steering) {
return this.steering.pathways();
}
return [];
}
get pathwayPriority(): string[] | null {
if (this.steering) {
return this.steering.pathwayPriority;
}
return null;
}
set pathwayPriority(pathwayPriority: string[]) {
if (this.steering) {
const pathwaysList = this.steering.pathways();
const filteredPathwayPriority = pathwayPriority.filter((pathwayId) => {
return pathwaysList.indexOf(pathwayId) !== -1;
});
if (pathwayPriority.length < 1) {
this.warn(
`pathwayPriority ${pathwayPriority} should contain at least one pathway from list: ${pathwaysList}`,
);
return;
}
this.steering.pathwayPriority = filteredPathwayPriority;
}
}
protected onError(event: Events.ERROR, data: ErrorData) {
if (data.fatal || !data.context) {
return;
}
if (
data.context.type === PlaylistContextType.LEVEL &&
data.context.level === this.level
) {
this.checkRetry(data);
}
}
// reset errors on the successful load of a fragment
protected onFragBuffered(
event: Events.FRAG_BUFFERED,
{ frag }: FragBufferedData,
) {
if (frag !== undefined && frag.type === PlaylistLevelType.MAIN) {
const el = frag.elementaryStreams;
if (!Object.keys(el).some((type) => !!el[type])) {
return;
}
const level = this._levels[frag.level];
if (level?.loadError) {
this.log(
`Resetting level error count of ${level.loadError} on frag buffered`,
);
level.loadError = 0;
}
}
}
protected onLevelLoaded(event: Events.LEVEL_LOADED, data: LevelLoadedData) {
const { level, details } = data;
const curLevel = data.levelInfo;
if (!curLevel) {
this.warn(`Invalid level index ${level}`);
if (data.deliveryDirectives?.skip) {
details.deltaUpdateFailed = true;
}
return;
}
// only process level loaded events matching with expected level or prior to switch when media playlist is loaded directly
if (curLevel === this.currentLevel || data.withoutMultiVariant) {
// reset level load error counter on successful level loaded only if there is no issues with fragments
if (curLevel.fragmentError === 0) {
curLevel.loadError = 0;
}
// Ignore matching details populated by loading a Media Playlist directly
let previousDetails = curLevel.details;
if (previousDetails === data.details && previousDetails.advanced) {
previousDetails = undefined;
}
this.playlistLoaded(level, data, previousDetails);
} else if (data.deliveryDirectives?.skip) {
// received a delta playlist update that cannot be merged
details.deltaUpdateFailed = true;
}
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters) {
super.loadPlaylist();
if (this.shouldLoadPlaylist(this.currentLevel)) {
this.scheduleLoading(this.currentLevel, hlsUrlParameters);
}
}
protected loadingPlaylist(
currentLevel: Level,
hlsUrlParameters: HlsUrlParameters | undefined,
) {
super.loadingPlaylist(currentLevel, hlsUrlParameters);
const url = this.getUrlWithDirectives(currentLevel.uri, hlsUrlParameters);
const currentLevelIndex = this.currentLevelIndex;
const pathwayId = currentLevel.attrs['PATHWAY-ID'];
const details = currentLevel.details;
const age = details?.age;
this.log(
`Loading level index ${currentLevelIndex}${
hlsUrlParameters?.msn !== undefined
? ' at sn ' + hlsUrlParameters.msn + ' part ' + hlsUrlParameters.part
: ''
}${pathwayId ? ' Pathway ' + pathwayId : ''}${age && details.live ? ' age ' + age.toFixed(1) + (details.type ? ' ' + details.type || '' : '') : ''} ${url}`,
);
this.hls.trigger(Events.LEVEL_LOADING, {
url,
level: currentLevelIndex,
levelInfo: currentLevel,
pathwayId: currentLevel.attrs['PATHWAY-ID'],
id: 0, // Deprecated Level urlId
deliveryDirectives: hlsUrlParameters || null,
});
}
get nextLoadLevel() {
if (this.manualLevelIndex !== -1) {
return this.manualLevelIndex;
} else {
return this.hls.nextAutoLevel;
}
}
set nextLoadLevel(nextLevel) {
this.level = nextLevel;
if (this.manualLevelIndex === -1) {
this.hls.nextAutoLevel = nextLevel;
}
}
removeLevel(levelIndex: number) {
if (this._levels.length === 1) {
return;
}
const levels = this._levels.filter((level, index) => {
if (index !== levelIndex) {
return true;
}
if (this.steering) {
this.steering.removeLevel(level);
}
if (level === this.currentLevel) {
this.currentLevel = null;
this.currentLevelIndex = -1;
if (level.details) {
level.details.fragments.forEach((f) => (f.level = -1));
}
}
return false;
});
reassignFragmentLevelIndexes(levels);
this._levels = levels;
if (this.currentLevelIndex > -1 && this.currentLevel?.details) {
this.currentLevelIndex = this.currentLevel.details.fragments[0].level;
}
if (this.manualLevelIndex > -1) {
this.manualLevelIndex = this.currentLevelIndex;
}
const maxLevel = levels.length - 1;
this._firstLevel = Math.min(this._firstLevel, maxLevel);
if (this._startLevel) {
this._startLevel = Math.min(this._startLevel, maxLevel);
}
this.hls.trigger(Events.LEVELS_UPDATED, { levels });
}
private onLevelsUpdated(
event: Events.LEVELS_UPDATED,
{ levels }: LevelsUpdatedData,
) {
this._levels = levels;
}
public checkMaxAutoUpdated() {
const { autoLevelCapping, maxAutoLevel, maxHdcpLevel } = this.hls;
if (this._maxAutoLevel !== maxAutoLevel) {
this._maxAutoLevel = maxAutoLevel;
this.hls.trigger(Events.MAX_AUTO_LEVEL_UPDATED, {
autoLevelCapping,
levels: this.levels,
maxAutoLevel,
minAutoLevel: this.hls.minAutoLevel,
maxHdcpLevel,
});
}
}
}
function assignTrackIdsByGroup(tracks: MediaPlaylist[]): void {
const groups = {};
tracks.forEach((track) => {
const groupId = track.groupId || '';
track.id = groups[groupId] = groups[groupId] || 0;
groups[groupId]++;
});
}
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
import BaseStreamController, { State } from './base-stream-controller';
import { findFragmentByPTS } from './fragment-finders';
import { FragmentState } from './fragment-tracker';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import {
type Fragment,
isMediaFragment,
type MediaFragment,
} from '../loader/fragment';
import { Level } from '../types/level';
import { PlaylistLevelType } from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
import { alignMediaPlaylistByPDT } from '../utils/discontinuities';
import {
getAesModeFromFullSegmentMethod,
isFullSegmentEncryption,
} from '../utils/encryption-methods-util';
import { addSliding } from '../utils/level-helper';
import { subtitleOptionsIdentical } from '../utils/media-option-attributes';
import type { FragmentTracker } from './fragment-tracker';
import type Hls from '../hls';
import type KeyLoader from '../loader/key-loader';
import type { LevelDetails } from '../loader/level-details';
import type { NetworkComponentAPI } from '../types/component-api';
import type {
BufferFlushingData,
ErrorData,
FragLoadedData,
LevelLoadedData,
MediaDetachingData,
SubtitleFragProcessed,
SubtitleTracksUpdatedData,
TrackLoadedData,
TrackSwitchedData,
} from '../types/events';
import type { Bufferable } from '../utils/buffer-helper';
const TICK_INTERVAL = 500; // how often to tick in ms
interface TimeRange {
start: number;
end: number;
}
export class SubtitleStreamController
extends BaseStreamController
implements NetworkComponentAPI
{
private currentTrackId: number = -1;
private tracksBuffered: Array<TimeRange[]> = [];
private mainDetails: LevelDetails | null = null;
constructor(
hls: Hls,
fragmentTracker: FragmentTracker,
keyLoader: KeyLoader,
) {
super(
hls,
fragmentTracker,
keyLoader,
'subtitle-stream-controller',
PlaylistLevelType.SUBTITLE,
);
this.registerListeners();
}
protected onHandlerDestroying() {
this.unregisterListeners();
super.onHandlerDestroying();
this.mainDetails = null;
}
protected registerListeners() {
super.registerListeners();
const { hls } = this;
hls.on(Events.LEVEL_LOADED, this.onLevelLoaded, this);
hls.on(Events.SUBTITLE_TRACKS_UPDATED, this.onSubtitleTracksUpdated, this);
hls.on(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this);
hls.on(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.on(Events.SUBTITLE_FRAG_PROCESSED, this.onSubtitleFragProcessed, this);
hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
}
protected unregisterListeners() {
super.unregisterListeners();
const { hls } = this;
hls.off(Events.LEVEL_LOADED, this.onLevelLoaded, this);
hls.off(Events.SUBTITLE_TRACKS_UPDATED, this.onSubtitleTracksUpdated, this);
hls.off(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this);
hls.off(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.off(Events.SUBTITLE_FRAG_PROCESSED, this.onSubtitleFragProcessed, this);
hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
}
startLoad(startPosition: number, skipSeekToStartPosition?: boolean) {
this.stopLoad();
this.state = State.IDLE;
this.setInterval(TICK_INTERVAL);
this.nextLoadPosition = this.lastCurrentTime =
startPosition + this.timelineOffset;
this.startPosition = skipSeekToStartPosition ? -1 : startPosition;
this.tick();
}
protected onManifestLoading() {
super.onManifestLoading();
this.mainDetails = null;
}
protected onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
this.tracksBuffered = [];
super.onMediaDetaching(event, data);
}
private onLevelLoaded(event: Events.LEVEL_LOADED, data: LevelLoadedData) {
this.mainDetails = data.details;
}
private onSubtitleFragProcessed(
event: Events.SUBTITLE_FRAG_PROCESSED,
data: SubtitleFragProcessed,
) {
const { frag, success } = data;
if (!this.fragContextChanged(frag)) {
if (isMediaFragment(frag)) {
this.fragPrevious = frag;
}
this.state = State.IDLE;
}
if (!success) {
return;
}
const buffered = this.tracksBuffered[this.currentTrackId];
if (!buffered) {
return;
}
// Create/update a buffered array matching the interface used by BufferHelper.bufferedInfo
// so we can re-use the logic used to detect how much has been buffered
let timeRange: TimeRange | undefined;
const fragStart = frag.start;
for (let i = 0; i < buffered.length; i++) {
if (fragStart >= buffered[i].start && fragStart <= buffered[i].end) {
timeRange = buffered[i];
break;
}
}
const fragEnd = frag.start + frag.duration;
if (timeRange) {
timeRange.end = fragEnd;
} else {
timeRange = {
start: fragStart,
end: fragEnd,
};
buffered.push(timeRange);
}
this.fragmentTracker.fragBuffered(frag as MediaFragment);
this.fragBufferedComplete(frag, null);
if (this.media) {
this.tick();
}
}
private onBufferFlushing(
event: Events.BUFFER_FLUSHING,
data: BufferFlushingData,
) {
const { startOffset, endOffset } = data;
if (startOffset === 0 && endOffset !== Number.POSITIVE_INFINITY) {
const endOffsetSubtitles = endOffset - 1;
if (endOffsetSubtitles <= 0) {
return;
}
data.endOffsetSubtitles = Math.max(0, endOffsetSubtitles);
this.tracksBuffered.forEach((buffered) => {
for (let i = 0; i < buffered.length; ) {
if (buffered[i].end <= endOffsetSubtitles) {
buffered.shift();
continue;
} else if (buffered[i].start < endOffsetSubtitles) {
buffered[i].start = endOffsetSubtitles;
} else {
break;
}
i++;
}
});
this.fragmentTracker.removeFragmentsInRange(
startOffset,
endOffsetSubtitles,
PlaylistLevelType.SUBTITLE,
);
}
}
// If something goes wrong, proceed to next frag, if we were processing one.
protected onError(event: Events.ERROR, data: ErrorData) {
const frag = data.frag;
if (frag?.type === PlaylistLevelType.SUBTITLE) {
if (data.details === ErrorDetails.FRAG_GAP) {
this.fragmentTracker.fragBuffered(frag as MediaFragment, true);
}
if (this.fragCurrent) {
this.fragCurrent.abortRequests();
}
if (this.state !== State.STOPPED) {
this.state = State.IDLE;
}
}
}
// Got all new subtitle levels.
private onSubtitleTracksUpdated(
event: Events.SUBTITLE_TRACKS_UPDATED,
{ subtitleTracks }: SubtitleTracksUpdatedData,
) {
if (this.levels && subtitleOptionsIdentical(this.levels, subtitleTracks)) {
this.levels = subtitleTracks.map(
(mediaPlaylist) => new Level(mediaPlaylist),
);
return;
}
this.tracksBuffered = [];
this.levels = subtitleTracks.map((mediaPlaylist) => {
const level = new Level(mediaPlaylist);
this.tracksBuffered[level.id] = [];
return level;
});
this.fragmentTracker.removeFragmentsInRange(
0,
Number.POSITIVE_INFINITY,
PlaylistLevelType.SUBTITLE,
);
this.fragPrevious = null;
this.mediaBuffer = null;
}
private onSubtitleTrackSwitch(
event: Events.SUBTITLE_TRACK_SWITCH,
data: TrackSwitchedData,
) {
this.currentTrackId = data.id;
if (!this.levels?.length || this.currentTrackId === -1) {
this.clearInterval();
return;
}
// Check if track has the necessary details to load fragments
const currentTrack = this.levels[this.currentTrackId];
if (currentTrack?.details) {
this.mediaBuffer = this.mediaBufferTimeRanges;
} else {
this.mediaBuffer = null;
}
if (currentTrack && this.state !== State.STOPPED) {
this.setInterval(TICK_INTERVAL);
}
}
// Got a new set of subtitle fragments.
private onSubtitleTrackLoaded(
event: Events.SUBTITLE_TRACK_LOADED,
data: TrackLoadedData,
) {
const { currentTrackId, levels } = this;
const { details: newDetails, id: trackId } = data;
if (!levels) {
this.warn(`Subtitle tracks were reset while loading level ${trackId}`);
return;
}
const track: Level = levels[trackId];
if (trackId >= levels.length || !track) {
return;
}
this.log(
`Subtitle track ${trackId} loaded [${newDetails.startSN},${
newDetails.endSN
}]${
newDetails.lastPartSn
? `[part-${newDetails.lastPartSn}-${newDetails.lastPartIndex}]`
: ''
},duration:${newDetails.totalduration}`,
);
this.mediaBuffer = this.mediaBufferTimeRanges;
let sliding = 0;
if (newDetails.live || track.details?.live) {
if (newDetails.deltaUpdateFailed) {
return;
}
const mainDetails = this.mainDetails;
if (!mainDetails) {
this.startFragRequested = false;
return;
}
const mainSlidingStartFragment = mainDetails.fragments[0];
if (!track.details) {
if (newDetails.hasProgramDateTime && mainDetails.hasProgramDateTime) {
alignMediaPlaylistByPDT(newDetails, mainDetails);
sliding = newDetails.fragmentStart;
} else if (mainSlidingStartFragment) {
// line up live playlist with main so that fragments in range are loaded
sliding = mainSlidingStartFragment.start;
addSliding(newDetails, sliding);
}
} else {
sliding = this.alignPlaylists(
newDetails,
track.details,
this.levelLastLoaded?.details,
);
if (sliding === 0 && mainSlidingStartFragment) {
// realign with main when there is no overlap with last refresh
sliding = mainSlidingStartFragment.start;
addSliding(newDetails, sliding);
}
}
// compute start position if we are aligned with the main playlist
if (mainDetails && !this.startFragRequested) {
this.setStartPosition(mainDetails, sliding);
}
}
track.details = newDetails;
this.levelLastLoaded = track;
if (trackId !== currentTrackId) {
return;
}
this.hls.trigger(Events.SUBTITLE_TRACK_UPDATED, {
details: newDetails,
id: trackId,
groupId: data.groupId,
});
// trigger handler right now
this.tick();
// If playlist is misaligned because of bad PDT or drift, delete details to resync with main on reload
if (
newDetails.live &&
!this.fragCurrent &&
this.media &&
this.state === State.IDLE
) {
const foundFrag = findFragmentByPTS(
null,
newDetails.fragments,
this.media.currentTime,
0,
);
if (!foundFrag) {
this.warn('Subtitle playlist not aligned with playback');
track.details = undefined;
}
}
}
_handleFragmentLoadComplete(fragLoadedData: FragLoadedData) {
const { frag, payload } = fragLoadedData;
const decryptData = frag.decryptdata;
const hls = this.hls;
if (this.fragContextChanged(frag)) {
return;
}
// check to see if the payload needs to be decrypted
if (
payload &&
payload.byteLength > 0 &&
decryptData?.key &&
decryptData.iv &&
isFullSegmentEncryption(decryptData.method)
) {
const startTime = performance.now();
// decrypt the subtitles
this.decrypter
.decrypt(
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer,
getAesModeFromFullSegmentMethod(decryptData.method),
)
.catch((err) => {
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_DECRYPT_ERROR,
fatal: false,
error: err,
reason: err.message,
frag,
});
throw err;
})
.then((decryptedData) => {
const endTime = performance.now();
hls.trigger(Events.FRAG_DECRYPTED, {
frag,
payload: decryptedData,
stats: {
tstart: startTime,
tdecrypt: endTime,
},
});
})
.catch((err) => {
this.warn(`${err.name}: ${err.message}`);
this.state = State.IDLE;
});
}
}
doTick() {
if (!this.media) {
this.state = State.IDLE;
return;
}
if (this.state === State.IDLE) {
const { currentTrackId, levels } = this;
const track = levels?.[currentTrackId];
if (!track || !levels.length || !track.details) {
return;
}
if (this.waitForLive(track)) {
return;
}
const { config } = this;
const currentTime = this.getLoadPosition();
const bufferedInfo = BufferHelper.bufferedInfo(
this.tracksBuffered[this.currentTrackId] || [],
currentTime,
config.maxBufferHole,
);
const { end: targetBufferTime, len: bufferLen } = bufferedInfo;
const trackDetails = track.details as LevelDetails;
const maxBufLen =
this.hls.maxBufferLength + trackDetails.levelTargetDuration;
if (bufferLen > maxBufLen) {
return;
}
const fragments = trackDetails.fragments;
const fragLen = fragments.length;
const end = trackDetails.edge;
let foundFrag: MediaFragment | null = null;
const fragPrevious = this.fragPrevious;
if (targetBufferTime < end) {
const tolerance = config.maxFragLookUpTolerance;
const lookupTolerance =
targetBufferTime > end - tolerance ? 0 : tolerance;
foundFrag = findFragmentByPTS(
fragPrevious,
fragments,
Math.max(fragments[0].start, targetBufferTime),
lookupTolerance,
);
if (
!foundFrag &&
fragPrevious &&
fragPrevious.start < fragments[0].start
) {
foundFrag = fragments[0];
}
} else {
foundFrag = fragments[fragLen - 1];
}
foundFrag = this.filterReplacedPrimary(foundFrag, track.details);
if (!foundFrag) {
return;
}
// Load earlier fragment in same discontinuity to make up for misaligned playlists and cues that extend beyond end of segment
const curSNIdx = foundFrag.sn - trackDetails.startSN;
const prevFrag = fragments[curSNIdx - 1];
if (
prevFrag &&
prevFrag.cc === foundFrag.cc &&
this.fragmentTracker.getState(prevFrag) === FragmentState.NOT_LOADED
) {
foundFrag = prevFrag;
}
if (
this.fragmentTracker.getState(foundFrag) === FragmentState.NOT_LOADED
) {
// only load if fragment is not loaded
const fragToLoad = this.mapToInitFragWhenRequired(foundFrag);
if (fragToLoad) {
this.loadFragment(fragToLoad, track, targetBufferTime);
}
}
}
}
protected loadFragment(
frag: Fragment,
level: Level,
targetBufferTime: number,
) {
if (!isMediaFragment(frag)) {
this._loadInitSegment(frag, level);
} else {
super.loadFragment(frag, level, targetBufferTime);
}
}
get mediaBufferTimeRanges(): Bufferable {
return new BufferableInstance(
this.tracksBuffered[this.currentTrackId] || [],
);
}
}
class BufferableInstance implements Bufferable {
public readonly buffered: TimeRanges;
constructor(timeranges: TimeRange[]) {
const getRange = (
name: 'start' | 'end',
index: number,
length: number,
): number => {
index = index >>> 0;
if (index > length - 1) {
throw new DOMException(
`Failed to execute '${name}' on 'TimeRanges': The index provided (${index}) is greater than the maximum bound (${length})`,
);
}
return timeranges[index][name];
};
this.buffered = {
get length() {
return timeranges.length;
},
end(index: number): number {
return getRange('end', index, timeranges.length);
},
start(index: number): number {
return getRange('start', index, timeranges.length);
},
};
}
}
+589
View File
@@ -0,0 +1,589 @@
import BasePlaylistController from './base-playlist-controller';
import { Events } from '../events';
import { PlaylistContextType } from '../types/loader';
import {
mediaAttributesIdentical,
subtitleTrackMatchesTextTrack,
} from '../utils/media-option-attributes';
import { findMatchingOption, matchesOption } from '../utils/rendition-helper';
import {
clearCurrentCues,
filterSubtitleTracks,
} from '../utils/texttrack-utils';
import type Hls from '../hls';
import type {
ErrorData,
LevelLoadingData,
LevelSwitchingData,
ManifestParsedData,
MediaAttachedData,
MediaDetachingData,
SubtitleTracksUpdatedData,
TrackLoadedData,
} from '../types/events';
import type { HlsUrlParameters } from '../types/level';
import type {
MediaPlaylist,
SubtitleSelectionOption,
} from '../types/media-playlist';
class SubtitleTrackController extends BasePlaylistController {
private media: HTMLMediaElement | null = null;
private tracks: MediaPlaylist[] = [];
private groupIds: (string | undefined)[] | null = null;
private tracksInGroup: MediaPlaylist[] = [];
private trackId: number = -1;
private currentTrack: MediaPlaylist | null = null;
private selectDefaultTrack: boolean = true;
private queuedDefaultTrack: number = -1;
private useTextTrackPolling: boolean = false;
private subtitlePollingInterval: number = -1;
private _subtitleDisplay: boolean = true;
private asyncPollTrackChange = () => this.pollTrackChange(0);
constructor(hls: Hls) {
super(hls, 'subtitle-track-controller');
this.registerListeners();
}
public destroy() {
this.unregisterListeners();
this.tracks.length = 0;
this.tracksInGroup.length = 0;
this.currentTrack = null;
// @ts-ignore
this.onTextTracksChanged = this.asyncPollTrackChange = null;
super.destroy();
}
public get subtitleDisplay(): boolean {
return this._subtitleDisplay;
}
public set subtitleDisplay(value: boolean) {
this._subtitleDisplay = value;
if (this.trackId > -1) {
this.toggleTrackModes();
}
}
private registerListeners() {
const { hls } = this;
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
hls.on(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.on(Events.ERROR, this.onError, this);
}
private unregisterListeners() {
const { hls } = this;
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
hls.off(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.off(Events.ERROR, this.onError, this);
}
// Listen for subtitle track change, then extract the current track ID.
protected onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData,
): void {
this.media = data.media;
if (!this.media) {
return;
}
if (this.queuedDefaultTrack > -1) {
this.subtitleTrack = this.queuedDefaultTrack;
this.queuedDefaultTrack = -1;
}
this.useTextTrackPolling = !(
this.media.textTracks && 'onchange' in this.media.textTracks
);
if (this.useTextTrackPolling) {
this.pollTrackChange(500);
} else {
this.media.textTracks.addEventListener(
'change',
this.asyncPollTrackChange,
);
}
}
private pollTrackChange(timeout: number) {
self.clearInterval(this.subtitlePollingInterval);
this.subtitlePollingInterval = self.setInterval(
this.onTextTracksChanged,
timeout,
);
}
protected onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
const media = this.media;
if (!media) {
return;
}
const transferringMedia = !!data.transferMedia;
self.clearInterval(this.subtitlePollingInterval);
if (!this.useTextTrackPolling) {
media.textTracks.removeEventListener('change', this.asyncPollTrackChange);
}
if (this.trackId > -1) {
this.queuedDefaultTrack = this.trackId;
}
// Disable all subtitle tracks before detachment so when reattached only tracks in that content are enabled.
this.subtitleTrack = -1;
this.media = null;
if (transferringMedia) {
return;
}
const textTracks = filterSubtitleTracks(media.textTracks);
// Clear loaded cues on media detachment from tracks
textTracks.forEach((track) => {
clearCurrentCues(track);
});
}
protected onManifestLoading(): void {
this.tracks = [];
this.groupIds = null;
this.tracksInGroup = [];
this.trackId = -1;
this.currentTrack = null;
this.selectDefaultTrack = true;
}
// Fired whenever a new manifest is loaded.
protected onManifestParsed(
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
): void {
this.tracks = data.subtitleTracks;
}
protected onSubtitleTrackLoaded(
event: Events.SUBTITLE_TRACK_LOADED,
data: TrackLoadedData,
): void {
const { id, groupId, details } = data;
const trackInActiveGroup = this.tracksInGroup[id];
if (!trackInActiveGroup || trackInActiveGroup.groupId !== groupId) {
this.warn(
`Subtitle track with id:${id} and group:${groupId} not found in active group ${trackInActiveGroup?.groupId}`,
);
return;
}
const curDetails = trackInActiveGroup.details;
trackInActiveGroup.details = data.details;
this.log(
`Subtitle track ${id} "${trackInActiveGroup.name}" lang:${trackInActiveGroup.lang} group:${groupId} loaded [${details.startSN}-${details.endSN}]`,
);
if (id === this.trackId) {
this.playlistLoaded(id, data, curDetails);
}
}
protected onLevelLoading(
event: Events.LEVEL_LOADING,
data: LevelLoadingData,
): void {
this.switchLevel(data.level);
}
protected onLevelSwitching(
event: Events.LEVEL_SWITCHING,
data: LevelSwitchingData,
): void {
this.switchLevel(data.level);
}
private switchLevel(levelIndex: number) {
const levelInfo = this.hls.levels[levelIndex];
if (!levelInfo) {
return;
}
const subtitleGroups = levelInfo.subtitleGroups || null;
const currentGroups = this.groupIds;
let currentTrack = this.currentTrack;
if (
!subtitleGroups ||
currentGroups?.length !== subtitleGroups?.length ||
subtitleGroups?.some((groupId) => currentGroups?.indexOf(groupId) === -1)
) {
this.groupIds = subtitleGroups;
this.trackId = -1;
this.currentTrack = null;
const subtitleTracks = this.tracks.filter(
(track): boolean =>
!subtitleGroups || subtitleGroups.indexOf(track.groupId) !== -1,
);
if (subtitleTracks.length) {
// Disable selectDefaultTrack if there are no default tracks
if (
this.selectDefaultTrack &&
!subtitleTracks.some((track) => track.default)
) {
this.selectDefaultTrack = false;
}
// track.id should match hls.audioTracks index
subtitleTracks.forEach((track, i) => {
track.id = i;
});
} else if (!currentTrack && !this.tracksInGroup.length) {
// Do not dispatch SUBTITLE_TRACKS_UPDATED when there were and are no tracks
return;
}
this.tracksInGroup = subtitleTracks;
// Find preferred track
const subtitlePreference = this.hls.config.subtitlePreference;
if (!currentTrack && subtitlePreference) {
this.selectDefaultTrack = false;
const groupIndex = findMatchingOption(
subtitlePreference,
subtitleTracks,
);
if (groupIndex > -1) {
currentTrack = subtitleTracks[groupIndex];
} else {
const allIndex = findMatchingOption(subtitlePreference, this.tracks);
currentTrack = this.tracks[allIndex];
}
}
// Select initial track
let trackId = this.findTrackId(currentTrack);
if (trackId === -1 && currentTrack) {
trackId = this.findTrackId(null);
}
// Dispatch events and load track if needed
const subtitleTracksUpdated: SubtitleTracksUpdatedData = {
subtitleTracks,
};
this.log(
`Updating subtitle tracks, ${
subtitleTracks.length
} track(s) found in "${subtitleGroups?.join(',')}" group-id`,
);
this.hls.trigger(Events.SUBTITLE_TRACKS_UPDATED, subtitleTracksUpdated);
if (trackId !== -1 && this.trackId === -1) {
this.setSubtitleTrack(trackId);
}
}
}
private findTrackId(currentTrack: MediaPlaylist | null): number {
const tracks = this.tracksInGroup;
const selectDefault = this.selectDefaultTrack;
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (
(selectDefault && !track.default) ||
(!selectDefault && !currentTrack)
) {
continue;
}
if (!currentTrack || matchesOption(track, currentTrack)) {
return i;
}
}
if (currentTrack) {
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (
mediaAttributesIdentical(currentTrack.attrs, track.attrs, [
'LANGUAGE',
'ASSOC-LANGUAGE',
'CHARACTERISTICS',
])
) {
return i;
}
}
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (
mediaAttributesIdentical(currentTrack.attrs, track.attrs, [
'LANGUAGE',
])
) {
return i;
}
}
}
return -1;
}
private findTrackForTextTrack(textTrack: TextTrack | null): number {
if (textTrack) {
const tracks = this.tracksInGroup;
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (subtitleTrackMatchesTextTrack(track, textTrack)) {
return i;
}
}
}
return -1;
}
protected onError(event: Events.ERROR, data: ErrorData): void {
if (data.fatal || !data.context) {
return;
}
if (
data.context.type === PlaylistContextType.SUBTITLE_TRACK &&
data.context.id === this.trackId &&
(!this.groupIds || this.groupIds.indexOf(data.context.groupId) !== -1)
) {
this.checkRetry(data);
}
}
get allSubtitleTracks(): MediaPlaylist[] {
return this.tracks;
}
/** get alternate subtitle tracks list from playlist **/
get subtitleTracks(): MediaPlaylist[] {
return this.tracksInGroup;
}
/** get/set index of the selected subtitle track (based on index in subtitle track lists) **/
get subtitleTrack(): number {
return this.trackId;
}
set subtitleTrack(newId: number) {
this.selectDefaultTrack = false;
this.setSubtitleTrack(newId);
}
public setSubtitleOption(
subtitleOption: MediaPlaylist | SubtitleSelectionOption | undefined,
): MediaPlaylist | null {
this.hls.config.subtitlePreference = subtitleOption;
if (subtitleOption) {
if (subtitleOption.id === -1) {
this.setSubtitleTrack(-1);
return null;
}
const allSubtitleTracks = this.allSubtitleTracks;
this.selectDefaultTrack = false;
if (allSubtitleTracks.length) {
// First see if current option matches (no switch op)
const currentTrack = this.currentTrack;
if (currentTrack && matchesOption(subtitleOption, currentTrack)) {
return currentTrack;
}
// Find option in current group
const groupIndex = findMatchingOption(
subtitleOption,
this.tracksInGroup,
);
if (groupIndex > -1) {
const track = this.tracksInGroup[groupIndex];
this.setSubtitleTrack(groupIndex);
return track;
} else if (currentTrack) {
// If this is not the initial selection return null
// option should have matched one in active group
return null;
} else {
// Find the option in all tracks for initial selection
const allIndex = findMatchingOption(
subtitleOption,
allSubtitleTracks,
);
if (allIndex > -1) {
return allSubtitleTracks[allIndex];
}
}
}
}
return null;
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
super.loadPlaylist();
if (this.shouldLoadPlaylist(this.currentTrack)) {
this.scheduleLoading(this.currentTrack, hlsUrlParameters);
}
}
protected loadingPlaylist(
currentTrack: MediaPlaylist,
hlsUrlParameters: HlsUrlParameters | undefined,
) {
super.loadingPlaylist(currentTrack, hlsUrlParameters);
const id = currentTrack.id;
const groupId = currentTrack.groupId as string;
const url = this.getUrlWithDirectives(currentTrack.url, hlsUrlParameters);
const details = currentTrack.details;
const age = details?.age;
this.log(
`Loading subtitle ${id} "${currentTrack.name}" lang:${currentTrack.lang} group:${groupId}${
hlsUrlParameters?.msn !== undefined
? ' at sn ' + hlsUrlParameters.msn + ' part ' + hlsUrlParameters.part
: ''
}${age && details.live ? ' age ' + age.toFixed(1) + (details.type ? ' ' + details.type || '' : '') : ''} ${url}`,
);
this.hls.trigger(Events.SUBTITLE_TRACK_LOADING, {
url,
id,
groupId,
deliveryDirectives: hlsUrlParameters || null,
track: currentTrack,
});
}
/**
* Disables the old subtitleTrack and sets current mode on the next subtitleTrack.
* This operates on the DOM textTracks.
* A value of -1 will disable all subtitle tracks.
*/
private toggleTrackModes(): void {
const { media } = this;
if (!media) {
return;
}
const textTracks = filterSubtitleTracks(media.textTracks);
const currentTrack = this.currentTrack;
let nextTrack;
if (currentTrack) {
nextTrack = textTracks.filter((textTrack) =>
subtitleTrackMatchesTextTrack(currentTrack, textTrack),
)[0];
if (!nextTrack) {
this.warn(
`Unable to find subtitle TextTrack with name "${currentTrack.name}" and language "${currentTrack.lang}"`,
);
}
}
[].slice.call(textTracks).forEach((track) => {
if (track.mode !== 'disabled' && track !== nextTrack) {
track.mode = 'disabled';
}
});
if (nextTrack) {
const mode = this.subtitleDisplay ? 'showing' : 'hidden';
if (nextTrack.mode !== mode) {
nextTrack.mode = mode;
}
}
}
/**
* This method is responsible for validating the subtitle index and periodically reloading if live.
* Dispatches the SUBTITLE_TRACK_SWITCH event, which instructs the subtitle-stream-controller to load the selected track.
*/
private setSubtitleTrack(newId: number): void {
const tracks = this.tracksInGroup;
// setting this.subtitleTrack will trigger internal logic
// if media has not been attached yet, it will fail
// we keep a reference to the default track id
// and we'll set subtitleTrack when onMediaAttached is triggered
if (!this.media) {
this.queuedDefaultTrack = newId;
return;
}
// exit if track id as already set or invalid
if (newId < -1 || newId >= tracks.length || !Number.isFinite(newId)) {
this.warn(`Invalid subtitle track id: ${newId}`);
return;
}
this.selectDefaultTrack = false;
const lastTrack = this.currentTrack;
const track: MediaPlaylist | null = tracks[newId] || null;
this.trackId = newId;
this.currentTrack = track;
this.toggleTrackModes();
if (!track) {
// switch to -1
this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, { id: newId });
return;
}
const trackLoaded = !!track.details && !track.details.live;
if (newId === this.trackId && track === lastTrack && trackLoaded) {
return;
}
this.log(
`Switching to subtitle-track ${newId}` +
(track
? ` "${track.name}" lang:${track.lang} group:${track.groupId}`
: ''),
);
const { id, groupId = '', name, type, url } = track;
this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, {
id,
groupId,
name,
type,
url,
});
const hlsUrlParameters = this.switchParams(
track.url,
lastTrack?.details,
track.details,
);
this.loadPlaylist(hlsUrlParameters);
}
private onTextTracksChanged = () => {
if (!this.useTextTrackPolling) {
self.clearInterval(this.subtitlePollingInterval);
}
// Media is undefined when switching streams via loadSource()
if (!this.media || !this.hls.config.renderTextTracksNatively) {
return;
}
let textTrack: TextTrack | null = null;
const tracks = filterSubtitleTracks(this.media.textTracks);
for (let i = 0; i < tracks.length; i++) {
if (tracks[i].mode === 'hidden') {
// Do not break in case there is a following track with showing.
textTrack = tracks[i];
} else if (tracks[i].mode === 'showing') {
textTrack = tracks[i];
break;
}
}
// Find internal track index for TextTrack
const trackId = this.findTrackForTextTrack(textTrack);
if (this.subtitleTrack !== trackId) {
this.setSubtitleTrack(trackId);
}
};
}
export default SubtitleTrackController;
+814
View File
@@ -0,0 +1,814 @@
import { Events } from '../events';
import { PlaylistLevelType } from '../types/loader';
import Cea608Parser from '../utils/cea-608-parser';
import { IMSC1_CODEC, parseIMSC1 } from '../utils/imsc1-ttml-parser';
import {
subtitleOptionsIdentical,
subtitleTrackMatchesTextTrack,
} from '../utils/media-option-attributes';
import { appendUint8Array } from '../utils/mp4-tools';
import OutputFilter from '../utils/output-filter';
import {
addCueToTrack,
clearCurrentCues,
filterSubtitleTracks,
removeCuesInRange,
sendAddTrackEvent,
} from '../utils/texttrack-utils';
import { parseWebVTT } from '../utils/webvtt-parser';
import type { HlsConfig } from '../config';
import type Hls from '../hls';
import type { Fragment } from '../loader/fragment';
import type { ComponentAPI } from '../types/component-api';
import type {
BufferFlushingData,
FragDecryptedData,
FragLoadedData,
FragLoadingData,
FragParsingUserdataData,
InitPTSFoundData,
ManifestLoadedData,
MediaAttachingData,
MediaDetachingData,
SubtitleTracksUpdatedData,
} from '../types/events';
import type { MediaPlaylist } from '../types/media-playlist';
import type { VTTCCs } from '../types/vtt';
import type { CaptionScreen } from '../utils/cea-608-parser';
import type { CuesInterface } from '../utils/cues';
import type { TimestampOffset } from '../utils/timescale-conversion';
type TrackProperties = {
label: string;
languageCode: string;
media?: MediaPlaylist;
};
type NonNativeCaptionsTrack = {
_id?: string;
label: string;
kind: string;
default: boolean;
closedCaptions?: MediaPlaylist;
subtitleTrack?: MediaPlaylist;
};
export class TimelineController implements ComponentAPI {
private hls: Hls;
private media: HTMLMediaElement | null = null;
private config: HlsConfig;
private enabled: boolean = true;
private Cues: CuesInterface;
private textTracks: Array<TextTrack> = [];
private tracks: Array<MediaPlaylist> = [];
private initPTS: TimestampOffset[] = [];
private unparsedVttFrags: Array<FragLoadedData | FragDecryptedData> = [];
private captionsTracks: Record<string, TextTrack> = {};
private nonNativeCaptionsTracks: Record<string, NonNativeCaptionsTrack> = {};
private cea608Parser1?: Cea608Parser;
private cea608Parser2?: Cea608Parser;
private lastCc: number = -1; // Last video (CEA-608) fragment CC
private lastSn: number = -1; // Last video (CEA-608) fragment MSN
private lastPartIndex: number = -1; // Last video (CEA-608) fragment Part Index
private prevCC: number = -1; // Last subtitle fragment CC
private vttCCs: VTTCCs = newVTTCCs();
private captionsProperties: {
textTrack1: TrackProperties;
textTrack2: TrackProperties;
textTrack3: TrackProperties;
textTrack4: TrackProperties;
};
constructor(hls: Hls) {
this.hls = hls;
this.config = hls.config;
this.Cues = hls.config.cueHandler;
this.captionsProperties = {
textTrack1: {
label: this.config.captionsTextTrack1Label,
languageCode: this.config.captionsTextTrack1LanguageCode,
},
textTrack2: {
label: this.config.captionsTextTrack2Label,
languageCode: this.config.captionsTextTrack2LanguageCode,
},
textTrack3: {
label: this.config.captionsTextTrack3Label,
languageCode: this.config.captionsTextTrack3LanguageCode,
},
textTrack4: {
label: this.config.captionsTextTrack4Label,
languageCode: this.config.captionsTextTrack4LanguageCode,
},
};
hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.on(Events.SUBTITLE_TRACKS_UPDATED, this.onSubtitleTracksUpdated, this);
hls.on(Events.FRAG_LOADING, this.onFragLoading, this);
hls.on(Events.FRAG_LOADED, this.onFragLoaded, this);
hls.on(Events.FRAG_PARSING_USERDATA, this.onFragParsingUserdata, this);
hls.on(Events.FRAG_DECRYPTED, this.onFragDecrypted, this);
hls.on(Events.INIT_PTS_FOUND, this.onInitPtsFound, this);
hls.on(Events.SUBTITLE_TRACKS_CLEARED, this.onSubtitleTracksCleared, this);
hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
}
public destroy(): void {
const { hls } = this;
hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.off(Events.SUBTITLE_TRACKS_UPDATED, this.onSubtitleTracksUpdated, this);
hls.off(Events.FRAG_LOADING, this.onFragLoading, this);
hls.off(Events.FRAG_LOADED, this.onFragLoaded, this);
hls.off(Events.FRAG_PARSING_USERDATA, this.onFragParsingUserdata, this);
hls.off(Events.FRAG_DECRYPTED, this.onFragDecrypted, this);
hls.off(Events.INIT_PTS_FOUND, this.onInitPtsFound, this);
hls.off(Events.SUBTITLE_TRACKS_CLEARED, this.onSubtitleTracksCleared, this);
hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
// @ts-ignore
this.hls = this.config = this.media = null;
this.cea608Parser1 = this.cea608Parser2 = undefined;
}
private initCea608Parsers() {
const channel1 = new OutputFilter(this, 'textTrack1');
const channel2 = new OutputFilter(this, 'textTrack2');
const channel3 = new OutputFilter(this, 'textTrack3');
const channel4 = new OutputFilter(this, 'textTrack4');
this.cea608Parser1 = new Cea608Parser(1, channel1, channel2);
this.cea608Parser2 = new Cea608Parser(3, channel3, channel4);
}
public addCues(
trackName: string,
startTime: number,
endTime: number,
screen: CaptionScreen,
cueRanges: Array<[number, number]>,
) {
// skip cues which overlap more than 50% with previously parsed time ranges
let merged = false;
for (let i = cueRanges.length; i--; ) {
const cueRange = cueRanges[i];
const overlap = intersection(
cueRange[0],
cueRange[1],
startTime,
endTime,
);
if (overlap >= 0) {
cueRange[0] = Math.min(cueRange[0], startTime);
cueRange[1] = Math.max(cueRange[1], endTime);
merged = true;
if (overlap / (endTime - startTime) > 0.5) {
return;
}
}
}
if (!merged) {
cueRanges.push([startTime, endTime]);
}
if (this.config.renderTextTracksNatively) {
const track = this.captionsTracks[trackName];
this.Cues.newCue(track, startTime, endTime, screen);
} else {
const cues = this.Cues.newCue(null, startTime, endTime, screen);
this.hls.trigger(Events.CUES_PARSED, {
type: 'captions',
cues,
track: trackName,
});
}
}
// Triggered when an initial PTS is found; used for synchronisation of WebVTT.
private onInitPtsFound(
event: Events.INIT_PTS_FOUND,
{ frag, id, initPTS, timescale, trackId }: InitPTSFoundData,
) {
const { unparsedVttFrags } = this;
if (id === PlaylistLevelType.MAIN) {
this.initPTS[frag.cc] = { baseTime: initPTS, timescale, trackId };
}
// Due to asynchronous processing, initial PTS may arrive later than the first VTT fragments are loaded.
// Parse any unparsed fragments upon receiving the initial PTS.
if (unparsedVttFrags.length) {
this.unparsedVttFrags = [];
unparsedVttFrags.forEach((data) => {
if (this.initPTS[data.frag.cc]) {
this.onFragLoaded(Events.FRAG_LOADED, data as FragLoadedData);
} else {
this.hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag: data.frag,
error: new Error(
'Subtitle discontinuity domain does not match main',
),
});
}
});
}
}
private getExistingTrack(label: string, language: string): TextTrack | null {
const { media } = this;
if (media) {
for (let i = 0; i < media.textTracks.length; i++) {
const textTrack = media.textTracks[i];
if (
canReuseVttTextTrack(textTrack, {
name: label,
lang: language,
characteristics:
'transcribes-spoken-dialog,describes-music-and-sound',
attrs: {} as any,
})
) {
return textTrack;
}
}
}
return null;
}
public createCaptionsTrack(trackName: string) {
if (this.config.renderTextTracksNatively) {
this.createNativeTrack(trackName);
} else {
this.createNonNativeTrack(trackName);
}
}
private createNativeTrack(trackName: string) {
if (this.captionsTracks[trackName]) {
return;
}
const { captionsProperties, captionsTracks, media } = this;
const { label, languageCode } = captionsProperties[trackName];
// Enable reuse of existing text track.
const existingTrack = this.getExistingTrack(label, languageCode);
if (!existingTrack) {
const textTrack = this.createTextTrack('captions', label, languageCode);
if (textTrack) {
// Set a special property on the track so we know it's managed by Hls.js
textTrack[trackName] = true;
captionsTracks[trackName] = textTrack;
}
} else {
captionsTracks[trackName] = existingTrack;
clearCurrentCues(captionsTracks[trackName]);
sendAddTrackEvent(captionsTracks[trackName], media as HTMLMediaElement);
}
}
private createNonNativeTrack(trackName: string) {
if (this.nonNativeCaptionsTracks[trackName]) {
return;
}
// Create a list of a single track for the provider to consume
const trackProperties: TrackProperties = this.captionsProperties[trackName];
if (!trackProperties) {
return;
}
const label = trackProperties.label as string;
const track = {
_id: trackName,
label,
kind: 'captions',
default: trackProperties.media ? !!trackProperties.media.default : false,
closedCaptions: trackProperties.media,
};
this.nonNativeCaptionsTracks[trackName] = track;
this.hls.trigger(Events.NON_NATIVE_TEXT_TRACKS_FOUND, { tracks: [track] });
}
private createTextTrack(
kind: TextTrackKind,
label: string,
lang?: string,
): TextTrack | undefined {
const media = this.media;
if (!media) {
return;
}
return media.addTextTrack(kind, label, lang);
}
private onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
) {
this.media = data.media;
if (!data.mediaSource) {
this._cleanTracks();
}
}
private onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
const transferringMedia = !!data.transferMedia;
this.media = null;
if (transferringMedia) {
return;
}
const { captionsTracks } = this;
Object.keys(captionsTracks).forEach((trackName) => {
clearCurrentCues(captionsTracks[trackName]);
delete captionsTracks[trackName];
});
this.nonNativeCaptionsTracks = {};
}
private onManifestLoading() {
// Detect discontinuity in video fragment (CEA-608) parsing
this.lastCc = -1;
this.lastSn = -1;
this.lastPartIndex = -1;
// Detect discontinuity in subtitle manifests
this.prevCC = -1;
this.vttCCs = newVTTCCs();
// Reset tracks
this._cleanTracks();
this.tracks = [];
this.captionsTracks = {};
this.nonNativeCaptionsTracks = {};
this.textTracks = [];
this.unparsedVttFrags = [];
this.initPTS = [];
if (this.cea608Parser1 && this.cea608Parser2) {
this.cea608Parser1.reset();
this.cea608Parser2.reset();
}
}
private _cleanTracks() {
// clear outdated subtitles
const { media } = this;
if (!media) {
return;
}
const textTracks = media.textTracks;
if (textTracks) {
for (let i = 0; i < textTracks.length; i++) {
clearCurrentCues(textTracks[i]);
}
}
}
private onSubtitleTracksUpdated(
event: Events.SUBTITLE_TRACKS_UPDATED,
data: SubtitleTracksUpdatedData,
) {
const tracks: Array<MediaPlaylist> = data.subtitleTracks || [];
const hasIMSC1 = tracks.some((track) => track.textCodec === IMSC1_CODEC);
if (this.config.enableWebVTT || (hasIMSC1 && this.config.enableIMSC1)) {
const listIsIdentical = subtitleOptionsIdentical(this.tracks, tracks);
if (listIsIdentical) {
this.tracks = tracks;
return;
}
this.textTracks = [];
this.tracks = tracks;
if (this.config.renderTextTracksNatively) {
const media = this.media;
const inUseTracks: (TextTrack | null)[] | null = media
? filterSubtitleTracks(media.textTracks)
: null;
this.tracks.forEach((track, index) => {
// Reuse tracks with the same label and lang, but do not reuse 608/708 tracks
let textTrack: TextTrack | undefined;
if (inUseTracks) {
let inUseTrack: TextTrack | null = null;
for (let i = 0; i < inUseTracks.length; i++) {
if (
inUseTracks[i] &&
canReuseVttTextTrack(inUseTracks[i], track)
) {
inUseTrack = inUseTracks[i];
inUseTracks[i] = null;
break;
}
}
if (inUseTrack) {
textTrack = inUseTrack;
}
}
if (textTrack) {
clearCurrentCues(textTrack);
} else {
const textTrackKind = captionsOrSubtitlesFromCharacteristics(track);
textTrack = this.createTextTrack(
textTrackKind,
track.name,
track.lang,
);
if (textTrack) {
textTrack.mode = 'disabled';
}
}
if (textTrack) {
this.textTracks.push(textTrack);
}
});
// Warn when video element has captions or subtitle TextTracks carried over from another source
if (inUseTracks?.length) {
const unusedTextTracks = inUseTracks
.filter((t) => t !== null)
.map((t) => (t as TextTrack).label);
if (unusedTextTracks.length) {
this.hls.logger.warn(
`Media element contains unused subtitle tracks: ${unusedTextTracks.join(
', ',
)}. Replace media element for each source to clear TextTracks and captions menu.`,
);
}
}
} else if (this.tracks.length) {
// Create a list of tracks for the provider to consume
const tracksList = this.tracks.map((track) => {
return {
label: track.name,
kind: track.type.toLowerCase(),
default: track.default,
subtitleTrack: track,
};
});
this.hls.trigger(Events.NON_NATIVE_TEXT_TRACKS_FOUND, {
tracks: tracksList,
});
}
}
}
private onManifestLoaded(
event: Events.MANIFEST_LOADED,
data: ManifestLoadedData,
) {
if (this.config.enableCEA708Captions && data.captions) {
data.captions.forEach((captionsTrack) => {
const instreamIdMatch = /(?:CC|SERVICE)([1-4])/.exec(
captionsTrack.instreamId as string,
);
if (!instreamIdMatch) {
return;
}
const trackName = `textTrack${instreamIdMatch[1]}`;
const trackProperties: TrackProperties =
this.captionsProperties[trackName];
if (!trackProperties) {
return;
}
trackProperties.label = captionsTrack.name;
if (captionsTrack.lang) {
// optional attribute
trackProperties.languageCode = captionsTrack.lang;
}
trackProperties.media = captionsTrack;
});
}
}
private closedCaptionsForLevel(frag: Fragment): string | undefined {
const level = this.hls.levels[frag.level];
return level?.attrs['CLOSED-CAPTIONS'];
}
private onFragLoading(event: Events.FRAG_LOADING, data: FragLoadingData) {
// if this frag isn't contiguous, clear the parser so cues with bad start/end times aren't added to the textTrack
if (this.enabled && data.frag.type === PlaylistLevelType.MAIN) {
const { cea608Parser1, cea608Parser2, lastSn } = this;
const { cc, sn } = data.frag;
const partIndex = data.part?.index ?? -1;
if (cea608Parser1 && cea608Parser2) {
if (
sn !== lastSn + 1 ||
(sn === lastSn && partIndex !== this.lastPartIndex + 1) ||
cc !== this.lastCc
) {
cea608Parser1.reset();
cea608Parser2.reset();
}
}
this.lastCc = cc as number;
this.lastSn = sn as number;
this.lastPartIndex = partIndex;
}
}
private onFragLoaded(
event: Events.FRAG_LOADED,
data: FragDecryptedData | FragLoadedData,
) {
const { frag, payload } = data;
if (frag.type === PlaylistLevelType.SUBTITLE) {
// If fragment is subtitle type, parse as WebVTT.
if (payload.byteLength) {
const decryptData = frag.decryptdata;
// fragment after decryption has a stats object
const decrypted = 'stats' in data;
// If the subtitles are not encrypted, parse VTTs now. Otherwise, we need to wait.
if (decryptData == null || !decryptData.encrypted || decrypted) {
const trackPlaylistMedia = this.tracks[frag.level];
const vttCCs = this.vttCCs;
if (!vttCCs[frag.cc]) {
vttCCs[frag.cc] = {
start: frag.start,
prevCC: this.prevCC,
new: true,
};
this.prevCC = frag.cc;
}
if (
trackPlaylistMedia &&
trackPlaylistMedia.textCodec === IMSC1_CODEC
) {
this._parseIMSC1(frag, payload);
} else {
this._parseVTTs(data);
}
}
} else {
// In case there is no payload, finish unsuccessfully.
this.hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag,
error: new Error('Empty subtitle payload'),
});
}
}
}
private _parseIMSC1(frag: Fragment, payload: ArrayBuffer) {
const hls = this.hls;
parseIMSC1(
payload,
this.initPTS[frag.cc],
(cues) => {
this._appendCues(cues, frag.level);
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: true,
frag: frag,
});
},
(error) => {
hls.logger.log(`Failed to parse IMSC1: ${error}`);
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag: frag,
error,
});
},
);
}
private _parseVTTs(data: FragDecryptedData | FragLoadedData) {
const { frag, payload } = data;
// We need an initial synchronisation PTS. Store fragments as long as none has arrived
const { initPTS, unparsedVttFrags } = this;
const maxAvCC = initPTS.length - 1;
if (!initPTS[frag.cc] && maxAvCC === -1) {
unparsedVttFrags.push(data);
return;
}
const hls = this.hls;
// Parse the WebVTT file contents.
const payloadWebVTT = frag.initSegment?.data
? appendUint8Array(frag.initSegment.data, new Uint8Array(payload)).buffer
: payload;
parseWebVTT(
payloadWebVTT,
this.initPTS[frag.cc],
this.vttCCs,
frag.cc,
frag.start,
(cues) => {
this._appendCues(cues, frag.level);
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: true,
frag: frag,
});
},
(error) => {
const missingInitPTS =
error.message === 'Missing initPTS for VTT MPEGTS';
if (missingInitPTS) {
unparsedVttFrags.push(data);
} else {
this._fallbackToIMSC1(frag, payload);
}
// Something went wrong while parsing. Trigger event with success false.
hls.logger.log(`Failed to parse VTT cue: ${error}`);
if (missingInitPTS && maxAvCC > frag.cc) {
return;
}
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag: frag,
error,
});
},
);
}
private _fallbackToIMSC1(frag: Fragment, payload: ArrayBuffer) {
// If textCodec is unknown, try parsing as IMSC1. Set textCodec based on the result
const trackPlaylistMedia = this.tracks[frag.level];
if (!trackPlaylistMedia.textCodec) {
parseIMSC1(
payload,
this.initPTS[frag.cc],
() => {
trackPlaylistMedia.textCodec = IMSC1_CODEC;
this._parseIMSC1(frag, payload);
},
() => {
trackPlaylistMedia.textCodec = 'wvtt';
},
);
}
}
private _appendCues(cues: VTTCue[], fragLevel: number) {
const hls = this.hls;
if (this.config.renderTextTracksNatively) {
const textTrack = this.textTracks[fragLevel];
// WebVTTParser.parse is an async method and if the currently selected text track mode is set to "disabled"
// before parsing is done then don't try to access currentTrack.cues.getCueById as cues will be null
// and trying to access getCueById method of cues will throw an exception
// Because we check if the mode is disabled, we can force check `cues` below. They can't be null.
if (!textTrack || textTrack.mode === 'disabled') {
return;
}
cues.forEach((cue) => addCueToTrack(textTrack, cue));
} else {
const currentTrack = this.tracks[fragLevel];
if (!currentTrack) {
return;
}
const track = currentTrack.default ? 'default' : 'subtitles' + fragLevel;
hls.trigger(Events.CUES_PARSED, { type: 'subtitles', cues, track });
}
}
private onFragDecrypted(
event: Events.FRAG_DECRYPTED,
data: FragDecryptedData,
) {
const { frag } = data;
if (frag.type === PlaylistLevelType.SUBTITLE) {
this.onFragLoaded(Events.FRAG_LOADED, data as unknown as FragLoadedData);
}
}
private onSubtitleTracksCleared() {
this.tracks = [];
this.captionsTracks = {};
}
private onFragParsingUserdata(
event: Events.FRAG_PARSING_USERDATA,
data: FragParsingUserdataData,
) {
if (!this.enabled || !this.config.enableCEA708Captions) {
return;
}
const { frag, samples } = data;
if (
frag.type === PlaylistLevelType.MAIN &&
this.closedCaptionsForLevel(frag) === 'NONE'
) {
return;
}
// If the event contains captions (found in the bytes property), push all bytes into the parser immediately
// It will create the proper timestamps based on the PTS value
for (let i = 0; i < samples.length; i++) {
const ccBytes = samples[i].bytes;
if (ccBytes) {
if (!this.cea608Parser1) {
this.initCea608Parsers();
}
const ccdatas = this.extractCea608Data(ccBytes);
this.cea608Parser1!.addData(samples[i].pts, ccdatas[0]);
this.cea608Parser2!.addData(samples[i].pts, ccdatas[1]);
}
}
}
onBufferFlushing(
event: Events.BUFFER_FLUSHING,
{ startOffset, endOffset, endOffsetSubtitles, type }: BufferFlushingData,
) {
const { media } = this;
if (!media || media.currentTime < endOffset) {
return;
}
// Clear 608 caption cues from the captions TextTracks when the video back buffer is flushed
// Forward cues are never removed because we can loose streamed 608 content from recent fragments
if (!type || type === 'video') {
const { captionsTracks } = this;
Object.keys(captionsTracks).forEach((trackName) =>
removeCuesInRange(captionsTracks[trackName], startOffset, endOffset),
);
}
if (this.config.renderTextTracksNatively) {
// Clear VTT/IMSC1 subtitle cues from the subtitle TextTracks when the back buffer is flushed
if (startOffset === 0 && endOffsetSubtitles !== undefined) {
const { textTracks } = this;
Object.keys(textTracks).forEach((trackName) =>
removeCuesInRange(
textTracks[trackName],
startOffset,
endOffsetSubtitles,
),
);
}
}
}
private extractCea608Data(byteArray: Uint8Array): number[][] {
const actualCCBytes: number[][] = [[], []];
const count = byteArray[0] & 0x1f;
let position = 2;
for (let j = 0; j < count; j++) {
const tmpByte = byteArray[position++];
const ccbyte1 = 0x7f & byteArray[position++];
const ccbyte2 = 0x7f & byteArray[position++];
if (ccbyte1 === 0 && ccbyte2 === 0) {
continue;
}
const ccValid = (0x04 & tmpByte) !== 0; // Support all four channels
if (ccValid) {
const ccType = 0x03 & tmpByte;
if (
0x00 /* CEA608 field1*/ === ccType ||
0x01 /* CEA608 field2*/ === ccType
) {
// Exclude CEA708 CC data.
actualCCBytes[ccType].push(ccbyte1);
actualCCBytes[ccType].push(ccbyte2);
}
}
}
return actualCCBytes;
}
}
function captionsOrSubtitlesFromCharacteristics(
track: Pick<MediaPlaylist, 'name' | 'lang' | 'attrs' | 'characteristics'>,
): TextTrackKind {
if (track.characteristics) {
if (
/transcribes-spoken-dialog/gi.test(track.characteristics) &&
/describes-music-and-sound/gi.test(track.characteristics)
) {
return 'captions';
}
}
return 'subtitles';
}
function canReuseVttTextTrack(
inUseTrack: TextTrack | null,
manifestTrack: Pick<
MediaPlaylist,
'name' | 'lang' | 'attrs' | 'characteristics'
>,
): boolean {
return (
!!inUseTrack &&
inUseTrack.kind === captionsOrSubtitlesFromCharacteristics(manifestTrack) &&
subtitleTrackMatchesTextTrack(manifestTrack, inUseTrack)
);
}
function intersection(x1: number, x2: number, y1: number, y2: number): number {
return Math.min(x2, y2) - Math.max(x1, y1);
}
function newVTTCCs(): VTTCCs {
return {
ccOffset: 0,
presentationOffset: 0,
0: {
start: 0,
prevCC: -1,
new: true,
},
};
}
+32
View File
@@ -0,0 +1,32 @@
import { DecrypterAesMode } from './decrypter-aes-mode';
export default class AESCrypto {
private subtle: SubtleCrypto;
private aesIV: Uint8Array;
private aesMode: DecrypterAesMode;
constructor(subtle: SubtleCrypto, iv: Uint8Array, aesMode: DecrypterAesMode) {
this.subtle = subtle;
this.aesIV = iv;
this.aesMode = aesMode;
}
decrypt(data: ArrayBuffer, key: CryptoKey) {
switch (this.aesMode) {
case DecrypterAesMode.cbc:
return this.subtle.decrypt(
{ name: 'AES-CBC', iv: this.aesIV },
key,
data,
);
case DecrypterAesMode.ctr:
return this.subtle.decrypt(
{ name: 'AES-CTR', counter: this.aesIV, length: 64 }, //64 : NIST SP800-38A standard suggests that the counter should occupy half of the counter block
key,
data,
);
default:
throw new Error(`[AESCrypto] invalid aes mode ${this.aesMode}`);
}
}
}
+339
View File
@@ -0,0 +1,339 @@
// PKCS7
export function removePadding(array: Uint8Array<ArrayBuffer>) {
const outputBytes = array.byteLength;
const paddingBytes =
outputBytes && new DataView(array.buffer).getUint8(outputBytes - 1);
if (paddingBytes) {
return array.slice(0, outputBytes - paddingBytes);
}
return array;
}
export default class AESDecryptor {
private rcon: Array<number> = [
0x0, 0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36,
];
private subMix: Array<Uint32Array> = [
new Uint32Array(256),
new Uint32Array(256),
new Uint32Array(256),
new Uint32Array(256),
];
private invSubMix: Array<Uint32Array> = [
new Uint32Array(256),
new Uint32Array(256),
new Uint32Array(256),
new Uint32Array(256),
];
private sBox: Uint32Array = new Uint32Array(256);
private invSBox: Uint32Array = new Uint32Array(256);
private key: Uint32Array = new Uint32Array(0);
private ksRows: number = 0;
private keySize: number = 0;
private keySchedule!: Uint32Array;
private invKeySchedule!: Uint32Array;
constructor() {
this.initTable();
}
// Using view.getUint32() also swaps the byte order.
uint8ArrayToUint32Array_(arrayBuffer) {
const view = new DataView(arrayBuffer);
const newArray = new Uint32Array(4);
for (let i = 0; i < 4; i++) {
newArray[i] = view.getUint32(i * 4);
}
return newArray;
}
initTable() {
const sBox = this.sBox;
const invSBox = this.invSBox;
const subMix = this.subMix;
const subMix0 = subMix[0];
const subMix1 = subMix[1];
const subMix2 = subMix[2];
const subMix3 = subMix[3];
const invSubMix = this.invSubMix;
const invSubMix0 = invSubMix[0];
const invSubMix1 = invSubMix[1];
const invSubMix2 = invSubMix[2];
const invSubMix3 = invSubMix[3];
const d = new Uint32Array(256);
let x = 0;
let xi = 0;
let i = 0;
for (i = 0; i < 256; i++) {
if (i < 128) {
d[i] = i << 1;
} else {
d[i] = (i << 1) ^ 0x11b;
}
}
for (i = 0; i < 256; i++) {
let sx = xi ^ (xi << 1) ^ (xi << 2) ^ (xi << 3) ^ (xi << 4);
sx = (sx >>> 8) ^ (sx & 0xff) ^ 0x63;
sBox[x] = sx;
invSBox[sx] = x;
// Compute multiplication
const x2 = d[x];
const x4 = d[x2];
const x8 = d[x4];
// Compute sub/invSub bytes, mix columns tables
let t = (d[sx] * 0x101) ^ (sx * 0x1010100);
subMix0[x] = (t << 24) | (t >>> 8);
subMix1[x] = (t << 16) | (t >>> 16);
subMix2[x] = (t << 8) | (t >>> 24);
subMix3[x] = t;
// Compute inv sub bytes, inv mix columns tables
t = (x8 * 0x1010101) ^ (x4 * 0x10001) ^ (x2 * 0x101) ^ (x * 0x1010100);
invSubMix0[sx] = (t << 24) | (t >>> 8);
invSubMix1[sx] = (t << 16) | (t >>> 16);
invSubMix2[sx] = (t << 8) | (t >>> 24);
invSubMix3[sx] = t;
// Compute next counter
if (!x) {
x = xi = 1;
} else {
x = x2 ^ d[d[d[x8 ^ x2]]];
xi ^= d[d[xi]];
}
}
}
expandKey(keyBuffer: ArrayBuffer) {
// convert keyBuffer to Uint32Array
const key = this.uint8ArrayToUint32Array_(keyBuffer);
let sameKey = true;
let offset = 0;
while (offset < key.length && sameKey) {
sameKey = key[offset] === this.key[offset];
offset++;
}
if (sameKey) {
return;
}
this.key = key;
const keySize = (this.keySize = key.length);
if (keySize !== 4 && keySize !== 6 && keySize !== 8) {
throw new Error('Invalid aes key size=' + keySize);
}
const ksRows = (this.ksRows = (keySize + 6 + 1) * 4);
let ksRow;
let invKsRow;
const keySchedule = (this.keySchedule = new Uint32Array(ksRows));
const invKeySchedule = (this.invKeySchedule = new Uint32Array(ksRows));
const sbox = this.sBox;
const rcon = this.rcon;
const invSubMix = this.invSubMix;
const invSubMix0 = invSubMix[0];
const invSubMix1 = invSubMix[1];
const invSubMix2 = invSubMix[2];
const invSubMix3 = invSubMix[3];
let prev;
let t;
for (ksRow = 0; ksRow < ksRows; ksRow++) {
if (ksRow < keySize) {
prev = keySchedule[ksRow] = key[ksRow];
continue;
}
t = prev;
if (ksRow % keySize === 0) {
// Rot word
t = (t << 8) | (t >>> 24);
// Sub word
t =
(sbox[t >>> 24] << 24) |
(sbox[(t >>> 16) & 0xff] << 16) |
(sbox[(t >>> 8) & 0xff] << 8) |
sbox[t & 0xff];
// Mix Rcon
t ^= rcon[(ksRow / keySize) | 0] << 24;
} else if (keySize > 6 && ksRow % keySize === 4) {
// Sub word
t =
(sbox[t >>> 24] << 24) |
(sbox[(t >>> 16) & 0xff] << 16) |
(sbox[(t >>> 8) & 0xff] << 8) |
sbox[t & 0xff];
}
keySchedule[ksRow] = prev = (keySchedule[ksRow - keySize] ^ t) >>> 0;
}
for (invKsRow = 0; invKsRow < ksRows; invKsRow++) {
ksRow = ksRows - invKsRow;
if (invKsRow & 3) {
t = keySchedule[ksRow];
} else {
t = keySchedule[ksRow - 4];
}
if (invKsRow < 4 || ksRow <= 4) {
invKeySchedule[invKsRow] = t;
} else {
invKeySchedule[invKsRow] =
invSubMix0[sbox[t >>> 24]] ^
invSubMix1[sbox[(t >>> 16) & 0xff]] ^
invSubMix2[sbox[(t >>> 8) & 0xff]] ^
invSubMix3[sbox[t & 0xff]];
}
invKeySchedule[invKsRow] = invKeySchedule[invKsRow] >>> 0;
}
}
// Adding this as a method greatly improves performance.
networkToHostOrderSwap(word) {
return (
(word << 24) |
((word & 0xff00) << 8) |
((word & 0xff0000) >> 8) |
(word >>> 24)
);
}
decrypt(
inputArrayBuffer: ArrayBufferLike,
offset: number,
aesIV: ArrayBuffer,
) {
const nRounds = this.keySize + 6;
const invKeySchedule = this.invKeySchedule;
const invSBOX = this.invSBox;
const invSubMix = this.invSubMix;
const invSubMix0 = invSubMix[0];
const invSubMix1 = invSubMix[1];
const invSubMix2 = invSubMix[2];
const invSubMix3 = invSubMix[3];
const initVector = this.uint8ArrayToUint32Array_(aesIV);
let initVector0 = initVector[0];
let initVector1 = initVector[1];
let initVector2 = initVector[2];
let initVector3 = initVector[3];
const inputInt32 = new Int32Array(inputArrayBuffer);
const outputInt32 = new Int32Array(inputInt32.length);
let t0, t1, t2, t3;
let s0, s1, s2, s3;
let inputWords0, inputWords1, inputWords2, inputWords3;
let ksRow, i;
const swapWord = this.networkToHostOrderSwap;
while (offset < inputInt32.length) {
inputWords0 = swapWord(inputInt32[offset]);
inputWords1 = swapWord(inputInt32[offset + 1]);
inputWords2 = swapWord(inputInt32[offset + 2]);
inputWords3 = swapWord(inputInt32[offset + 3]);
s0 = inputWords0 ^ invKeySchedule[0];
s1 = inputWords3 ^ invKeySchedule[1];
s2 = inputWords2 ^ invKeySchedule[2];
s3 = inputWords1 ^ invKeySchedule[3];
ksRow = 4;
// Iterate through the rounds of decryption
for (i = 1; i < nRounds; i++) {
t0 =
invSubMix0[s0 >>> 24] ^
invSubMix1[(s1 >> 16) & 0xff] ^
invSubMix2[(s2 >> 8) & 0xff] ^
invSubMix3[s3 & 0xff] ^
invKeySchedule[ksRow];
t1 =
invSubMix0[s1 >>> 24] ^
invSubMix1[(s2 >> 16) & 0xff] ^
invSubMix2[(s3 >> 8) & 0xff] ^
invSubMix3[s0 & 0xff] ^
invKeySchedule[ksRow + 1];
t2 =
invSubMix0[s2 >>> 24] ^
invSubMix1[(s3 >> 16) & 0xff] ^
invSubMix2[(s0 >> 8) & 0xff] ^
invSubMix3[s1 & 0xff] ^
invKeySchedule[ksRow + 2];
t3 =
invSubMix0[s3 >>> 24] ^
invSubMix1[(s0 >> 16) & 0xff] ^
invSubMix2[(s1 >> 8) & 0xff] ^
invSubMix3[s2 & 0xff] ^
invKeySchedule[ksRow + 3];
// Update state
s0 = t0;
s1 = t1;
s2 = t2;
s3 = t3;
ksRow = ksRow + 4;
}
// Shift rows, sub bytes, add round key
t0 =
(invSBOX[s0 >>> 24] << 24) ^
(invSBOX[(s1 >> 16) & 0xff] << 16) ^
(invSBOX[(s2 >> 8) & 0xff] << 8) ^
invSBOX[s3 & 0xff] ^
invKeySchedule[ksRow];
t1 =
(invSBOX[s1 >>> 24] << 24) ^
(invSBOX[(s2 >> 16) & 0xff] << 16) ^
(invSBOX[(s3 >> 8) & 0xff] << 8) ^
invSBOX[s0 & 0xff] ^
invKeySchedule[ksRow + 1];
t2 =
(invSBOX[s2 >>> 24] << 24) ^
(invSBOX[(s3 >> 16) & 0xff] << 16) ^
(invSBOX[(s0 >> 8) & 0xff] << 8) ^
invSBOX[s1 & 0xff] ^
invKeySchedule[ksRow + 2];
t3 =
(invSBOX[s3 >>> 24] << 24) ^
(invSBOX[(s0 >> 16) & 0xff] << 16) ^
(invSBOX[(s1 >> 8) & 0xff] << 8) ^
invSBOX[s2 & 0xff] ^
invKeySchedule[ksRow + 3];
// Write
outputInt32[offset] = swapWord(t0 ^ initVector0);
outputInt32[offset + 1] = swapWord(t3 ^ initVector1);
outputInt32[offset + 2] = swapWord(t2 ^ initVector2);
outputInt32[offset + 3] = swapWord(t1 ^ initVector3);
// reset initVector to last 4 unsigned int
initVector0 = inputWords0;
initVector1 = inputWords1;
initVector2 = inputWords2;
initVector3 = inputWords3;
offset = offset + 4;
}
return outputInt32.buffer;
}
}
+4
View File
@@ -0,0 +1,4 @@
export const enum DecrypterAesMode {
cbc = 0,
ctr = 1,
}
+225
View File
@@ -0,0 +1,225 @@
import AESCrypto from './aes-crypto';
import AESDecryptor, { removePadding } from './aes-decryptor';
import { DecrypterAesMode } from './decrypter-aes-mode';
import FastAESKey from './fast-aes-key';
import { logger } from '../utils/logger';
import { appendUint8Array } from '../utils/mp4-tools';
import type { HlsConfig } from '../config';
const CHUNK_SIZE = 16; // 16 bytes, 128 bits
export default class Decrypter {
private logEnabled: boolean = true;
private removePKCS7Padding: boolean;
private subtle: SubtleCrypto | null = null;
private softwareDecrypter: AESDecryptor | null = null;
private key: ArrayBuffer | null = null;
private fastAesKey: FastAESKey | null = null;
private remainderData: Uint8Array<ArrayBuffer> | null = null;
private currentIV: ArrayBuffer | null = null;
private currentResult: ArrayBuffer | null = null;
private useSoftware: boolean;
private enableSoftwareAES: boolean;
constructor(config: HlsConfig, { removePKCS7Padding = true } = {}) {
this.enableSoftwareAES = config.enableSoftwareAES;
this.removePKCS7Padding = removePKCS7Padding;
// built in decryptor expects PKCS7 padding
if (removePKCS7Padding) {
try {
const browserCrypto = self.crypto;
if (browserCrypto) {
this.subtle =
browserCrypto.subtle ||
((browserCrypto as any).webkitSubtle as SubtleCrypto);
}
} catch (e) {
/* no-op */
}
}
this.useSoftware = !this.subtle;
}
destroy() {
this.subtle = null;
this.softwareDecrypter = null;
this.key = null;
this.fastAesKey = null;
this.remainderData = null;
this.currentIV = null;
this.currentResult = null;
}
public isSync() {
return this.useSoftware;
}
public flush(): Uint8Array<ArrayBuffer> | null {
const { currentResult, remainderData } = this;
if (!currentResult || remainderData) {
this.reset();
return null;
}
const data = new Uint8Array(currentResult);
this.reset();
if (this.removePKCS7Padding) {
return removePadding(data);
}
return data;
}
public reset() {
this.currentResult = null;
this.currentIV = null;
this.remainderData = null;
if (this.softwareDecrypter) {
this.softwareDecrypter = null;
}
}
public decrypt(
data: Uint8Array | ArrayBuffer,
key: ArrayBuffer,
iv: ArrayBuffer,
aesMode: DecrypterAesMode,
): Promise<ArrayBuffer> {
if (this.useSoftware) {
return new Promise((resolve, reject) => {
const dataView = ArrayBuffer.isView(data) ? data : new Uint8Array(data);
this.softwareDecrypt(dataView, key, iv, aesMode);
const decryptResult = this.flush();
if (decryptResult) {
resolve(decryptResult.buffer);
} else {
reject(new Error('[softwareDecrypt] Failed to decrypt data'));
}
});
}
return this.webCryptoDecrypt(new Uint8Array(data), key, iv, aesMode);
}
// Software decryption is progressive. Progressive decryption may not return a result on each call. Any cached
// data is handled in the flush() call
public softwareDecrypt(
data: Uint8Array,
key: ArrayBuffer,
iv: ArrayBuffer,
aesMode: DecrypterAesMode,
): ArrayBuffer | null {
const { currentIV, currentResult, remainderData } = this;
if (aesMode !== DecrypterAesMode.cbc || key.byteLength !== 16) {
logger.warn('SoftwareDecrypt: can only handle AES-128-CBC');
return null;
}
this.logOnce('JS AES decrypt');
// The output is staggered during progressive parsing - the current result is cached, and emitted on the next call
// This is done in order to strip PKCS7 padding, which is found at the end of each segment. We only know we've reached
// the end on flush(), but by that time we have already received all bytes for the segment.
// Progressive decryption does not work with WebCrypto
if (remainderData) {
data = appendUint8Array(remainderData, data);
this.remainderData = null;
}
// Byte length must be a multiple of 16 (AES-128 = 128 bit blocks = 16 bytes)
const currentChunk = this.getValidChunk(data);
if (!currentChunk.length) {
return null;
}
if (currentIV) {
iv = currentIV;
}
let softwareDecrypter = this.softwareDecrypter;
if (!softwareDecrypter) {
softwareDecrypter = this.softwareDecrypter = new AESDecryptor();
}
softwareDecrypter.expandKey(key);
const result = currentResult;
this.currentResult = softwareDecrypter.decrypt(currentChunk.buffer, 0, iv);
this.currentIV = currentChunk.slice(-16).buffer;
if (!result) {
return null;
}
return result;
}
public webCryptoDecrypt(
data: Uint8Array<ArrayBuffer>,
key: ArrayBuffer,
iv: ArrayBuffer,
aesMode: DecrypterAesMode,
): Promise<ArrayBuffer> {
if (this.key !== key || !this.fastAesKey) {
if (!this.subtle) {
return Promise.resolve(this.onWebCryptoError(data, key, iv, aesMode));
}
this.key = key;
this.fastAesKey = new FastAESKey(this.subtle, key, aesMode);
}
return this.fastAesKey
.expandKey()
.then((aesKey: CryptoKey) => {
// decrypt using web crypto
if (!this.subtle) {
return Promise.reject(new Error('web crypto not initialized'));
}
this.logOnce('WebCrypto AES decrypt');
const crypto = new AESCrypto(this.subtle, new Uint8Array(iv), aesMode);
return crypto.decrypt(data.buffer, aesKey);
})
.catch((err) => {
logger.warn(
`[decrypter]: WebCrypto Error, disable WebCrypto API, ${err.name}: ${err.message}`,
);
return this.onWebCryptoError(data, key, iv, aesMode);
});
}
private onWebCryptoError(
data: Uint8Array,
key: ArrayBuffer,
iv: ArrayBuffer,
aesMode: DecrypterAesMode,
): ArrayBuffer | never {
const enableSoftwareAES = this.enableSoftwareAES;
if (enableSoftwareAES) {
this.useSoftware = true;
this.logEnabled = true;
this.softwareDecrypt(data, key, iv, aesMode);
const decryptResult = this.flush();
if (decryptResult) {
return decryptResult.buffer;
}
}
throw new Error(
'WebCrypto' +
(enableSoftwareAES ? ' and softwareDecrypt' : '') +
': failed to decrypt data',
);
}
private getValidChunk(data: Uint8Array): Uint8Array {
let currentChunk = data;
const splitPoint = data.length - (data.length % CHUNK_SIZE);
if (splitPoint !== data.length) {
currentChunk = data.slice(0, splitPoint);
this.remainderData = data.slice(splitPoint);
}
return currentChunk;
}
private logOnce(msg: string) {
if (!this.logEnabled) {
return;
}
logger.log(`[decrypter]: ${msg}`);
this.logEnabled = false;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { DecrypterAesMode } from './decrypter-aes-mode';
export default class FastAESKey {
private subtle: SubtleCrypto;
private key: ArrayBuffer;
private aesMode: DecrypterAesMode;
constructor(
subtle: SubtleCrypto,
key: ArrayBuffer,
aesMode: DecrypterAesMode,
) {
this.subtle = subtle;
this.key = key;
this.aesMode = aesMode;
}
expandKey() {
const subtleAlgoName = getSubtleAlgoName(this.aesMode);
return this.subtle.importKey(
'raw',
this.key,
{ name: subtleAlgoName },
false,
['encrypt', 'decrypt'],
);
}
}
function getSubtleAlgoName(aesMode: DecrypterAesMode) {
switch (aesMode) {
case DecrypterAesMode.cbc:
return 'AES-CBC';
case DecrypterAesMode.ctr:
return 'AES-CTR';
default:
throw new Error(`[FastAESKey] invalid aes mode ${aesMode}`);
}
}
+17
View File
@@ -0,0 +1,17 @@
declare const __VERSION__: string;
// Dynamic Modules
declare const __USE_ALT_AUDIO__: boolean;
declare const __USE_EME_DRM__: boolean;
declare const __USE_SUBTITLES__: boolean;
declare const __USE_CMCD__: boolean;
declare const __USE_CONTENT_STEERING__: boolean;
declare const __USE_VARIABLE_SUBSTITUTION__: boolean;
declare const __USE_M2TS_ADVANCED_CODECS__: boolean;
declare const __USE_MEDIA_CAPABILITIES__: boolean;
declare const __USE_INTERSTITIALS__: boolean;
// __IN_WORKER__ is provided from a closure call around the final UMD bundle.
declare const __IN_WORKER__: boolean;
// __HLS_WORKER_BUNDLE__ is the name of the closure around the final UMD bundle.
declare const __HLS_WORKER_BUNDLE__: Function;
+96
View File
@@ -0,0 +1,96 @@
/**
* AAC demuxer
*/
import { getId3Data } from '@svta/common-media-library/id3/getId3Data';
import * as ADTS from './adts';
import BaseAudioDemuxer from './base-audio-demuxer';
import * as MpegAudio from './mpegaudio';
import type { HlsConfig } from '../../config';
import type { HlsEventEmitter } from '../../events';
import type { DemuxedAudioTrack } from '../../types/demuxer';
import type { ILogger } from '../../utils/logger';
class AACDemuxer extends BaseAudioDemuxer {
private readonly observer: HlsEventEmitter;
private readonly config: HlsConfig;
constructor(observer: HlsEventEmitter, config) {
super();
this.observer = observer;
this.config = config;
}
resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number,
) {
super.resetInitSegment(initSegment, audioCodec, videoCodec, trackDuration);
this._audioTrack = {
container: 'audio/adts',
type: 'audio',
id: 2,
pid: -1,
sequenceNumber: 0,
segmentCodec: 'aac',
samples: [],
manifestCodec: audioCodec,
duration: trackDuration,
inputTimeScale: 90000,
dropped: 0,
};
}
// Source for probe info - https://wiki.multimedia.cx/index.php?title=ADTS
static probe(data: Uint8Array | undefined, logger: ILogger): boolean {
if (!data) {
return false;
}
// Check for the ADTS sync word
// Look for ADTS header | 1111 1111 | 1111 X00X | where X can be either 0 or 1
// Layer bits (position 14 and 15) in header should be always 0 for ADTS
// More info https://wiki.multimedia.cx/index.php?title=ADTS
const id3Data = getId3Data(data, 0);
let offset = id3Data?.length || 0;
if (MpegAudio.probe(data, offset)) {
return false;
}
for (let length = data.length; offset < length; offset++) {
if (ADTS.probe(data, offset)) {
logger.log('ADTS sync word found !');
return true;
}
}
return false;
}
canParse(data, offset) {
return ADTS.canParse(data, offset);
}
appendFrame(track: DemuxedAudioTrack, data: Uint8Array, offset: number) {
ADTS.initTrackConfig(
track,
this.observer,
data,
offset,
track.manifestCodec,
);
const frame = ADTS.appendFrame(
track,
data,
offset,
this.basePTS as number,
this.frameIndex,
);
if (frame && frame.missing === 0) {
return frame;
}
}
}
export default AACDemuxer;
+170
View File
@@ -0,0 +1,170 @@
import { getId3Data } from '@svta/common-media-library/id3/getId3Data';
import { getId3Timestamp } from '@svta/common-media-library/id3/getId3Timestamp';
import BaseAudioDemuxer from './base-audio-demuxer';
import { getAudioBSID } from './dolby';
import type { HlsEventEmitter } from '../../events';
import type { AudioFrame, DemuxedAudioTrack } from '../../types/demuxer';
export class AC3Demuxer extends BaseAudioDemuxer {
private readonly observer: HlsEventEmitter;
constructor(observer: HlsEventEmitter) {
super();
this.observer = observer;
}
resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number,
) {
super.resetInitSegment(initSegment, audioCodec, videoCodec, trackDuration);
this._audioTrack = {
container: 'audio/ac-3',
type: 'audio',
id: 2,
pid: -1,
sequenceNumber: 0,
segmentCodec: 'ac3',
samples: [],
manifestCodec: audioCodec,
duration: trackDuration,
inputTimeScale: 90000,
dropped: 0,
};
}
canParse(data: Uint8Array, offset: number): boolean {
return offset + 64 < data.length;
}
appendFrame(
track: DemuxedAudioTrack,
data: Uint8Array,
offset: number,
): AudioFrame | void {
const frameLength = appendFrame(
track,
data,
offset,
this.basePTS as number,
this.frameIndex,
);
if (frameLength !== -1) {
const sample = track.samples[track.samples.length - 1];
return { sample, length: frameLength, missing: 0 };
}
}
static probe(data: Uint8Array | undefined): boolean {
if (!data) {
return false;
}
const id3Data = getId3Data(data, 0);
if (!id3Data) {
return false;
}
// look for the ac-3 sync bytes
const offset = id3Data.length;
if (
data[offset] === 0x0b &&
data[offset + 1] === 0x77 &&
getId3Timestamp(id3Data) !== undefined &&
// check the bsid to confirm ac-3
getAudioBSID(data, offset) < 16
) {
return true;
}
return false;
}
}
export function appendFrame(
track: DemuxedAudioTrack,
data: Uint8Array,
start: number,
pts: number,
frameIndex: number,
): number {
if (start + 8 > data.length) {
return -1; // not enough bytes left
}
if (data[start] !== 0x0b || data[start + 1] !== 0x77) {
return -1; // invalid magic
}
// get sample rate
const samplingRateCode = data[start + 4] >> 6;
if (samplingRateCode >= 3) {
return -1; // invalid sampling rate
}
const samplingRateMap = [48000, 44100, 32000];
const sampleRate = samplingRateMap[samplingRateCode];
// get frame size
const frameSizeCode = data[start + 4] & 0x3f;
const frameSizeMap = [
64, 69, 96, 64, 70, 96, 80, 87, 120, 80, 88, 120, 96, 104, 144, 96, 105,
144, 112, 121, 168, 112, 122, 168, 128, 139, 192, 128, 140, 192, 160, 174,
240, 160, 175, 240, 192, 208, 288, 192, 209, 288, 224, 243, 336, 224, 244,
336, 256, 278, 384, 256, 279, 384, 320, 348, 480, 320, 349, 480, 384, 417,
576, 384, 418, 576, 448, 487, 672, 448, 488, 672, 512, 557, 768, 512, 558,
768, 640, 696, 960, 640, 697, 960, 768, 835, 1152, 768, 836, 1152, 896, 975,
1344, 896, 976, 1344, 1024, 1114, 1536, 1024, 1115, 1536, 1152, 1253, 1728,
1152, 1254, 1728, 1280, 1393, 1920, 1280, 1394, 1920,
];
const frameLength = frameSizeMap[frameSizeCode * 3 + samplingRateCode] * 2;
if (start + frameLength > data.length) {
return -1;
}
// get channel count
const channelMode = data[start + 6] >> 5;
let skipCount = 0;
if (channelMode === 2) {
skipCount += 2;
} else {
if (channelMode & 1 && channelMode !== 1) {
skipCount += 2;
}
if (channelMode & 4) {
skipCount += 2;
}
}
const lfeon =
(((data[start + 6] << 8) | data[start + 7]) >> (12 - skipCount)) & 1;
const channelsMap = [2, 1, 2, 3, 3, 4, 4, 5];
const channelCount = channelsMap[channelMode] + lfeon;
// build dac3 box
const bsid = data[start + 5] >> 3;
const bsmod = data[start + 5] & 7;
const config = new Uint8Array([
(samplingRateCode << 6) | (bsid << 1) | (bsmod >> 2),
((bsmod & 3) << 6) |
(channelMode << 3) |
(lfeon << 2) |
(frameSizeCode >> 4),
(frameSizeCode << 4) & 0xe0,
]);
const frameDuration = (1536 / sampleRate) * 90000;
const stamp = pts + frameIndex * frameDuration;
const unit = data.subarray(start, start + frameLength);
track.config = config;
track.channelCount = channelCount;
track.samplerate = sampleRate;
track.samples.push({ unit, pts: stamp });
return frameLength;
}
+249
View File
@@ -0,0 +1,249 @@
/**
* ADTS parser helper
* @link https://wiki.multimedia.cx/index.php?title=ADTS
*/
import { ErrorDetails, ErrorTypes } from '../../errors';
import { Events } from '../../events';
import { logger } from '../../utils/logger';
import type { HlsEventEmitter } from '../../events';
import type {
AudioFrame,
AudioSample,
DemuxedAudioTrack,
} from '../../types/demuxer';
type AudioConfig = {
config: [number, number];
samplerate: number;
channelCount: number;
codec: string;
parsedCodec: string;
manifestCodec: string | undefined;
};
type FrameHeader = {
headerLength: number;
frameLength: number;
};
export function getAudioConfig(
observer: HlsEventEmitter,
data: Uint8Array,
offset: number,
manifestCodec: string | undefined,
): AudioConfig | void {
const adtsSamplingRates = [
96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025,
8000, 7350,
];
const byte2 = data[offset + 2];
const adtsSamplingIndex = (byte2 >> 2) & 0xf;
if (adtsSamplingIndex > 12) {
const error = new Error(`invalid ADTS sampling index:${adtsSamplingIndex}`);
observer.emit(Events.ERROR, Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
fatal: true,
error,
reason: error.message,
});
return;
}
// MPEG-4 Audio Object Type (profile_ObjectType+1)
const adtsObjectType = ((byte2 >> 6) & 0x3) + 1;
const channelCount = ((data[offset + 3] >> 6) & 0x3) | ((byte2 & 1) << 2);
const codec = 'mp4a.40.' + adtsObjectType;
/* refer to http://wiki.multimedia.cx/index.php?title=MPEG-4_Audio#Audio_Specific_Config
ISO/IEC 14496-3 - Table 1.13 — Syntax of AudioSpecificConfig()
Audio Profile / Audio Object Type
0: Null
1: AAC Main
2: AAC LC (Low Complexity)
3: AAC SSR (Scalable Sample Rate)
4: AAC LTP (Long Term Prediction)
5: SBR (Spectral Band Replication)
6: AAC Scalable
sampling freq
0: 96000 Hz
1: 88200 Hz
2: 64000 Hz
3: 48000 Hz
4: 44100 Hz
5: 32000 Hz
6: 24000 Hz
7: 22050 Hz
8: 16000 Hz
9: 12000 Hz
10: 11025 Hz
11: 8000 Hz
12: 7350 Hz
13: Reserved
14: Reserved
15: frequency is written explictly
Channel Configurations
These are the channel configurations:
0: Defined in AOT Specifc Config
1: 1 channel: front-center
2: 2 channels: front-left, front-right
*/
// audioObjectType = profile => profile, the MPEG-4 Audio Object Type minus 1
const samplerate = adtsSamplingRates[adtsSamplingIndex];
let aacSampleIndex = adtsSamplingIndex;
if (adtsObjectType === 5 || adtsObjectType === 29) {
// HE-AAC uses SBR (Spectral Band Replication) , high frequencies are constructed from low frequencies
// there is a factor 2 between frame sample rate and output sample rate
// multiply frequency by 2 (see table above, equivalent to substract 3)
aacSampleIndex -= 3;
}
const config: [number, number] = [
(adtsObjectType << 3) | ((aacSampleIndex & 0x0e) >> 1),
((aacSampleIndex & 0x01) << 7) | (channelCount << 3),
];
logger.log(
`manifest codec:${manifestCodec}, parsed codec:${codec}, channels:${channelCount}, rate:${samplerate} (ADTS object type:${adtsObjectType} sampling index:${adtsSamplingIndex})`,
);
return {
config,
samplerate,
channelCount,
codec,
parsedCodec: codec,
manifestCodec,
};
}
export function isHeaderPattern(data: Uint8Array, offset: number): boolean {
return data[offset] === 0xff && (data[offset + 1] & 0xf6) === 0xf0;
}
export function getHeaderLength(data: Uint8Array, offset: number): number {
return data[offset + 1] & 0x01 ? 7 : 9;
}
export function getFullFrameLength(data: Uint8Array, offset: number): number {
return (
((data[offset + 3] & 0x03) << 11) |
(data[offset + 4] << 3) |
((data[offset + 5] & 0xe0) >>> 5)
);
}
export function canGetFrameLength(data: Uint8Array, offset: number): boolean {
return offset + 5 < data.length;
}
export function isHeader(data: Uint8Array, offset: number): boolean {
// Look for ADTS header | 1111 1111 | 1111 X00X | where X can be either 0 or 1
// Layer bits (position 14 and 15) in header should be always 0 for ADTS
// More info https://wiki.multimedia.cx/index.php?title=ADTS
return offset + 1 < data.length && isHeaderPattern(data, offset);
}
export function canParse(data: Uint8Array, offset: number): boolean {
return (
canGetFrameLength(data, offset) &&
isHeaderPattern(data, offset) &&
getFullFrameLength(data, offset) <= data.length - offset
);
}
export function probe(data: Uint8Array, offset: number): boolean {
// same as isHeader but we also check that ADTS frame follows last ADTS frame
// or end of data is reached
if (isHeader(data, offset)) {
// ADTS header Length
const headerLength = getHeaderLength(data, offset);
if (offset + headerLength >= data.length) {
return false;
}
// ADTS frame Length
const frameLength = getFullFrameLength(data, offset);
if (frameLength <= headerLength) {
return false;
}
const newOffset = offset + frameLength;
return newOffset === data.length || isHeader(data, newOffset);
}
return false;
}
export function initTrackConfig(
track: DemuxedAudioTrack,
observer: HlsEventEmitter,
data: Uint8Array,
offset: number,
audioCodec: string | undefined,
) {
if (!track.samplerate) {
const config = getAudioConfig(observer, data, offset, audioCodec);
if (!config) {
return;
}
Object.assign(track, config);
}
}
export function getFrameDuration(samplerate: number): number {
return (1024 * 90000) / samplerate;
}
export function parseFrameHeader(
data: Uint8Array,
offset: number,
): FrameHeader | void {
// The protection skip bit tells us if we have 2 bytes of CRC data at the end of the ADTS header
const headerLength = getHeaderLength(data, offset);
if (offset + headerLength <= data.length) {
// retrieve frame size
const frameLength = getFullFrameLength(data, offset) - headerLength;
if (frameLength > 0) {
// logger.log(`AAC frame, offset/length/total/pts:${offset+headerLength}/${frameLength}/${data.byteLength}`);
return { headerLength, frameLength };
}
}
}
export function appendFrame(
track: DemuxedAudioTrack,
data: Uint8Array,
offset: number,
pts: number,
frameIndex: number,
): AudioFrame {
const frameDuration = getFrameDuration(track.samplerate as number);
const stamp = pts + frameIndex * frameDuration;
const header = parseFrameHeader(data, offset);
let unit: Uint8Array;
if (header) {
const { frameLength, headerLength } = header;
const length = headerLength + frameLength;
const missing = Math.max(0, offset + length - data.length);
// logger.log(`AAC frame ${frameIndex}, pts:${stamp} length@offset/total: ${frameLength}@${offset+headerLength}/${data.byteLength} missing: ${missing}`);
if (missing) {
unit = new Uint8Array(length - headerLength);
unit.set(data.subarray(offset + headerLength, data.length), 0);
} else {
unit = data.subarray(offset + headerLength, offset + length);
}
const sample: AudioSample = {
unit,
pts: stamp,
};
if (!missing) {
track.samples.push(sample as AudioSample);
}
return { sample, length, missing };
}
// overflow incomplete header
const length = data.length - offset;
unit = new Uint8Array(length);
unit.set(data.subarray(offset, data.length), 0);
const sample: AudioSample = {
unit,
pts: stamp,
};
return { sample, length, missing: -1 };
}
+205
View File
@@ -0,0 +1,205 @@
import { canParseId3 } from '@svta/common-media-library/id3/canParseId3';
import { getId3Data } from '@svta/common-media-library/id3/getId3Data';
import { getId3Timestamp } from '@svta/common-media-library/id3/getId3Timestamp';
import {
type AudioFrame,
type DemuxedAudioTrack,
type DemuxedMetadataTrack,
type DemuxedUserdataTrack,
type DemuxedVideoTrackBase,
type Demuxer,
type DemuxerResult,
type KeyData,
MetadataSchema,
} from '../../types/demuxer';
import { appendUint8Array } from '../../utils/mp4-tools';
import { dummyTrack } from '../dummy-demuxed-track';
import type {
RationalTimestamp,
TimestampOffset,
} from '../../utils/timescale-conversion';
class BaseAudioDemuxer implements Demuxer {
protected _audioTrack?: DemuxedAudioTrack;
protected _id3Track?: DemuxedMetadataTrack;
protected frameIndex: number = 0;
protected cachedData: Uint8Array | null = null;
protected basePTS: number | null = null;
protected initPTS: TimestampOffset | null = null;
protected lastPTS: number | null = null;
resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number,
) {
this._id3Track = {
type: 'id3',
id: 3,
pid: -1,
inputTimeScale: 90000,
sequenceNumber: 0,
samples: [],
dropped: 0,
};
}
resetTimeStamp(deaultTimestamp: TimestampOffset | null) {
this.initPTS = deaultTimestamp;
this.resetContiguity();
}
resetContiguity(): void {
this.basePTS = null;
this.lastPTS = null;
this.frameIndex = 0;
}
canParse(data: Uint8Array, offset: number): boolean {
return false;
}
appendFrame(
track: DemuxedAudioTrack,
data: Uint8Array,
offset: number,
): AudioFrame | void {}
// feed incoming data to the front of the parsing pipeline
demux(data: Uint8Array, timeOffset: number): DemuxerResult {
if (this.cachedData) {
data = appendUint8Array(this.cachedData, data);
this.cachedData = null;
}
let id3Data: Uint8Array | undefined = getId3Data(data, 0);
let offset = id3Data ? id3Data.length : 0;
let lastDataIndex;
const track = this._audioTrack as DemuxedAudioTrack;
const id3Track = this._id3Track as DemuxedMetadataTrack;
const timestamp = id3Data ? getId3Timestamp(id3Data) : undefined;
const length = data.length;
if (
this.basePTS === null ||
(this.frameIndex === 0 && Number.isFinite(timestamp))
) {
this.basePTS = initPTSFn(timestamp, timeOffset, this.initPTS);
this.lastPTS = this.basePTS;
}
if (this.lastPTS === null) {
this.lastPTS = this.basePTS;
}
// more expressive than alternative: id3Data?.length
if (id3Data && id3Data.length > 0) {
id3Track.samples.push({
pts: this.lastPTS,
dts: this.lastPTS,
data: id3Data,
type: MetadataSchema.audioId3,
duration: Number.POSITIVE_INFINITY,
});
}
while (offset < length) {
if (this.canParse(data, offset)) {
const frame = this.appendFrame(track, data, offset);
if (frame) {
this.frameIndex++;
this.lastPTS = frame.sample.pts;
offset += frame.length;
lastDataIndex = offset;
} else {
offset = length;
}
} else if (canParseId3(data, offset)) {
// after a canParse, a call to getId3Data *should* always returns some data
id3Data = getId3Data(data, offset)!;
id3Track.samples.push({
pts: this.lastPTS,
dts: this.lastPTS,
data: id3Data,
type: MetadataSchema.audioId3,
duration: Number.POSITIVE_INFINITY,
});
offset += id3Data.length;
lastDataIndex = offset;
} else {
offset++;
}
if (offset === length && lastDataIndex !== length) {
const partialData = data.slice(lastDataIndex);
if (this.cachedData) {
this.cachedData = appendUint8Array(this.cachedData, partialData);
} else {
this.cachedData = partialData;
}
}
}
return {
audioTrack: track,
videoTrack: dummyTrack() as DemuxedVideoTrackBase,
id3Track,
textTrack: dummyTrack() as DemuxedUserdataTrack,
};
}
demuxSampleAes(
data: Uint8Array,
keyData: KeyData,
timeOffset: number,
): Promise<DemuxerResult> {
return Promise.reject(
new Error(
`[${this}] This demuxer does not support Sample-AES decryption`,
),
);
}
flush(timeOffset: number): DemuxerResult {
// Parse cache in case of remaining frames.
const cachedData = this.cachedData;
if (cachedData) {
this.cachedData = null;
this.demux(cachedData, 0);
}
return {
audioTrack: this._audioTrack as DemuxedAudioTrack,
videoTrack: dummyTrack() as DemuxedVideoTrackBase,
id3Track: this._id3Track as DemuxedMetadataTrack,
textTrack: dummyTrack() as DemuxedUserdataTrack,
};
}
destroy() {
this.cachedData = null;
// @ts-ignore
this._audioTrack = this._id3Track = undefined;
}
}
/**
* Initialize PTS
* <p>
* use timestamp unless it is undefined, NaN or Infinity
* </p>
*/
export const initPTSFn = (
timestamp: number | undefined,
timeOffset: number,
initPTS: RationalTimestamp | null,
): number => {
if (Number.isFinite(timestamp as number)) {
return timestamp! * 90;
}
const init90kHz = initPTS
? (initPTS.baseTime * 90000) / initPTS.timescale
: 0;
return timeOffset * 90000 + init90kHz;
};
export default BaseAudioDemuxer;
+21
View File
@@ -0,0 +1,21 @@
export const getAudioBSID = (data: Uint8Array, offset: number): number => {
// check the bsid to confirm ac-3 | ec-3
let bsid = 0;
let numBits = 5;
offset += numBits;
const temp = new Uint32Array(1); // unsigned 32 bit for temporary storage
const mask = new Uint32Array(1); // unsigned 32 bit mask value
const byte = new Uint8Array(1); // unsigned 8 bit for temporary storage
while (numBits > 0) {
byte[0] = data[offset];
// read remaining bits, upto 8 bits at a time
const bits = Math.min(numBits, 8);
const shift = 8 - bits;
mask[0] = (0xff000000 >>> (24 + shift)) << shift;
temp[0] = (byte[0] & mask[0]) >> shift;
bsid = !bsid ? temp[0] : (bsid << bits) | temp[0];
offset += 1;
numBits -= bits;
}
return bsid;
};
+85
View File
@@ -0,0 +1,85 @@
/**
* MP3 demuxer
*/
import { getId3Data } from '@svta/common-media-library/id3/getId3Data';
import { getId3Timestamp } from '@svta/common-media-library/id3/getId3Timestamp';
import BaseAudioDemuxer from './base-audio-demuxer';
import { getAudioBSID } from './dolby';
import * as MpegAudio from './mpegaudio';
import { logger } from '../../utils/logger';
class MP3Demuxer extends BaseAudioDemuxer {
resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number,
) {
super.resetInitSegment(initSegment, audioCodec, videoCodec, trackDuration);
this._audioTrack = {
container: 'audio/mpeg',
type: 'audio',
id: 2,
pid: -1,
sequenceNumber: 0,
segmentCodec: 'mp3',
samples: [],
manifestCodec: audioCodec,
duration: trackDuration,
inputTimeScale: 90000,
dropped: 0,
};
}
static probe(data: Uint8Array | undefined): boolean {
if (!data) {
return false;
}
// check if data contains ID3 timestamp and MPEG sync word
// Look for MPEG header | 1111 1111 | 111X XYZX | where X can be either 0 or 1 and Y or Z should be 1
// Layer bits (position 14 and 15) in header should be always different from 0 (Layer I or Layer II or Layer III)
// More info http://www.mp3-tech.org/programmer/frame_header.html
const id3Data = getId3Data(data, 0);
let offset = id3Data?.length || 0;
// Check for ac-3|ec-3 sync bytes and return false if present
if (
id3Data &&
data[offset] === 0x0b &&
data[offset + 1] === 0x77 &&
getId3Timestamp(id3Data) !== undefined &&
// check the bsid to confirm ac-3 or ec-3 (not mp3)
getAudioBSID(data, offset) <= 16
) {
return false;
}
for (let length = data.length; offset < length; offset++) {
if (MpegAudio.probe(data, offset)) {
logger.log('MPEG Audio sync word found !');
return true;
}
}
return false;
}
canParse(data, offset) {
return MpegAudio.canParse(data, offset);
}
appendFrame(track, data, offset) {
if (this.basePTS === null) {
return;
}
return MpegAudio.appendFrame(
track,
data,
offset,
this.basePTS,
this.frameIndex,
);
}
}
export default MP3Demuxer;
+177
View File
@@ -0,0 +1,177 @@
/**
* MPEG parser helper
*/
import type { DemuxedAudioTrack } from '../../types/demuxer';
let chromeVersion: number | null = null;
const BitratesMap = [
32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 32, 48, 56,
64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 32, 40, 48, 56, 64, 80,
96, 112, 128, 160, 192, 224, 256, 320, 32, 48, 56, 64, 80, 96, 112, 128, 144,
160, 176, 192, 224, 256, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144,
160,
];
const SamplingRateMap = [
44100, 48000, 32000, 22050, 24000, 16000, 11025, 12000, 8000,
];
const SamplesCoefficients = [
// MPEG 2.5
[
0, // Reserved
72, // Layer3
144, // Layer2
12, // Layer1
],
// Reserved
[
0, // Reserved
0, // Layer3
0, // Layer2
0, // Layer1
],
// MPEG 2
[
0, // Reserved
72, // Layer3
144, // Layer2
12, // Layer1
],
// MPEG 1
[
0, // Reserved
144, // Layer3
144, // Layer2
12, // Layer1
],
];
const BytesInSlot = [
0, // Reserved
1, // Layer3
1, // Layer2
4, // Layer1
];
export function appendFrame(
track: DemuxedAudioTrack,
data: Uint8Array,
offset: number,
pts: number,
frameIndex: number,
) {
// Using http://www.datavoyage.com/mpgscript/mpeghdr.htm as a reference
if (offset + 24 > data.length) {
return;
}
const header = parseHeader(data, offset);
if (header && offset + header.frameLength <= data.length) {
const frameDuration = (header.samplesPerFrame * 90000) / header.sampleRate;
const stamp = pts + frameIndex * frameDuration;
const sample = {
unit: data.subarray(offset, offset + header.frameLength),
pts: stamp,
dts: stamp,
};
track.config = [];
track.channelCount = header.channelCount;
track.samplerate = header.sampleRate;
track.samples.push(sample);
return { sample, length: header.frameLength, missing: 0 };
}
}
export function parseHeader(data: Uint8Array, offset: number) {
const mpegVersion = (data[offset + 1] >> 3) & 3;
const mpegLayer = (data[offset + 1] >> 1) & 3;
const bitRateIndex = (data[offset + 2] >> 4) & 15;
const sampleRateIndex = (data[offset + 2] >> 2) & 3;
if (
mpegVersion !== 1 &&
bitRateIndex !== 0 &&
bitRateIndex !== 15 &&
sampleRateIndex !== 3
) {
const paddingBit = (data[offset + 2] >> 1) & 1;
const channelMode = data[offset + 3] >> 6;
const columnInBitrates =
mpegVersion === 3 ? 3 - mpegLayer : mpegLayer === 3 ? 3 : 4;
const bitRate =
BitratesMap[columnInBitrates * 14 + bitRateIndex - 1] * 1000;
const columnInSampleRates =
mpegVersion === 3 ? 0 : mpegVersion === 2 ? 1 : 2;
const sampleRate =
SamplingRateMap[columnInSampleRates * 3 + sampleRateIndex];
const channelCount = channelMode === 3 ? 1 : 2; // If bits of channel mode are `11` then it is a single channel (Mono)
const sampleCoefficient = SamplesCoefficients[mpegVersion][mpegLayer];
const bytesInSlot = BytesInSlot[mpegLayer];
const samplesPerFrame = sampleCoefficient * 8 * bytesInSlot;
const frameLength =
Math.floor((sampleCoefficient * bitRate) / sampleRate + paddingBit) *
bytesInSlot;
if (chromeVersion === null) {
const userAgent = navigator.userAgent || '';
const result = userAgent.match(/Chrome\/(\d+)/i);
chromeVersion = result ? parseInt(result[1]) : 0;
}
const needChromeFix = !!chromeVersion && chromeVersion <= 87;
if (
needChromeFix &&
mpegLayer === 2 &&
bitRate >= 224000 &&
channelMode === 0
) {
// Work around bug in Chromium by setting channelMode to dual-channel (01) instead of stereo (00)
data[offset + 3] = data[offset + 3] | 0x80;
}
return { sampleRate, channelCount, frameLength, samplesPerFrame };
}
}
export function isHeaderPattern(data: Uint8Array, offset: number): boolean {
return (
data[offset] === 0xff &&
(data[offset + 1] & 0xe0) === 0xe0 &&
(data[offset + 1] & 0x06) !== 0x00
);
}
export function isHeader(data: Uint8Array, offset: number): boolean {
// Look for MPEG header | 1111 1111 | 111X XYZX | where X can be either 0 or 1 and Y or Z should be 1
// Layer bits (position 14 and 15) in header should be always different from 0 (Layer I or Layer II or Layer III)
// More info http://www.mp3-tech.org/programmer/frame_header.html
return offset + 1 < data.length && isHeaderPattern(data, offset);
}
export function canParse(data: Uint8Array, offset: number): boolean {
const headerSize = 4;
return isHeaderPattern(data, offset) && headerSize <= data.length - offset;
}
export function probe(data: Uint8Array, offset: number): boolean {
// same as isHeader but we also check that MPEG frame follows last MPEG frame
// or end of data is reached
if (offset + 1 < data.length && isHeaderPattern(data, offset)) {
// MPEG header Length
const headerLength = 4;
// MPEG frame Length
const header = parseHeader(data, offset);
let frameLength = headerLength;
if (header?.frameLength) {
frameLength = header.frameLength;
}
const newOffset = offset + frameLength;
return newOffset === data.length || isHeader(data, newOffset);
}
return false;
}
+42
View File
@@ -0,0 +1,42 @@
export default class ChunkCache {
private chunks: Array<Uint8Array<ArrayBuffer>> = [];
public dataLength: number = 0;
push(chunk: Uint8Array<ArrayBuffer>) {
this.chunks.push(chunk);
this.dataLength += chunk.length;
}
flush(): Uint8Array<ArrayBuffer> {
const { chunks, dataLength } = this;
let result: Uint8Array<ArrayBuffer>;
if (!chunks.length) {
return new Uint8Array(0);
} else if (chunks.length === 1) {
result = chunks[0];
} else {
result = concatUint8Arrays(chunks, dataLength);
}
this.reset();
return result;
}
reset() {
this.chunks.length = 0;
this.dataLength = 0;
}
}
function concatUint8Arrays(
chunks: Array<Uint8Array<ArrayBuffer>>,
dataLength: number,
): Uint8Array<ArrayBuffer> {
const result = new Uint8Array(dataLength);
let offset = 0;
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
+13
View File
@@ -0,0 +1,13 @@
import type { DemuxedTrack } from '../types/demuxer';
export function dummyTrack(type = '', inputTimeScale = 90000): DemuxedTrack {
return {
type,
id: -1,
pid: -1,
inputTimeScale,
sequenceNumber: -1,
samples: [],
dropped: 0,
};
}
+75
View File
@@ -0,0 +1,75 @@
// ensure the worker ends up in the bundle
// If the worker should not be included this gets aliased to empty.js
import './transmuxer-worker';
import { version } from '../version';
const workerStore: Record<string, WorkerContext> = {};
export function hasUMDWorker(): boolean {
return typeof __HLS_WORKER_BUNDLE__ === 'function';
}
export type WorkerContext = {
worker: Worker;
objectURL?: string;
scriptURL?: string;
clientCount: number;
};
export function injectWorker(): WorkerContext {
const workerContext = workerStore[version];
if (workerContext) {
workerContext.clientCount++;
return workerContext;
}
const blob = new self.Blob(
[
`var exports={};var module={exports:exports};function define(f){f()};define.amd=true;(${__HLS_WORKER_BUNDLE__.toString()})(true);`,
],
{
type: 'text/javascript',
},
);
const objectURL = self.URL.createObjectURL(blob);
const worker = new self.Worker(objectURL);
const result = {
worker,
objectURL,
clientCount: 1,
};
workerStore[version] = result;
return result;
}
export function loadWorker(path: string): WorkerContext {
const workerContext = workerStore[path];
if (workerContext) {
workerContext.clientCount++;
return workerContext;
}
const scriptURL = new self.URL(path, self.location.href).href;
const worker = new self.Worker(scriptURL);
const result = {
worker,
scriptURL,
clientCount: 1,
};
workerStore[path] = result;
return result;
}
export function removeWorkerFromStore(path?: string | null) {
const workerContext = workerStore[path || version];
if (workerContext) {
const clientCount = workerContext.clientCount--;
if (clientCount === 1) {
const { worker, objectURL } = workerContext;
delete workerStore[path || version];
if (objectURL) {
// revoke the Object URL that was used to create transmuxer worker, so as not to leak it
self.URL.revokeObjectURL(objectURL);
}
worker.terminate();
}
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* MP4 demuxer
*/
import { dummyTrack } from './dummy-demuxed-track';
import {
type DemuxedAudioTrack,
type DemuxedMetadataTrack,
type DemuxedUserdataTrack,
type Demuxer,
type DemuxerResult,
type KeyData,
MetadataSchema,
type PassthroughTrack,
} from '../types/demuxer';
import {
appendUint8Array,
findBox,
hasMoofData,
parseEmsg,
parseInitSegment,
parseSamples,
RemuxerTrackIdConfig,
segmentValidRange,
} from '../utils/mp4-tools';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
import type { IEmsgParsingData } from '../utils/mp4-tools';
const emsgSchemePattern = /\/emsg[-/]ID3/i;
class MP4Demuxer implements Demuxer {
private remainderData: Uint8Array | null = null;
private timeOffset: number = 0;
private config: HlsConfig;
private videoTrack?: PassthroughTrack;
private audioTrack?: DemuxedAudioTrack;
private id3Track?: DemuxedMetadataTrack;
private txtTrack?: DemuxedUserdataTrack;
constructor(observer: HlsEventEmitter, config: HlsConfig) {
this.config = config;
}
public resetTimeStamp() {}
public resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number,
) {
const videoTrack = (this.videoTrack = dummyTrack(
'video',
1,
) as PassthroughTrack);
const audioTrack = (this.audioTrack = dummyTrack(
'audio',
1,
) as DemuxedAudioTrack);
const captionTrack = (this.txtTrack = dummyTrack(
'text',
1,
) as DemuxedUserdataTrack);
this.id3Track = dummyTrack('id3', 1) as DemuxedMetadataTrack;
this.timeOffset = 0;
if (!initSegment?.byteLength) {
return;
}
const initData = parseInitSegment(initSegment);
if (initData.video) {
const { id, timescale, codec, supplemental } = initData.video;
videoTrack.id = id;
videoTrack.timescale = captionTrack.timescale = timescale;
videoTrack.codec = codec;
videoTrack.supplemental = supplemental;
}
if (initData.audio) {
const { id, timescale, codec } = initData.audio;
audioTrack.id = id;
audioTrack.timescale = timescale;
audioTrack.codec = codec;
}
captionTrack.id = RemuxerTrackIdConfig.text;
videoTrack.sampleDuration = 0;
videoTrack.duration = audioTrack.duration = trackDuration;
}
public resetContiguity(): void {
this.remainderData = null;
}
static probe(data: Uint8Array) {
return hasMoofData(data);
}
public demux(data: Uint8Array, timeOffset: number): DemuxerResult {
this.timeOffset = timeOffset;
// Load all data into the avc track. The CMAF remuxer will look for the data in the samples object; the rest of the fields do not matter
let videoSamples = data;
const videoTrack = this.videoTrack as PassthroughTrack;
const textTrack = this.txtTrack as DemuxedUserdataTrack;
if (this.config.progressive) {
// Split the bytestream into two ranges: one encompassing all data up until the start of the last moof, and everything else.
// This is done to guarantee that we're sending valid data to MSE - when demuxing progressively, we have no guarantee
// that the fetch loader gives us flush moof+mdat pairs. If we push jagged data to MSE, it will throw an exception.
if (this.remainderData) {
videoSamples = appendUint8Array(this.remainderData, data);
}
const segmentedData = segmentValidRange(videoSamples);
this.remainderData = segmentedData.remainder;
videoTrack.samples = segmentedData.valid || new Uint8Array();
} else {
videoTrack.samples = videoSamples;
}
const id3Track = this.extractID3Track(videoTrack, timeOffset);
textTrack.samples = parseSamples(timeOffset, videoTrack);
return {
videoTrack,
audioTrack: this.audioTrack as DemuxedAudioTrack,
id3Track,
textTrack: this.txtTrack as DemuxedUserdataTrack,
};
}
public flush() {
const timeOffset = this.timeOffset;
const videoTrack = this.videoTrack as PassthroughTrack;
const textTrack = this.txtTrack as DemuxedUserdataTrack;
videoTrack.samples = this.remainderData || new Uint8Array();
this.remainderData = null;
const id3Track = this.extractID3Track(videoTrack, this.timeOffset);
textTrack.samples = parseSamples(timeOffset, videoTrack);
return {
videoTrack,
audioTrack: dummyTrack() as DemuxedAudioTrack,
id3Track,
textTrack: dummyTrack() as DemuxedUserdataTrack,
};
}
private extractID3Track(
videoTrack: PassthroughTrack,
timeOffset: number,
): DemuxedMetadataTrack {
const id3Track = this.id3Track as DemuxedMetadataTrack;
if (videoTrack.samples.length) {
const emsgs = findBox(videoTrack.samples, ['emsg']);
if (emsgs) {
emsgs.forEach((data: Uint8Array) => {
const emsgInfo = parseEmsg(data);
if (emsgSchemePattern.test(emsgInfo.schemeIdUri)) {
const pts = getEmsgStartTime(emsgInfo, timeOffset);
let duration =
emsgInfo.eventDuration === 0xffffffff
? Number.POSITIVE_INFINITY
: emsgInfo.eventDuration / emsgInfo.timeScale;
// Safari takes anything <= 0.001 seconds and maps it to Infinity
if (duration <= 0.001) {
duration = Number.POSITIVE_INFINITY;
}
const payload = emsgInfo.payload;
id3Track.samples.push({
data: payload,
len: payload.byteLength,
dts: pts,
pts: pts,
type: MetadataSchema.emsg,
duration: duration,
});
} else if (
this.config.enableEmsgKLVMetadata &&
emsgInfo.schemeIdUri.startsWith('urn:misb:KLV:bin:1910.1')
) {
const pts = getEmsgStartTime(emsgInfo, timeOffset);
id3Track.samples.push({
data: emsgInfo.payload,
len: emsgInfo.payload.byteLength,
dts: pts,
pts: pts,
type: MetadataSchema.misbklv,
duration: Number.POSITIVE_INFINITY,
});
}
});
}
}
return id3Track;
}
demuxSampleAes(
data: Uint8Array,
keyData: KeyData,
timeOffset: number,
): Promise<DemuxerResult> {
return Promise.reject(
new Error('The MP4 demuxer does not support SAMPLE-AES decryption'),
);
}
destroy() {
// @ts-ignore
this.config = null;
this.remainderData = null;
this.videoTrack =
this.audioTrack =
this.id3Track =
this.txtTrack =
undefined;
}
}
function getEmsgStartTime(
emsgInfo: IEmsgParsingData,
timeOffset: number,
): number {
return Number.isFinite(emsgInfo.presentationTime)
? (emsgInfo.presentationTime as number) / emsgInfo.timeScale
: timeOffset +
(emsgInfo.presentationTimeDelta as number) / emsgInfo.timeScale;
}
export default MP4Demuxer;
+198
View File
@@ -0,0 +1,198 @@
/**
* SAMPLE-AES decrypter
*/
import Decrypter from '../crypt/decrypter';
import { DecrypterAesMode } from '../crypt/decrypter-aes-mode';
import { discardEPB } from '../utils/mp4-tools';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
import type {
AACAudioSample,
DemuxedVideoTrackBase,
KeyData,
VideoSample,
VideoSampleUnit,
} from '../types/demuxer';
class SampleAesDecrypter {
private keyData: KeyData;
private decrypter: Decrypter;
constructor(observer: HlsEventEmitter, config: HlsConfig, keyData: KeyData) {
this.keyData = keyData;
this.decrypter = new Decrypter(config, {
removePKCS7Padding: false,
});
}
decryptBuffer(encryptedData: Uint8Array | ArrayBuffer): Promise<ArrayBuffer> {
return this.decrypter.decrypt(
encryptedData,
this.keyData.key.buffer,
this.keyData.iv.buffer,
DecrypterAesMode.cbc,
);
}
// AAC - encrypt all full 16 bytes blocks starting from offset 16
private decryptAacSample(
samples: AACAudioSample[],
sampleIndex: number,
callback: () => void,
) {
const curUnit = samples[sampleIndex].unit;
if (curUnit.length <= 16) {
// No encrypted portion in this sample (first 16 bytes is not
// encrypted, see https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/HLS_Sample_Encryption/Encryption/Encryption.html),
return;
}
const encryptedData = curUnit.subarray(
16,
curUnit.length - (curUnit.length % 16),
);
const encryptedBuffer = encryptedData.buffer.slice(
encryptedData.byteOffset,
encryptedData.byteOffset + encryptedData.length,
);
this.decryptBuffer(encryptedBuffer)
.then((decryptedBuffer: ArrayBuffer) => {
const decryptedData = new Uint8Array(decryptedBuffer);
curUnit.set(decryptedData, 16);
if (!this.decrypter.isSync()) {
this.decryptAacSamples(samples, sampleIndex + 1, callback);
}
})
.catch(callback);
}
decryptAacSamples(
samples: AACAudioSample[],
sampleIndex: number,
callback: () => void,
) {
for (; ; sampleIndex++) {
if (sampleIndex >= samples.length) {
callback();
return;
}
if (samples[sampleIndex].unit.length < 32) {
continue;
}
this.decryptAacSample(samples, sampleIndex, callback);
if (!this.decrypter.isSync()) {
return;
}
}
}
// AVC - encrypt one 16 bytes block out of ten, starting from offset 32
getAvcEncryptedData(decodedData: Uint8Array) {
const encryptedDataLen =
Math.floor((decodedData.length - 48) / 160) * 16 + 16;
const encryptedData = new Int8Array(encryptedDataLen);
let outputPos = 0;
for (
let inputPos = 32;
inputPos < decodedData.length - 16;
inputPos += 160, outputPos += 16
) {
encryptedData.set(
decodedData.subarray(inputPos, inputPos + 16),
outputPos,
);
}
return encryptedData;
}
getAvcDecryptedUnit(decodedData: Uint8Array, decryptedData: ArrayBufferLike) {
const uint8DecryptedData = new Uint8Array(decryptedData);
let inputPos = 0;
for (
let outputPos = 32;
outputPos < decodedData.length - 16;
outputPos += 160, inputPos += 16
) {
decodedData.set(
uint8DecryptedData.subarray(inputPos, inputPos + 16),
outputPos,
);
}
return decodedData;
}
decryptAvcSample(
samples: VideoSample[],
sampleIndex: number,
unitIndex: number,
callback: () => void,
curUnit: VideoSampleUnit,
) {
const decodedData = discardEPB(curUnit.data);
const encryptedData = this.getAvcEncryptedData(decodedData);
this.decryptBuffer(encryptedData.buffer)
.then((decryptedBuffer) => {
curUnit.data = this.getAvcDecryptedUnit(decodedData, decryptedBuffer);
if (!this.decrypter.isSync()) {
this.decryptAvcSamples(samples, sampleIndex, unitIndex + 1, callback);
}
})
.catch(callback);
}
decryptAvcSamples(
samples: DemuxedVideoTrackBase['samples'],
sampleIndex: number,
unitIndex: number,
callback: () => void,
) {
if (samples instanceof Uint8Array) {
throw new Error('Cannot decrypt samples of type Uint8Array');
}
for (; ; sampleIndex++, unitIndex = 0) {
if (sampleIndex >= samples.length) {
callback();
return;
}
const curUnits = samples[sampleIndex].units;
for (; ; unitIndex++) {
if (unitIndex >= curUnits.length) {
break;
}
const curUnit = curUnits[unitIndex];
if (
curUnit.data.length <= 48 ||
(curUnit.type !== 1 && curUnit.type !== 5)
) {
continue;
}
this.decryptAvcSample(
samples,
sampleIndex,
unitIndex,
callback,
curUnit,
);
if (!this.decrypter.isSync()) {
return;
}
}
}
}
}
export default SampleAesDecrypter;
+449
View File
@@ -0,0 +1,449 @@
import { EventEmitter } from 'eventemitter3';
import {
hasUMDWorker,
injectWorker,
loadWorker,
removeWorkerFromStore as removeWorkerClient,
} from './inject-worker';
import Transmuxer, {
isPromise,
TransmuxConfig,
TransmuxState,
} from '../demux/transmuxer';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { PlaylistLevelType } from '../types/loader';
import { getM2TSSupportedAudioTypes } from '../utils/codecs';
import { stringify } from '../utils/safe-json-stringify';
import type { WorkerContext } from './inject-worker';
import type { HlsEventEmitter, HlsListeners } from '../events';
import type Hls from '../hls';
import type { MediaFragment, Part } from '../loader/fragment';
import type { ErrorData, FragDecryptedData } from '../types/events';
import type { ChunkMetadata, TransmuxerResult } from '../types/transmuxer';
import type { TimestampOffset } from '../utils/timescale-conversion';
let transmuxerInstanceCount: number = 0;
export default class TransmuxerInterface {
public error: Error | null = null;
private hls: Hls;
private id: PlaylistLevelType;
private instanceNo: number = transmuxerInstanceCount++;
private observer: HlsEventEmitter;
private frag: MediaFragment | null = null;
private part: Part | null = null;
private useWorker: boolean;
private workerContext: WorkerContext | null = null;
private transmuxer: Transmuxer | null = null;
private onTransmuxComplete: (transmuxResult: TransmuxerResult) => void;
private onFlush: (chunkMeta: ChunkMetadata) => void;
constructor(
hls: Hls,
id: PlaylistLevelType,
onTransmuxComplete: (transmuxResult: TransmuxerResult) => void,
onFlush: (chunkMeta: ChunkMetadata) => void,
) {
const config = hls.config;
this.hls = hls;
this.id = id;
this.useWorker = !!config.enableWorker;
this.onTransmuxComplete = onTransmuxComplete;
this.onFlush = onFlush;
const forwardMessage = (
ev: Events.ERROR | Events.FRAG_DECRYPTED,
data: ErrorData | FragDecryptedData,
) => {
data = data || {};
data.frag = this.frag || undefined;
if (ev === Events.ERROR) {
data = data as ErrorData;
data.parent = this.id;
data.part = this.part;
this.error = data.error;
}
this.hls.trigger(ev, data);
};
// forward events to main thread
this.observer = new EventEmitter() as HlsEventEmitter;
this.observer.on(Events.FRAG_DECRYPTED, forwardMessage);
this.observer.on(Events.ERROR, forwardMessage);
const m2tsTypeSupported = getM2TSSupportedAudioTypes(
config.preferManagedMediaSource,
);
if (this.useWorker && typeof Worker !== 'undefined') {
const logger = this.hls.logger;
const canCreateWorker = config.workerPath || hasUMDWorker();
if (canCreateWorker) {
try {
if (config.workerPath) {
logger.log(`loading Web Worker ${config.workerPath} for "${id}"`);
this.workerContext = loadWorker(config.workerPath);
} else {
logger.log(`injecting Web Worker for "${id}"`);
this.workerContext = injectWorker();
}
const { worker } = this.workerContext;
worker.addEventListener('message', this.onWorkerMessage);
worker.addEventListener('error', this.onWorkerError);
worker.postMessage({
instanceNo: this.instanceNo,
cmd: 'init',
typeSupported: m2tsTypeSupported,
id,
config: stringify(config),
});
} catch (err) {
logger.warn(
`Error setting up "${id}" Web Worker, fallback to inline`,
err,
);
this.terminateWorker();
this.error = null;
this.transmuxer = new Transmuxer(
this.observer,
m2tsTypeSupported,
config,
'',
id,
hls.logger,
);
}
return;
}
}
this.transmuxer = new Transmuxer(
this.observer,
m2tsTypeSupported,
config,
'',
id,
hls.logger,
);
}
reset() {
this.frag = null;
this.part = null;
if (this.workerContext) {
const instanceNo = this.instanceNo;
this.instanceNo = transmuxerInstanceCount++;
const config = this.hls.config;
const m2tsTypeSupported = getM2TSSupportedAudioTypes(
config.preferManagedMediaSource,
);
this.workerContext.worker.postMessage({
instanceNo: this.instanceNo,
cmd: 'reset',
resetNo: instanceNo,
typeSupported: m2tsTypeSupported,
id: this.id,
config: stringify(config),
});
}
}
private terminateWorker() {
if (this.workerContext) {
const { worker } = this.workerContext;
this.workerContext = null;
worker.removeEventListener('message', this.onWorkerMessage);
worker.removeEventListener('error', this.onWorkerError);
removeWorkerClient(this.hls.config.workerPath);
}
}
destroy() {
if (this.workerContext) {
this.terminateWorker();
// @ts-ignore
this.onWorkerMessage = this.onWorkerError = null;
} else {
const transmuxer = this.transmuxer;
if (transmuxer) {
transmuxer.destroy();
this.transmuxer = null;
}
}
const observer = this.observer;
if (observer) {
observer.removeAllListeners();
}
this.frag = null;
this.part = null;
// @ts-ignore
this.observer = null;
// @ts-ignore
this.hls = null;
}
push(
data: ArrayBuffer,
initSegmentData: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
frag: MediaFragment,
part: Part | null,
duration: number,
accurateTimeOffset: boolean,
chunkMeta: ChunkMetadata,
defaultInitPTS?: TimestampOffset,
) {
chunkMeta.transmuxing.start = self.performance.now();
const { instanceNo, transmuxer } = this;
const timeOffset = part ? part.start : frag.start;
// TODO: push "clear-lead" decrypt data for unencrypted fragments in streams with encrypted ones
const decryptdata = frag.decryptdata;
const lastFrag = this.frag;
const discontinuity = !(lastFrag && frag.cc === lastFrag.cc);
const trackSwitch = !(lastFrag && chunkMeta.level === lastFrag.level);
const snDiff = lastFrag ? chunkMeta.sn - lastFrag.sn : -1;
const partDiff = this.part ? chunkMeta.part - this.part.index : -1;
const progressive =
snDiff === 0 &&
chunkMeta.id > 1 &&
chunkMeta.id === lastFrag?.stats.chunkCount;
const contiguous =
!trackSwitch &&
(snDiff === 1 ||
(snDiff === 0 && (partDiff === 1 || (progressive && partDiff <= 0))));
const now = self.performance.now();
if (trackSwitch || snDiff || frag.stats.parsing.start === 0) {
frag.stats.parsing.start = now;
}
if (part && (partDiff || !contiguous)) {
part.stats.parsing.start = now;
}
const initSegmentChange = !(
lastFrag && frag.initSegment?.url === lastFrag.initSegment?.url
);
const state = new TransmuxState(
discontinuity,
contiguous,
accurateTimeOffset,
trackSwitch,
timeOffset,
initSegmentChange,
);
if (!contiguous || discontinuity || initSegmentChange) {
this.hls.logger
.log(`[transmuxer-interface]: Starting new transmux session for ${frag.type} sn: ${chunkMeta.sn}${
chunkMeta.part > -1 ? ' part: ' + chunkMeta.part : ''
} ${this.id === PlaylistLevelType.MAIN ? 'level' : 'track'}: ${chunkMeta.level} id: ${chunkMeta.id}
discontinuity: ${discontinuity}
trackSwitch: ${trackSwitch}
contiguous: ${contiguous}
accurateTimeOffset: ${accurateTimeOffset}
timeOffset: ${timeOffset}
initSegmentChange: ${initSegmentChange}`);
const config = new TransmuxConfig(
audioCodec,
videoCodec,
initSegmentData,
duration,
defaultInitPTS,
);
this.configureTransmuxer(config);
}
this.frag = frag;
this.part = part;
// Frags with sn of 'initSegment' are not transmuxed
if (this.workerContext) {
// post fragment payload as transferable objects for ArrayBuffer (no copy)
this.workerContext.worker.postMessage(
{
instanceNo,
cmd: 'demux',
data,
decryptdata,
chunkMeta,
state,
},
data instanceof ArrayBuffer ? [data] : [],
);
} else if (transmuxer) {
const transmuxResult = transmuxer.push(
data,
decryptdata,
chunkMeta,
state,
);
if (isPromise(transmuxResult)) {
transmuxResult
.then((data) => {
this.handleTransmuxComplete(data);
})
.catch((error) => {
this.transmuxerError(
error,
chunkMeta,
'transmuxer-interface push error',
);
});
} else {
this.handleTransmuxComplete(transmuxResult as TransmuxerResult);
}
}
}
flush(chunkMeta: ChunkMetadata) {
chunkMeta.transmuxing.start = self.performance.now();
const { instanceNo, transmuxer } = this;
if (this.workerContext) {
1;
this.workerContext.worker.postMessage({
instanceNo,
cmd: 'flush',
chunkMeta,
});
} else if (transmuxer) {
const transmuxResult = transmuxer.flush(chunkMeta);
if (isPromise(transmuxResult)) {
transmuxResult
.then((data) => {
this.handleFlushResult(data, chunkMeta);
})
.catch((error) => {
this.transmuxerError(
error,
chunkMeta,
'transmuxer-interface flush error',
);
});
} else {
this.handleFlushResult(transmuxResult, chunkMeta);
}
}
}
private transmuxerError(
error: Error,
chunkMeta: ChunkMetadata,
reason: string,
) {
if (!this.hls) {
return;
}
this.error = error;
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
chunkMeta,
frag: this.frag || undefined,
part: this.part || undefined,
fatal: false,
error,
err: error,
reason,
});
}
private handleFlushResult(
results: Array<TransmuxerResult>,
chunkMeta: ChunkMetadata,
) {
results.forEach((result) => {
this.handleTransmuxComplete(result);
});
this.onFlush(chunkMeta);
}
private onWorkerMessage = (
event: MessageEvent<{
event: string;
data?: any;
instanceNo?: number;
} | null>,
) => {
const data = event.data;
const hls = this.hls;
if (!hls || !data?.event || data.instanceNo !== this.instanceNo) {
return;
}
switch (data.event) {
case 'init': {
const objectURL = this.workerContext?.objectURL;
if (objectURL) {
// revoke the Object URL that was used to create transmuxer worker, so as not to leak it
self.URL.revokeObjectURL(objectURL);
}
break;
}
case 'transmuxComplete': {
this.handleTransmuxComplete(data.data);
break;
}
case 'flush': {
this.onFlush(data.data);
break;
}
// pass logs from the worker thread to the main logger
case 'workerLog': {
if (hls.logger[data.data.logType]) {
hls.logger[data.data.logType](data.data.message);
}
break;
}
default: {
data.data = data.data || {};
data.data.frag = this.frag;
data.data.part = this.part;
data.data.id = this.id;
hls.trigger(data.event as keyof HlsListeners, data.data);
break;
}
}
};
private onWorkerError = (event) => {
if (!this.hls) {
return;
}
const error = new Error(
`${event.message} (${event.filename}:${event.lineno})`,
);
this.hls.config.enableWorker = false;
this.hls.logger.warn(
`Error in "${this.id}" Web Worker, fallback to inline`,
);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.OTHER_ERROR,
details: ErrorDetails.INTERNAL_EXCEPTION,
fatal: false,
event: 'demuxerWorker',
error,
});
};
private configureTransmuxer(config: TransmuxConfig) {
const { instanceNo, transmuxer } = this;
if (this.workerContext) {
this.workerContext.worker.postMessage({
instanceNo,
cmd: 'configure',
config,
});
} else if (transmuxer) {
transmuxer.configure(config);
}
}
private handleTransmuxComplete(result: TransmuxerResult) {
result.chunkMeta.transmuxing.end = self.performance.now();
this.onTransmuxComplete(result);
}
}
+221
View File
@@ -0,0 +1,221 @@
import { EventEmitter } from 'eventemitter3';
import Transmuxer, { isPromise } from '../demux/transmuxer';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { enableLogs, type ILogger } from '../utils/logger';
import type { RemuxedTrack, RemuxerResult } from '../types/remuxer';
import type { ChunkMetadata, TransmuxerResult } from '../types/transmuxer';
const transmuxers: (Transmuxer | undefined)[] = [];
if (typeof __IN_WORKER__ !== 'undefined' && __IN_WORKER__) {
startWorker();
}
function startWorker() {
self.addEventListener('message', (ev) => {
const data = ev.data;
const instanceNo = data.instanceNo;
if (instanceNo === undefined) {
return;
}
const transmuxer = transmuxers[instanceNo];
if (data.cmd === 'reset') {
delete transmuxers[data.resetNo];
if (transmuxer) {
transmuxer.destroy();
}
data.cmd = 'init';
}
if (data.cmd === 'init') {
const config = JSON.parse(data.config);
const observer = new EventEmitter();
observer.on(Events.FRAG_DECRYPTED, forwardMessage);
observer.on(Events.ERROR, forwardMessage);
const logger = enableLogs(config.debug, data.id);
forwardWorkerLogs(logger, instanceNo);
transmuxers[instanceNo] = new Transmuxer(
observer,
data.typeSupported,
config,
'',
data.id,
logger,
);
forwardMessage('init', null, instanceNo);
return;
}
if (!transmuxer) {
return;
}
switch (data.cmd) {
case 'configure': {
transmuxer.configure(data.config);
break;
}
case 'demux': {
const transmuxResult: TransmuxerResult | Promise<TransmuxerResult> =
transmuxer.push(
data.data,
data.decryptdata,
data.chunkMeta,
data.state,
);
if (isPromise(transmuxResult)) {
transmuxResult
.then((data) => {
emitTransmuxComplete(self, data, instanceNo);
})
.catch((error) => {
forwardMessage(
Events.ERROR,
{
instanceNo,
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
chunkMeta: data.chunkMeta,
fatal: false,
error,
err: error,
reason: `transmuxer-worker push error`,
},
instanceNo,
);
});
} else {
emitTransmuxComplete(self, transmuxResult, instanceNo);
}
break;
}
case 'flush': {
const chunkMeta = data.chunkMeta as ChunkMetadata;
const transmuxResult = transmuxer.flush(chunkMeta);
if (isPromise(transmuxResult)) {
transmuxResult
.then((results: Array<TransmuxerResult>) => {
handleFlushResult(
self,
results as Array<TransmuxerResult>,
chunkMeta,
instanceNo,
);
})
.catch((error) => {
forwardMessage(
Events.ERROR,
{
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
chunkMeta: data.chunkMeta,
fatal: false,
error,
err: error,
reason: `transmuxer-worker flush error`,
},
instanceNo,
);
});
} else {
handleFlushResult(
self,
transmuxResult as Array<TransmuxerResult>,
chunkMeta,
instanceNo,
);
}
break;
}
default:
break;
}
});
}
function emitTransmuxComplete(
self: any,
transmuxResult: TransmuxerResult,
instanceNo: number,
): boolean {
if (isEmptyResult(transmuxResult.remuxResult)) {
return false;
}
const transferable: Array<ArrayBuffer> = [];
const { audio, video } = transmuxResult.remuxResult;
if (audio) {
addToTransferable(transferable, audio);
}
if (video) {
addToTransferable(transferable, video);
}
self.postMessage(
{ event: 'transmuxComplete', data: transmuxResult, instanceNo },
transferable,
);
return true;
}
// Converts data to a transferable object https://developers.google.com/web/updates/2011/12/Transferable-Objects-Lightning-Fast)
// in order to minimize message passing overhead
function addToTransferable(
transferable: Array<ArrayBuffer>,
track: RemuxedTrack,
) {
if (track.data1) {
transferable.push(track.data1.buffer);
}
if (track.data2) {
transferable.push(track.data2.buffer);
}
}
function handleFlushResult(
self: any,
results: Array<TransmuxerResult>,
chunkMeta: ChunkMetadata,
instanceNo: number,
) {
const parsed = results.reduce(
(parsed, result) =>
emitTransmuxComplete(self, result, instanceNo) || parsed,
false,
);
if (!parsed) {
// Emit at least one "transmuxComplete" message even if media is not found to update stream-controller state to PARSING
self.postMessage({
event: 'transmuxComplete',
data: results[0],
instanceNo,
});
}
self.postMessage({ event: 'flush', data: chunkMeta, instanceNo });
}
function forwardMessage(event, data, instanceNo) {
self.postMessage({ event, data, instanceNo });
}
function forwardWorkerLogs(logger: ILogger, instanceNo: number) {
for (const logFn in logger) {
logger[logFn] = function () {
const message = Array.prototype.join.call(arguments, ' ');
forwardMessage(
'workerLog',
{
logType: logFn,
message,
},
instanceNo,
);
};
}
}
function isEmptyResult(remuxResult: RemuxerResult) {
return (
!remuxResult.audio &&
!remuxResult.video &&
!remuxResult.text &&
!remuxResult.id3 &&
!remuxResult.initSegment
);
}
+560
View File
@@ -0,0 +1,560 @@
import AACDemuxer from './audio/aacdemuxer';
import { AC3Demuxer } from './audio/ac3-demuxer';
import MP3Demuxer from './audio/mp3demuxer';
import Decrypter from '../crypt/decrypter';
import MP4Demuxer from '../demux/mp4demuxer';
import TSDemuxer from '../demux/tsdemuxer';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import MP4Remuxer from '../remux/mp4-remuxer';
import PassThroughRemuxer from '../remux/passthrough-remuxer';
import { PlaylistLevelType } from '../types/loader';
import {
getAesModeFromFullSegmentMethod,
isFullSegmentEncryption,
} from '../utils/encryption-methods-util';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
import type { DecryptData } from '../loader/level-key';
import type { Demuxer, DemuxerResult, KeyData } from '../types/demuxer';
import type { Remuxer } from '../types/remuxer';
import type { ChunkMetadata, TransmuxerResult } from '../types/transmuxer';
import type { TypeSupported } from '../utils/codecs';
import type { ILogger } from '../utils/logger';
import type { TimestampOffset } from '../utils/timescale-conversion';
let now: () => number;
// performance.now() not available on WebWorker, at least on Safari Desktop
try {
now = self.performance.now.bind(self.performance);
} catch (err) {
now = Date.now;
}
type MuxConfig =
| { demux: typeof MP4Demuxer; remux: typeof PassThroughRemuxer }
| { demux: typeof TSDemuxer; remux: typeof MP4Remuxer }
| { demux: typeof AC3Demuxer; remux: typeof MP4Remuxer }
| { demux: typeof AACDemuxer; remux: typeof MP4Remuxer }
| { demux: typeof MP3Demuxer; remux: typeof MP4Remuxer };
const muxConfig: MuxConfig[] = [
{ demux: MP4Demuxer, remux: PassThroughRemuxer },
{ demux: TSDemuxer, remux: MP4Remuxer },
{ demux: AACDemuxer, remux: MP4Remuxer },
{ demux: MP3Demuxer, remux: MP4Remuxer },
];
if (__USE_M2TS_ADVANCED_CODECS__) {
muxConfig.splice(2, 0, { demux: AC3Demuxer, remux: MP4Remuxer });
}
export default class Transmuxer {
private asyncResult: boolean = false;
private logger: ILogger;
private observer: HlsEventEmitter;
private typeSupported: TypeSupported;
private config: HlsConfig;
private id: PlaylistLevelType;
private demuxer?: Demuxer;
private remuxer?: Remuxer;
private decrypter?: Decrypter;
private probe!: Function;
private decryptionPromise: Promise<TransmuxerResult> | null = null;
private transmuxConfig!: TransmuxConfig;
private currentTransmuxState!: TransmuxState;
constructor(
observer: HlsEventEmitter,
typeSupported: TypeSupported,
config: HlsConfig,
vendor: string,
id: PlaylistLevelType,
logger: ILogger,
) {
this.observer = observer;
this.typeSupported = typeSupported;
this.config = config;
this.id = id;
this.logger = logger;
}
configure(transmuxConfig: TransmuxConfig) {
this.transmuxConfig = transmuxConfig;
if (this.decrypter) {
this.decrypter.reset();
}
}
push(
data: ArrayBuffer,
decryptdata: DecryptData | null,
chunkMeta: ChunkMetadata,
state?: TransmuxState,
): TransmuxerResult | Promise<TransmuxerResult> {
const stats = chunkMeta.transmuxing;
stats.executeStart = now();
let uintData: Uint8Array<ArrayBuffer> = new Uint8Array(data);
const { currentTransmuxState, transmuxConfig } = this;
if (state) {
this.currentTransmuxState = state;
}
const {
contiguous,
discontinuity,
trackSwitch,
accurateTimeOffset,
timeOffset,
initSegmentChange,
} = state || currentTransmuxState;
const {
audioCodec,
videoCodec,
defaultInitPts,
duration,
initSegmentData,
} = transmuxConfig;
const keyData = getEncryptionType(uintData, decryptdata);
if (keyData && isFullSegmentEncryption(keyData.method)) {
const decrypter = this.getDecrypter();
const aesMode = getAesModeFromFullSegmentMethod(keyData.method);
// Software decryption is synchronous; webCrypto is not
if (decrypter.isSync()) {
// Software decryption is progressive. Progressive decryption may not return a result on each call. Any cached
// data is handled in the flush() call
let decryptedData = decrypter.softwareDecrypt(
uintData,
keyData.key.buffer,
keyData.iv.buffer,
aesMode,
);
// For Low-Latency HLS Parts, decrypt in place, since part parsing is expected on push progress
const loadingParts = chunkMeta.part > -1;
if (loadingParts) {
const data = decrypter.flush();
decryptedData = data ? data.buffer : data;
}
if (!decryptedData) {
stats.executeEnd = now();
return emptyResult(chunkMeta);
}
uintData = new Uint8Array(decryptedData);
} else {
this.asyncResult = true;
this.decryptionPromise = decrypter
.webCryptoDecrypt(
uintData,
keyData.key.buffer,
keyData.iv.buffer,
aesMode,
)
.then((decryptedData): TransmuxerResult => {
// Calling push here is important; if flush() is called while this is still resolving, this ensures that
// the decrypted data has been transmuxed
const result = this.push(
decryptedData,
null,
chunkMeta,
) as TransmuxerResult;
this.decryptionPromise = null;
return result;
});
return this.decryptionPromise;
}
}
const resetMuxers = this.needsProbing(discontinuity, trackSwitch);
if (resetMuxers) {
const error = this.configureTransmuxer(uintData);
if (error) {
this.logger.warn(`[transmuxer] ${error.message}`);
this.observer.emit(Events.ERROR, Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_PARSING_ERROR,
fatal: false,
error,
reason: error.message,
});
stats.executeEnd = now();
return emptyResult(chunkMeta);
}
}
if (discontinuity || trackSwitch || initSegmentChange || resetMuxers) {
this.resetInitSegment(
initSegmentData,
audioCodec,
videoCodec,
duration,
decryptdata,
);
}
if (discontinuity || initSegmentChange || resetMuxers) {
this.resetInitialTimestamp(defaultInitPts);
}
if (!contiguous) {
this.resetContiguity();
}
const result = this.transmux(
uintData,
keyData,
timeOffset,
accurateTimeOffset,
chunkMeta,
);
this.asyncResult = isPromise(result);
const currentState = this.currentTransmuxState;
currentState.contiguous = true;
currentState.discontinuity = false;
currentState.trackSwitch = false;
stats.executeEnd = now();
return result;
}
// Due to data caching, flush calls can produce more than one TransmuxerResult (hence the Array type)
flush(
chunkMeta: ChunkMetadata,
): TransmuxerResult[] | Promise<TransmuxerResult[]> {
const stats = chunkMeta.transmuxing;
stats.executeStart = now();
const { decrypter, currentTransmuxState, decryptionPromise } = this;
if (decryptionPromise) {
this.asyncResult = true;
// Upon resolution, the decryption promise calls push() and returns its TransmuxerResult up the stack. Therefore
// only flushing is required for async decryption
return decryptionPromise.then(() => {
return this.flush(chunkMeta);
});
}
const transmuxResults: TransmuxerResult[] = [];
const { timeOffset } = currentTransmuxState;
if (decrypter) {
// The decrypter may have data cached, which needs to be demuxed. In this case we'll have two TransmuxResults
// This happens in the case that we receive only 1 push call for a segment (either for non-progressive downloads,
// or for progressive downloads with small segments)
const decryptedData = decrypter.flush();
if (decryptedData) {
// Push always returns a TransmuxerResult if decryptdata is null
transmuxResults.push(
this.push(decryptedData.buffer, null, chunkMeta) as TransmuxerResult,
);
}
}
const { demuxer, remuxer } = this;
if (!demuxer || !remuxer) {
// If probing failed, then Hls.js has been given content its not able to handle
stats.executeEnd = now();
const emptyResults = [emptyResult(chunkMeta)];
if (this.asyncResult) {
return Promise.resolve(emptyResults);
}
return emptyResults;
}
const demuxResultOrPromise = demuxer.flush(timeOffset);
if (isPromise(demuxResultOrPromise)) {
this.asyncResult = true;
// Decrypt final SAMPLE-AES samples
return demuxResultOrPromise.then((demuxResult) => {
this.flushRemux(transmuxResults, demuxResult, chunkMeta);
return transmuxResults;
});
}
this.flushRemux(transmuxResults, demuxResultOrPromise, chunkMeta);
if (this.asyncResult) {
return Promise.resolve(transmuxResults);
}
return transmuxResults;
}
private flushRemux(
transmuxResults: TransmuxerResult[],
demuxResult: DemuxerResult,
chunkMeta: ChunkMetadata,
) {
const { audioTrack, videoTrack, id3Track, textTrack } = demuxResult;
const { accurateTimeOffset, timeOffset } = this.currentTransmuxState;
this.logger.log(
`[transmuxer.ts]: Flushed ${this.id} sn: ${chunkMeta.sn}${
chunkMeta.part > -1 ? ' part: ' + chunkMeta.part : ''
} of ${this.id === PlaylistLevelType.MAIN ? 'level' : 'track'} ${chunkMeta.level}`,
);
const remuxResult = this.remuxer!.remux(
audioTrack,
videoTrack,
id3Track,
textTrack,
timeOffset,
accurateTimeOffset,
true,
this.id,
);
transmuxResults.push({
remuxResult,
chunkMeta,
});
chunkMeta.transmuxing.executeEnd = now();
}
resetInitialTimestamp(defaultInitPts: TimestampOffset | null) {
const { demuxer, remuxer } = this;
if (!demuxer || !remuxer) {
return;
}
demuxer.resetTimeStamp(defaultInitPts);
remuxer.resetTimeStamp(defaultInitPts);
}
resetContiguity() {
const { demuxer, remuxer } = this;
if (!demuxer || !remuxer) {
return;
}
demuxer.resetContiguity();
remuxer.resetNextTimestamp();
}
resetInitSegment(
initSegmentData: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number,
decryptdata: DecryptData | null,
) {
const { demuxer, remuxer } = this;
if (!demuxer || !remuxer) {
return;
}
demuxer.resetInitSegment(
initSegmentData,
audioCodec,
videoCodec,
trackDuration,
);
remuxer.resetInitSegment(
initSegmentData,
audioCodec,
videoCodec,
decryptdata,
);
}
destroy(): void {
if (this.demuxer) {
this.demuxer.destroy();
this.demuxer = undefined;
}
if (this.remuxer) {
this.remuxer.destroy();
this.remuxer = undefined;
}
}
private transmux(
data: Uint8Array,
keyData: KeyData | null,
timeOffset: number,
accurateTimeOffset: boolean,
chunkMeta: ChunkMetadata,
): TransmuxerResult | Promise<TransmuxerResult> {
let result: TransmuxerResult | Promise<TransmuxerResult>;
if (keyData && keyData.method === 'SAMPLE-AES') {
result = this.transmuxSampleAes(
data,
keyData,
timeOffset,
accurateTimeOffset,
chunkMeta,
);
} else {
result = this.transmuxUnencrypted(
data,
timeOffset,
accurateTimeOffset,
chunkMeta,
);
}
return result;
}
private transmuxUnencrypted(
data: Uint8Array,
timeOffset: number,
accurateTimeOffset: boolean,
chunkMeta: ChunkMetadata,
): TransmuxerResult {
const { audioTrack, videoTrack, id3Track, textTrack } = (
this.demuxer as Demuxer
).demux(data, timeOffset, false, !this.config.progressive);
const remuxResult = this.remuxer!.remux(
audioTrack,
videoTrack,
id3Track,
textTrack,
timeOffset,
accurateTimeOffset,
false,
this.id,
);
return {
remuxResult,
chunkMeta,
};
}
private transmuxSampleAes(
data: Uint8Array,
decryptData: KeyData,
timeOffset: number,
accurateTimeOffset: boolean,
chunkMeta: ChunkMetadata,
): Promise<TransmuxerResult> {
return (this.demuxer as Demuxer)
.demuxSampleAes(data, decryptData, timeOffset)
.then((demuxResult) => {
const remuxResult = this.remuxer!.remux(
demuxResult.audioTrack,
demuxResult.videoTrack,
demuxResult.id3Track,
demuxResult.textTrack,
timeOffset,
accurateTimeOffset,
false,
this.id,
);
return {
remuxResult,
chunkMeta,
};
});
}
private configureTransmuxer(data: Uint8Array): void | Error {
const { config, observer, typeSupported } = this;
// probe for content type
let mux;
for (let i = 0, len = muxConfig.length; i < len; i++) {
if (muxConfig[i].demux?.probe(data, this.logger)) {
mux = muxConfig[i];
break;
}
}
if (!mux) {
return new Error('Failed to find demuxer by probing fragment data');
}
// so let's check that current remuxer and demuxer are still valid
const demuxer = this.demuxer;
const remuxer = this.remuxer;
const Remuxer: MuxConfig['remux'] = mux.remux;
const Demuxer: MuxConfig['demux'] = mux.demux;
if (!remuxer || !(remuxer instanceof Remuxer)) {
this.remuxer = new Remuxer(observer, config, typeSupported, this.logger);
}
if (!demuxer || !(demuxer instanceof Demuxer)) {
this.demuxer = new Demuxer(observer, config, typeSupported, this.logger);
this.probe = Demuxer.probe;
}
}
private needsProbing(discontinuity: boolean, trackSwitch: boolean): boolean {
// in case of continuity change, or track switch
// we might switch from content type (AAC container to TS container, or TS to fmp4 for example)
return !this.demuxer || !this.remuxer || discontinuity || trackSwitch;
}
private getDecrypter(): Decrypter {
let decrypter = this.decrypter;
if (!decrypter) {
decrypter = this.decrypter = new Decrypter(this.config);
}
return decrypter;
}
}
function getEncryptionType(
data: Uint8Array,
decryptData: DecryptData | null,
): KeyData | null {
let encryptionType: KeyData | null = null;
if (
data.byteLength > 0 &&
decryptData?.key != null &&
decryptData.iv !== null &&
decryptData.method != null
) {
encryptionType = decryptData as KeyData;
}
return encryptionType;
}
const emptyResult = (chunkMeta): TransmuxerResult => ({
remuxResult: {},
chunkMeta,
});
export function isPromise<T>(p: Promise<T> | any): p is Promise<T> {
return 'then' in p && p.then instanceof Function;
}
export class TransmuxConfig {
public audioCodec?: string;
public videoCodec?: string;
public initSegmentData?: Uint8Array;
public duration: number;
public defaultInitPts: TimestampOffset | null;
constructor(
audioCodec: string | undefined,
videoCodec: string | undefined,
initSegmentData: Uint8Array | undefined,
duration: number,
defaultInitPts?: TimestampOffset,
) {
this.audioCodec = audioCodec;
this.videoCodec = videoCodec;
this.initSegmentData = initSegmentData;
this.duration = duration;
this.defaultInitPts = defaultInitPts || null;
}
}
export class TransmuxState {
public discontinuity: boolean;
public contiguous: boolean;
public accurateTimeOffset: boolean;
public trackSwitch: boolean;
public timeOffset: number;
public initSegmentChange: boolean;
constructor(
discontinuity: boolean,
contiguous: boolean,
accurateTimeOffset: boolean,
trackSwitch: boolean,
timeOffset: number,
initSegmentChange: boolean,
) {
this.discontinuity = discontinuity;
this.contiguous = contiguous;
this.accurateTimeOffset = accurateTimeOffset;
this.trackSwitch = trackSwitch;
this.timeOffset = timeOffset;
this.initSegmentChange = initSegmentChange;
}
}
+1053
View File
File diff suppressed because it is too large Load Diff
+401
View File
@@ -0,0 +1,401 @@
import BaseVideoParser from './base-video-parser';
import ExpGolomb from './exp-golomb';
import { parseSEIMessageFromNALu } from '../../utils/mp4-tools';
import type {
DemuxedUserdataTrack,
DemuxedVideoTrack,
} from '../../types/demuxer';
import type { PES } from '../tsdemuxer';
class AvcVideoParser extends BaseVideoParser {
public parsePES(
track: DemuxedVideoTrack,
textTrack: DemuxedUserdataTrack,
pes: PES,
endOfSegment: boolean,
) {
const units = this.parseNALu(track, pes.data, endOfSegment);
let VideoSample = this.VideoSample;
let push: boolean;
let spsfound = false;
// free pes.data to save up some memory
(pes as any).data = null;
// if new NAL units found and last sample still there, let's push ...
// this helps parsing streams with missing AUD (only do this if AUD never found)
if (VideoSample && units.length && !track.audFound) {
this.pushAccessUnit(VideoSample, track);
VideoSample = this.VideoSample = this.createVideoSample(
false,
pes.pts,
pes.dts,
);
}
units.forEach((unit) => {
switch (unit.type) {
// NDR
case 1: {
let iskey = false;
push = true;
const data = unit.data;
// only check slice type to detect KF in case SPS found in same packet (any keyframe is preceded by SPS ...)
if (spsfound && data.length > 4) {
// retrieve slice type by parsing beginning of NAL unit (follow H264 spec, slice_header definition) to detect keyframe embedded in NDR
const sliceType = this.readSliceType(data);
// 2 : I slice, 4 : SI slice, 7 : I slice, 9: SI slice
// SI slice : A slice that is coded using intra prediction only and using quantisation of the prediction samples.
// An SI slice can be coded such that its decoded samples can be constructed identically to an SP slice.
// I slice: A slice that is not an SI slice that is decoded using intra prediction only.
// if (sliceType === 2 || sliceType === 7) {
if (
sliceType === 2 ||
sliceType === 4 ||
sliceType === 7 ||
sliceType === 9
) {
iskey = true;
}
}
if (iskey) {
// if we have non-keyframe data already, that cannot belong to the same frame as a keyframe, so force a push
if (VideoSample?.frame && !VideoSample.key) {
this.pushAccessUnit(VideoSample, track);
VideoSample = this.VideoSample = null;
}
}
if (!VideoSample) {
VideoSample = this.VideoSample = this.createVideoSample(
true,
pes.pts,
pes.dts,
);
}
VideoSample.frame = true;
VideoSample.key = iskey;
break;
// IDR
}
case 5:
push = true;
// handle PES not starting with AUD
// if we have frame data already, that cannot belong to the same frame, so force a push
if (VideoSample?.frame && !VideoSample.key) {
this.pushAccessUnit(VideoSample, track);
VideoSample = this.VideoSample = null;
}
if (!VideoSample) {
VideoSample = this.VideoSample = this.createVideoSample(
true,
pes.pts,
pes.dts,
);
}
VideoSample.key = true;
VideoSample.frame = true;
break;
// SEI
case 6: {
push = true;
parseSEIMessageFromNALu(
unit.data,
1,
pes.pts as number,
textTrack.samples,
);
break;
// SPS
}
case 7: {
push = true;
spsfound = true;
const sps = unit.data;
const config = this.readSPS(sps);
if (
!track.sps ||
track.width !== config.width ||
track.height !== config.height ||
track.pixelRatio?.[0] !== config.pixelRatio[0] ||
track.pixelRatio?.[1] !== config.pixelRatio[1]
) {
track.width = config.width;
track.height = config.height;
track.pixelRatio = config.pixelRatio;
track.sps = [sps];
const codecarray = sps.subarray(1, 4);
let codecstring = 'avc1.';
for (let i = 0; i < 3; i++) {
let h = codecarray[i].toString(16);
if (h.length < 2) {
h = '0' + h;
}
codecstring += h;
}
track.codec = codecstring;
}
break;
}
// PPS
case 8:
push = true;
track.pps = [unit.data];
break;
// AUD
case 9:
push = true;
track.audFound = true;
if (VideoSample?.frame) {
this.pushAccessUnit(VideoSample, track);
VideoSample = null;
}
if (!VideoSample) {
VideoSample = this.VideoSample = this.createVideoSample(
false,
pes.pts,
pes.dts,
);
}
break;
// Filler Data
case 12:
push = true;
break;
default:
push = false;
break;
}
if (VideoSample && push) {
const units = VideoSample.units;
units.push(unit);
}
});
// if last PES packet, push samples
if (endOfSegment && VideoSample) {
this.pushAccessUnit(VideoSample, track);
this.VideoSample = null;
}
}
protected getNALuType(data: Uint8Array, offset: number): number {
return data[offset] & 0x1f;
}
readSliceType(data: Uint8Array) {
const eg = new ExpGolomb(data);
// skip NALu type
eg.readUByte();
// discard first_mb_in_slice
eg.readUEG();
// return slice_type
return eg.readUEG();
}
/**
* The scaling list is optionally transmitted as part of a sequence parameter
* set and is not relevant to transmuxing.
* @param count the number of entries in this scaling list
* @see Recommendation ITU-T H.264, Section 7.3.2.1.1.1
*/
skipScalingList(count: number, reader: ExpGolomb): void {
let lastScale = 8;
let nextScale = 8;
let deltaScale;
for (let j = 0; j < count; j++) {
if (nextScale !== 0) {
deltaScale = reader.readEG();
nextScale = (lastScale + deltaScale + 256) % 256;
}
lastScale = nextScale === 0 ? lastScale : nextScale;
}
}
/**
* Read a sequence parameter set and return some interesting video
* properties. A sequence parameter set is the H264 metadata that
* describes the properties of upcoming video frames.
* @returns an object with configuration parsed from the
* sequence parameter set, including the dimensions of the
* associated video frames.
*/
readSPS(sps: Uint8Array): {
width: number;
height: number;
pixelRatio: [number, number];
} {
const eg = new ExpGolomb(sps);
let frameCropLeftOffset = 0;
let frameCropRightOffset = 0;
let frameCropTopOffset = 0;
let frameCropBottomOffset = 0;
let numRefFramesInPicOrderCntCycle;
let scalingListCount;
let i;
const readUByte = eg.readUByte.bind(eg);
const readBits = eg.readBits.bind(eg);
const readUEG = eg.readUEG.bind(eg);
const readBoolean = eg.readBoolean.bind(eg);
const skipBits = eg.skipBits.bind(eg);
const skipEG = eg.skipEG.bind(eg);
const skipUEG = eg.skipUEG.bind(eg);
const skipScalingList = this.skipScalingList.bind(this);
readUByte();
const profileIdc = readUByte(); // profile_idc
readBits(5); // profileCompat constraint_set[0-4]_flag, u(5)
skipBits(3); // reserved_zero_3bits u(3),
readUByte(); // level_idc u(8)
skipUEG(); // seq_parameter_set_id
// some profiles have more optional data we don't need
if (
profileIdc === 100 ||
profileIdc === 110 ||
profileIdc === 122 ||
profileIdc === 244 ||
profileIdc === 44 ||
profileIdc === 83 ||
profileIdc === 86 ||
profileIdc === 118 ||
profileIdc === 128
) {
const chromaFormatIdc = readUEG();
if (chromaFormatIdc === 3) {
skipBits(1);
} // separate_colour_plane_flag
skipUEG(); // bit_depth_luma_minus8
skipUEG(); // bit_depth_chroma_minus8
skipBits(1); // qpprime_y_zero_transform_bypass_flag
if (readBoolean()) {
// seq_scaling_matrix_present_flag
scalingListCount = chromaFormatIdc !== 3 ? 8 : 12;
for (i = 0; i < scalingListCount; i++) {
if (readBoolean()) {
// seq_scaling_list_present_flag[ i ]
if (i < 6) {
skipScalingList(16, eg);
} else {
skipScalingList(64, eg);
}
}
}
}
}
skipUEG(); // log2_max_frame_num_minus4
const picOrderCntType = readUEG();
if (picOrderCntType === 0) {
readUEG(); // log2_max_pic_order_cnt_lsb_minus4
} else if (picOrderCntType === 1) {
skipBits(1); // delta_pic_order_always_zero_flag
skipEG(); // offset_for_non_ref_pic
skipEG(); // offset_for_top_to_bottom_field
numRefFramesInPicOrderCntCycle = readUEG();
for (i = 0; i < numRefFramesInPicOrderCntCycle; i++) {
skipEG();
} // offset_for_ref_frame[ i ]
}
skipUEG(); // max_num_ref_frames
skipBits(1); // gaps_in_frame_num_value_allowed_flag
const picWidthInMbsMinus1 = readUEG();
const picHeightInMapUnitsMinus1 = readUEG();
const frameMbsOnlyFlag = readBits(1);
if (frameMbsOnlyFlag === 0) {
skipBits(1);
} // mb_adaptive_frame_field_flag
skipBits(1); // direct_8x8_inference_flag
if (readBoolean()) {
// frame_cropping_flag
frameCropLeftOffset = readUEG();
frameCropRightOffset = readUEG();
frameCropTopOffset = readUEG();
frameCropBottomOffset = readUEG();
}
let pixelRatio: [number, number] = [1, 1];
if (readBoolean()) {
// vui_parameters_present_flag
if (readBoolean()) {
// aspect_ratio_info_present_flag
const aspectRatioIdc = readUByte();
switch (aspectRatioIdc) {
case 1:
pixelRatio = [1, 1];
break;
case 2:
pixelRatio = [12, 11];
break;
case 3:
pixelRatio = [10, 11];
break;
case 4:
pixelRatio = [16, 11];
break;
case 5:
pixelRatio = [40, 33];
break;
case 6:
pixelRatio = [24, 11];
break;
case 7:
pixelRatio = [20, 11];
break;
case 8:
pixelRatio = [32, 11];
break;
case 9:
pixelRatio = [80, 33];
break;
case 10:
pixelRatio = [18, 11];
break;
case 11:
pixelRatio = [15, 11];
break;
case 12:
pixelRatio = [64, 33];
break;
case 13:
pixelRatio = [160, 99];
break;
case 14:
pixelRatio = [4, 3];
break;
case 15:
pixelRatio = [3, 2];
break;
case 16:
pixelRatio = [2, 1];
break;
case 255: {
pixelRatio = [
(readUByte() << 8) | readUByte(),
(readUByte() << 8) | readUByte(),
];
break;
}
}
}
}
return {
width: Math.ceil(
(picWidthInMbsMinus1 + 1) * 16 -
frameCropLeftOffset * 2 -
frameCropRightOffset * 2,
),
height:
(2 - frameMbsOnlyFlag) * (picHeightInMapUnitsMinus1 + 1) * 16 -
(frameMbsOnlyFlag ? 2 : 4) *
(frameCropTopOffset + frameCropBottomOffset),
pixelRatio: pixelRatio,
};
}
}
export default AvcVideoParser;
+198
View File
@@ -0,0 +1,198 @@
import { appendUint8Array } from '../../utils/mp4-tools';
import type {
DemuxedUserdataTrack,
DemuxedVideoTrack,
VideoSample,
VideoSampleUnit,
} from '../../types/demuxer';
import type { ParsedVideoSample } from '../tsdemuxer';
import type { PES } from '../tsdemuxer';
abstract class BaseVideoParser {
protected VideoSample: ParsedVideoSample | null = null;
protected createVideoSample(
key: boolean,
pts: number | undefined,
dts: number | undefined,
): ParsedVideoSample {
return {
key,
frame: false,
pts,
dts,
units: [],
length: 0,
};
}
protected getLastNalUnit(
samples: VideoSample[],
): VideoSampleUnit | undefined {
let VideoSample = this.VideoSample;
let lastUnit: VideoSampleUnit | undefined;
// try to fallback to previous sample if current one is empty
if (!VideoSample || VideoSample.units.length === 0) {
VideoSample = samples[samples.length - 1];
}
if (VideoSample?.units) {
const units = VideoSample.units;
lastUnit = units[units.length - 1];
}
return lastUnit;
}
protected pushAccessUnit(
VideoSample: ParsedVideoSample,
videoTrack: DemuxedVideoTrack,
) {
if (VideoSample.units.length && VideoSample.frame) {
// if sample does not have PTS/DTS, patch with last sample PTS/DTS
if (VideoSample.pts === undefined) {
const samples = videoTrack.samples;
const nbSamples = samples.length;
if (nbSamples) {
const lastSample = samples[nbSamples - 1];
VideoSample.pts = lastSample.pts;
VideoSample.dts = lastSample.dts;
} else {
// dropping samples, no timestamp found
videoTrack.dropped++;
return;
}
}
videoTrack.samples.push(VideoSample as VideoSample);
}
}
abstract parsePES(
track: DemuxedVideoTrack,
textTrack: DemuxedUserdataTrack,
pes: PES,
last: boolean,
);
protected abstract getNALuType(data: Uint8Array, offset: number): number;
protected parseNALu(
track: DemuxedVideoTrack,
array: Uint8Array,
endOfSegment: boolean,
): Array<{
data: Uint8Array;
type: number;
state?: number;
}> {
const len = array.byteLength;
let state = track.naluState || 0;
const lastState = state;
const units: VideoSampleUnit[] = [];
let i = 0;
let value: number;
let overflow: number;
let unitType: number;
let lastUnitStart = -1;
let lastUnitType: number = 0;
// logger.log('PES:' + Hex.hexDump(array));
if (state === -1) {
// special use case where we found 3 or 4-byte start codes exactly at the end of previous PES packet
lastUnitStart = 0;
// NALu type is value read from offset 0
lastUnitType = this.getNALuType(array, 0);
state = 0;
i = 1;
}
while (i < len) {
value = array[i++];
// optimization. state 0 and 1 are the predominant case. let's handle them outside of the switch/case
if (!state) {
state = value ? 0 : 1;
continue;
}
if (state === 1) {
state = value ? 0 : 2;
continue;
}
// here we have state either equal to 2 or 3
if (!value) {
state = 3;
} else if (value === 1) {
overflow = i - state - 1;
if (lastUnitStart >= 0) {
const unit: VideoSampleUnit = {
data: array.subarray(lastUnitStart, overflow),
type: lastUnitType,
};
// logger.log('pushing NALU, type/size:' + unit.type + '/' + unit.data.byteLength);
units.push(unit);
} else {
// lastUnitStart is undefined => this is the first start code found in this PES packet
// first check if start code delimiter is overlapping between 2 PES packets,
// ie it started in last packet (lastState not zero)
// and ended at the beginning of this PES packet (i <= 4 - lastState)
const lastUnit = this.getLastNalUnit(track.samples);
if (lastUnit) {
if (lastState && i <= 4 - lastState) {
// start delimiter overlapping between PES packets
// strip start delimiter bytes from the end of last NAL unit
// check if lastUnit had a state different from zero
if (lastUnit.state) {
// strip last bytes
lastUnit.data = lastUnit.data.subarray(
0,
lastUnit.data.byteLength - lastState,
);
}
}
// If NAL units are not starting right at the beginning of the PES packet, push preceding data into previous NAL unit.
if (overflow > 0) {
// logger.log('first NALU found with overflow:' + overflow);
lastUnit.data = appendUint8Array(
lastUnit.data,
array.subarray(0, overflow),
);
lastUnit.state = 0;
}
}
}
// check if we can read unit type
if (i < len) {
unitType = this.getNALuType(array, i);
// logger.log('find NALU @ offset:' + i + ',type:' + unitType);
lastUnitStart = i;
lastUnitType = unitType;
state = 0;
} else {
// not enough byte to read unit type. let's read it on next PES parsing
state = -1;
}
} else {
state = 0;
}
}
if (lastUnitStart >= 0 && state >= 0) {
const unit: VideoSampleUnit = {
data: array.subarray(lastUnitStart, len),
type: lastUnitType,
state: state,
};
units.push(unit);
// logger.log('pushing NALU, type/size/state:' + unit.type + '/' + unit.data.byteLength + '/' + state);
}
// no NALu found
if (units.length === 0) {
// append pes.data to previous NAL unit
const lastUnit = this.getLastNalUnit(track.samples);
if (lastUnit) {
lastUnit.data = appendUint8Array(lastUnit.data, array);
}
}
track.naluState = state;
return units;
}
}
export default BaseVideoParser;
+153
View File
@@ -0,0 +1,153 @@
/**
* Parser for exponential Golomb codes, a variable-bitwidth number encoding scheme used by h264.
*/
import { logger } from '../../utils/logger';
class ExpGolomb {
private data: Uint8Array;
public bytesAvailable: number;
private word: number;
private bitsAvailable: number;
constructor(data: Uint8Array) {
this.data = data;
// the number of bytes left to examine in this.data
this.bytesAvailable = data.byteLength;
// the current word being examined
this.word = 0; // :uint
// the number of bits left to examine in the current word
this.bitsAvailable = 0; // :uint
}
// ():void
loadWord(): void {
const data = this.data;
const bytesAvailable = this.bytesAvailable;
const position = data.byteLength - bytesAvailable;
const workingBytes = new Uint8Array(4);
const availableBytes = Math.min(4, bytesAvailable);
if (availableBytes === 0) {
throw new Error('no bytes available');
}
workingBytes.set(data.subarray(position, position + availableBytes));
this.word = new DataView(workingBytes.buffer).getUint32(0);
// track the amount of this.data that has been processed
this.bitsAvailable = availableBytes * 8;
this.bytesAvailable -= availableBytes;
}
// (count:int):void
skipBits(count: number): void {
let skipBytes; // :int
count = Math.min(count, this.bytesAvailable * 8 + this.bitsAvailable);
if (this.bitsAvailable > count) {
this.word <<= count;
this.bitsAvailable -= count;
} else {
count -= this.bitsAvailable;
skipBytes = count >> 3;
count -= skipBytes << 3;
this.bytesAvailable -= skipBytes;
this.loadWord();
this.word <<= count;
this.bitsAvailable -= count;
}
}
// (size:int):uint
readBits(size: number): number {
let bits = Math.min(this.bitsAvailable, size); // :uint
const valu = this.word >>> (32 - bits); // :uint
if (size > 32) {
logger.error('Cannot read more than 32 bits at a time');
}
this.bitsAvailable -= bits;
if (this.bitsAvailable > 0) {
this.word <<= bits;
} else if (this.bytesAvailable > 0) {
this.loadWord();
} else {
throw new Error('no bits available');
}
bits = size - bits;
if (bits > 0 && this.bitsAvailable) {
return (valu << bits) | this.readBits(bits);
} else {
return valu;
}
}
// ():uint
skipLZ(): number {
let leadingZeroCount; // :uint
for (
leadingZeroCount = 0;
leadingZeroCount < this.bitsAvailable;
++leadingZeroCount
) {
if ((this.word & (0x80000000 >>> leadingZeroCount)) !== 0) {
// the first bit of working word is 1
this.word <<= leadingZeroCount;
this.bitsAvailable -= leadingZeroCount;
return leadingZeroCount;
}
}
// we exhausted word and still have not found a 1
this.loadWord();
return leadingZeroCount + this.skipLZ();
}
// ():void
skipUEG(): void {
this.skipBits(1 + this.skipLZ());
}
// ():void
skipEG(): void {
this.skipBits(1 + this.skipLZ());
}
// ():uint
readUEG(): number {
const clz = this.skipLZ(); // :uint
return this.readBits(clz + 1) - 1;
}
// ():int
readEG(): number {
const valu = this.readUEG(); // :int
if (0x01 & valu) {
// the number is odd if the low order bit is set
return (1 + valu) >>> 1; // add 1 to make it even, and divide by 2
} else {
return -1 * (valu >>> 1); // divide by two then make it negative
}
}
// Some convenience functions
// :Boolean
readBoolean(): boolean {
return this.readBits(1) === 1;
}
// ():int
readUByte(): number {
return this.readBits(8);
}
// ():int
readUShort(): number {
return this.readBits(16);
}
// ():int
readUInt(): number {
return this.readBits(32);
}
}
export default ExpGolomb;
+736
View File
@@ -0,0 +1,736 @@
import BaseVideoParser from './base-video-parser';
import ExpGolomb from './exp-golomb';
import { parseSEIMessageFromNALu } from '../../utils/mp4-tools';
import type {
DemuxedUserdataTrack,
DemuxedVideoTrack,
} from '../../types/demuxer';
import type { ParsedVideoSample } from '../tsdemuxer';
import type { PES } from '../tsdemuxer';
class HevcVideoParser extends BaseVideoParser {
protected initVPS: Uint8Array | null = null;
public parsePES(
track: DemuxedVideoTrack,
textTrack: DemuxedUserdataTrack,
pes: PES,
endOfSegment: boolean,
) {
const units = this.parseNALu(track, pes.data, endOfSegment);
let VideoSample = this.VideoSample;
let push: boolean;
let spsfound = false;
// free pes.data to save up some memory
(pes as any).data = null;
// if new NAL units found and last sample still there, let's push ...
// this helps parsing streams with missing AUD (only do this if AUD never found)
if (VideoSample && units.length && !track.audFound) {
this.pushAccessUnit(VideoSample, track);
VideoSample = this.VideoSample = this.createVideoSample(
false,
pes.pts,
pes.dts,
);
}
units.forEach((unit) => {
switch (unit.type) {
// NON-IDR, NON RANDOM ACCESS SLICE
case 0:
case 1:
case 2:
case 3:
case 4:
case 5:
case 6:
case 7:
case 8:
case 9:
if (!VideoSample) {
VideoSample = this.VideoSample = this.createVideoSample(
false,
pes.pts,
pes.dts,
);
}
VideoSample.frame = true;
push = true;
break;
// CRA, BLA (random access picture)
case 16:
case 17:
case 18:
case 21:
push = true;
if (spsfound) {
// handle PES not starting with AUD
// if we have frame data already, that cannot belong to the same frame, so force a push
if (VideoSample?.frame && !VideoSample.key) {
this.pushAccessUnit(VideoSample, track);
VideoSample = this.VideoSample = null;
}
}
if (!VideoSample) {
VideoSample = this.VideoSample = this.createVideoSample(
true,
pes.pts,
pes.dts,
);
}
VideoSample.key = true;
VideoSample.frame = true;
break;
// IDR
case 19:
case 20:
push = true;
// handle PES not starting with AUD
// if we have frame data already, that cannot belong to the same frame, so force a push
if (VideoSample?.frame && !VideoSample.key) {
this.pushAccessUnit(VideoSample, track);
VideoSample = this.VideoSample = null;
}
if (!VideoSample) {
VideoSample = this.VideoSample = this.createVideoSample(
true,
pes.pts,
pes.dts,
);
}
VideoSample.key = true;
VideoSample.frame = true;
break;
// SEI
case 39:
push = true;
parseSEIMessageFromNALu(
unit.data,
2, // NALu header size
pes.pts as number,
textTrack.samples,
);
break;
// VPS
case 32:
push = true;
if (!track.vps) {
if (typeof track.params !== 'object') {
track.params = {};
}
track.params = Object.assign(track.params, this.readVPS(unit.data));
this.initVPS = unit.data;
}
track.vps = [unit.data];
break;
// SPS
case 33:
push = true;
spsfound = true;
if (
track.vps !== undefined &&
track.vps[0] !== this.initVPS &&
track.sps !== undefined &&
!this.matchSPS(track.sps[0], unit.data)
) {
this.initVPS = track.vps[0];
track.sps = track.pps = undefined;
}
if (!track.sps) {
const config = this.readSPS(unit.data);
track.width = config.width;
track.height = config.height;
track.pixelRatio = config.pixelRatio;
track.codec = config.codecString;
track.sps = [];
if (typeof track.params !== 'object') {
track.params = {};
}
for (const prop in config.params) {
track.params[prop] = config.params[prop];
}
}
this.pushParameterSet(track.sps, unit.data, track.vps);
if (!VideoSample) {
VideoSample = this.VideoSample = this.createVideoSample(
true,
pes.pts,
pes.dts,
);
}
VideoSample.key = true;
break;
// PPS
case 34:
push = true;
if (typeof track.params === 'object') {
if (!track.pps) {
track.pps = [];
const config = this.readPPS(unit.data);
for (const prop in config) {
track.params[prop] = config[prop];
}
}
this.pushParameterSet(track.pps, unit.data, track.vps);
}
break;
// ACCESS UNIT DELIMITER
case 35:
push = true;
track.audFound = true;
if (VideoSample?.frame) {
this.pushAccessUnit(VideoSample, track);
VideoSample = null;
}
if (!VideoSample) {
VideoSample = this.VideoSample = this.createVideoSample(
false,
pes.pts,
pes.dts,
);
}
break;
default:
push = false;
break;
}
if (VideoSample && push) {
const units = VideoSample.units;
units.push(unit);
}
});
// if last PES packet, push samples
if (endOfSegment && VideoSample) {
this.pushAccessUnit(VideoSample, track);
this.VideoSample = null;
}
}
private pushParameterSet(
parameterSets: Uint8Array[],
data: Uint8Array,
vps: Uint8Array[] | undefined,
) {
if ((vps && vps[0] === this.initVPS) || (!vps && !parameterSets.length)) {
parameterSets.push(data);
}
}
protected getNALuType(data: Uint8Array, offset: number): number {
return (data[offset] & 0x7e) >>> 1;
}
protected ebsp2rbsp(arr: Uint8Array): Uint8Array {
const dst = new Uint8Array(arr.byteLength);
let dstIdx = 0;
for (let i = 0; i < arr.byteLength; i++) {
if (i >= 2) {
// Unescape: Skip 0x03 after 00 00
if (arr[i] === 0x03 && arr[i - 1] === 0x00 && arr[i - 2] === 0x00) {
continue;
}
}
dst[dstIdx] = arr[i];
dstIdx++;
}
return new Uint8Array(dst.buffer, 0, dstIdx);
}
protected pushAccessUnit(
VideoSample: ParsedVideoSample,
videoTrack: DemuxedVideoTrack,
) {
super.pushAccessUnit(VideoSample, videoTrack);
if (this.initVPS) {
this.initVPS = null; // null initVPS to prevent possible track's sps/pps growth until next VPS
}
}
readVPS(vps: Uint8Array): {
numTemporalLayers: number;
temporalIdNested: boolean;
} {
const eg = new ExpGolomb(vps);
// remove header
eg.readUByte();
eg.readUByte();
eg.readBits(4); // video_parameter_set_id
eg.skipBits(2);
eg.readBits(6); // max_layers_minus1
const max_sub_layers_minus1 = eg.readBits(3);
const temporal_id_nesting_flag = eg.readBoolean();
// ...vui fps can be here, but empty fps value is not critical for metadata
return {
numTemporalLayers: max_sub_layers_minus1 + 1,
temporalIdNested: temporal_id_nesting_flag,
};
}
readSPS(sps: Uint8Array): {
codecString: string;
params: object;
width: number;
height: number;
pixelRatio: [number, number];
} {
const eg = new ExpGolomb(this.ebsp2rbsp(sps));
eg.readUByte();
eg.readUByte();
eg.readBits(4); //video_parameter_set_id
const max_sub_layers_minus1 = eg.readBits(3);
eg.readBoolean(); // temporal_id_nesting_flag
// profile_tier_level
const general_profile_space = eg.readBits(2);
const general_tier_flag = eg.readBoolean();
const general_profile_idc = eg.readBits(5);
const general_profile_compatibility_flags_1 = eg.readUByte();
const general_profile_compatibility_flags_2 = eg.readUByte();
const general_profile_compatibility_flags_3 = eg.readUByte();
const general_profile_compatibility_flags_4 = eg.readUByte();
const general_constraint_indicator_flags_1 = eg.readUByte();
const general_constraint_indicator_flags_2 = eg.readUByte();
const general_constraint_indicator_flags_3 = eg.readUByte();
const general_constraint_indicator_flags_4 = eg.readUByte();
const general_constraint_indicator_flags_5 = eg.readUByte();
const general_constraint_indicator_flags_6 = eg.readUByte();
const general_level_idc = eg.readUByte();
const sub_layer_profile_present_flags: boolean[] = [];
const sub_layer_level_present_flags: boolean[] = [];
for (let i = 0; i < max_sub_layers_minus1; i++) {
sub_layer_profile_present_flags.push(eg.readBoolean());
sub_layer_level_present_flags.push(eg.readBoolean());
}
if (max_sub_layers_minus1 > 0) {
for (let i = max_sub_layers_minus1; i < 8; i++) {
eg.readBits(2);
}
}
for (let i = 0; i < max_sub_layers_minus1; i++) {
if (sub_layer_profile_present_flags[i]) {
eg.readUByte(); // sub_layer_profile_space, sub_layer_tier_flag, sub_layer_profile_idc
eg.readUByte();
eg.readUByte();
eg.readUByte();
eg.readUByte(); // sub_layer_profile_compatibility_flag
eg.readUByte();
eg.readUByte();
eg.readUByte();
eg.readUByte();
eg.readUByte();
eg.readUByte();
}
if (sub_layer_level_present_flags[i]) {
eg.readUByte();
}
}
eg.readUEG(); // seq_parameter_set_id
const chroma_format_idc = eg.readUEG();
if (chroma_format_idc == 3) {
eg.skipBits(1); //separate_colour_plane_flag
}
const pic_width_in_luma_samples = eg.readUEG();
const pic_height_in_luma_samples = eg.readUEG();
const conformance_window_flag = eg.readBoolean();
let pic_left_offset = 0,
pic_right_offset = 0,
pic_top_offset = 0,
pic_bottom_offset = 0;
if (conformance_window_flag) {
pic_left_offset += eg.readUEG();
pic_right_offset += eg.readUEG();
pic_top_offset += eg.readUEG();
pic_bottom_offset += eg.readUEG();
}
const bit_depth_luma_minus8 = eg.readUEG();
const bit_depth_chroma_minus8 = eg.readUEG();
const log2_max_pic_order_cnt_lsb_minus4 = eg.readUEG();
const sub_layer_ordering_info_present_flag = eg.readBoolean();
for (
let i = sub_layer_ordering_info_present_flag ? 0 : max_sub_layers_minus1;
i <= max_sub_layers_minus1;
i++
) {
eg.skipUEG(); // max_dec_pic_buffering_minus1[i]
eg.skipUEG(); // max_num_reorder_pics[i]
eg.skipUEG(); // max_latency_increase_plus1[i]
}
eg.skipUEG(); // log2_min_luma_coding_block_size_minus3
eg.skipUEG(); // log2_diff_max_min_luma_coding_block_size
eg.skipUEG(); // log2_min_transform_block_size_minus2
eg.skipUEG(); // log2_diff_max_min_transform_block_size
eg.skipUEG(); // max_transform_hierarchy_depth_inter
eg.skipUEG(); // max_transform_hierarchy_depth_intra
const scaling_list_enabled_flag = eg.readBoolean();
if (scaling_list_enabled_flag) {
const sps_scaling_list_data_present_flag = eg.readBoolean();
if (sps_scaling_list_data_present_flag) {
for (let sizeId = 0; sizeId < 4; sizeId++) {
for (
let matrixId = 0;
matrixId < (sizeId === 3 ? 2 : 6);
matrixId++
) {
const scaling_list_pred_mode_flag = eg.readBoolean();
if (!scaling_list_pred_mode_flag) {
eg.readUEG(); // scaling_list_pred_matrix_id_delta
} else {
const coefNum = Math.min(64, 1 << (4 + (sizeId << 1)));
if (sizeId > 1) {
eg.readEG();
}
for (let i = 0; i < coefNum; i++) {
eg.readEG();
}
}
}
}
}
}
eg.readBoolean(); // amp_enabled_flag
eg.readBoolean(); // sample_adaptive_offset_enabled_flag
const pcm_enabled_flag = eg.readBoolean();
if (pcm_enabled_flag) {
eg.readUByte();
eg.skipUEG();
eg.skipUEG();
eg.readBoolean();
}
const num_short_term_ref_pic_sets = eg.readUEG();
let num_delta_pocs = 0;
for (let i = 0; i < num_short_term_ref_pic_sets; i++) {
let inter_ref_pic_set_prediction_flag = false;
if (i !== 0) {
inter_ref_pic_set_prediction_flag = eg.readBoolean();
}
if (inter_ref_pic_set_prediction_flag) {
if (i === num_short_term_ref_pic_sets) {
eg.readUEG();
}
eg.readBoolean();
eg.readUEG();
let next_num_delta_pocs = 0;
for (let j = 0; j <= num_delta_pocs; j++) {
const used_by_curr_pic_flag = eg.readBoolean();
let use_delta_flag = false;
if (!used_by_curr_pic_flag) {
use_delta_flag = eg.readBoolean();
}
if (used_by_curr_pic_flag || use_delta_flag) {
next_num_delta_pocs++;
}
}
num_delta_pocs = next_num_delta_pocs;
} else {
const num_negative_pics = eg.readUEG();
const num_positive_pics = eg.readUEG();
num_delta_pocs = num_negative_pics + num_positive_pics;
for (let j = 0; j < num_negative_pics; j++) {
eg.readUEG();
eg.readBoolean();
}
for (let j = 0; j < num_positive_pics; j++) {
eg.readUEG();
eg.readBoolean();
}
}
}
const long_term_ref_pics_present_flag = eg.readBoolean();
if (long_term_ref_pics_present_flag) {
const num_long_term_ref_pics_sps = eg.readUEG();
for (let i = 0; i < num_long_term_ref_pics_sps; i++) {
for (let j = 0; j < log2_max_pic_order_cnt_lsb_minus4 + 4; j++) {
eg.readBits(1);
}
eg.readBits(1);
}
}
let min_spatial_segmentation_idc = 0;
let sar_width = 1,
sar_height = 1;
let fps_fixed = true,
fps_den = 1,
fps_num = 0;
eg.readBoolean(); // sps_temporal_mvp_enabled_flag
eg.readBoolean(); // strong_intra_smoothing_enabled_flag
let default_display_window_flag = false;
const vui_parameters_present_flag = eg.readBoolean();
if (vui_parameters_present_flag) {
const aspect_ratio_info_present_flag = eg.readBoolean();
if (aspect_ratio_info_present_flag) {
const aspect_ratio_idc = eg.readUByte();
const sar_width_table = [
1, 12, 10, 16, 40, 24, 20, 32, 80, 18, 15, 64, 160, 4, 3, 2,
];
const sar_height_table = [
1, 11, 11, 11, 33, 11, 11, 11, 33, 11, 11, 33, 99, 3, 2, 1,
];
if (aspect_ratio_idc > 0 && aspect_ratio_idc < 16) {
sar_width = sar_width_table[aspect_ratio_idc - 1];
sar_height = sar_height_table[aspect_ratio_idc - 1];
} else if (aspect_ratio_idc === 255) {
sar_width = eg.readBits(16);
sar_height = eg.readBits(16);
}
}
const overscan_info_present_flag = eg.readBoolean();
if (overscan_info_present_flag) {
eg.readBoolean();
}
const video_signal_type_present_flag = eg.readBoolean();
if (video_signal_type_present_flag) {
eg.readBits(3);
eg.readBoolean();
const colour_description_present_flag = eg.readBoolean();
if (colour_description_present_flag) {
eg.readUByte();
eg.readUByte();
eg.readUByte();
}
}
const chroma_loc_info_present_flag = eg.readBoolean();
if (chroma_loc_info_present_flag) {
eg.readUEG();
eg.readUEG();
}
eg.readBoolean(); // neutral_chroma_indication_flag
eg.readBoolean(); // field_seq_flag
eg.readBoolean(); // frame_field_info_present_flag
default_display_window_flag = eg.readBoolean();
if (default_display_window_flag) {
eg.skipUEG();
eg.skipUEG();
eg.skipUEG();
eg.skipUEG();
}
const vui_timing_info_present_flag = eg.readBoolean();
if (vui_timing_info_present_flag) {
fps_den = eg.readBits(32);
fps_num = eg.readBits(32);
const vui_poc_proportional_to_timing_flag = eg.readBoolean();
if (vui_poc_proportional_to_timing_flag) {
eg.readUEG();
}
const vui_hrd_parameters_present_flag = eg.readBoolean();
if (vui_hrd_parameters_present_flag) {
//const commonInfPresentFlag = true;
//if (commonInfPresentFlag) {
const nal_hrd_parameters_present_flag = eg.readBoolean();
const vcl_hrd_parameters_present_flag = eg.readBoolean();
let sub_pic_hrd_params_present_flag = false;
if (
nal_hrd_parameters_present_flag ||
vcl_hrd_parameters_present_flag
) {
sub_pic_hrd_params_present_flag = eg.readBoolean();
if (sub_pic_hrd_params_present_flag) {
eg.readUByte();
eg.readBits(5);
eg.readBoolean();
eg.readBits(5);
}
eg.readBits(4); // bit_rate_scale
eg.readBits(4); // cpb_size_scale
if (sub_pic_hrd_params_present_flag) {
eg.readBits(4);
}
eg.readBits(5);
eg.readBits(5);
eg.readBits(5);
}
//}
for (let i = 0; i <= max_sub_layers_minus1; i++) {
fps_fixed = eg.readBoolean(); // fixed_pic_rate_general_flag
const fixed_pic_rate_within_cvs_flag =
fps_fixed || eg.readBoolean();
let low_delay_hrd_flag = false;
if (fixed_pic_rate_within_cvs_flag) {
eg.readEG();
} else {
low_delay_hrd_flag = eg.readBoolean();
}
const cpb_cnt = low_delay_hrd_flag ? 1 : eg.readUEG() + 1;
if (nal_hrd_parameters_present_flag) {
for (let j = 0; j < cpb_cnt; j++) {
eg.readUEG();
eg.readUEG();
if (sub_pic_hrd_params_present_flag) {
eg.readUEG();
eg.readUEG();
}
eg.skipBits(1);
}
}
if (vcl_hrd_parameters_present_flag) {
for (let j = 0; j < cpb_cnt; j++) {
eg.readUEG();
eg.readUEG();
if (sub_pic_hrd_params_present_flag) {
eg.readUEG();
eg.readUEG();
}
eg.skipBits(1);
}
}
}
}
}
const bitstream_restriction_flag = eg.readBoolean();
if (bitstream_restriction_flag) {
eg.readBoolean(); // tiles_fixed_structure_flag
eg.readBoolean(); // motion_vectors_over_pic_boundaries_flag
eg.readBoolean(); // restricted_ref_pic_lists_flag
min_spatial_segmentation_idc = eg.readUEG();
}
}
let width = pic_width_in_luma_samples,
height = pic_height_in_luma_samples;
if (conformance_window_flag) {
let chroma_scale_w = 1,
chroma_scale_h = 1;
if (chroma_format_idc === 1) {
// YUV 420
chroma_scale_w = chroma_scale_h = 2;
} else if (chroma_format_idc == 2) {
// YUV 422
chroma_scale_w = 2;
}
width =
pic_width_in_luma_samples -
chroma_scale_w * pic_right_offset -
chroma_scale_w * pic_left_offset;
height =
pic_height_in_luma_samples -
chroma_scale_h * pic_bottom_offset -
chroma_scale_h * pic_top_offset;
}
const profile_space_string = general_profile_space
? ['A', 'B', 'C'][general_profile_space]
: '';
const profile_compatibility_buf =
(general_profile_compatibility_flags_1 << 24) |
(general_profile_compatibility_flags_2 << 16) |
(general_profile_compatibility_flags_3 << 8) |
general_profile_compatibility_flags_4;
let profile_compatibility_rev = 0;
for (let i = 0; i < 32; i++) {
profile_compatibility_rev =
(profile_compatibility_rev |
(((profile_compatibility_buf >> i) & 1) << (31 - i))) >>>
0; // reverse bit position (and cast as UInt32)
}
let profile_compatibility_flags_string =
profile_compatibility_rev.toString(16);
if (
general_profile_idc === 1 &&
profile_compatibility_flags_string === '2'
) {
profile_compatibility_flags_string = '6';
}
const tier_flag_string = general_tier_flag ? 'H' : 'L';
return {
codecString: `hvc1.${profile_space_string}${general_profile_idc}.${profile_compatibility_flags_string}.${tier_flag_string}${general_level_idc}.B0`,
params: {
general_tier_flag,
general_profile_idc,
general_profile_space,
general_profile_compatibility_flags: [
general_profile_compatibility_flags_1,
general_profile_compatibility_flags_2,
general_profile_compatibility_flags_3,
general_profile_compatibility_flags_4,
],
general_constraint_indicator_flags: [
general_constraint_indicator_flags_1,
general_constraint_indicator_flags_2,
general_constraint_indicator_flags_3,
general_constraint_indicator_flags_4,
general_constraint_indicator_flags_5,
general_constraint_indicator_flags_6,
],
general_level_idc,
bit_depth: bit_depth_luma_minus8 + 8,
bit_depth_luma_minus8,
bit_depth_chroma_minus8,
min_spatial_segmentation_idc,
chroma_format_idc: chroma_format_idc,
frame_rate: {
fixed: fps_fixed,
fps: fps_num / fps_den,
},
},
width,
height,
pixelRatio: [sar_width, sar_height],
};
}
readPPS(pps: Uint8Array): {
parallelismType: number;
} {
const eg = new ExpGolomb(this.ebsp2rbsp(pps));
eg.readUByte();
eg.readUByte();
eg.skipUEG(); // pic_parameter_set_id
eg.skipUEG(); // seq_parameter_set_id
eg.skipBits(2); // dependent_slice_segments_enabled_flag, output_flag_present_flag
eg.skipBits(3); // num_extra_slice_header_bits
eg.skipBits(2); // sign_data_hiding_enabled_flag, cabac_init_present_flag
eg.skipUEG();
eg.skipUEG();
eg.skipEG(); // init_qp_minus26
eg.skipBits(2); // constrained_intra_pred_flag, transform_skip_enabled_flag
const cu_qp_delta_enabled_flag = eg.readBoolean();
if (cu_qp_delta_enabled_flag) {
eg.skipUEG();
}
eg.skipEG(); // cb_qp_offset
eg.skipEG(); // cr_qp_offset
eg.skipBits(4); // pps_slice_chroma_qp_offsets_present_flag, weighted_pred_flag, weighted_bipred_flag, transquant_bypass_enabled_flag
const tiles_enabled_flag = eg.readBoolean();
const entropy_coding_sync_enabled_flag = eg.readBoolean();
let parallelismType = 1; // slice-based parallel decoding
if (entropy_coding_sync_enabled_flag && tiles_enabled_flag) {
parallelismType = 0; // mixed-type parallel decoding
} else if (entropy_coding_sync_enabled_flag) {
parallelismType = 3; // wavefront-based parallel decoding
} else if (tiles_enabled_flag) {
parallelismType = 2; // tile-based parallel decoding
}
return {
parallelismType,
};
}
matchSPS(sps1: Uint8Array, sps2: Uint8Array): boolean {
// compare without headers and VPS related params
return (
String.fromCharCode.apply(null, sps1).substr(3) ===
String.fromCharCode.apply(null, sps2).substr(3)
);
}
}
export default HevcVideoParser;
+5
View File
@@ -0,0 +1,5 @@
// This file is inserted as a shim for modules which we do not want to include into the distro.
// This replacement is done in the "alias" plugin of the rollup config.
// Use a ES dedicated file as Rollup assigns an object in the output
// For example: "var KeySystemFormats = emptyEs.KeySystemFormats;"
module.exports = {};
+3
View File
@@ -0,0 +1,3 @@
// This file is inserted as a shim for modules which we do not want to include into the distro.
// This replacement is done in the "alias" plugin of the rollup config.
module.exports = undefined;
+103
View File
@@ -0,0 +1,103 @@
export enum ErrorTypes {
// Identifier for a network error (loading error / timeout ...)
NETWORK_ERROR = 'networkError',
// Identifier for a media Error (video/parsing/mediasource error)
MEDIA_ERROR = 'mediaError',
// EME (encrypted media extensions) errors
KEY_SYSTEM_ERROR = 'keySystemError',
// Identifier for a mux Error (demuxing/remuxing)
MUX_ERROR = 'muxError',
// Identifier for all other errors
OTHER_ERROR = 'otherError',
}
export enum ErrorDetails {
KEY_SYSTEM_NO_KEYS = 'keySystemNoKeys',
KEY_SYSTEM_NO_ACCESS = 'keySystemNoAccess',
KEY_SYSTEM_NO_SESSION = 'keySystemNoSession',
KEY_SYSTEM_NO_CONFIGURED_LICENSE = 'keySystemNoConfiguredLicense',
KEY_SYSTEM_LICENSE_REQUEST_FAILED = 'keySystemLicenseRequestFailed',
KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED = 'keySystemServerCertificateRequestFailed',
KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED = 'keySystemServerCertificateUpdateFailed',
KEY_SYSTEM_SESSION_UPDATE_FAILED = 'keySystemSessionUpdateFailed',
KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED = 'keySystemStatusOutputRestricted',
KEY_SYSTEM_STATUS_INTERNAL_ERROR = 'keySystemStatusInternalError',
KEY_SYSTEM_DESTROY_MEDIA_KEYS_ERROR = 'keySystemDestroyMediaKeysError',
KEY_SYSTEM_DESTROY_CLOSE_SESSION_ERROR = 'keySystemDestroyCloseSessionError',
KEY_SYSTEM_DESTROY_REMOVE_SESSION_ERROR = 'keySystemDestroyRemoveSessionError',
// Identifier for a manifest load error - data: { url : faulty URL, response : { code: error code, text: error text }}
MANIFEST_LOAD_ERROR = 'manifestLoadError',
// Identifier for a manifest load timeout - data: { url : faulty URL, response : { code: error code, text: error text }}
MANIFEST_LOAD_TIMEOUT = 'manifestLoadTimeOut',
// Identifier for a manifest parsing error - data: { url : faulty URL, reason : error reason}
MANIFEST_PARSING_ERROR = 'manifestParsingError',
// Identifier for a manifest with only incompatible codecs error - data: { url : faulty URL, reason : error reason}
MANIFEST_INCOMPATIBLE_CODECS_ERROR = 'manifestIncompatibleCodecsError',
// Identifier for a level which contains no fragments - data: { url: faulty URL, reason: "no fragments found in level", level: index of the bad level }
LEVEL_EMPTY_ERROR = 'levelEmptyError',
// Identifier for a level load error - data: { url : faulty URL, response : { code: error code, text: error text }}
LEVEL_LOAD_ERROR = 'levelLoadError',
// Identifier for a level load timeout - data: { url : faulty URL, response : { code: error code, text: error text }}
LEVEL_LOAD_TIMEOUT = 'levelLoadTimeOut',
// Identifier for a level parse error - data: { url : faulty URL, error: Error, reason: error message }
LEVEL_PARSING_ERROR = 'levelParsingError',
// Identifier for a level switch error - data: { level : faulty level Id, event : error description}
LEVEL_SWITCH_ERROR = 'levelSwitchError',
// Identifier for an audio track load error - data: { url : faulty URL, response : { code: error code, text: error text }}
AUDIO_TRACK_LOAD_ERROR = 'audioTrackLoadError',
// Identifier for an audio track load timeout - data: { url : faulty URL, response : { code: error code, text: error text }}
AUDIO_TRACK_LOAD_TIMEOUT = 'audioTrackLoadTimeOut',
// Identifier for a subtitle track load error - data: { url : faulty URL, response : { code: error code, text: error text }}
SUBTITLE_LOAD_ERROR = 'subtitleTrackLoadError',
// Identifier for a subtitle track load timeout - data: { url : faulty URL, response : { code: error code, text: error text }}
SUBTITLE_TRACK_LOAD_TIMEOUT = 'subtitleTrackLoadTimeOut',
// Identifier for fragment load error - data: { frag : fragment object, response : { code: error code, text: error text }}
FRAG_LOAD_ERROR = 'fragLoadError',
// Identifier for fragment load timeout error - data: { frag : fragment object}
FRAG_LOAD_TIMEOUT = 'fragLoadTimeOut',
// Identifier for a fragment decryption error event - data: {id : demuxer Id,frag: fragment object, reason : parsing error description }
FRAG_DECRYPT_ERROR = 'fragDecryptError',
// Identifier for a fragment parsing error event - data: { id : demuxer Id, reason : parsing error description }
// will be renamed DEMUX_PARSING_ERROR and switched to MUX_ERROR in the next major release
FRAG_PARSING_ERROR = 'fragParsingError',
// Identifier for a fragment or part load skipped because of a GAP tag or attribute
FRAG_GAP = 'fragGap',
// Identifier for a remux alloc error event - data: { id : demuxer Id, frag : fragment object, bytes : nb of bytes on which allocation failed , reason : error text }
REMUX_ALLOC_ERROR = 'remuxAllocError',
// Identifier for decrypt key load error - data: { frag : fragment object, response : { code: error code, text: error text }}
KEY_LOAD_ERROR = 'keyLoadError',
// Identifier for decrypt key load timeout error - data: { frag : fragment object}
KEY_LOAD_TIMEOUT = 'keyLoadTimeOut',
// Triggered when an exception occurs while adding a sourceBuffer to MediaSource - data : { error : exception , mimeType : mimeType }
BUFFER_ADD_CODEC_ERROR = 'bufferAddCodecError',
// Triggered when source buffer(s) could not be created using level (manifest CODECS attribute), parsed media, or best guess codec(s) - data: { reason : error reason }
BUFFER_INCOMPATIBLE_CODECS_ERROR = 'bufferIncompatibleCodecsError',
// Identifier for a buffer append error - data: append error description
BUFFER_APPEND_ERROR = 'bufferAppendError',
// Identifier for a buffer appending error event - data: appending error description
BUFFER_APPENDING_ERROR = 'bufferAppendingError',
// Identifier for a buffer stalled error event
BUFFER_STALLED_ERROR = 'bufferStalledError',
// Identifier for a buffer full event
BUFFER_FULL_ERROR = 'bufferFullError',
// Identifier for a buffer seek over hole event
BUFFER_SEEK_OVER_HOLE = 'bufferSeekOverHole',
// Identifier for a buffer nudge on stall (playback is stuck although currentTime is in a buffered area)
BUFFER_NUDGE_ON_STALL = 'bufferNudgeOnStall',
// Identifier for a Interstitial Asset List load error - data: { url: faulty URL, response: { code: error code, text: error text } }
ASSET_LIST_LOAD_ERROR = 'assetListLoadError',
// Identifier for a Interstitial Asset List load timeout - data: { url: faulty URL, response: { code: error code, text: error text } }
ASSET_LIST_LOAD_TIMEOUT = 'assetListLoadTimeout',
// Identifier for a Interstitial Asset List parsing error - data: { url : faulty URL, reason : error reason, response : { code: error code, text: error text }}
ASSET_LIST_PARSING_ERROR = 'assetListParsingError',
// Identifier for a Interstitial Asset List parsing error - data: { url : faulty URL, reason : error reason, response : { code: error code, text: error text }}
INTERSTITIAL_ASSET_ITEM_ERROR = 'interstitialAssetItemError',
// Identifier for an internal exception happening inside hls.js while handling an event
INTERNAL_EXCEPTION = 'internalException',
// Identifier for an internal call to abort a loader
INTERNAL_ABORTED = 'aborted',
// Triggered when attachMedia fails
ATTACH_MEDIA_ERROR = 'attachMediaError',
// Uncategorized error
UNKNOWN = 'unknown',
}
+527
View File
@@ -0,0 +1,527 @@
import type {
AssetListLoadedData,
AssetListLoadingData,
AudioTrackLoadedData,
AudioTracksUpdatedData,
AudioTrackSwitchedData,
AudioTrackSwitchingData,
AudioTrackUpdatedData,
BackBufferData,
BufferAppendedData,
BufferAppendingData,
BufferCodecsData,
BufferCreatedData,
BufferEOSData,
BufferFlushedData,
BufferFlushingData,
CuesParsedData,
ErrorData,
FPSDropData,
FPSDropLevelCappingData,
FragBufferedData,
FragChangedData,
FragDecryptedData,
FragLoadedData,
FragLoadEmergencyAbortedData,
FragLoadingData,
FragParsedData,
FragParsingInitSegmentData,
FragParsingMetadataData,
FragParsingUserdataData,
InitPTSFoundData,
InterstitialAssetEndedData,
InterstitialAssetErrorData,
InterstitialAssetPlayerCreatedData,
InterstitialAssetStartedData,
InterstitialEndedData,
InterstitialsBufferedToBoundaryData,
InterstitialsPrimaryResumed,
InterstitialStartedData,
InterstitialsUpdatedData,
KeyLoadedData,
KeyLoadingData,
LevelLoadedData,
LevelLoadingData,
LevelPTSUpdatedData,
LevelsUpdatedData,
LevelSwitchedData,
LevelSwitchingData,
LevelUpdatedData,
LiveBackBufferData,
ManifestLoadedData,
ManifestLoadingData,
ManifestParsedData,
MaxAutoLevelUpdatedData,
MediaAttachedData,
MediaAttachingData,
MediaDetachedData,
MediaDetachingData,
MediaEndedData,
NonNativeTextTracksData,
SteeringManifestLoadedData,
SubtitleFragProcessedData,
SubtitleTrackLoadedData,
SubtitleTracksUpdatedData,
SubtitleTrackSwitchData,
SubtitleTrackUpdatedData,
TrackLoadingData,
} from './types/events';
export enum Events {
// Fired before MediaSource is attaching to media element
MEDIA_ATTACHING = 'hlsMediaAttaching',
// Fired when MediaSource has been successfully attached to media element
MEDIA_ATTACHED = 'hlsMediaAttached',
// Fired before detaching MediaSource from media element
MEDIA_DETACHING = 'hlsMediaDetaching',
// Fired when MediaSource has been detached from media element
MEDIA_DETACHED = 'hlsMediaDetached',
// Fired when HTMLMediaElement dispatches "ended" event, or stalls at end of VOD program
MEDIA_ENDED = 'hlsMediaEnded',
// Fired after playback stall is resolved with playing, seeked, or ended event following BUFFER_STALLED_ERROR
STALL_RESOLVED = 'hlsStallResolved',
// Fired when the buffer is going to be reset
BUFFER_RESET = 'hlsBufferReset',
// Fired when we know about the codecs that we need buffers for to push into - data: {tracks : { container, codec, levelCodec, initSegment, metadata }}
BUFFER_CODECS = 'hlsBufferCodecs',
// fired when sourcebuffers have been created - data: { tracks : tracks }
BUFFER_CREATED = 'hlsBufferCreated',
// fired when we append a segment to the buffer - data: { segment: segment object }
BUFFER_APPENDING = 'hlsBufferAppending',
// fired when we are done with appending a media segment to the buffer - data : { parent : segment parent that triggered BUFFER_APPENDING, pending : nb of segments waiting for appending for this segment parent}
BUFFER_APPENDED = 'hlsBufferAppended',
// fired when the stream is finished and we want to notify the media buffer that there will be no more data - data: { }
BUFFER_EOS = 'hlsBufferEos',
// fired when all buffers are full to the end of the program, after calling MediaSource.endOfStream() (unless restricted)
BUFFERED_TO_END = 'hlsBufferedToEnd',
// fired when the media buffer should be flushed - data { startOffset, endOffset }
BUFFER_FLUSHING = 'hlsBufferFlushing',
// fired when the media buffer has been flushed - data: { }
BUFFER_FLUSHED = 'hlsBufferFlushed',
// fired to signal that a manifest loading starts - data: { url : manifestURL}
MANIFEST_LOADING = 'hlsManifestLoading',
// fired after manifest has been loaded - data: { levels : [available quality levels], audioTracks : [ available audio tracks ], url : manifestURL, stats : LoaderStats }
MANIFEST_LOADED = 'hlsManifestLoaded',
// fired after manifest has been parsed - data: { levels : [available quality levels], firstLevel : index of first quality level appearing in Manifest}
MANIFEST_PARSED = 'hlsManifestParsed',
// fired when a level switch is requested - data: { level : id of new level }
LEVEL_SWITCHING = 'hlsLevelSwitching',
// fired when a level switch is effective - data: { level : id of new level }
LEVEL_SWITCHED = 'hlsLevelSwitched',
// fired when a level playlist loading starts - data: { url : level URL, level : id of level being loaded}
LEVEL_LOADING = 'hlsLevelLoading',
// fired when a level playlist loading finishes - data: { details : levelDetails object, level : id of loaded level, stats : LoaderStats }
LEVEL_LOADED = 'hlsLevelLoaded',
// fired when a level's details have been updated based on previous details, after it has been loaded - data: { details : levelDetails object, level : id of updated level }
LEVEL_UPDATED = 'hlsLevelUpdated',
// fired when a level's PTS information has been updated after parsing a fragment - data: { details : levelDetails object, level : id of updated level, drift: PTS drift observed when parsing last fragment }
LEVEL_PTS_UPDATED = 'hlsLevelPtsUpdated',
// fired to notify that levels have changed after removing a level - data: { levels : [available quality levels] }
LEVELS_UPDATED = 'hlsLevelsUpdated',
// fired to notify that audio track lists has been updated - data: { audioTracks : audioTracks }
AUDIO_TRACKS_UPDATED = 'hlsAudioTracksUpdated',
// fired when an audio track switching is requested - data: { id : audio track id }
AUDIO_TRACK_SWITCHING = 'hlsAudioTrackSwitching',
// fired when an audio track switch actually occurs - data: { id : audio track id }
AUDIO_TRACK_SWITCHED = 'hlsAudioTrackSwitched',
// fired when an audio track loading starts - data: { url : audio track URL, id : audio track id }
AUDIO_TRACK_LOADING = 'hlsAudioTrackLoading',
// fired when an audio track loading finishes - data: { details : levelDetails object, id : audio track id, stats : LoaderStats }
AUDIO_TRACK_LOADED = 'hlsAudioTrackLoaded',
// fired when an audio tracks's details have been updated based on previous details, after it has been loaded - data: { details : levelDetails object, id : track id }
AUDIO_TRACK_UPDATED = 'hlsAudioTrackUpdated',
// fired to notify that subtitle track lists has been updated - data: { subtitleTracks : subtitleTracks }
SUBTITLE_TRACKS_UPDATED = 'hlsSubtitleTracksUpdated',
// fired to notify that subtitle tracks were cleared as a result of stopping the media
SUBTITLE_TRACKS_CLEARED = 'hlsSubtitleTracksCleared',
// fired when an subtitle track switch occurs - data: { id : subtitle track id }
SUBTITLE_TRACK_SWITCH = 'hlsSubtitleTrackSwitch',
// fired when a subtitle track loading starts - data: { url : subtitle track URL, id : subtitle track id }
SUBTITLE_TRACK_LOADING = 'hlsSubtitleTrackLoading',
// fired when a subtitle track loading finishes - data: { details : levelDetails object, id : subtitle track id, stats : LoaderStats }
SUBTITLE_TRACK_LOADED = 'hlsSubtitleTrackLoaded',
// fired when a subtitle racks's details have been updated based on previous details, after it has been loaded - data: { details : levelDetails object, id : track id }
SUBTITLE_TRACK_UPDATED = 'hlsSubtitleTrackUpdated',
// fired when a subtitle fragment has been processed - data: { success : boolean, frag : the processed frag }
SUBTITLE_FRAG_PROCESSED = 'hlsSubtitleFragProcessed',
// fired when a set of VTTCues to be managed externally has been parsed - data: { type: string, track: string, cues: [ VTTCue ] }
CUES_PARSED = 'hlsCuesParsed',
// fired when a text track to be managed externally is found - data: { tracks: [ { label: string, kind: string, default: boolean } ] }
NON_NATIVE_TEXT_TRACKS_FOUND = 'hlsNonNativeTextTracksFound',
// fired when the first timestamp is found - data: { id : demuxer id, initPTS: initPTS, timescale: timescale, frag : fragment object }
INIT_PTS_FOUND = 'hlsInitPtsFound',
// fired when a fragment loading starts - data: { frag : fragment object }
FRAG_LOADING = 'hlsFragLoading',
// fired when a fragment loading is progressing - data: { frag : fragment object, { trequest, tfirst, loaded } }
// FRAG_LOAD_PROGRESS = 'hlsFragLoadProgress',
// Identifier for fragment load aborting for emergency switch down - data: { frag : fragment object }
FRAG_LOAD_EMERGENCY_ABORTED = 'hlsFragLoadEmergencyAborted',
// fired when a fragment loading is completed - data: { frag : fragment object, payload : fragment payload, stats : LoaderStats }
FRAG_LOADED = 'hlsFragLoaded',
// fired when a fragment has finished decrypting - data: { id : demuxer id, frag: fragment object, payload : fragment payload, stats : { tstart, tdecrypt } }
FRAG_DECRYPTED = 'hlsFragDecrypted',
// fired when Init Segment has been extracted from fragment - data: { id : demuxer id, frag: fragment object, moov : moov MP4 box, codecs : codecs found while parsing fragment }
FRAG_PARSING_INIT_SEGMENT = 'hlsFragParsingInitSegment',
// fired when parsing sei text is completed - data: { id : demuxer id, frag: fragment object, samples : [ sei samples pes ] }
FRAG_PARSING_USERDATA = 'hlsFragParsingUserdata',
// fired when parsing id3 is completed - data: { id : demuxer id, frag: fragment object, samples : [ id3 samples pes ] }
FRAG_PARSING_METADATA = 'hlsFragParsingMetadata',
// fired when data have been extracted from fragment - data: { id : demuxer id, frag: fragment object, data1 : moof MP4 box or TS fragments, data2 : mdat MP4 box or null}
// FRAG_PARSING_DATA = 'hlsFragParsingData',
// fired when fragment parsing is completed - data: { id : demuxer id, frag: fragment object }
FRAG_PARSED = 'hlsFragParsed',
// fired when fragment remuxed MP4 boxes have all been appended into SourceBuffer - data: { id : demuxer id, frag : fragment object, stats : LoaderStats }
FRAG_BUFFERED = 'hlsFragBuffered',
// fired when fragment matching with current media position is changing - data : { id : demuxer id, frag : fragment object }
FRAG_CHANGED = 'hlsFragChanged',
// Identifier for a FPS drop event - data: { currentDropped, currentDecoded, totalDroppedFrames }
FPS_DROP = 'hlsFpsDrop',
// triggered when FPS drop triggers auto level capping - data: { level, droppedLevel }
FPS_DROP_LEVEL_CAPPING = 'hlsFpsDropLevelCapping',
// triggered when maxAutoLevel changes - data { autoLevelCapping, levels, maxAutoLevel, minAutoLevel, maxHdcpLevel }
MAX_AUTO_LEVEL_UPDATED = 'hlsMaxAutoLevelUpdated',
// Identifier for an error event - data: { type : error type, details : error details, fatal : if true, hls.js cannot/will not try to recover, if false, hls.js will try to recover,other error specific data }
ERROR = 'hlsError',
// fired when hls.js instance starts destroying. Different from MEDIA_DETACHED as one could want to detach and reattach a media to the instance of hls.js to handle mid-rolls for example - data: { }
DESTROYING = 'hlsDestroying',
// fired when a decrypt key loading starts - data: { frag : fragment object }
KEY_LOADING = 'hlsKeyLoading',
// fired when a decrypt key loading is completed - data: { frag : fragment object, keyInfo : KeyLoaderInfo }
KEY_LOADED = 'hlsKeyLoaded',
// deprecated; please use BACK_BUFFER_REACHED - data : { bufferEnd: number }
LIVE_BACK_BUFFER_REACHED = 'hlsLiveBackBufferReached',
// fired when the back buffer is reached as defined by the backBufferLength config option - data : { bufferEnd: number }
BACK_BUFFER_REACHED = 'hlsBackBufferReached',
// fired after steering manifest has been loaded - data: { steeringManifest: SteeringManifest object, url: steering manifest URL }
STEERING_MANIFEST_LOADED = 'hlsSteeringManifestLoaded',
// fired when asset list has begun loading
ASSET_LIST_LOADING = 'hlsAssetListLoading',
// fired when a valid asset list is loaded
ASSET_LIST_LOADED = 'hlsAssetListLoaded',
// fired when the list of Interstitial Events and Interstitial Schedule is updated
INTERSTITIALS_UPDATED = 'hlsInterstitialsUpdated',
// fired when the buffer reaches an Interstitial Schedule boundary (both Primary segments and Interstitial Assets)
INTERSTITIALS_BUFFERED_TO_BOUNDARY = 'hlsInterstitialsBufferedToBoundary',
// fired when a player instance for an Interstitial Asset has been created
INTERSTITIAL_ASSET_PLAYER_CREATED = 'hlsInterstitialAssetPlayerCreated',
// Interstitial playback started
INTERSTITIAL_STARTED = 'hlsInterstitialStarted',
// InterstitialAsset playback started
INTERSTITIAL_ASSET_STARTED = 'hlsInterstitialAssetStarted',
// InterstitialAsset playback ended
INTERSTITIAL_ASSET_ENDED = 'hlsInterstitialAssetEnded',
// InterstitialAsset playback errored
INTERSTITIAL_ASSET_ERROR = 'hlsInterstitialAssetError',
// Interstitial playback ended
INTERSTITIAL_ENDED = 'hlsInterstitialEnded',
// Interstitial schedule resumed primary playback
INTERSTITIALS_PRIMARY_RESUMED = 'hlsInterstitialsPrimaryResumed',
// Interstitial players dispatch this event when playout limit is reached
PLAYOUT_LIMIT_REACHED = 'hlsPlayoutLimitReached',
// Event DateRange cue "enter" event dispatched
EVENT_CUE_ENTER = 'hlsEventCueEnter',
}
/**
* Defines each Event type and payload by Event name. Used in {@link hls.js#HlsEventEmitter} to strongly type the event listener API.
*/
export interface HlsListeners {
[Events.MEDIA_ATTACHING]: (
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
) => void;
[Events.MEDIA_ATTACHED]: (
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData,
) => void;
[Events.MEDIA_DETACHING]: (
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) => void;
[Events.MEDIA_DETACHED]: (
event: Events.MEDIA_DETACHED,
data: MediaDetachedData,
) => void;
[Events.MEDIA_ENDED]: (
event: Events.MEDIA_ENDED,
data: MediaEndedData,
) => void;
[Events.STALL_RESOLVED]: (event: Events.STALL_RESOLVED, data: {}) => void;
[Events.BUFFER_RESET]: (event: Events.BUFFER_RESET) => void;
[Events.BUFFER_CODECS]: (
event: Events.BUFFER_CODECS,
data: BufferCodecsData,
) => void;
[Events.BUFFER_CREATED]: (
event: Events.BUFFER_CREATED,
data: BufferCreatedData,
) => void;
[Events.BUFFER_APPENDING]: (
event: Events.BUFFER_APPENDING,
data: BufferAppendingData,
) => void;
[Events.BUFFER_APPENDED]: (
event: Events.BUFFER_APPENDED,
data: BufferAppendedData,
) => void;
[Events.BUFFER_EOS]: (event: Events.BUFFER_EOS, data: BufferEOSData) => void;
[Events.BUFFERED_TO_END]: (event: Events.BUFFERED_TO_END) => void;
[Events.BUFFER_FLUSHING]: (
event: Events.BUFFER_FLUSHING,
data: BufferFlushingData,
) => void;
[Events.BUFFER_FLUSHED]: (
event: Events.BUFFER_FLUSHED,
data: BufferFlushedData,
) => void;
[Events.MANIFEST_LOADING]: (
event: Events.MANIFEST_LOADING,
data: ManifestLoadingData,
) => void;
[Events.MANIFEST_LOADED]: (
event: Events.MANIFEST_LOADED,
data: ManifestLoadedData,
) => void;
[Events.MANIFEST_PARSED]: (
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
) => void;
[Events.LEVEL_SWITCHING]: (
event: Events.LEVEL_SWITCHING,
data: LevelSwitchingData,
) => void;
[Events.LEVEL_SWITCHED]: (
event: Events.LEVEL_SWITCHED,
data: LevelSwitchedData,
) => void;
[Events.LEVEL_LOADING]: (
event: Events.LEVEL_LOADING,
data: LevelLoadingData,
) => void;
[Events.LEVEL_LOADED]: (
event: Events.LEVEL_LOADED,
data: LevelLoadedData,
) => void;
[Events.LEVEL_UPDATED]: (
event: Events.LEVEL_UPDATED,
data: LevelUpdatedData,
) => void;
[Events.LEVEL_PTS_UPDATED]: (
event: Events.LEVEL_PTS_UPDATED,
data: LevelPTSUpdatedData,
) => void;
[Events.LEVELS_UPDATED]: (
event: Events.LEVELS_UPDATED,
data: LevelsUpdatedData,
) => void;
[Events.AUDIO_TRACKS_UPDATED]: (
event: Events.AUDIO_TRACKS_UPDATED,
data: AudioTracksUpdatedData,
) => void;
[Events.AUDIO_TRACK_SWITCHING]: (
event: Events.AUDIO_TRACK_SWITCHING,
data: AudioTrackSwitchingData,
) => void;
[Events.AUDIO_TRACK_SWITCHED]: (
event: Events.AUDIO_TRACK_SWITCHED,
data: AudioTrackSwitchedData,
) => void;
[Events.AUDIO_TRACK_LOADING]: (
event: Events.AUDIO_TRACK_LOADING,
data: TrackLoadingData,
) => void;
[Events.AUDIO_TRACK_LOADED]: (
event: Events.AUDIO_TRACK_LOADED,
data: AudioTrackLoadedData,
) => void;
[Events.AUDIO_TRACK_UPDATED]: (
event: Events.AUDIO_TRACK_UPDATED,
data: AudioTrackUpdatedData,
) => void;
[Events.SUBTITLE_TRACKS_UPDATED]: (
event: Events.SUBTITLE_TRACKS_UPDATED,
data: SubtitleTracksUpdatedData,
) => void;
[Events.SUBTITLE_TRACKS_CLEARED]: (
event: Events.SUBTITLE_TRACKS_CLEARED,
) => void;
[Events.SUBTITLE_TRACK_SWITCH]: (
event: Events.SUBTITLE_TRACK_SWITCH,
data: SubtitleTrackSwitchData,
) => void;
[Events.SUBTITLE_TRACK_LOADING]: (
event: Events.SUBTITLE_TRACK_LOADING,
data: TrackLoadingData,
) => void;
[Events.SUBTITLE_TRACK_LOADED]: (
event: Events.SUBTITLE_TRACK_LOADED,
data: SubtitleTrackLoadedData,
) => void;
[Events.SUBTITLE_TRACK_UPDATED]: (
event: Events.SUBTITLE_TRACK_UPDATED,
data: SubtitleTrackUpdatedData,
) => void;
[Events.SUBTITLE_FRAG_PROCESSED]: (
event: Events.SUBTITLE_FRAG_PROCESSED,
data: SubtitleFragProcessedData,
) => void;
[Events.CUES_PARSED]: (
event: Events.CUES_PARSED,
data: CuesParsedData,
) => void;
[Events.NON_NATIVE_TEXT_TRACKS_FOUND]: (
event: Events.NON_NATIVE_TEXT_TRACKS_FOUND,
data: NonNativeTextTracksData,
) => void;
[Events.INIT_PTS_FOUND]: (
event: Events.INIT_PTS_FOUND,
data: InitPTSFoundData,
) => void;
[Events.FRAG_LOADING]: (
event: Events.FRAG_LOADING,
data: FragLoadingData,
) => void;
// [Events.FRAG_LOAD_PROGRESS]: TodoEventType
[Events.FRAG_LOAD_EMERGENCY_ABORTED]: (
event: Events.FRAG_LOAD_EMERGENCY_ABORTED,
data: FragLoadEmergencyAbortedData,
) => void;
[Events.FRAG_LOADED]: (
event: Events.FRAG_LOADED,
data: FragLoadedData,
) => void;
[Events.FRAG_DECRYPTED]: (
event: Events.FRAG_DECRYPTED,
data: FragDecryptedData,
) => void;
[Events.FRAG_PARSING_INIT_SEGMENT]: (
event: Events.FRAG_PARSING_INIT_SEGMENT,
data: FragParsingInitSegmentData,
) => void;
[Events.FRAG_PARSING_USERDATA]: (
event: Events.FRAG_PARSING_USERDATA,
data: FragParsingUserdataData,
) => void;
[Events.FRAG_PARSING_METADATA]: (
event: Events.FRAG_PARSING_METADATA,
data: FragParsingMetadataData,
) => void;
// [Events.FRAG_PARSING_DATA]: TodoEventType
[Events.FRAG_PARSED]: (
event: Events.FRAG_PARSED,
data: FragParsedData,
) => void;
[Events.FRAG_BUFFERED]: (
event: Events.FRAG_BUFFERED,
data: FragBufferedData,
) => void;
[Events.FRAG_CHANGED]: (
event: Events.FRAG_CHANGED,
data: FragChangedData,
) => void;
[Events.FPS_DROP]: (event: Events.FPS_DROP, data: FPSDropData) => void;
[Events.FPS_DROP_LEVEL_CAPPING]: (
event: Events.FPS_DROP_LEVEL_CAPPING,
data: FPSDropLevelCappingData,
) => void;
[Events.MAX_AUTO_LEVEL_UPDATED]: (
event: Events.MAX_AUTO_LEVEL_UPDATED,
data: MaxAutoLevelUpdatedData,
) => void;
[Events.ERROR]: (event: Events.ERROR, data: ErrorData) => void;
[Events.DESTROYING]: (event: Events.DESTROYING) => void;
[Events.KEY_LOADING]: (
event: Events.KEY_LOADING,
data: KeyLoadingData,
) => void;
[Events.KEY_LOADED]: (event: Events.KEY_LOADED, data: KeyLoadedData) => void;
[Events.LIVE_BACK_BUFFER_REACHED]: (
event: Events.LIVE_BACK_BUFFER_REACHED,
data: LiveBackBufferData,
) => void;
[Events.BACK_BUFFER_REACHED]: (
event: Events.BACK_BUFFER_REACHED,
data: BackBufferData,
) => void;
[Events.STEERING_MANIFEST_LOADED]: (
event: Events.STEERING_MANIFEST_LOADED,
data: SteeringManifestLoadedData,
) => void;
[Events.ASSET_LIST_LOADING]: (
event: Events.ASSET_LIST_LOADING,
data: AssetListLoadingData,
) => void;
[Events.ASSET_LIST_LOADED]: (
event: Events.ASSET_LIST_LOADED,
data: AssetListLoadedData,
) => void;
[Events.INTERSTITIALS_UPDATED]: (
event: Events.INTERSTITIALS_UPDATED,
data: InterstitialsUpdatedData,
) => void;
[Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY]: (
event: Events.INTERSTITIALS_BUFFERED_TO_BOUNDARY,
data: InterstitialsBufferedToBoundaryData,
) => void;
[Events.INTERSTITIAL_ASSET_PLAYER_CREATED]: (
event: Events.INTERSTITIAL_ASSET_PLAYER_CREATED,
data: InterstitialAssetPlayerCreatedData,
) => void;
[Events.INTERSTITIAL_STARTED]: (
event: Events.INTERSTITIAL_STARTED,
data: InterstitialStartedData,
) => void;
[Events.INTERSTITIAL_ASSET_STARTED]: (
event: Events.INTERSTITIAL_ASSET_STARTED,
data: InterstitialAssetStartedData,
) => void;
[Events.INTERSTITIAL_ASSET_ENDED]: (
event: Events.INTERSTITIAL_ASSET_ENDED,
data: InterstitialAssetEndedData,
) => void;
[Events.INTERSTITIAL_ASSET_ERROR]: (
event: Events.INTERSTITIAL_ASSET_ERROR,
data: InterstitialAssetErrorData,
) => void;
[Events.INTERSTITIAL_ENDED]: (
event: Events.INTERSTITIAL_ENDED,
data: InterstitialEndedData,
) => void;
[Events.INTERSTITIALS_PRIMARY_RESUMED]: (
event: Events.INTERSTITIALS_PRIMARY_RESUMED,
data: InterstitialsPrimaryResumed,
) => void;
[Events.PLAYOUT_LIMIT_REACHED]: (
event: Events.PLAYOUT_LIMIT_REACHED,
data: {},
) => void;
[Events.EVENT_CUE_ENTER]: (event: Events.EVENT_CUE_ENTER, data: {}) => void;
}
export interface HlsEventEmitter {
on<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener: HlsListeners[E],
context?: Context,
): void;
once<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener: HlsListeners[E],
context?: Context,
): void;
removeAllListeners<E extends keyof HlsListeners>(event?: E): void;
off<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener?: HlsListeners[E],
context?: Context,
once?: boolean,
): void;
listeners<E extends keyof HlsListeners>(event: E): HlsListeners[E][];
emit<E extends keyof HlsListeners>(
event: E,
name: E,
eventObject: Parameters<HlsListeners[E]>[1],
): boolean;
listenerCount<E extends keyof HlsListeners>(event: E): number;
}
+3
View File
@@ -0,0 +1,3 @@
import Hls from './hls';
export default Hls;
+69
View File
@@ -0,0 +1,69 @@
import AbrController from './controller/abr-controller';
import AudioStreamController from './controller/audio-stream-controller';
import AudioTrackController from './controller/audio-track-controller';
import BasePlaylistController from './controller/base-playlist-controller';
import BaseStreamController from './controller/base-stream-controller';
import BufferController from './controller/buffer-controller';
import CapLevelController from './controller/cap-level-controller';
import CMCDController from './controller/cmcd-controller';
import ContentSteeringController from './controller/content-steering-controller';
import EMEController from './controller/eme-controller';
import ErrorController from './controller/error-controller';
import FPSController from './controller/fps-controller';
import SubtitleTrackController from './controller/subtitle-track-controller';
import Hls from './hls';
import M3U8Parser from './loader/m3u8-parser';
import Cues from './utils/cues';
import FetchLoader from './utils/fetch-loader';
import XhrLoader from './utils/xhr-loader';
export default Hls;
export {
Hls,
AbrController,
AudioStreamController,
AudioTrackController,
BasePlaylistController,
BaseStreamController,
BufferController,
CapLevelController,
CMCDController,
ContentSteeringController,
EMEController,
ErrorController,
FPSController,
SubtitleTrackController,
XhrLoader,
FetchLoader,
Cues,
M3U8Parser,
};
export { Events } from './events';
export { ErrorTypes, ErrorDetails } from './errors';
export { Level } from './types/level';
export { TimelineController } from './controller/timeline-controller';
export { SubtitleStreamController } from './controller/subtitle-stream-controller';
export {
KeySystems,
KeySystemFormats,
requestMediaKeySystemAccess,
} from './utils/mediakeys-helper';
export { DateRange } from './loader/date-range';
export { LoadStats } from './loader/load-stats';
export { LevelKey } from './loader/level-key';
export { LevelDetails } from './loader/level-details';
export { MetadataSchema } from './types/demuxer';
export { HlsSkip, HlsUrlParameters } from './types/level';
export { PlaylistLevelType } from './types/loader';
export { ChunkMetadata } from './types/transmuxer';
export { BaseSegment, Fragment, Part } from './loader/fragment';
export {
NetworkErrorAction,
ErrorActionFlags,
} from './controller/error-controller';
export { AttrList } from './utils/attr-list';
export { fetchSupported } from './utils/fetch-loader';
export { isSupported, isMSESupported } from './is-supported';
export { getMediaSource } from './utils/mediasource-helper';
+1539
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
import { mimeTypeForCodec } from './utils/codecs';
import { getMediaSource } from './utils/mediasource-helper';
import type { ExtendedSourceBuffer } from './types/buffer';
function getSourceBuffer(): typeof self.SourceBuffer {
return self.SourceBuffer || (self as any).WebKitSourceBuffer;
}
export function isMSESupported(): boolean {
const mediaSource = getMediaSource();
if (!mediaSource) {
return false;
}
// if SourceBuffer is exposed ensure its API is valid
// Older browsers do not expose SourceBuffer globally so checking SourceBuffer.prototype is impossible
const sourceBuffer = getSourceBuffer();
return (
!sourceBuffer ||
(sourceBuffer.prototype &&
typeof sourceBuffer.prototype.appendBuffer === 'function' &&
typeof sourceBuffer.prototype.remove === 'function')
);
}
export function isSupported(): boolean {
if (!isMSESupported()) {
return false;
}
const mediaSource = getMediaSource();
return (
typeof mediaSource?.isTypeSupported === 'function' &&
(['avc1.42E01E,mp4a.40.2', 'av01.0.01M.08', 'vp09.00.50.08'].some(
(codecsForVideoContainer) =>
mediaSource.isTypeSupported(
mimeTypeForCodec(codecsForVideoContainer, 'video'),
),
) ||
['mp4a.40.2', 'fLaC'].some((codecForAudioContainer) =>
mediaSource.isTypeSupported(
mimeTypeForCodec(codecForAudioContainer, 'audio'),
),
))
);
}
export function changeTypeSupported(): boolean {
const sourceBuffer = getSourceBuffer();
return (
typeof (sourceBuffer?.prototype as ExtendedSourceBuffer)?.changeType ===
'function'
);
}
+207
View File
@@ -0,0 +1,207 @@
import { AttrList } from '../utils/attr-list';
import { logger } from '../utils/logger';
import type { MediaFragmentRef } from './fragment';
// Avoid exporting const enum so that these values can be inlined
const enum DateRangeAttribute {
ID = 'ID',
CLASS = 'CLASS',
CUE = 'CUE',
START_DATE = 'START-DATE',
DURATION = 'DURATION',
END_DATE = 'END-DATE',
END_ON_NEXT = 'END-ON-NEXT',
PLANNED_DURATION = 'PLANNED-DURATION',
SCTE35_OUT = 'SCTE35-OUT',
SCTE35_IN = 'SCTE35-IN',
SCTE35_CMD = 'SCTE35-CMD',
}
export type DateRangeCue = {
pre: boolean;
post: boolean;
once: boolean;
};
const CLASS_INTERSTITIAL = 'com.apple.hls.interstitial';
export function isDateRangeCueAttribute(attrName: string): boolean {
return (
attrName !== DateRangeAttribute.ID &&
attrName !== DateRangeAttribute.CLASS &&
attrName !== DateRangeAttribute.CUE &&
attrName !== DateRangeAttribute.START_DATE &&
attrName !== DateRangeAttribute.DURATION &&
attrName !== DateRangeAttribute.END_DATE &&
attrName !== DateRangeAttribute.END_ON_NEXT
);
}
export function isSCTE35Attribute(attrName: string): boolean {
return (
attrName === DateRangeAttribute.SCTE35_OUT ||
attrName === DateRangeAttribute.SCTE35_IN ||
attrName === DateRangeAttribute.SCTE35_CMD
);
}
export class DateRange {
public attr: AttrList;
public tagAnchor: MediaFragmentRef | null;
public tagOrder: number;
private _startDate: Date;
private _endDate?: Date;
private _dateAtEnd?: Date;
private _cue?: DateRangeCue;
private _badValueForSameId?: string;
constructor(
dateRangeAttr: AttrList,
dateRangeWithSameId?: DateRange | undefined,
tagCount: number = 0,
) {
this.tagAnchor = dateRangeWithSameId?.tagAnchor || null;
this.tagOrder = dateRangeWithSameId?.tagOrder ?? tagCount;
if (dateRangeWithSameId) {
const previousAttr = dateRangeWithSameId.attr;
for (const key in previousAttr) {
if (
Object.prototype.hasOwnProperty.call(dateRangeAttr, key) &&
dateRangeAttr[key] !== previousAttr[key]
) {
logger.warn(
`DATERANGE tag attribute: "${key}" does not match for tags with ID: "${dateRangeAttr.ID}"`,
);
this._badValueForSameId = key;
break;
}
}
// Merge DateRange tags with the same ID
dateRangeAttr = Object.assign(
new AttrList({}),
previousAttr,
dateRangeAttr,
);
}
this.attr = dateRangeAttr;
if (dateRangeWithSameId) {
this._startDate = dateRangeWithSameId._startDate;
this._cue = dateRangeWithSameId._cue;
this._endDate = dateRangeWithSameId._endDate;
this._dateAtEnd = dateRangeWithSameId._dateAtEnd;
} else {
this._startDate = new Date(dateRangeAttr[DateRangeAttribute.START_DATE]);
}
if (DateRangeAttribute.END_DATE in this.attr) {
const endDate =
dateRangeWithSameId?.endDate ||
new Date(this.attr[DateRangeAttribute.END_DATE]);
if (Number.isFinite(endDate.getTime())) {
this._endDate = endDate;
}
}
}
get id(): string {
return this.attr.ID;
}
get class(): string {
return this.attr.CLASS;
}
get cue(): DateRangeCue {
const _cue = this._cue;
if (_cue === undefined) {
return (this._cue = this.attr.enumeratedStringList(
this.attr.CUE ? 'CUE' : 'X-CUE',
{
pre: false,
post: false,
once: false,
},
));
}
return _cue;
}
get startTime(): number {
const { tagAnchor } = this;
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (tagAnchor === null || tagAnchor.programDateTime === null) {
logger.warn(
`Expected tagAnchor Fragment with PDT set for DateRange "${this.id}": ${tagAnchor}`,
);
return NaN;
}
return (
tagAnchor.start +
(this.startDate.getTime() - tagAnchor.programDateTime) / 1000
);
}
get startDate(): Date {
return this._startDate;
}
get endDate(): Date | null {
const dateAtEnd = this._endDate || this._dateAtEnd;
if (dateAtEnd) {
return dateAtEnd;
}
const duration = this.duration;
if (duration !== null) {
return (this._dateAtEnd = new Date(
this._startDate.getTime() + duration * 1000,
));
}
return null;
}
get duration(): number | null {
if (DateRangeAttribute.DURATION in this.attr) {
const duration = this.attr.decimalFloatingPoint(
DateRangeAttribute.DURATION,
);
if (Number.isFinite(duration)) {
return duration;
}
} else if (this._endDate) {
return (this._endDate.getTime() - this._startDate.getTime()) / 1000;
}
return null;
}
get plannedDuration(): number | null {
if (DateRangeAttribute.PLANNED_DURATION in this.attr) {
return this.attr.decimalFloatingPoint(
DateRangeAttribute.PLANNED_DURATION,
);
}
return null;
}
get endOnNext(): boolean {
return this.attr.bool(DateRangeAttribute.END_ON_NEXT);
}
get isInterstitial(): boolean {
return this.class === CLASS_INTERSTITIAL;
}
get isValid(): boolean {
return (
!!this.id &&
!this._badValueForSameId &&
Number.isFinite(this.startDate.getTime()) &&
(this.duration === null || this.duration >= 0) &&
(!this.endOnNext || !!this.class) &&
(!this.attr.CUE ||
(!this.cue.pre && !this.cue.post) ||
this.cue.pre !== this.cue.post) &&
(!this.isInterstitial ||
'X-ASSET-URI' in this.attr ||
'X-ASSET-LIST' in this.attr)
);
}
}
+402
View File
@@ -0,0 +1,402 @@
import { ErrorDetails, ErrorTypes } from '../errors';
import { getLoaderConfigWithoutReties } from '../utils/error-helper';
import type { BaseSegment, Fragment, Part } from './fragment';
import type { HlsConfig } from '../config';
import type {
ErrorData,
FragLoadedData,
PartsLoadedData,
} from '../types/events';
import type {
FragmentLoaderContext,
Loader,
LoaderCallbacks,
LoaderConfiguration,
} from '../types/loader';
const MIN_CHUNK_SIZE = Math.pow(2, 17); // 128kb
export default class FragmentLoader {
private readonly config: HlsConfig;
private loader: Loader<FragmentLoaderContext> | null = null;
private partLoadTimeout: number = -1;
constructor(config: HlsConfig) {
this.config = config;
}
destroy() {
if (this.loader) {
this.loader.destroy();
this.loader = null;
}
}
abort() {
if (this.loader) {
// Abort the loader for current fragment. Only one may load at any given time
this.loader.abort();
}
}
load(
frag: Fragment,
onProgress?: FragmentLoadProgressCallback,
): Promise<FragLoadedData> {
const url = frag.url;
if (!url) {
return Promise.reject(
new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.FRAG_LOAD_ERROR,
fatal: false,
frag,
error: new Error(
`Fragment does not have a ${url ? 'part list' : 'url'}`,
),
networkDetails: null,
}),
);
}
this.abort();
const config = this.config;
const FragmentILoader = config.fLoader;
const DefaultILoader = config.loader;
return new Promise((resolve, reject) => {
if (this.loader) {
this.loader.destroy();
}
if (frag.gap) {
if (frag.tagList.some((tags) => tags[0] === 'GAP')) {
reject(createGapLoadError(frag));
return;
} else {
// Reset temporary treatment as GAP tag
frag.gap = false;
}
}
const loader = (this.loader = FragmentILoader
? new FragmentILoader(config)
: (new DefaultILoader(config) as Loader<FragmentLoaderContext>));
const loaderContext = createLoaderContext(frag);
frag.loader = loader;
const loadPolicy = getLoaderConfigWithoutReties(
config.fragLoadPolicy.default,
);
const loaderConfig: LoaderConfiguration = {
loadPolicy,
timeout: loadPolicy.maxLoadTimeMs,
maxRetry: 0,
retryDelay: 0,
maxRetryDelay: 0,
highWaterMark: frag.sn === 'initSegment' ? Infinity : MIN_CHUNK_SIZE,
};
// Assign frag stats to the loader's stats reference
frag.stats = loader.stats;
const callbacks: LoaderCallbacks<FragmentLoaderContext> = {
onSuccess: (response, stats, context, networkDetails) => {
this.resetLoader(frag, loader);
let payload = response.data as ArrayBuffer;
if (context.resetIV && frag.decryptdata) {
frag.decryptdata.iv = new Uint8Array(payload.slice(0, 16));
payload = payload.slice(16);
}
resolve({
frag,
part: null,
payload,
networkDetails,
});
},
onError: (response, context, networkDetails, stats) => {
this.resetLoader(frag, loader);
reject(
new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.FRAG_LOAD_ERROR,
fatal: false,
frag,
response: { url, data: undefined, ...response },
error: new Error(`HTTP Error ${response.code} ${response.text}`),
networkDetails,
stats,
}),
);
},
onAbort: (stats, context, networkDetails) => {
this.resetLoader(frag, loader);
reject(
new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.INTERNAL_ABORTED,
fatal: false,
frag,
error: new Error('Aborted'),
networkDetails,
stats,
}),
);
},
onTimeout: (stats, context, networkDetails) => {
this.resetLoader(frag, loader);
reject(
new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.FRAG_LOAD_TIMEOUT,
fatal: false,
frag,
error: new Error(`Timeout after ${loaderConfig.timeout}ms`),
networkDetails,
stats,
}),
);
},
};
if (onProgress) {
callbacks.onProgress = (stats, context, data, networkDetails) =>
onProgress({
frag,
part: null,
payload: data as ArrayBuffer,
networkDetails,
});
}
loader.load(loaderContext, loaderConfig, callbacks);
});
}
public loadPart(
frag: Fragment,
part: Part,
onProgress: FragmentLoadProgressCallback,
): Promise<FragLoadedData> {
this.abort();
const config = this.config;
const FragmentILoader = config.fLoader;
const DefaultILoader = config.loader;
return new Promise((resolve, reject) => {
if (this.loader) {
this.loader.destroy();
}
if (frag.gap || part.gap) {
reject(createGapLoadError(frag, part));
return;
}
const loader = (this.loader = FragmentILoader
? new FragmentILoader(config)
: (new DefaultILoader(config) as Loader<FragmentLoaderContext>));
const loaderContext = createLoaderContext(frag, part);
frag.loader = loader;
// Should we define another load policy for parts?
const loadPolicy = getLoaderConfigWithoutReties(
config.fragLoadPolicy.default,
);
const loaderConfig: LoaderConfiguration = {
loadPolicy,
timeout: loadPolicy.maxLoadTimeMs,
maxRetry: 0,
retryDelay: 0,
maxRetryDelay: 0,
highWaterMark: MIN_CHUNK_SIZE,
};
// Assign part stats to the loader's stats reference
part.stats = loader.stats;
loader.load(loaderContext, loaderConfig, {
onSuccess: (response, stats, context, networkDetails) => {
this.resetLoader(frag, loader);
this.updateStatsFromPart(frag, part);
const partLoadedData: FragLoadedData = {
frag,
part,
payload: response.data as ArrayBuffer,
networkDetails,
};
onProgress(partLoadedData);
resolve(partLoadedData);
},
onError: (response, context, networkDetails, stats) => {
this.resetLoader(frag, loader);
reject(
new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.FRAG_LOAD_ERROR,
fatal: false,
frag,
part,
response: {
url: loaderContext.url,
data: undefined,
...response,
},
error: new Error(`HTTP Error ${response.code} ${response.text}`),
networkDetails,
stats,
}),
);
},
onAbort: (stats, context, networkDetails) => {
frag.stats.aborted = part.stats.aborted;
this.resetLoader(frag, loader);
reject(
new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.INTERNAL_ABORTED,
fatal: false,
frag,
part,
error: new Error('Aborted'),
networkDetails,
stats,
}),
);
},
onTimeout: (stats, context, networkDetails) => {
this.resetLoader(frag, loader);
reject(
new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.FRAG_LOAD_TIMEOUT,
fatal: false,
frag,
part,
error: new Error(`Timeout after ${loaderConfig.timeout}ms`),
networkDetails,
stats,
}),
);
},
});
});
}
private updateStatsFromPart(frag: Fragment, part: Part) {
const fragStats = frag.stats;
const partStats = part.stats;
const partTotal = partStats.total;
fragStats.loaded += partStats.loaded;
if (partTotal) {
const estTotalParts = Math.round(frag.duration / part.duration);
const estLoadedParts = Math.min(
Math.round(fragStats.loaded / partTotal),
estTotalParts,
);
const estRemainingParts = estTotalParts - estLoadedParts;
const estRemainingBytes =
estRemainingParts * Math.round(fragStats.loaded / estLoadedParts);
fragStats.total = fragStats.loaded + estRemainingBytes;
} else {
fragStats.total = Math.max(fragStats.loaded, fragStats.total);
}
const fragLoading = fragStats.loading;
const partLoading = partStats.loading;
if (fragLoading.start) {
// add to fragment loader latency
fragLoading.first += partLoading.first - partLoading.start;
} else {
fragLoading.start = partLoading.start;
fragLoading.first = partLoading.first;
}
fragLoading.end = partLoading.end;
}
private resetLoader(frag: Fragment, loader: Loader<FragmentLoaderContext>) {
frag.loader = null;
if (this.loader === loader) {
self.clearTimeout(this.partLoadTimeout);
this.loader = null;
}
loader.destroy();
}
}
function createLoaderContext(
frag: Fragment,
part: Part | null = null,
): FragmentLoaderContext {
const segment: BaseSegment = part || frag;
const loaderContext: FragmentLoaderContext = {
frag,
part,
responseType: 'arraybuffer',
url: segment.url,
headers: {},
rangeStart: 0,
rangeEnd: 0,
};
const start = segment.byteRangeStartOffset as number;
const end = segment.byteRangeEndOffset as number;
if (Number.isFinite(start) && Number.isFinite(end)) {
let byteRangeStart = start;
let byteRangeEnd = end;
if (
frag.sn === 'initSegment' &&
isMethodFullSegmentAesCbc(frag.decryptdata?.method)
) {
// MAP segment encrypted with method 'AES-128' or 'AES-256' (cbc), when served with HTTP Range,
// has the unencrypted size specified in the range.
// Ref: https://tools.ietf.org/html/draft-pantos-hls-rfc8216bis-08#section-6.3.6
const fragmentLen = end - start;
if (fragmentLen % 16) {
byteRangeEnd = end + (16 - (fragmentLen % 16));
}
if (start !== 0) {
loaderContext.resetIV = true;
byteRangeStart = start - 16;
}
}
loaderContext.rangeStart = byteRangeStart;
loaderContext.rangeEnd = byteRangeEnd;
}
return loaderContext;
}
function createGapLoadError(frag: Fragment, part?: Part): LoadError {
const error = new Error(`GAP ${frag.gap ? 'tag' : 'attribute'} found`);
const errorData: FragLoadFailResult = {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_GAP,
fatal: false,
frag,
error,
networkDetails: null,
};
if (part) {
errorData.part = part;
}
(part ? part : frag).stats.aborted = true;
return new LoadError(errorData);
}
function isMethodFullSegmentAesCbc(method) {
return method === 'AES-128' || method === 'AES-256';
}
export class LoadError extends Error {
public readonly data: FragLoadFailResult;
constructor(data: FragLoadFailResult) {
super(data.error.message);
this.data = data;
}
}
export interface FragLoadFailResult extends ErrorData {
frag: Fragment;
part?: Part;
response?: {
data: any;
// error status code
code: number;
// error description
text: string;
url: string;
};
networkDetails: any;
}
export type FragmentLoadProgressCallback = (
result: FragLoadedData | PartsLoadedData,
) => void;
+484
View File
@@ -0,0 +1,484 @@
import { buildAbsoluteURL } from 'url-toolkit';
import { LoadStats } from './load-stats';
import type { LevelKey } from './level-key';
import type {
FragmentLoaderContext,
KeyLoaderContext,
Loader,
PlaylistLevelType,
} from '../types/loader';
import type { AttrList } from '../utils/attr-list';
import type { KeySystemFormats } from '../utils/mediakeys-helper';
export const enum ElementaryStreamTypes {
AUDIO = 'audio',
VIDEO = 'video',
AUDIOVIDEO = 'audiovideo',
}
export interface ElementaryStreamInfo {
startPTS: number;
endPTS: number;
startDTS: number;
endDTS: number;
partial?: boolean;
}
export type ElementaryStreams = Record<
ElementaryStreamTypes,
ElementaryStreamInfo | null
>;
export type Base = {
url: string;
};
export class BaseSegment {
private _byteRange: [number, number] | null = null;
private _url: string | null = null;
private _stats: LoadStats | null = null;
private _streams: ElementaryStreams | null = null;
// baseurl is the URL to the playlist
public readonly base: Base;
// relurl is the portion of the URL that comes from inside the playlist.
public relurl?: string;
constructor(base: Base | string) {
if (typeof base === 'string') {
base = { url: base };
}
this.base = base;
makeEnumerable(this, 'stats');
}
// setByteRange converts a EXT-X-BYTERANGE attribute into a two element array
setByteRange(value: string, previous?: BaseSegment) {
const params = value.split('@', 2);
let start: number;
if (params.length === 1) {
start = previous?.byteRangeEndOffset || 0;
} else {
start = parseInt(params[1]);
}
this._byteRange = [start, parseInt(params[0]) + start];
}
get baseurl(): string {
return this.base.url;
}
get byteRange(): [number, number] | [] {
if (this._byteRange === null) {
return [];
}
return this._byteRange;
}
get byteRangeStartOffset(): number | undefined {
return this.byteRange[0];
}
get byteRangeEndOffset(): number | undefined {
return this.byteRange[1];
}
get elementaryStreams(): ElementaryStreams {
if (this._streams === null) {
this._streams = {
[ElementaryStreamTypes.AUDIO]: null,
[ElementaryStreamTypes.VIDEO]: null,
[ElementaryStreamTypes.AUDIOVIDEO]: null,
};
}
return this._streams;
}
set elementaryStreams(value: ElementaryStreams) {
this._streams = value;
}
get hasStats(): boolean {
return this._stats !== null;
}
get hasStreams(): boolean {
return this._streams !== null;
}
get stats(): LoadStats {
if (this._stats === null) {
this._stats = new LoadStats();
}
return this._stats;
}
set stats(value: LoadStats) {
this._stats = value;
}
get url(): string {
if (!this._url && this.baseurl && this.relurl) {
this._url = buildAbsoluteURL(this.baseurl, this.relurl, {
alwaysNormalize: true,
});
}
return this._url || '';
}
set url(value: string) {
this._url = value;
}
clearElementaryStreamInfo() {
const { elementaryStreams } = this;
elementaryStreams[ElementaryStreamTypes.AUDIO] = null;
elementaryStreams[ElementaryStreamTypes.VIDEO] = null;
elementaryStreams[ElementaryStreamTypes.AUDIOVIDEO] = null;
}
}
export interface MediaFragment extends Fragment {
sn: number;
ref: MediaFragmentRef;
}
export type MediaFragmentRef = {
base: Base;
start: number;
duration: number;
sn: number;
programDateTime: number | null;
};
export function isMediaFragment(frag: Fragment): frag is MediaFragment {
return frag.sn !== 'initSegment';
}
/**
* Object representing parsed data from an HLS Segment. Found in {@link hls.js#LevelDetails.fragments}.
*/
export class Fragment extends BaseSegment {
private _decryptdata: LevelKey | null = null;
private _programDateTime: number | null = null;
private _ref: MediaFragmentRef | null = null;
// Approximate bit rate of the fragment expressed in bits per second (bps) as indicated by the last EXT-X-BITRATE (kbps) tag
private _bitrate?: number;
public rawProgramDateTime: string | null = null;
public tagList: Array<string[]> = [];
// EXTINF has to be present for a m3u8 to be considered valid
public duration: number = 0;
// sn notates the sequence number for a segment, and if set to a string can be 'initSegment'
public sn: number | 'initSegment' = 0;
// levelkeys are the EXT-X-KEY tags that apply to this segment for decryption
// core difference from the private field _decryptdata is the lack of the initialized IV
// _decryptdata will set the IV for this segment based on the segment number in the fragment
public levelkeys?: { [key: string]: LevelKey | undefined };
// A string representing the fragment type
public readonly type: PlaylistLevelType;
// A reference to the loader. Set while the fragment is loading, and removed afterwards. Used to abort fragment loading
public loader: Loader<FragmentLoaderContext> | null = null;
// A reference to the key loader. Set while the key is loading, and removed afterwards. Used to abort key loading
public keyLoader: Loader<KeyLoaderContext> | null = null;
// The level/track index to which the fragment belongs
public level: number = -1;
// The continuity counter of the fragment
public cc: number = 0;
// The starting Presentation Time Stamp (PTS) of the fragment. Set after transmux complete.
public startPTS?: number;
// The ending Presentation Time Stamp (PTS) of the fragment. Set after transmux complete.
public endPTS?: number;
// The starting Decode Time Stamp (DTS) of the fragment. Set after transmux complete.
public startDTS?: number;
// The ending Decode Time Stamp (DTS) of the fragment. Set after transmux complete.
public endDTS?: number;
// The start time of the fragment, as listed in the manifest. Updated after transmux complete.
public start: number = 0;
// The offset time (seconds) of the fragment from the start of the Playlist
public playlistOffset: number = 0;
// Set by `updateFragPTSDTS` in level-helper
public deltaPTS?: number;
// The maximum starting Presentation Time Stamp (audio/video PTS) of the fragment. Set after transmux complete.
public maxStartPTS?: number;
// The minimum ending Presentation Time Stamp (audio/video PTS) of the fragment. Set after transmux complete.
public minEndPTS?: number;
// Init Segment bytes (unset for media segments)
public data?: Uint8Array;
// A flag indicating whether the segment was downloaded in order to test bitrate, and was not buffered
public bitrateTest: boolean = false;
// #EXTINF segment title
public title: string | null = null;
// The Media Initialization Section for this segment
public initSegment: Fragment | null = null;
// Fragment is the last fragment in the media playlist
public endList?: boolean;
// Fragment is marked by an EXT-X-GAP tag indicating that it does not contain media data and should not be loaded
public gap?: boolean;
// Deprecated
public urlId: number = 0;
constructor(type: PlaylistLevelType, base: Base | string) {
super(base);
this.type = type;
}
get byteLength(): number | null {
if (this.hasStats) {
const total = this.stats.total;
if (total) {
return total;
}
}
if (this.byteRange.length) {
const start = this.byteRange[0];
const end = this.byteRange[1];
if (Number.isFinite(start) && Number.isFinite(end)) {
return (end as number) - (start as number);
}
}
return null;
}
get bitrate(): number | null {
if (this.byteLength) {
return (this.byteLength * 8) / this.duration;
}
if (this._bitrate) {
return this._bitrate;
}
return null;
}
set bitrate(value: number) {
this._bitrate = value;
}
get decryptdata(): LevelKey | null {
const { levelkeys } = this;
if (!levelkeys || levelkeys.NONE) {
return null;
}
if (levelkeys.identity) {
if (!this._decryptdata) {
this._decryptdata = levelkeys.identity.getDecryptData(this.sn);
}
} else if (!this._decryptdata?.keyId) {
const keyFormats = Object.keys(levelkeys);
if (keyFormats.length === 1) {
const levelKey = (this._decryptdata = levelkeys[keyFormats[0]] || null);
if (levelKey) {
this._decryptdata = levelKey.getDecryptData(this.sn, levelkeys);
}
} else {
// Multiple keys. key-loader to call Fragment.setKeyFormat based on selected key-system.
}
}
return this._decryptdata;
}
get end(): number {
return this.start + this.duration;
}
get endProgramDateTime() {
if (this.programDateTime === null) {
return null;
}
const duration = !Number.isFinite(this.duration) ? 0 : this.duration;
return this.programDateTime + duration * 1000;
}
get encrypted() {
// At the m3u8-parser level we need to add support for manifest signalled keyformats
// when we want the fragment to start reporting that it is encrypted.
// Currently, keyFormat will only be set for identity keys
if (this._decryptdata?.encrypted) {
return true;
} else if (this.levelkeys) {
const keyFormats = Object.keys(this.levelkeys);
const len = keyFormats.length;
if (len > 1 || (len === 1 && this.levelkeys[keyFormats[0]]?.encrypted)) {
return true;
}
}
return false;
}
get programDateTime(): number | null {
if (this._programDateTime === null && this.rawProgramDateTime) {
this.programDateTime = Date.parse(this.rawProgramDateTime);
}
return this._programDateTime;
}
set programDateTime(value: number | null) {
if (!Number.isFinite(value)) {
this._programDateTime = this.rawProgramDateTime = null;
return;
}
this._programDateTime = value;
}
get ref(): MediaFragmentRef | null {
if (!isMediaFragment(this)) {
return null;
}
if (!this._ref) {
this._ref = {
base: this.base,
start: this.start,
duration: this.duration,
sn: this.sn,
programDateTime: this.programDateTime,
};
}
return this._ref;
}
addStart(value: number) {
this.setStart(this.start + value);
}
setStart(value: number) {
this.start = value;
if (this._ref) {
this._ref.start = value;
}
}
setDuration(value: number) {
this.duration = value;
if (this._ref) {
this._ref.duration = value;
}
}
setKeyFormat(keyFormat: KeySystemFormats) {
const levelkeys = this.levelkeys;
if (levelkeys) {
const key = levelkeys[keyFormat];
if (key && !this._decryptdata?.keyId) {
this._decryptdata = key.getDecryptData(this.sn, levelkeys);
}
}
}
abortRequests(): void {
this.loader?.abort();
this.keyLoader?.abort();
}
setElementaryStreamInfo(
type: ElementaryStreamTypes,
startPTS: number,
endPTS: number,
startDTS: number,
endDTS: number,
partial: boolean = false,
) {
const { elementaryStreams } = this;
const info = elementaryStreams[type];
if (!info) {
elementaryStreams[type] = {
startPTS,
endPTS,
startDTS,
endDTS,
partial,
};
return;
}
info.startPTS = Math.min(info.startPTS, startPTS);
info.endPTS = Math.max(info.endPTS, endPTS);
info.startDTS = Math.min(info.startDTS, startDTS);
info.endDTS = Math.max(info.endDTS, endDTS);
}
}
/**
* Object representing parsed data from an HLS Partial Segment. Found in {@link hls.js#LevelDetails.partList}.
*/
export class Part extends BaseSegment {
public readonly fragOffset: number = 0;
public readonly duration: number = 0;
public readonly gap: boolean = false;
public readonly independent: boolean = false;
public readonly relurl: string;
public readonly fragment: MediaFragment;
public readonly index: number;
constructor(
partAttrs: AttrList,
frag: MediaFragment,
base: Base | string,
index: number,
previous?: Part,
) {
super(base);
this.duration = partAttrs.decimalFloatingPoint('DURATION');
this.gap = partAttrs.bool('GAP');
this.independent = partAttrs.bool('INDEPENDENT');
this.relurl = partAttrs.enumeratedString('URI') as string;
this.fragment = frag;
this.index = index;
const byteRange = partAttrs.enumeratedString('BYTERANGE');
if (byteRange) {
this.setByteRange(byteRange, previous);
}
if (previous) {
this.fragOffset = previous.fragOffset + previous.duration;
}
}
get start(): number {
return this.fragment.start + this.fragOffset;
}
get end(): number {
return this.start + this.duration;
}
get loaded(): boolean {
const { elementaryStreams } = this;
return !!(
elementaryStreams.audio ||
elementaryStreams.video ||
elementaryStreams.audiovideo
);
}
}
function getOwnPropertyDescriptorFromPrototypeChain(
object: Object | undefined,
property: string,
) {
const prototype = Object.getPrototypeOf(object);
if (prototype) {
const propertyDescriptor = Object.getOwnPropertyDescriptor(
prototype,
property,
);
if (propertyDescriptor) {
return propertyDescriptor;
}
return getOwnPropertyDescriptorFromPrototypeChain(prototype, property);
}
}
function makeEnumerable(object: Object, property: string) {
const d = getOwnPropertyDescriptorFromPrototypeChain(object, property);
if (d) {
d.enumerable = true;
Object.defineProperty(object, property, d);
}
}
+161
View File
@@ -0,0 +1,161 @@
import {
type AssetListJSON,
getInterstitialUrl,
type InterstitialEventWithAssetList,
} from './interstitial-event';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import type { InterstitialEvent } from './interstitial-event';
import type Hls from '../hls';
import type { ErrorData } from '../types/events';
import type {
Loader,
LoaderCallbacks,
LoaderConfiguration,
LoaderContext,
LoaderResponse,
LoaderStats,
} from '../types/loader';
export class AssetListLoader {
private hls: Hls;
constructor(hls: Hls) {
this.hls = hls;
}
destroy() {
// @ts-ignore
this.hls = null;
}
loadAssetList(
interstitial: InterstitialEventWithAssetList,
hlsStartOffset: number | undefined,
): Loader<LoaderContext> | undefined {
const assetListUrl = interstitial.assetListUrl;
let url: URL;
try {
url = getInterstitialUrl(
assetListUrl,
this.hls.sessionId,
interstitial.baseUrl,
);
} catch (error) {
const errorData = this.assignAssetListError(
interstitial,
ErrorDetails.ASSET_LIST_LOAD_ERROR,
error,
assetListUrl,
);
this.hls.trigger(Events.ERROR, errorData);
return;
}
if (hlsStartOffset && url.protocol !== 'data:') {
url.searchParams.set('_HLS_start_offset', '' + hlsStartOffset);
}
const config = this.hls.config;
const Loader = config.loader;
const loader = new Loader(config) as Loader<LoaderContext>;
const context: LoaderContext = {
responseType: 'json',
url: url.href,
};
const loadPolicy = config.interstitialAssetListLoadPolicy.default;
const loaderConfig: LoaderConfiguration = {
loadPolicy,
timeout: loadPolicy.maxLoadTimeMs,
maxRetry: 0,
retryDelay: 0,
maxRetryDelay: 0,
};
const callbacks: LoaderCallbacks<LoaderContext> = {
onSuccess: (
response: LoaderResponse,
stats: LoaderStats,
context: LoaderContext,
networkDetails: any,
) => {
const assetListResponse = response.data as AssetListJSON;
const assets = assetListResponse?.ASSETS;
if (!Array.isArray(assets)) {
const errorData = this.assignAssetListError(
interstitial,
ErrorDetails.ASSET_LIST_PARSING_ERROR,
new Error(`Invalid interstitial asset list`),
context.url,
stats,
networkDetails,
);
this.hls.trigger(Events.ERROR, errorData);
return;
}
interstitial.assetListResponse = assetListResponse;
this.hls.trigger(Events.ASSET_LIST_LOADED, {
event: interstitial,
assetListResponse,
networkDetails,
});
},
onError: (
error: { code: number; text: string },
context: LoaderContext,
networkDetails: any,
stats: LoaderStats,
) => {
const errorData = this.assignAssetListError(
interstitial,
ErrorDetails.ASSET_LIST_LOAD_ERROR,
new Error(
`Error loading X-ASSET-LIST: HTTP status ${error.code} ${error.text} (${context.url})`,
),
context.url,
stats,
networkDetails,
);
this.hls.trigger(Events.ERROR, errorData);
},
onTimeout: (
stats: LoaderStats,
context: LoaderContext,
networkDetails: any,
) => {
const errorData = this.assignAssetListError(
interstitial,
ErrorDetails.ASSET_LIST_LOAD_TIMEOUT,
new Error(`Timeout loading X-ASSET-LIST (${context.url})`),
context.url,
stats,
networkDetails,
);
this.hls.trigger(Events.ERROR, errorData);
},
};
loader.load(context, loaderConfig, callbacks);
this.hls.trigger(Events.ASSET_LIST_LOADING, {
event: interstitial,
});
return loader;
}
assignAssetListError(
interstitial: InterstitialEvent,
details: ErrorDetails,
error: Error,
url: string,
stats?: LoaderStats,
networkDetails?: any,
): ErrorData {
interstitial.error = error;
return {
type: ErrorTypes.NETWORK_ERROR,
details,
fatal: false,
interstitial,
url,
error,
networkDetails,
stats,
};
}
}
+337
View File
@@ -0,0 +1,337 @@
import { hash } from '../utils/hash';
import type { DateRange, DateRangeCue } from './date-range';
import type { MediaFragmentRef } from './fragment';
import type { Loader, LoaderContext } from '../types/loader';
export const ALIGNED_END_THRESHOLD_SECONDS = 0.025;
export type PlaybackRestrictions = {
skip: boolean;
jump: boolean;
};
export type SnapOptions = {
out: boolean;
in: boolean;
};
export enum TimelineOccupancy {
Point,
Range,
}
export type AssetListJSON = {
ASSETS: Array<{ URI: string; DURATION: string }>;
};
export interface InterstitialEventWithAssetList extends InterstitialEvent {
assetListUrl: string;
}
export type BaseData = {
url: string;
};
export type InterstitialId = string;
export type InterstitialAssetId = string;
export type InterstitialAssetItem = {
parentIdentifier: InterstitialId;
identifier: InterstitialAssetId;
duration: number | null;
startOffset: number; // asset start offset from start of interstitial event
timelineStart: number; // asset start on media element timeline
uri: string;
error?: Error;
};
export function generateAssetIdentifier(
interstitial: InterstitialEvent,
uri: string,
assetListIndex: number,
): string {
return `${interstitial.identifier}-${assetListIndex + 1}-${hash(uri)}`;
}
export class InterstitialEvent {
private base: BaseData;
private _duration: number | null = null;
private _timelineStart: number | null = null;
private appendInPlaceDisabled?: boolean;
public appendInPlaceStarted?: boolean;
public dateRange: DateRange;
public hasPlayed: boolean = false;
public cumulativeDuration: number = 0;
public resumeOffset: number = NaN;
public playoutLimit: number = NaN;
public restrictions: PlaybackRestrictions = {
skip: false,
jump: false,
};
public snapOptions: SnapOptions = {
out: false,
in: false,
};
public assetList: InterstitialAssetItem[] = [];
public assetListLoader?: Loader<LoaderContext>;
public assetListResponse: AssetListJSON | null = null;
public resumeAnchor?: MediaFragmentRef;
public error?: Error;
public resetOnResume?: boolean;
constructor(dateRange: DateRange, base: BaseData) {
this.base = base;
this.dateRange = dateRange;
this.setDateRange(dateRange);
}
public setDateRange(dateRange: DateRange) {
this.dateRange = dateRange;
this.resumeOffset = dateRange.attr.optionalFloat(
'X-RESUME-OFFSET',
this.resumeOffset,
);
this.playoutLimit = dateRange.attr.optionalFloat(
'X-PLAYOUT-LIMIT',
this.playoutLimit,
);
this.restrictions = dateRange.attr.enumeratedStringList(
'X-RESTRICT',
this.restrictions,
);
this.snapOptions = dateRange.attr.enumeratedStringList(
'X-SNAP',
this.snapOptions,
);
}
public reset() {
this.appendInPlaceStarted = false;
this.assetListLoader?.destroy();
this.assetListLoader = undefined;
if (!this.supplementsPrimary) {
this.assetListResponse = null;
this.assetList = [];
this._duration = null;
}
// `error?` is reset when seeking back over interstitial `startOffset`
// using `schedule.resetErrorsInRange(start, end)`.
}
public isAssetPastPlayoutLimit(assetIndex: number): boolean {
if (assetIndex > 0 && assetIndex >= this.assetList.length) {
return true;
}
const playoutLimit = this.playoutLimit;
if (assetIndex <= 0 || isNaN(playoutLimit)) {
return false;
}
if (playoutLimit === 0) {
return true;
}
const assetOffset = this.assetList[assetIndex]?.startOffset || 0;
return assetOffset > playoutLimit;
}
public findAssetIndex(asset: InterstitialAssetItem): number {
const index = this.assetList.indexOf(asset);
return index;
}
get identifier(): InterstitialId {
return this.dateRange.id;
}
get startDate(): Date {
return this.dateRange.startDate;
}
get startTime(): number {
// Primary media timeline start time
const startTime = this.dateRange.startTime;
if (this.snapOptions.out) {
const frag = this.dateRange.tagAnchor;
if (frag) {
return getSnapToFragmentTime(startTime, frag);
}
}
return startTime;
}
get startOffset(): number {
return this.cue.pre ? 0 : this.startTime;
}
get startIsAligned(): boolean {
if (this.startTime === 0 || this.snapOptions.out) {
return true;
}
const frag = this.dateRange.tagAnchor;
if (frag) {
const startTime = this.dateRange.startTime;
const snappedStart = getSnapToFragmentTime(startTime, frag);
return startTime - snappedStart < 0.1;
}
return false;
}
get resumptionOffset(): number {
const resumeOffset = this.resumeOffset;
const offset = Number.isFinite(resumeOffset) ? resumeOffset : this.duration;
return this.cumulativeDuration + offset;
}
get resumeTime(): number {
// Primary media timeline resumption time
const resumeTime = this.startOffset + this.resumptionOffset;
if (this.snapOptions.in) {
const frag = this.resumeAnchor;
if (frag) {
return getSnapToFragmentTime(resumeTime, frag);
}
}
return resumeTime;
}
get appendInPlace(): boolean {
if (this.appendInPlaceStarted) {
return true;
}
if (this.appendInPlaceDisabled) {
return false;
}
if (
!this.cue.once &&
!this.cue.pre && // preroll starts at startPosition before startPosition is known (live)
this.startIsAligned &&
((isNaN(this.playoutLimit) && isNaN(this.resumeOffset)) ||
(this.resumeOffset &&
this.duration &&
Math.abs(this.resumeOffset - this.duration) <
ALIGNED_END_THRESHOLD_SECONDS))
) {
return true;
}
return false;
}
set appendInPlace(value: boolean) {
if (this.appendInPlaceStarted) {
this.resetOnResume = !value;
return;
}
this.appendInPlaceDisabled = !value;
}
// Extended timeline start time
get timelineStart(): number {
if (this._timelineStart !== null) {
return this._timelineStart;
}
return this.startTime;
}
set timelineStart(value: number) {
this._timelineStart = value;
}
get duration(): number {
const playoutLimit = this.playoutLimit;
let duration: number;
if (this._duration !== null) {
duration = this._duration;
} else if (this.dateRange.duration) {
duration = this.dateRange.duration;
} else {
duration = this.dateRange.plannedDuration || 0;
}
if (!isNaN(playoutLimit) && playoutLimit < duration) {
duration = playoutLimit;
}
return duration;
}
set duration(value: number) {
this._duration = value;
}
get cue(): DateRangeCue {
return this.dateRange.cue;
}
get timelineOccupancy() {
if (this.dateRange.attr['X-TIMELINE-OCCUPIES'] === 'RANGE') {
return TimelineOccupancy.Range;
}
return TimelineOccupancy.Point;
}
get supplementsPrimary(): boolean {
return this.dateRange.attr['X-TIMELINE-STYLE'] === 'PRIMARY';
}
get contentMayVary(): boolean {
return this.dateRange.attr['X-CONTENT-MAY-VARY'] !== 'NO';
}
get assetUrl(): string | undefined {
return this.dateRange.attr['X-ASSET-URI'];
}
get assetListUrl(): string | undefined {
return this.dateRange.attr['X-ASSET-LIST'];
}
get baseUrl(): string {
return this.base.url;
}
get assetListLoaded(): boolean {
return this.assetList.length > 0 || this.assetListResponse !== null;
}
toString(): string {
return eventToString(this);
}
}
function getSnapToFragmentTime(time: number, frag: MediaFragmentRef) {
return time - frag.start < frag.duration / 2 &&
!(
Math.abs(time - (frag.start + frag.duration)) <
ALIGNED_END_THRESHOLD_SECONDS
)
? frag.start
: frag.start + frag.duration;
}
export function getInterstitialUrl(
uri: string,
sessionId: string,
baseUrl?: string,
): URL | never {
const url = new self.URL(uri, baseUrl);
if (url.protocol !== 'data:') {
url.searchParams.set('_HLS_primary_id', sessionId);
}
return url;
}
export function getNextAssetIndex(
interstitial: InterstitialEvent,
assetListIndex: number,
): number {
while (interstitial.assetList[++assetListIndex]?.error) {
/* no-op */
}
return assetListIndex;
}
function eventToString(interstitial: InterstitialEvent): string {
return `["${interstitial.identifier}" ${interstitial.cue.pre ? '<pre>' : interstitial.cue.post ? '<post>' : ''}${interstitial.timelineStart.toFixed(2)}-${interstitial.resumeTime.toFixed(2)}]`;
}
export function eventAssetToString(asset: InterstitialAssetItem): string {
const start = asset.timelineStart;
const duration = asset.duration || 0;
return `["${asset.identifier}" ${start.toFixed(2)}-${(start + duration).toFixed(2)}]`;
}
+438
View File
@@ -0,0 +1,438 @@
import { LoadError } from './fragment-loader';
import { LevelKey } from './level-key';
import { ErrorDetails, ErrorTypes } from '../errors';
import { type Fragment, isMediaFragment } from '../loader/fragment';
import { arrayToHex } from '../utils/hex';
import { Logger } from '../utils/logger';
import {
getKeySystemsForConfig,
keySystemFormatToKeySystemDomain,
} from '../utils/mediakeys-helper';
import { KeySystemFormats } from '../utils/mediakeys-helper';
import { parseKeyIdsFromTenc } from '../utils/mp4-tools';
import type { HlsConfig } from '../config';
import type EMEController from '../controller/eme-controller';
import type {
EMEKeyError,
MediaKeySessionContext,
} from '../controller/eme-controller';
import type { ComponentAPI } from '../types/component-api';
import type { KeyLoadedData } from '../types/events';
import type {
KeyLoaderContext,
Loader,
LoaderCallbacks,
LoaderConfiguration,
LoaderResponse,
LoaderStats,
PlaylistLevelType,
} from '../types/loader';
import type { ILogger } from '../utils/logger';
export interface KeyLoaderInfo {
decryptdata: LevelKey;
keyLoadPromise: Promise<KeyLoadedData> | null;
loader: Loader<KeyLoaderContext> | null;
mediaKeySessionContext: MediaKeySessionContext | null;
}
export default class KeyLoader extends Logger implements ComponentAPI {
private readonly config: HlsConfig;
private keyIdToKeyInfo: { [keyId: string]: KeyLoaderInfo | undefined } = {};
public emeController: EMEController | null = null;
constructor(config: HlsConfig, logger: ILogger) {
super('key-loader', logger);
this.config = config;
}
abort(type?: PlaylistLevelType) {
for (const id in this.keyIdToKeyInfo) {
const loader = this.keyIdToKeyInfo[id]!.loader;
if (loader) {
if (type && type !== loader.context?.frag.type) {
return;
}
loader.abort();
}
}
}
detach() {
for (const id in this.keyIdToKeyInfo) {
const keyInfo = this.keyIdToKeyInfo[id]!;
// Remove cached EME keys on detach
if (
keyInfo.mediaKeySessionContext ||
keyInfo.decryptdata.isCommonEncryption
) {
delete this.keyIdToKeyInfo[id];
}
}
}
destroy() {
this.detach();
for (const id in this.keyIdToKeyInfo) {
const loader = this.keyIdToKeyInfo[id]!.loader;
if (loader) {
loader.destroy();
}
}
this.keyIdToKeyInfo = {};
}
createKeyLoadError(
frag: Fragment,
details: ErrorDetails = ErrorDetails.KEY_LOAD_ERROR,
error: Error,
networkDetails?: any,
response?: { url: string; data: undefined; code: number; text: string },
): LoadError {
return new LoadError({
type: ErrorTypes.NETWORK_ERROR,
details,
fatal: false,
frag,
response,
error,
networkDetails,
});
}
loadClear(
loadingFrag: Fragment,
encryptedFragments: Fragment[],
startFragRequested: boolean,
): null | Promise<void> {
if (
__USE_EME_DRM__ &&
this.emeController &&
this.config.emeEnabled &&
!this.emeController.getSelectedKeySystemFormats().length
) {
// Access key-system with nearest key on start (loading frag is unencrypted)
if (encryptedFragments.length) {
for (let i = 0, l = encryptedFragments.length; i < l; i++) {
const frag = encryptedFragments[i];
// Loading at or before segment with EXT-X-KEY, or first frag loading and last EXT-X-KEY
if (
(loadingFrag.cc <= frag.cc &&
(!isMediaFragment(loadingFrag) ||
!isMediaFragment(frag) ||
loadingFrag.sn < frag.sn)) ||
(!startFragRequested && i == l - 1)
) {
return this.emeController
.selectKeySystemFormat(frag)
.then((keySystemFormat) => {
if (!this.emeController) {
return;
}
frag.setKeyFormat(keySystemFormat);
const keySystem =
keySystemFormatToKeySystemDomain(keySystemFormat);
if (keySystem) {
return this.emeController.getKeySystemAccess([keySystem]);
}
});
}
}
}
if (this.config.requireKeySystemAccessOnStart) {
const keySystemsInConfig = getKeySystemsForConfig(this.config);
if (keySystemsInConfig.length) {
return this.emeController.getKeySystemAccess(keySystemsInConfig);
}
}
}
return null;
}
load(frag: Fragment): Promise<KeyLoadedData> {
if (
!frag.decryptdata &&
frag.encrypted &&
this.emeController &&
this.config.emeEnabled
) {
// Multiple keys, but none selected, resolve in eme-controller
return this.emeController
.selectKeySystemFormat(frag)
.then((keySystemFormat) => {
return this.loadInternal(frag, keySystemFormat);
});
}
return this.loadInternal(frag);
}
loadInternal(
frag: Fragment,
keySystemFormat?: KeySystemFormats,
): Promise<KeyLoadedData> {
if (__USE_EME_DRM__ && keySystemFormat) {
frag.setKeyFormat(keySystemFormat);
}
const decryptdata = frag.decryptdata;
if (!decryptdata) {
const error = new Error(
keySystemFormat
? `Expected frag.decryptdata to be defined after setting format ${keySystemFormat}`
: `Missing decryption data on fragment in onKeyLoading (emeEnabled with controller: ${this.emeController && this.config.emeEnabled})`,
);
return Promise.reject(
this.createKeyLoadError(frag, ErrorDetails.KEY_LOAD_ERROR, error),
);
}
const uri = decryptdata.uri;
if (!uri) {
return Promise.reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
new Error(`Invalid key URI: "${uri}"`),
),
);
}
const id = getKeyId(decryptdata);
let keyInfo = this.keyIdToKeyInfo[id];
if (keyInfo?.decryptdata.key) {
decryptdata.key = keyInfo.decryptdata.key;
return Promise.resolve({ frag, keyInfo });
}
// Return key load promise once it has a mediakey session with an usable key status
if (this.emeController && keyInfo?.keyLoadPromise) {
const keyStatus = this.emeController.getKeyStatus(keyInfo.decryptdata);
switch (keyStatus) {
case 'usable':
case 'usable-in-future':
return keyInfo.keyLoadPromise.then((keyLoadedData) => {
// Return the correct fragment with updated decryptdata key and loaded keyInfo
const { keyInfo } = keyLoadedData;
decryptdata.key = keyInfo.decryptdata.key;
return { frag, keyInfo };
});
}
// If we have a key session and status and it is not pending or usable, continue
// This will go back to the eme-controller for expired keys to get a new keyLoadPromise
}
// Load the key or return the loading promise
this.log(
`${this.keyIdToKeyInfo[id] ? 'Rel' : 'L'}oading${decryptdata.keyId ? ' keyId: ' + arrayToHex(decryptdata.keyId) : ''} URI: ${decryptdata.uri} from ${frag.type} ${frag.level}`,
);
keyInfo = this.keyIdToKeyInfo[id] = {
decryptdata,
keyLoadPromise: null,
loader: null,
mediaKeySessionContext: null,
};
switch (decryptdata.method) {
case 'SAMPLE-AES':
case 'SAMPLE-AES-CENC':
case 'SAMPLE-AES-CTR':
if (decryptdata.keyFormat === 'identity') {
// loadKeyHTTP handles http(s) and data URLs
return this.loadKeyHTTP(keyInfo, frag);
}
return this.loadKeyEME(keyInfo, frag);
case 'AES-128':
case 'AES-256':
case 'AES-256-CTR':
return this.loadKeyHTTP(keyInfo, frag);
default:
return Promise.reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
new Error(
`Key supplied with unsupported METHOD: "${decryptdata.method}"`,
),
),
);
}
}
loadKeyEME(keyInfo: KeyLoaderInfo, frag: Fragment): Promise<KeyLoadedData> {
const keyLoadedData: KeyLoadedData = { frag, keyInfo };
if (this.emeController && this.config.emeEnabled) {
if (!keyInfo.decryptdata.keyId && frag.initSegment?.data) {
const keyIds = parseKeyIdsFromTenc(
frag.initSegment.data as Uint8Array<ArrayBuffer>,
);
if (keyIds.length) {
let keyId = keyIds[0];
if (keyId.some((b) => b !== 0)) {
this.log(`Using keyId found in init segment ${arrayToHex(keyId)}`);
LevelKey.setKeyIdForUri(keyInfo.decryptdata.uri, keyId);
} else {
keyId = LevelKey.addKeyIdForUri(keyInfo.decryptdata.uri);
this.log(`Generating keyId to patch media ${arrayToHex(keyId)}`);
}
keyInfo.decryptdata.keyId = keyId;
}
}
if (!keyInfo.decryptdata.keyId && !isMediaFragment(frag)) {
// Resolve so that unencrypted init segment is loaded
// key id is extracted from tenc box when processing key for next segment above
return Promise.resolve(keyLoadedData);
}
const keySessionContextPromise =
this.emeController.loadKey(keyLoadedData);
return (keyInfo.keyLoadPromise = keySessionContextPromise.then(
(keySessionContext) => {
keyInfo.mediaKeySessionContext = keySessionContext;
return keyLoadedData;
},
)).catch((error: EMEKeyError | Error) => {
// Remove promise for license renewal or retry
keyInfo.keyLoadPromise = null;
if ('data' in error) {
error.data.frag = frag;
}
throw error;
});
}
return Promise.resolve(keyLoadedData);
}
loadKeyHTTP(keyInfo: KeyLoaderInfo, frag: Fragment): Promise<KeyLoadedData> {
const config = this.config;
const Loader = config.loader;
const keyLoader = new Loader(config) as Loader<KeyLoaderContext>;
frag.keyLoader = keyInfo.loader = keyLoader;
return (keyInfo.keyLoadPromise = new Promise((resolve, reject) => {
const loaderContext: KeyLoaderContext = {
keyInfo,
frag,
responseType: 'arraybuffer',
url: keyInfo.decryptdata.uri,
};
// maxRetry is 0 so that instead of retrying the same key on the same variant multiple times,
// key-loader will trigger an error and rely on stream-controller to handle retry logic.
// this will also align retry logic with fragment-loader
const loadPolicy = config.keyLoadPolicy.default;
const loaderConfig: LoaderConfiguration = {
loadPolicy,
timeout: loadPolicy.maxLoadTimeMs,
maxRetry: 0,
retryDelay: 0,
maxRetryDelay: 0,
};
const loaderCallbacks: LoaderCallbacks<KeyLoaderContext> = {
onSuccess: (
response: LoaderResponse,
stats: LoaderStats,
context: KeyLoaderContext,
networkDetails: any,
) => {
const { frag, keyInfo } = context;
const id = getKeyId(keyInfo.decryptdata);
if (!frag.decryptdata || keyInfo !== this.keyIdToKeyInfo[id]) {
return reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
new Error('after key load, decryptdata unset or changed'),
networkDetails,
),
);
}
keyInfo.decryptdata.key = frag.decryptdata.key = new Uint8Array(
response.data as ArrayBuffer,
);
// detach fragment key loader on load success
frag.keyLoader = null;
keyInfo.loader = null;
resolve({ frag, keyInfo });
},
onError: (
response: { code: number; text: string },
context: KeyLoaderContext,
networkDetails: any,
stats: LoaderStats,
) => {
this.resetLoader(context);
reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_ERROR,
new Error(
`HTTP Error ${response.code} loading key ${response.text}`,
),
networkDetails,
{ url: loaderContext.url, data: undefined, ...response },
),
);
},
onTimeout: (
stats: LoaderStats,
context: KeyLoaderContext,
networkDetails: any,
) => {
this.resetLoader(context);
reject(
this.createKeyLoadError(
frag,
ErrorDetails.KEY_LOAD_TIMEOUT,
new Error('key loading timed out'),
networkDetails,
),
);
},
onAbort: (
stats: LoaderStats,
context: KeyLoaderContext,
networkDetails: any,
) => {
this.resetLoader(context);
reject(
this.createKeyLoadError(
frag,
ErrorDetails.INTERNAL_ABORTED,
new Error('key loading aborted'),
networkDetails,
),
);
},
};
keyLoader.load(loaderContext, loaderConfig, loaderCallbacks);
}));
}
private resetLoader(context: KeyLoaderContext) {
const { frag, keyInfo, url: uri } = context;
const loader = keyInfo.loader;
if (frag.keyLoader === loader) {
frag.keyLoader = null;
keyInfo.loader = null;
}
const id = getKeyId(keyInfo.decryptdata) || uri;
delete this.keyIdToKeyInfo[id];
if (loader) {
loader.destroy();
}
}
}
function getKeyId(decryptdata: LevelKey) {
if (__USE_EME_DRM__ && decryptdata.keyFormat !== KeySystemFormats.FAIRPLAY) {
const keyId = decryptdata.keyId;
if (keyId) {
return arrayToHex(keyId);
}
}
return decryptdata.uri;
}
+203
View File
@@ -0,0 +1,203 @@
import type { DateRange } from './date-range';
import type { Fragment, MediaFragment, Part } from './fragment';
import type { LevelKey } from './level-key';
import type { VariableMap } from '../types/level';
import type { AttrList } from '../utils/attr-list';
import type { KeySystemFormats } from '../utils/mediakeys-helper';
const DEFAULT_TARGET_DURATION = 10;
/**
* Object representing parsed data from an HLS Media Playlist. Found in {@link hls.js#Level.details}.
*/
export class LevelDetails {
public PTSKnown: boolean = false;
public alignedSliding: boolean = false;
public averagetargetduration?: number;
public endCC: number = 0;
public endSN: number = 0;
public fragments: MediaFragment[];
public fragmentHint?: MediaFragment;
public partList: Part[] | null = null;
public dateRanges: Record<string, DateRange | undefined>;
public dateRangeTagCount: number = 0;
public live: boolean = true;
public requestScheduled: number = -1;
public ageHeader: number = 0;
public advancedDateTime?: number;
public updated: boolean = true;
public advanced: boolean = true;
public misses: number = 0;
public startCC: number = 0;
public startSN: number = 0;
public startTimeOffset: number | null = null;
public targetduration: number = 0;
public totalduration: number = 0;
public type: string | null = null;
public url: string;
public m3u8: string = '';
public version: number | null = null;
public canBlockReload: boolean = false;
public canSkipUntil: number = 0;
public canSkipDateRanges: boolean = false;
public skippedSegments: number = 0;
public recentlyRemovedDateranges?: string[];
public partHoldBack: number = 0;
public holdBack: number = 0;
public partTarget: number = 0;
public preloadHint?: AttrList;
public renditionReports?: AttrList[];
public tuneInGoal: number = 0;
public deltaUpdateFailed?: boolean;
public driftStartTime: number = 0;
public driftEndTime: number = 0;
public driftStart: number = 0;
public driftEnd: number = 0;
public encryptedFragments: Fragment[];
public playlistParsingError: Error | null = null;
public variableList: VariableMap | null = null;
public hasVariableRefs = false;
public appliedTimelineOffset?: number;
constructor(baseUrl: string) {
this.fragments = [];
this.encryptedFragments = [];
this.dateRanges = {};
this.url = baseUrl;
}
reloaded(previous: LevelDetails | undefined) {
if (!previous) {
this.advanced = true;
this.updated = true;
return;
}
const partSnDiff = this.lastPartSn - previous.lastPartSn;
const partIndexDiff = this.lastPartIndex - previous.lastPartIndex;
this.updated =
this.endSN !== previous.endSN ||
!!partIndexDiff ||
!!partSnDiff ||
!this.live;
this.advanced =
this.endSN > previous.endSN ||
partSnDiff > 0 ||
(partSnDiff === 0 && partIndexDiff > 0);
if (this.updated || this.advanced) {
this.misses = Math.floor(previous.misses * 0.6);
} else {
this.misses = previous.misses + 1;
}
}
hasKey(levelKey: LevelKey): boolean {
return this.encryptedFragments.some((frag) => {
let decryptdata = frag.decryptdata;
if (!decryptdata) {
frag.setKeyFormat(levelKey.keyFormat as KeySystemFormats);
decryptdata = frag.decryptdata;
}
return !!decryptdata && levelKey.matches(decryptdata);
});
}
get hasProgramDateTime(): boolean {
if (this.fragments.length) {
return Number.isFinite(
this.fragments[this.fragments.length - 1].programDateTime,
);
}
return false;
}
get levelTargetDuration(): number {
return (
this.averagetargetduration ||
this.targetduration ||
DEFAULT_TARGET_DURATION
);
}
get drift(): number {
const runTime = this.driftEndTime - this.driftStartTime;
if (runTime > 0) {
const runDuration = this.driftEnd - this.driftStart;
return (runDuration * 1000) / runTime;
}
return 1;
}
get edge(): number {
return this.partEnd || this.fragmentEnd;
}
get partEnd(): number {
if (this.partList?.length) {
return this.partList[this.partList.length - 1].end;
}
return this.fragmentEnd;
}
get fragmentEnd(): number {
if (this.fragments.length) {
return this.fragments[this.fragments.length - 1].end;
}
return 0;
}
get fragmentStart(): number {
if (this.fragments.length) {
return this.fragments[0].start;
}
return 0;
}
get age(): number {
if (this.advancedDateTime) {
return Math.max(Date.now() - this.advancedDateTime, 0) / 1000;
}
return 0;
}
get lastPartIndex(): number {
if (this.partList?.length) {
return this.partList[this.partList.length - 1].index;
}
return -1;
}
get maxPartIndex(): number {
const partList = this.partList;
if (partList) {
const lastIndex = this.lastPartIndex;
if (lastIndex !== -1) {
for (let i = partList.length; i--; ) {
if (partList[i].index > lastIndex) {
return partList[i].index;
}
}
return lastIndex;
}
}
return 0;
}
get lastPartSn(): number {
if (this.partList?.length) {
return this.partList[this.partList.length - 1].fragment.sn;
}
return this.endSN;
}
get expired(): boolean {
if (this.live && this.age && this.misses < 3) {
const playlistWindowDuration = this.partEnd - this.fragmentStart;
return (
this.age >
Math.max(playlistWindowDuration, this.totalduration) +
this.levelTargetDuration
);
}
return false;
}
}
+259
View File
@@ -0,0 +1,259 @@
import { arrayValuesMatch, optionalArrayValuesMatch } from '../utils/arrays';
import { isFullSegmentEncryption } from '../utils/encryption-methods-util';
import { hexToArrayBuffer } from '../utils/hex';
import { convertDataUriToArrayBytes } from '../utils/keysystem-util';
import { logger } from '../utils/logger';
import { KeySystemFormats, parsePlayReadyWRM } from '../utils/mediakeys-helper';
import { mp4pssh, parseMultiPssh } from '../utils/mp4-tools';
let keyUriToKeyIdMap: { [uri: string]: Uint8Array<ArrayBuffer> } = {};
export interface DecryptData {
uri: string;
method: string;
keyFormat: string;
keyFormatVersions: number[];
iv: Uint8Array<ArrayBuffer> | null;
key: Uint8Array<ArrayBuffer> | null;
keyId: Uint8Array<ArrayBuffer> | null;
pssh: Uint8Array<ArrayBuffer> | null;
encrypted: boolean;
isCommonEncryption: boolean;
}
export class LevelKey implements DecryptData {
public readonly uri: string;
public readonly method: string;
public readonly keyFormat: string;
public readonly keyFormatVersions: number[];
public readonly encrypted: boolean;
public readonly isCommonEncryption: boolean;
public iv: Uint8Array<ArrayBuffer> | null = null;
public key: Uint8Array<ArrayBuffer> | null = null;
public keyId: Uint8Array<ArrayBuffer> | null = null;
public pssh: Uint8Array<ArrayBuffer> | null = null;
static clearKeyUriToKeyIdMap() {
keyUriToKeyIdMap = {};
}
static setKeyIdForUri(uri: string, keyId: Uint8Array<ArrayBuffer>) {
keyUriToKeyIdMap[uri] = keyId;
}
static addKeyIdForUri(uri: string): Uint8Array<ArrayBuffer> {
const val = Object.keys(keyUriToKeyIdMap).length % Number.MAX_SAFE_INTEGER;
const keyId = new Uint8Array(16);
const dv = new DataView(keyId.buffer, 12, 4); // Just set the last 4 bytes
dv.setUint32(0, val);
keyUriToKeyIdMap[uri] = keyId;
return keyId;
}
constructor(
method: string,
uri: string,
format: string,
formatversions: number[] = [1],
iv: Uint8Array<ArrayBuffer> | null = null,
keyId?: string,
) {
this.method = method;
this.uri = uri;
this.keyFormat = format;
this.keyFormatVersions = formatversions;
this.iv = iv;
this.encrypted = method ? method !== 'NONE' : false;
this.isCommonEncryption =
this.encrypted && !isFullSegmentEncryption(method);
if (keyId?.startsWith('0x')) {
this.keyId = new Uint8Array(hexToArrayBuffer(keyId));
}
}
public matches(key: LevelKey): boolean {
return (
key.uri === this.uri &&
key.method === this.method &&
key.encrypted === this.encrypted &&
key.keyFormat === this.keyFormat &&
arrayValuesMatch(key.keyFormatVersions, this.keyFormatVersions) &&
optionalArrayValuesMatch(key.iv, this.iv) &&
optionalArrayValuesMatch(key.keyId, this.keyId)
);
}
public isSupported(): boolean {
// If it's Segment encryption or No encryption, just select that key system
if (this.method) {
if (isFullSegmentEncryption(this.method) || this.method === 'NONE') {
return true;
}
if (this.keyFormat === 'identity') {
// Maintain support for clear SAMPLE-AES with MPEG-3 TS
return this.method === 'SAMPLE-AES';
} else if (__USE_EME_DRM__) {
switch (this.keyFormat) {
case KeySystemFormats.FAIRPLAY:
case KeySystemFormats.WIDEVINE:
case KeySystemFormats.PLAYREADY:
case KeySystemFormats.CLEARKEY:
return (
['SAMPLE-AES', 'SAMPLE-AES-CENC', 'SAMPLE-AES-CTR'].indexOf(
this.method,
) !== -1
);
}
}
}
return false;
}
public getDecryptData(
sn: number | 'initSegment',
levelKeys?: { [key: string]: LevelKey | undefined },
): LevelKey | null {
if (!this.encrypted || !this.uri) {
return null;
}
if (isFullSegmentEncryption(this.method)) {
let iv = this.iv;
if (!iv) {
if (typeof sn !== 'number') {
// We are fetching decryption data for a initialization segment
// If the segment was encrypted with AES-128/256
// It must have an IV defined. We cannot substitute the Segment Number in.
logger.warn(
`missing IV for initialization segment with method="${this.method}" - compliance issue`,
);
// Explicitly set sn to resulting value from implicit conversions 'initSegment' values for IV generation.
sn = 0;
}
iv = createInitializationVector(sn);
}
const decryptdata = new LevelKey(
this.method,
this.uri,
'identity',
this.keyFormatVersions,
iv,
);
return decryptdata;
}
if (!__USE_EME_DRM__) {
return this;
}
if (this.keyId) {
// Handle case where key id is changed in KEY_LOADING event handler #7542#issuecomment-3305203929
const assignedKeyId = keyUriToKeyIdMap[this.uri];
if (assignedKeyId && !arrayValuesMatch(this.keyId, assignedKeyId)) {
LevelKey.setKeyIdForUri(this.uri, this.keyId);
}
if (this.pssh) {
return this;
}
}
// Key bytes are signalled the KEYID attribute, typically only found on WideVine KEY tags
// Initialize keyId if possible
const keyBytes = convertDataUriToArrayBytes(this.uri);
if (keyBytes) {
switch (this.keyFormat) {
case KeySystemFormats.WIDEVINE:
// Setting `pssh` on this LevelKey/DecryptData allows HLS.js to generate a session using
// the playlist-key before the "encrypted" event. (Comment out to only use "encrypted" path.)
this.pssh = keyBytes;
// In case of Widevine, if KEYID is not in the playlist, assume only two fields in the pssh KEY tag URI.
if (!this.keyId) {
const results = parseMultiPssh(keyBytes.buffer);
if (results.length) {
const psshData = results[0];
this.keyId = psshData.kids?.length ? psshData.kids[0] : null;
}
}
if (!this.keyId) {
this.keyId = getKeyIdFromPlayReadyKey(levelKeys);
}
break;
case KeySystemFormats.PLAYREADY: {
const PlayReadyKeySystemUUID = new Uint8Array([
0x9a, 0x04, 0xf0, 0x79, 0x98, 0x40, 0x42, 0x86, 0xab, 0x92, 0xe6,
0x5b, 0xe0, 0x88, 0x5f, 0x95,
]);
// Setting `pssh` on this LevelKey/DecryptData allows HLS.js to generate a session using
// the playlist-key before the "encrypted" event. (Comment out to only use "encrypted" path.)
this.pssh = mp4pssh(PlayReadyKeySystemUUID, null, keyBytes);
this.keyId = parsePlayReadyWRM(keyBytes);
break;
}
default: {
let keydata = keyBytes.subarray(0, 16);
if (keydata.length !== 16) {
const padded = new Uint8Array(16);
padded.set(keydata, 16 - keydata.length);
keydata = padded;
}
this.keyId = keydata;
break;
}
}
}
// Default behavior: get keyId from other KEY tag or URI lookup
if (!this.keyId || this.keyId.byteLength !== 16) {
let keyId: Uint8Array<ArrayBuffer> | null | undefined;
keyId = getKeyIdFromWidevineKey(levelKeys);
if (!keyId) {
keyId = getKeyIdFromPlayReadyKey(levelKeys);
if (!keyId) {
keyId = keyUriToKeyIdMap[this.uri];
}
}
if (keyId) {
this.keyId = keyId;
LevelKey.setKeyIdForUri(this.uri, keyId);
}
}
return this;
}
}
function getKeyIdFromWidevineKey(
levelKeys: { [key: string]: LevelKey | undefined } | undefined,
) {
const widevineKey = levelKeys?.[KeySystemFormats.WIDEVINE];
if (widevineKey) {
return widevineKey.keyId;
}
return null;
}
function getKeyIdFromPlayReadyKey(
levelKeys: { [key: string]: LevelKey | undefined } | undefined,
) {
const playReadyKey = levelKeys?.[KeySystemFormats.PLAYREADY];
if (playReadyKey) {
const playReadyKeyBytes = convertDataUriToArrayBytes(playReadyKey.uri);
if (playReadyKeyBytes) {
return parsePlayReadyWRM(playReadyKeyBytes);
}
}
return null;
}
function createInitializationVector(segmentNumber: number) {
const uint8View = new Uint8Array(16);
for (let i = 12; i < 16; i++) {
uint8View[i] = (segmentNumber >> (8 * (15 - i))) & 0xff;
}
return uint8View;
}
+17
View File
@@ -0,0 +1,17 @@
import type {
HlsPerformanceTiming,
HlsProgressivePerformanceTiming,
LoaderStats,
} from '../types/loader';
export class LoadStats implements LoaderStats {
aborted: boolean = false;
loaded: number = 0;
retry: number = 0;
total: number = 0;
chunkCount: number = 0;
bwEstimate: number = 0;
loading: HlsProgressivePerformanceTiming = { start: 0, first: 0, end: 0 };
parsing: HlsPerformanceTiming = { start: 0, end: 0 };
buffering: HlsProgressivePerformanceTiming = { start: 0, first: 0, end: 0 };
}
+1001
View File
File diff suppressed because it is too large Load Diff
+827
View File
@@ -0,0 +1,827 @@
/**
* PlaylistLoader - delegate for media manifest/playlist loading tasks. Takes care of parsing media to internal data-models.
*
* Once loaded, dispatches events with parsed data-models of manifest/levels/audio/subtitle tracks.
*
* Uses loader(s) set in config to do actual internal loading of resource tasks.
*/
import M3U8Parser from './m3u8-parser';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { PlaylistContextType, PlaylistLevelType } from '../types/loader';
import { AttrList } from '../utils/attr-list';
import {
areCodecsMediaSourceSupported,
sampleEntryCodesISO,
} from '../utils/codecs';
import { computeReloadInterval } from '../utils/level-helper';
import type { LevelDetails } from './level-details';
import type { LoaderConfig, RetryConfig } from '../config';
import type Hls from '../hls';
import type { NetworkComponentAPI } from '../types/component-api';
import type {
ErrorData,
LevelLoadingData,
LevelsUpdatedData,
ManifestLoadingData,
TrackLoadingData,
} from '../types/events';
import type { Level, LevelParsed, VariableMap } from '../types/level';
import type {
Loader,
LoaderCallbacks,
LoaderConfiguration,
LoaderContext,
LoaderResponse,
LoaderStats,
PlaylistLoaderContext,
} from '../types/loader';
import type { MediaAttributes, MediaPlaylist } from '../types/media-playlist';
function mapContextToLevelType(
context: PlaylistLoaderContext,
): PlaylistLevelType {
const { type } = context;
switch (type) {
case PlaylistContextType.AUDIO_TRACK:
return PlaylistLevelType.AUDIO;
case PlaylistContextType.SUBTITLE_TRACK:
return PlaylistLevelType.SUBTITLE;
default:
return PlaylistLevelType.MAIN;
}
}
function getResponseUrl(
response: LoaderResponse,
context: PlaylistLoaderContext,
): string {
let url = response.url;
// responseURL not supported on some browsers (it is used to detect URL redirection)
// data-uri mode also not supported (but no need to detect redirection)
if (url === undefined || url.indexOf('data:') === 0) {
// fallback to initial URL
url = context.url;
}
return url;
}
class PlaylistLoader implements NetworkComponentAPI {
private readonly hls: Hls;
private readonly loaders: {
[key: string]: Loader<LoaderContext>;
} = Object.create(null);
private variableList: VariableMap | null = null;
public onManifestLoaded = this.checkAutostartLoad;
constructor(hls: Hls) {
this.hls = hls;
this.registerListeners();
}
public startLoad(startPosition: number): void {}
public stopLoad(): void {
this.destroyInternalLoaders();
}
private registerListeners() {
const { hls } = this;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.on(Events.AUDIO_TRACK_LOADING, this.onAudioTrackLoading, this);
hls.on(Events.SUBTITLE_TRACK_LOADING, this.onSubtitleTrackLoading, this);
hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
}
private unregisterListeners() {
const { hls } = this;
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.off(Events.AUDIO_TRACK_LOADING, this.onAudioTrackLoading, this);
hls.off(Events.SUBTITLE_TRACK_LOADING, this.onSubtitleTrackLoading, this);
hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
}
/**
* Returns defaults or configured loader-type overloads (pLoader and loader config params)
*/
private createInternalLoader(
context: PlaylistLoaderContext,
): Loader<LoaderContext> {
const config = this.hls.config;
const PLoader = config.pLoader;
const Loader = config.loader;
const InternalLoader = PLoader || Loader;
const loader = new InternalLoader(config) as Loader<PlaylistLoaderContext>;
this.loaders[context.type] = loader;
return loader;
}
private getInternalLoader(
context: PlaylistLoaderContext,
): Loader<LoaderContext> | undefined {
return this.loaders[context.type];
}
private resetInternalLoader(contextType: PlaylistContextType): void {
if (this.loaders[contextType]) {
delete this.loaders[contextType];
}
}
/**
* Call `destroy` on all internal loader instances mapped (one per context type)
*/
private destroyInternalLoaders(): void {
for (const contextType in this.loaders) {
const loader = this.loaders[contextType];
if (loader) {
loader.destroy();
}
this.resetInternalLoader(contextType as PlaylistContextType);
}
}
public destroy(): void {
this.variableList = null;
this.unregisterListeners();
this.destroyInternalLoaders();
}
private onManifestLoading(
event: Events.MANIFEST_LOADING,
data: ManifestLoadingData,
) {
const { url } = data;
this.variableList = null;
this.load({
id: null,
level: 0,
responseType: 'text',
type: PlaylistContextType.MANIFEST,
url,
deliveryDirectives: null,
levelOrTrack: null,
});
}
private onLevelLoading(event: Events.LEVEL_LOADING, data: LevelLoadingData) {
const { id, level, pathwayId, url, deliveryDirectives, levelInfo } = data;
this.load({
id,
level,
pathwayId,
responseType: 'text',
type: PlaylistContextType.LEVEL,
url,
deliveryDirectives,
levelOrTrack: levelInfo,
});
}
private onAudioTrackLoading(
event: Events.AUDIO_TRACK_LOADING,
data: TrackLoadingData,
) {
const { id, groupId, url, deliveryDirectives, track } = data;
this.load({
id,
groupId,
level: null,
responseType: 'text',
type: PlaylistContextType.AUDIO_TRACK,
url,
deliveryDirectives,
levelOrTrack: track,
});
}
private onSubtitleTrackLoading(
event: Events.SUBTITLE_TRACK_LOADING,
data: TrackLoadingData,
) {
const { id, groupId, url, deliveryDirectives, track } = data;
this.load({
id,
groupId,
level: null,
responseType: 'text',
type: PlaylistContextType.SUBTITLE_TRACK,
url,
deliveryDirectives,
levelOrTrack: track,
});
}
private onLevelsUpdated(
event: Events.LEVELS_UPDATED,
data: LevelsUpdatedData,
) {
// abort and delete loader of removed levels
const loader = this.loaders[PlaylistContextType.LEVEL];
if (loader) {
const context = loader.context;
if (
context &&
!data.levels.some(
(lvl) => lvl === (context as PlaylistLoaderContext).levelOrTrack,
)
) {
loader.abort();
delete this.loaders[PlaylistContextType.LEVEL];
}
}
}
private load(context: PlaylistLoaderContext): void {
const config = this.hls.config;
// logger.debug(`[playlist-loader]: Loading playlist of type ${context.type}, level: ${context.level}, id: ${context.id}`);
// Check if a loader for this context already exists
let loader = this.getInternalLoader(context);
if (loader) {
const logger = this.hls.logger;
const loaderContext = loader.context as PlaylistLoaderContext;
if (
loaderContext &&
loaderContext.levelOrTrack === context.levelOrTrack &&
(loaderContext.url === context.url ||
(loaderContext.deliveryDirectives && !context.deliveryDirectives))
) {
// same URL can't overlap, or wait for blocking request
if (loaderContext.url === context.url) {
logger.log(
`[playlist-loader]: ignore ${context.url} ongoing request`,
);
} else {
logger.log(
`[playlist-loader]: ignore ${context.url} in favor of ${loaderContext.url}`,
);
}
return;
}
logger.log(
`[playlist-loader]: aborting previous loader for type: ${context.type}`,
);
loader.abort();
}
// apply different configs for retries depending on
// context (manifest, level, audio/subs playlist)
let loadPolicy: LoaderConfig;
if (context.type === PlaylistContextType.MANIFEST) {
loadPolicy = config.manifestLoadPolicy.default;
} else {
loadPolicy = Object.assign({}, config.playlistLoadPolicy.default, {
timeoutRetry: null,
errorRetry: null,
});
}
loader = this.createInternalLoader(context);
// Override level/track timeout for LL-HLS requests
// (the default of 10000ms is counter productive to blocking playlist reload requests)
if (Number.isFinite(context.deliveryDirectives?.part)) {
let levelDetails: LevelDetails | undefined;
if (
context.type === PlaylistContextType.LEVEL &&
context.level !== null
) {
levelDetails = this.hls.levels[context.level].details;
} else if (
context.type === PlaylistContextType.AUDIO_TRACK &&
context.id !== null
) {
levelDetails = this.hls.audioTracks[context.id].details;
} else if (
context.type === PlaylistContextType.SUBTITLE_TRACK &&
context.id !== null
) {
levelDetails = this.hls.subtitleTracks[context.id].details;
}
if (levelDetails) {
const partTarget = levelDetails.partTarget;
const targetDuration = levelDetails.targetduration;
if (partTarget && targetDuration) {
const maxLowLatencyPlaylistRefresh =
Math.max(partTarget * 3, targetDuration * 0.8) * 1000;
loadPolicy = Object.assign({}, loadPolicy, {
maxTimeToFirstByteMs: Math.min(
maxLowLatencyPlaylistRefresh,
loadPolicy.maxTimeToFirstByteMs,
),
maxLoadTimeMs: Math.min(
maxLowLatencyPlaylistRefresh,
loadPolicy.maxTimeToFirstByteMs,
),
});
}
}
}
const legacyRetryCompatibility: RetryConfig | Record<string, void> =
loadPolicy.errorRetry || loadPolicy.timeoutRetry || {};
const loaderConfig: LoaderConfiguration = {
loadPolicy,
timeout: loadPolicy.maxLoadTimeMs,
maxRetry: legacyRetryCompatibility.maxNumRetry || 0,
retryDelay: legacyRetryCompatibility.retryDelayMs || 0,
maxRetryDelay: legacyRetryCompatibility.maxRetryDelayMs || 0,
};
const loaderCallbacks: LoaderCallbacks<PlaylistLoaderContext> = {
onSuccess: (response, stats, context, networkDetails) => {
const loader = this.getInternalLoader(context) as
| Loader<PlaylistLoaderContext>
| undefined;
this.resetInternalLoader(context.type);
const string = response.data as string;
stats.parsing.start = performance.now();
if (
M3U8Parser.isMediaPlaylist(string) ||
context.type !== PlaylistContextType.MANIFEST
) {
this.handleTrackOrLevelPlaylist(
response,
stats,
context,
networkDetails || null,
loader,
);
} else {
this.handleMasterPlaylist(response, stats, context, networkDetails);
}
},
onError: (response, context, networkDetails, stats) => {
this.handleNetworkError(
context,
networkDetails,
false,
response,
stats,
);
},
onTimeout: (stats, context, networkDetails) => {
this.handleNetworkError(
context,
networkDetails,
true,
undefined,
stats,
);
},
};
// logger.debug(`[playlist-loader]: Calling internal loader delegate for URL: ${context.url}`);
loader.load(context, loaderConfig, loaderCallbacks);
}
private checkAutostartLoad() {
if (!this.hls) {
return;
}
const {
config: { autoStartLoad, startPosition },
forceStartLoad,
} = this.hls;
if (autoStartLoad || forceStartLoad) {
this.hls.logger.log(
`${autoStartLoad ? 'auto' : 'force'} startLoad with configured startPosition ${startPosition}`,
);
this.hls.startLoad(startPosition);
}
}
private handleMasterPlaylist(
response: LoaderResponse,
stats: LoaderStats,
context: PlaylistLoaderContext,
networkDetails: any,
): void {
const hls = this.hls;
const string = response.data as string;
const url = getResponseUrl(response, context);
const parsedResult = M3U8Parser.parseMasterPlaylist(string, url);
if (parsedResult.playlistParsingError) {
stats.parsing.end = performance.now();
this.handleManifestParsingError(
response,
context,
parsedResult.playlistParsingError,
networkDetails,
stats,
);
return;
}
const {
contentSteering,
levels,
sessionData,
sessionKeys,
startTimeOffset,
variableList,
} = parsedResult;
this.variableList = variableList;
// Treat unknown codec as audio or video codec based on passing `isTypeSupported` check
// (allows for playback of any supported codec even if not indexed in utils/codecs)
levels.forEach((levelParsed: LevelParsed) => {
const { unknownCodecs } = levelParsed;
if (unknownCodecs) {
const { preferManagedMediaSource } = this.hls.config;
let { audioCodec, videoCodec } = levelParsed;
for (let i = unknownCodecs.length; i--; ) {
const unknownCodec = unknownCodecs[i];
if (
areCodecsMediaSourceSupported(
unknownCodec,
'audio',
preferManagedMediaSource,
)
) {
levelParsed.audioCodec = audioCodec = audioCodec
? `${audioCodec},${unknownCodec}`
: unknownCodec;
sampleEntryCodesISO.audio[audioCodec.substring(0, 4)] = 2;
unknownCodecs.splice(i, 1);
} else if (
areCodecsMediaSourceSupported(
unknownCodec,
'video',
preferManagedMediaSource,
)
) {
levelParsed.videoCodec = videoCodec = videoCodec
? `${videoCodec},${unknownCodec}`
: unknownCodec;
sampleEntryCodesISO.video[videoCodec.substring(0, 4)] = 2;
unknownCodecs.splice(i, 1);
}
}
}
});
const {
AUDIO: audioTracks = [],
SUBTITLES: subtitles,
'CLOSED-CAPTIONS': captions,
} = M3U8Parser.parseMasterPlaylistMedia(string, url, parsedResult);
if (audioTracks.length) {
// check if we have found an audio track embedded in main playlist (audio track without URI attribute)
const embeddedAudioFound: boolean = audioTracks.some(
(audioTrack) => !audioTrack.url,
);
// if no embedded audio track defined, but audio codec signaled in quality level,
// we need to signal this main audio track this could happen with playlists with
// alt audio rendition in which quality levels (main)
// contains both audio+video. but with mixed audio track not signaled
if (
!embeddedAudioFound &&
levels[0].audioCodec &&
!levels[0].attrs.AUDIO
) {
this.hls.logger.log(
'[playlist-loader]: audio codec signaled in quality level, but no embedded audio track signaled, create one',
);
audioTracks.unshift({
type: 'main',
name: 'main',
groupId: 'main',
default: false,
autoselect: false,
forced: false,
id: -1,
attrs: new AttrList({}) as MediaAttributes,
bitrate: 0,
url: '',
});
}
}
hls.trigger(Events.MANIFEST_LOADED, {
levels,
audioTracks,
subtitles,
captions,
contentSteering,
url,
stats,
networkDetails,
sessionData,
sessionKeys,
startTimeOffset,
variableList,
});
}
private handleTrackOrLevelPlaylist(
response: LoaderResponse,
stats: LoaderStats,
context: PlaylistLoaderContext,
networkDetails: any,
loader: Loader<PlaylistLoaderContext> | undefined,
): void {
const hls = this.hls;
const { id, level, type } = context;
const url = getResponseUrl(response, context);
const levelId = Number.isFinite(level as number)
? (level as number)
: Number.isFinite(id as number)
? (id as number)
: 0;
const levelType = mapContextToLevelType(context);
const levelDetails = M3U8Parser.parseLevelPlaylist(
response.data as string,
url,
levelId,
levelType,
0,
this.variableList,
);
// We have done our first request (Manifest-type) and receive
// not a master playlist but a chunk-list (track/level)
// We fire the manifest-loaded event anyway with the parsed level-details
// by creating a single-level structure for it.
if (type === PlaylistContextType.MANIFEST) {
const singleLevel: LevelParsed = {
attrs: new AttrList({}),
bitrate: 0,
details: levelDetails,
name: '',
url,
};
levelDetails.requestScheduled =
stats.loading.start + computeReloadInterval(levelDetails, 0);
hls.trigger(Events.MANIFEST_LOADED, {
levels: [singleLevel],
audioTracks: [],
url,
stats,
networkDetails,
sessionData: null,
sessionKeys: null,
contentSteering: null,
startTimeOffset: null,
variableList: null,
});
}
// save parsing time
stats.parsing.end = performance.now();
// extend the context with the new levelDetails property
context.levelDetails = levelDetails;
this.handlePlaylistLoaded(
levelDetails,
response,
stats,
context,
networkDetails,
loader,
);
}
private handleManifestParsingError(
response: LoaderResponse,
context: PlaylistLoaderContext,
error: Error,
networkDetails: any,
stats: LoaderStats,
): void {
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.MANIFEST_PARSING_ERROR,
fatal: context.type === PlaylistContextType.MANIFEST,
url: response.url,
err: error,
error,
reason: error.message,
response,
context,
networkDetails,
stats,
});
}
private handleNetworkError(
context: PlaylistLoaderContext,
networkDetails: any,
timeout = false,
response: { code: number; text: string } | undefined,
stats: LoaderStats,
): void {
let message = `A network ${
timeout
? 'timeout'
: 'error' + (response ? ' (status ' + response.code + ')' : '')
} occurred while loading ${context.type}`;
if (context.type === PlaylistContextType.LEVEL) {
message += `: ${context.level} id: ${context.id}`;
} else if (
context.type === PlaylistContextType.AUDIO_TRACK ||
context.type === PlaylistContextType.SUBTITLE_TRACK
) {
message += ` id: ${context.id} group-id: "${context.groupId}"`;
}
const error = new Error(message);
this.hls.logger.warn(`[playlist-loader]: ${message}`);
let details = ErrorDetails.UNKNOWN;
let fatal = false;
const loader = this.getInternalLoader(context);
switch (context.type) {
case PlaylistContextType.MANIFEST:
details = timeout
? ErrorDetails.MANIFEST_LOAD_TIMEOUT
: ErrorDetails.MANIFEST_LOAD_ERROR;
fatal = true;
break;
case PlaylistContextType.LEVEL:
details = timeout
? ErrorDetails.LEVEL_LOAD_TIMEOUT
: ErrorDetails.LEVEL_LOAD_ERROR;
fatal = false;
break;
case PlaylistContextType.AUDIO_TRACK:
details = timeout
? ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT
: ErrorDetails.AUDIO_TRACK_LOAD_ERROR;
fatal = false;
break;
case PlaylistContextType.SUBTITLE_TRACK:
details = timeout
? ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT
: ErrorDetails.SUBTITLE_LOAD_ERROR;
fatal = false;
break;
}
if (loader) {
this.resetInternalLoader(context.type);
}
const errorData: ErrorData = {
type: ErrorTypes.NETWORK_ERROR,
details,
fatal,
url: context.url,
loader,
context,
error,
networkDetails,
stats,
};
if (response) {
const url = networkDetails?.url || context.url;
errorData.response = { url, data: undefined as any, ...response };
}
this.hls.trigger(Events.ERROR, errorData);
}
private handlePlaylistLoaded(
levelDetails: LevelDetails,
response: LoaderResponse,
stats: LoaderStats,
context: PlaylistLoaderContext,
networkDetails: any,
loader: Loader<PlaylistLoaderContext> | undefined,
): void {
const hls = this.hls;
const { type, level, levelOrTrack, id, groupId, deliveryDirectives } =
context;
const url = getResponseUrl(response, context);
const parent = mapContextToLevelType(context);
let levelIndex =
typeof context.level === 'number' && parent === PlaylistLevelType.MAIN
? (level as number)
: undefined;
const error = levelDetails.playlistParsingError;
if (error) {
this.hls.logger.warn(`${error} ${levelDetails.url}`);
if (!hls.config.ignorePlaylistParsingErrors) {
hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.LEVEL_PARSING_ERROR,
fatal: false,
url,
error,
reason: error.message,
response,
context,
level: levelIndex,
parent,
networkDetails,
stats,
});
return;
}
levelDetails.playlistParsingError = null;
}
if (!levelDetails.fragments.length) {
const error = (levelDetails.playlistParsingError = new Error(
'No Segments found in Playlist',
));
hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.LEVEL_EMPTY_ERROR,
fatal: false,
url,
error,
reason: error.message,
response,
context,
level: levelIndex,
parent,
networkDetails,
stats,
});
return;
}
if (levelDetails.live && loader) {
if (loader.getCacheAge) {
levelDetails.ageHeader = loader.getCacheAge() || 0;
}
if (!loader.getCacheAge || isNaN(levelDetails.ageHeader)) {
levelDetails.ageHeader = 0;
}
}
switch (type) {
case PlaylistContextType.MANIFEST:
case PlaylistContextType.LEVEL:
if (levelIndex) {
if (!levelOrTrack) {
// fall-through to hls.levels[0]
levelIndex = 0;
} else {
if (levelOrTrack !== hls.levels[levelIndex]) {
// correct levelIndex when lower levels were removed from hls.levels
const updatedIndex = hls.levels.indexOf(levelOrTrack as Level);
if (updatedIndex > -1) {
levelIndex = updatedIndex;
}
}
}
}
hls.trigger(Events.LEVEL_LOADED, {
details: levelDetails,
levelInfo: (levelOrTrack as Level | null) || hls.levels[0],
level: levelIndex || 0,
id: id || 0,
stats,
networkDetails,
deliveryDirectives,
withoutMultiVariant: type === PlaylistContextType.MANIFEST,
});
break;
case PlaylistContextType.AUDIO_TRACK:
hls.trigger(Events.AUDIO_TRACK_LOADED, {
details: levelDetails,
track: levelOrTrack as MediaPlaylist,
id: id || 0,
groupId: groupId || '',
stats,
networkDetails,
deliveryDirectives,
});
break;
case PlaylistContextType.SUBTITLE_TRACK:
hls.trigger(Events.SUBTITLE_TRACK_LOADED, {
details: levelDetails,
track: levelOrTrack as MediaPlaylist,
id: id || 0,
groupId: groupId || '',
stats,
networkDetails,
deliveryDirectives,
});
break;
}
}
}
export default PlaylistLoader;
+15
View File
@@ -0,0 +1,15 @@
// https://caniuse.com/mdn-javascript_builtins_number_isfinite
export const isFiniteNumber =
Number.isFinite ||
function (value) {
return typeof value === 'number' && isFinite(value);
};
// https://caniuse.com/mdn-javascript_builtins_number_issafeinteger
export const isSafeInteger =
Number.isSafeInteger ||
function (value) {
return typeof value === 'number' && Math.abs(value) <= MAX_SAFE_INTEGER;
};
export const MAX_SAFE_INTEGER = Number.MAX_SAFE_INTEGER || 9007199254740991;
+81
View File
@@ -0,0 +1,81 @@
/**
* AAC helper
*/
class AAC {
static getSilentFrame(
codec?: string,
channelCount?: number,
): Uint8Array | undefined {
switch (codec) {
case 'mp4a.40.2':
if (channelCount === 1) {
return new Uint8Array([0x00, 0xc8, 0x00, 0x80, 0x23, 0x80]);
} else if (channelCount === 2) {
return new Uint8Array([
0x21, 0x00, 0x49, 0x90, 0x02, 0x19, 0x00, 0x23, 0x80,
]);
} else if (channelCount === 3) {
return new Uint8Array([
0x00, 0xc8, 0x00, 0x80, 0x20, 0x84, 0x01, 0x26, 0x40, 0x08, 0x64,
0x00, 0x8e,
]);
} else if (channelCount === 4) {
return new Uint8Array([
0x00, 0xc8, 0x00, 0x80, 0x20, 0x84, 0x01, 0x26, 0x40, 0x08, 0x64,
0x00, 0x80, 0x2c, 0x80, 0x08, 0x02, 0x38,
]);
} else if (channelCount === 5) {
return new Uint8Array([
0x00, 0xc8, 0x00, 0x80, 0x20, 0x84, 0x01, 0x26, 0x40, 0x08, 0x64,
0x00, 0x82, 0x30, 0x04, 0x99, 0x00, 0x21, 0x90, 0x02, 0x38,
]);
} else if (channelCount === 6) {
return new Uint8Array([
0x00, 0xc8, 0x00, 0x80, 0x20, 0x84, 0x01, 0x26, 0x40, 0x08, 0x64,
0x00, 0x82, 0x30, 0x04, 0x99, 0x00, 0x21, 0x90, 0x02, 0x00, 0xb2,
0x00, 0x20, 0x08, 0xe0,
]);
}
break;
// handle HE-AAC below (mp4a.40.5 / mp4a.40.29)
default:
if (channelCount === 1) {
// ffmpeg -y -f lavfi -i "aevalsrc=0:d=0.05" -c:a libfdk_aac -profile:a aac_he -b:a 4k output.aac && hexdump -v -e '16/1 "0x%x," "\n"' -v output.aac
return new Uint8Array([
0x1, 0x40, 0x22, 0x80, 0xa3, 0x4e, 0xe6, 0x80, 0xba, 0x8, 0x0, 0x0,
0x0, 0x1c, 0x6, 0xf1, 0xc1, 0xa, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5e,
]);
} else if (channelCount === 2) {
// ffmpeg -y -f lavfi -i "aevalsrc=0|0:d=0.05" -c:a libfdk_aac -profile:a aac_he_v2 -b:a 4k output.aac && hexdump -v -e '16/1 "0x%x," "\n"' -v output.aac
return new Uint8Array([
0x1, 0x40, 0x22, 0x80, 0xa3, 0x5e, 0xe6, 0x80, 0xba, 0x8, 0x0, 0x0,
0x0, 0x0, 0x95, 0x0, 0x6, 0xf1, 0xa1, 0xa, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5e,
]);
} else if (channelCount === 3) {
// ffmpeg -y -f lavfi -i "aevalsrc=0|0|0:d=0.05" -c:a libfdk_aac -profile:a aac_he_v2 -b:a 4k output.aac && hexdump -v -e '16/1 "0x%x," "\n"' -v output.aac
return new Uint8Array([
0x1, 0x40, 0x22, 0x80, 0xa3, 0x5e, 0xe6, 0x80, 0xba, 0x8, 0x0, 0x0,
0x0, 0x0, 0x95, 0x0, 0x6, 0xf1, 0xa1, 0xa, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a, 0x5a,
0x5a, 0x5e,
]);
}
break;
}
return undefined;
}
}
export default AAC;
+1380
View File
File diff suppressed because it is too large Load Diff
+1261
View File
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
import {
flushTextTrackMetadataCueSamples,
flushTextTrackUserdataCueSamples,
} from './mp4-remuxer';
import { ElementaryStreamTypes } from '../loader/fragment';
import { getCodecCompatibleName } from '../utils/codecs';
import { type ILogger, Logger } from '../utils/logger';
import { patchEncyptionData } from '../utils/mp4-tools';
import { getSampleData, parseInitSegment } from '../utils/mp4-tools';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
import type { DecryptData } from '../loader/level-key';
import type {
DemuxedAudioTrack,
DemuxedMetadataTrack,
DemuxedUserdataTrack,
PassthroughTrack,
} from '../types/demuxer';
import type {
InitSegmentData,
RemuxedTrack,
Remuxer,
RemuxerResult,
} from '../types/remuxer';
import type { TrackSet } from '../types/track';
import type { TypeSupported } from '../utils/codecs';
import type { InitData, InitDataTrack, TrackTimes } from '../utils/mp4-tools';
import type { TimestampOffset } from '../utils/timescale-conversion';
class PassThroughRemuxer extends Logger implements Remuxer {
private emitInitSegment: boolean = false;
private audioCodec?: string;
private videoCodec?: string;
private initData?: InitData;
private initPTS: TimestampOffset | null = null;
private initTracks?: TrackSet;
private lastEndTime: number | null = null;
private isVideoContiguous: boolean = false;
constructor(
observer: HlsEventEmitter,
config: HlsConfig,
typeSupported: TypeSupported,
logger: ILogger,
) {
super('passthrough-remuxer', logger);
}
public destroy() {}
public resetTimeStamp(defaultInitPTS: TimestampOffset | null) {
this.lastEndTime = null;
const initPTS = this.initPTS;
if (initPTS && defaultInitPTS) {
if (
initPTS.baseTime === defaultInitPTS.baseTime &&
initPTS.timescale === defaultInitPTS.timescale
) {
return;
}
}
this.initPTS = defaultInitPTS;
}
public resetNextTimestamp() {
this.isVideoContiguous = false;
this.lastEndTime = null;
}
public resetInitSegment(
initSegment: Uint8Array<ArrayBuffer> | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
decryptdata: DecryptData | null,
) {
this.audioCodec = audioCodec;
this.videoCodec = videoCodec;
this.generateInitSegment(initSegment, decryptdata);
this.emitInitSegment = true;
}
private generateInitSegment(
initSegment: Uint8Array<ArrayBuffer> | undefined,
decryptdata?: DecryptData | null,
) {
let { audioCodec, videoCodec } = this;
if (!initSegment?.byteLength) {
this.initTracks = undefined;
this.initData = undefined;
return;
}
const { audio, video } = (this.initData = parseInitSegment(initSegment));
if (decryptdata) {
patchEncyptionData(initSegment, decryptdata);
} else {
const eitherTrack = audio || video;
if (eitherTrack?.encrypted) {
this.warn(
`Init segment with encrypted track with has no key ("${eitherTrack.codec}")!`,
);
}
}
// Get codec from initSegment
if (audio) {
audioCodec = getParsedTrackCodec(
audio,
ElementaryStreamTypes.AUDIO,
this,
);
}
if (video) {
videoCodec = getParsedTrackCodec(
video,
ElementaryStreamTypes.VIDEO,
this,
);
}
const tracks: TrackSet = {};
if (audio && video) {
tracks.audiovideo = {
container: 'video/mp4',
codec: audioCodec + ',' + videoCodec,
supplemental: video.supplemental,
encrypted: video.encrypted,
initSegment,
id: 'main',
};
} else if (audio) {
tracks.audio = {
container: 'audio/mp4',
codec: audioCodec,
encrypted: audio.encrypted,
initSegment,
id: 'audio',
};
} else if (video) {
tracks.video = {
container: 'video/mp4',
codec: videoCodec,
supplemental: video.supplemental,
encrypted: video.encrypted,
initSegment,
id: 'main',
};
} else {
this.warn('initSegment does not contain moov or trak boxes.');
}
this.initTracks = tracks;
}
public remux(
audioTrack: DemuxedAudioTrack,
videoTrack: PassthroughTrack,
id3Track: DemuxedMetadataTrack,
textTrack: DemuxedUserdataTrack,
timeOffset: number,
accurateTimeOffset: boolean,
): RemuxerResult {
let { initPTS, lastEndTime } = this;
const result: RemuxerResult = {
audio: undefined,
video: undefined,
text: textTrack,
id3: id3Track,
initSegment: undefined,
};
// If we haven't yet set a lastEndDTS, or it was reset, set it to the provided timeOffset. We want to use the
// lastEndDTS over timeOffset whenever possible; during progressive playback, the media source will not update
// the media duration (which is what timeOffset is provided as) before we need to process the next chunk.
if (!Number.isFinite(lastEndTime!)) {
lastEndTime = this.lastEndTime = timeOffset || 0;
}
// The binary segment data is added to the videoTrack in the mp4demuxer. We don't check to see if the data is only
// audio or video (or both); adding it to video was an arbitrary choice.
const data = videoTrack.samples;
if (!data.length) {
return result;
}
const initSegment: InitSegmentData = {
initPTS: undefined,
timescale: undefined,
trackId: undefined,
};
let initData = this.initData;
if (!initData?.length) {
this.generateInitSegment(data);
initData = this.initData;
}
if (!initData?.length) {
// We can't remux if the initSegment could not be generated
this.warn('Failed to generate initSegment.');
return result;
}
if (this.emitInitSegment) {
initSegment.tracks = this.initTracks;
this.emitInitSegment = false;
}
const trackSampleData = getSampleData(data, initData, this);
const audioSampleTimestamps = initData.audio
? trackSampleData[initData.audio.id]
: null;
const videoSampleTimestamps = initData.video
? trackSampleData[initData.video.id]
: null;
const videoStartTime = toStartEndOrDefault(videoSampleTimestamps, Infinity);
const audioStartTime = toStartEndOrDefault(audioSampleTimestamps, Infinity);
const videoEndTime = toStartEndOrDefault(videoSampleTimestamps, 0, true);
const audioEndTime = toStartEndOrDefault(audioSampleTimestamps, 0, true);
let decodeTime = timeOffset;
let duration = 0;
const syncOnAudio =
audioSampleTimestamps &&
(!videoSampleTimestamps ||
(!initPTS && audioStartTime < videoStartTime) ||
(initPTS && initPTS.trackId === initData.audio!.id));
const baseOffsetSamples = syncOnAudio
? audioSampleTimestamps
: videoSampleTimestamps;
if (baseOffsetSamples) {
const timescale = baseOffsetSamples.timescale;
const baseTime = baseOffsetSamples.start - timeOffset * timescale;
const trackId = syncOnAudio ? initData.audio!.id : initData.video!.id;
decodeTime = baseOffsetSamples.start / timescale;
duration = syncOnAudio
? audioEndTime - audioStartTime
: videoEndTime - videoStartTime;
if (
(accurateTimeOffset || !initPTS) &&
(isInvalidInitPts(initPTS, decodeTime, timeOffset, duration) ||
timescale !== initPTS.timescale)
) {
if (initPTS) {
this.warn(
`Timestamps at playlist time: ${accurateTimeOffset ? '' : '~'}${timeOffset} ${baseTime / timescale} != initPTS: ${initPTS.baseTime / initPTS.timescale} (${initPTS.baseTime}/${initPTS.timescale}) trackId: ${initPTS.trackId}`,
);
}
this.log(
`Found initPTS at playlist time: ${timeOffset} offset: ${decodeTime - timeOffset} (${baseTime}/${timescale}) trackId: ${trackId}`,
);
initPTS = null;
initSegment.initPTS = baseTime;
initSegment.timescale = timescale;
initSegment.trackId = trackId;
}
} else {
this.warn(
`No audio or video samples found for initPTS at playlist time: ${timeOffset}`,
);
}
if (!initPTS) {
if (
!initSegment.timescale ||
initSegment.trackId === undefined ||
initSegment.initPTS === undefined
) {
this.warn('Could not set initPTS');
initSegment.initPTS = decodeTime;
initSegment.timescale = 1;
initSegment.trackId = -1;
}
this.initPTS = initPTS = {
baseTime: initSegment.initPTS,
timescale: initSegment.timescale,
trackId: initSegment.trackId,
};
} else {
initSegment.initPTS = initPTS.baseTime;
initSegment.timescale = initPTS.timescale;
initSegment.trackId = initPTS.trackId;
}
const startTime = decodeTime - initPTS.baseTime / initPTS.timescale;
const endTime = startTime + duration;
if (duration > 0) {
this.lastEndTime = endTime;
} else {
this.warn('Duration parsed from mp4 should be greater than zero');
this.resetNextTimestamp();
}
const hasAudio = !!initData.audio;
const hasVideo = !!initData.video;
let type: any = '';
if (hasAudio) {
type += 'audio';
}
if (hasVideo) {
type += 'video';
}
const encrypted =
(initData.audio ? initData.audio.encrypted : false) ||
(initData.video ? initData.video.encrypted : false);
const track: RemuxedTrack = {
data1: data,
startPTS: startTime,
startDTS: startTime,
endPTS: endTime,
endDTS: endTime,
type,
hasAudio,
hasVideo,
nb: 1,
dropped: 0,
encrypted,
};
result.audio = hasAudio && !hasVideo ? track : undefined;
result.video = hasVideo ? track : undefined;
const videoSampleCount = videoSampleTimestamps?.sampleCount;
if (videoSampleCount) {
const firstKeyFrame = videoSampleTimestamps.keyFrameIndex;
const independent = firstKeyFrame !== -1;
track.nb = videoSampleCount;
track.dropped =
firstKeyFrame === 0 || this.isVideoContiguous
? 0
: independent
? firstKeyFrame
: videoSampleCount;
track.independent = independent;
track.firstKeyFrame = firstKeyFrame;
if (independent && videoSampleTimestamps.keyFrameStart) {
track.firstKeyFramePTS =
(videoSampleTimestamps.keyFrameStart - initPTS.baseTime) /
initPTS.timescale;
}
if (!this.isVideoContiguous) {
result.independent = independent;
}
this.isVideoContiguous ||= independent;
if (track.dropped) {
this.warn(
`fmp4 does not start with IDR: firstIDR ${firstKeyFrame}/${videoSampleCount} dropped: ${track.dropped} start: ${track.firstKeyFramePTS || 'NA'}`,
);
}
}
result.initSegment = initSegment;
result.id3 = flushTextTrackMetadataCueSamples(
id3Track,
timeOffset,
initPTS,
initPTS,
);
if (textTrack.samples.length) {
result.text = flushTextTrackUserdataCueSamples(
textTrack,
timeOffset,
initPTS,
);
}
return result;
}
}
function toStartEndOrDefault(
trackTimes: TrackTimes | null,
defaultValue: number,
end: boolean = false,
): number {
return trackTimes?.start !== undefined
? (trackTimes.start + (end ? trackTimes.duration : 0)) /
trackTimes.timescale
: defaultValue;
}
function isInvalidInitPts(
initPTS: TimestampOffset | null,
startDTS: number,
timeOffset: number,
duration: number,
): initPTS is null {
if (initPTS === null) {
return true;
}
// InitPTS is invalid when distance from program would be more than segment duration or a minimum of one second
const minDuration = Math.max(duration, 1);
const startTime = startDTS - initPTS.baseTime / initPTS.timescale;
return Math.abs(startTime - timeOffset) > minDuration;
}
function getParsedTrackCodec(
track: InitDataTrack,
type: ElementaryStreamTypes.AUDIO | ElementaryStreamTypes.VIDEO,
logger: ILogger,
): string {
const parsedCodec = track.codec;
if (parsedCodec && parsedCodec.length > 4) {
return parsedCodec;
}
if (type === ElementaryStreamTypes.AUDIO) {
if (
parsedCodec === 'ec-3' ||
parsedCodec === 'ac-3' ||
parsedCodec === 'alac'
) {
return parsedCodec;
}
if (parsedCodec === 'fLaC' || parsedCodec === 'Opus') {
// Opting not to get `preferManagedMediaSource` from player config for isSupported() check for simplicity
const preferManagedMediaSource = false;
return getCodecCompatibleName(parsedCodec, preferManagedMediaSource);
}
logger.warn(`Unhandled audio codec "${parsedCodec}" in mp4 MAP`);
return parsedCodec || 'mp4a';
}
// Provide defaults based on codec type
// This allows for some playback of some fmp4 playlists without CODECS defined in manifest
logger.warn(`Unhandled video codec "${parsedCodec}" in mp4 MAP`);
return parsedCodec || 'avc1';
}
export default PassThroughRemuxer;
+130
View File
@@ -0,0 +1,130 @@
import { type ILogger, Logger } from './utils/logger';
/**
* @ignore
* Sub-class specialization of EventHandler base class.
*
* TaskLoop allows to schedule a task function being called (optionnaly repeatedly) on the main loop,
* scheduled asynchroneously, avoiding recursive calls in the same tick.
*
* The task itself is implemented in `doTick`. It can be requested and called for single execution
* using the `tick` method.
*
* It will be assured that the task execution method (`tick`) only gets called once per main loop "tick",
* no matter how often it gets requested for execution. Execution in further ticks will be scheduled accordingly.
*
* If further execution requests have already been scheduled on the next tick, it can be checked with `hasNextTick`,
* and cancelled with `clearNextTick`.
*
* The task can be scheduled as an interval repeatedly with a period as parameter (see `setInterval`, `clearInterval`).
*
* Sub-classes need to implement the `doTick` method which will effectively have the task execution routine.
*
* Further explanations:
*
* The baseclass has a `tick` method that will schedule the doTick call. It may be called synchroneously
* only for a stack-depth of one. On re-entrant calls, sub-sequent calls are scheduled for next main loop ticks.
*
* When the task execution (`tick` method) is called in re-entrant way this is detected and
* we are limiting the task execution per call stack to exactly one, but scheduling/post-poning further
* task processing on the next main loop iteration (also known as "next tick" in the Node/JS runtime lingo).
*/
export default class TaskLoop extends Logger {
private readonly _boundTick: () => void;
private _tickTimer: number | null = null;
private _tickInterval: number | null = null;
private _tickCallCount = 0;
constructor(label: string, logger: ILogger) {
super(label, logger);
this._boundTick = this.tick.bind(this);
}
public destroy() {
this.onHandlerDestroying();
this.onHandlerDestroyed();
}
protected onHandlerDestroying() {
// clear all timers before unregistering from event bus
this.clearNextTick();
this.clearInterval();
}
protected onHandlerDestroyed() {}
public hasInterval(): boolean {
return !!this._tickInterval;
}
public hasNextTick(): boolean {
return !!this._tickTimer;
}
/**
* @param millis - Interval time (ms)
* @eturns True when interval has been scheduled, false when already scheduled (no effect)
*/
public setInterval(millis: number): boolean {
if (!this._tickInterval) {
this._tickCallCount = 0;
this._tickInterval = self.setInterval(this._boundTick, millis);
return true;
}
return false;
}
/**
* @returns True when interval was cleared, false when none was set (no effect)
*/
public clearInterval(): boolean {
if (this._tickInterval) {
self.clearInterval(this._tickInterval);
this._tickInterval = null;
return true;
}
return false;
}
/**
* @returns True when timeout was cleared, false when none was set (no effect)
*/
public clearNextTick(): boolean {
if (this._tickTimer) {
self.clearTimeout(this._tickTimer);
this._tickTimer = null;
return true;
}
return false;
}
/**
* Will call the subclass doTick implementation in this main loop tick
* or in the next one (via setTimeout(,0)) in case it has already been called
* in this tick (in case this is a re-entrant call).
*/
public tick(): void {
this._tickCallCount++;
if (this._tickCallCount === 1) {
this.doTick();
// re-entrant call to tick from previous doTick call stack
// -> schedule a call on the next main loop iteration to process this task processing request
if (this._tickCallCount > 1) {
// make sure only one timer exists at any time at max
this.tickImmediate();
}
this._tickCallCount = 0;
}
}
public tickImmediate(): void {
this.clearNextTick();
this._tickTimer = self.setTimeout(this._boundTick, 0);
}
/**
* For subclass to implement task logic
* @abstract
*/
protected doTick(): void {}
}
+105
View File
@@ -0,0 +1,105 @@
declare global {
interface ArrayBuffer {
' buffer_kind'?: 'array';
}
interface Uint8Array {
' buffer_kind'?: 'uint8';
}
}
export type SourceBufferName = 'video' | 'audio' | 'audiovideo';
/* eslint-disable no-restricted-globals */
// `SourceBuffer` global is restricted for is-supported check with prefixed MSE
export type ExtendedSourceBuffer = SourceBuffer & {
onbufferedchange?: ((this: SourceBuffer, ev: Event) => any) | null;
};
/* eslint-enable no-restricted-globals */
export interface BaseTrack {
id: 'audio' | 'main';
container: string;
codec?: string;
supplemental?: string;
encrypted?: boolean;
levelCodec?: string;
pendingCodec?: string;
metadata?: {
channelCount?: number;
width?: number;
height?: number;
};
}
export interface ParsedTrack extends BaseTrack {
initSegment?: Uint8Array;
}
export interface SourceBufferTrack extends BaseTrack {
buffer?: ExtendedSourceBuffer;
listeners: SourceBufferListener[];
ending?: boolean;
ended?: boolean;
}
export interface PendingSourceBufferTrack extends SourceBufferTrack {
buffer: undefined;
}
export interface BufferCreatedTrack extends BaseTrack {
buffer: ExtendedSourceBuffer;
}
export type ParsedTrackSet = Partial<Record<SourceBufferName, ParsedTrack>>;
export type BaseTrackSet = Partial<Record<SourceBufferName, BaseTrack>>;
export type SourceBufferTrackSet = Partial<
Record<SourceBufferName, SourceBufferTrack>
>;
export type BufferCreatedTrackSet = Partial<
Record<SourceBufferName, BufferCreatedTrack>
>;
export type EmptyTuple = [null, null];
export type SourceBuffersTuple = [
(
| [Extract<SourceBufferName, 'video' | 'audiovideo'>, ExtendedSourceBuffer]
| EmptyTuple
),
[Extract<SourceBufferName, 'audio'>, ExtendedSourceBuffer] | EmptyTuple,
];
export type MediaOverrides = {
duration?: number;
endOfStream?: boolean;
cueRemoval?: boolean;
};
export interface BufferOperationQueues {
video: Array<BufferOperation>;
audio: Array<BufferOperation>;
audiovideo: Array<BufferOperation>;
}
export interface BufferOperation {
label: string;
execute: Function;
onStart: Function;
onComplete: Function;
onError: Function;
start?: number;
end?: number;
}
export interface SourceBufferListener {
event: string;
listener: EventListener;
}
export type AttachMediaSourceData = {
media: HTMLMediaElement;
mediaSource: MediaSource | null;
tracks: SourceBufferTrackSet;
};
+20
View File
@@ -0,0 +1,20 @@
import type EwmaBandWidthEstimator from '../utils/ewma-bandwidth-estimator';
export interface ComponentAPI {
destroy(): void;
}
export interface AbrComponentAPI extends ComponentAPI {
firstAutoLevel: number;
forcedAutoLevel: number;
nextAutoLevel: number;
readonly bwEstimator?: EwmaBandWidthEstimator;
resetEstimator(abrEwmaDefaultEstimate: number);
}
export interface NetworkComponentAPI extends ComponentAPI {
startLoad(startPosition: number, skipSeekToStartPosition?: boolean): void;
stopLoad(): void;
pauseBuffering?(): void;
resumeBuffering?(): void;
}
+208
View File
@@ -0,0 +1,208 @@
import type { RationalTimestamp } from '../utils/timescale-conversion';
export interface Demuxer {
demux(
data: Uint8Array,
timeOffset: number,
isSampleAes?: boolean,
flush?: boolean,
): DemuxerResult;
demuxSampleAes(
data: Uint8Array,
keyData: KeyData,
timeOffset: number,
): Promise<DemuxerResult>;
flush(timeOffset?: number): DemuxerResult | Promise<DemuxerResult>;
destroy(): void;
resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
trackDuration: number,
);
resetTimeStamp(defaultInitPTS?: RationalTimestamp | null): void;
resetContiguity(): void;
}
export interface DemuxerResult {
audioTrack: DemuxedAudioTrack;
videoTrack: DemuxedVideoTrackBase;
id3Track: DemuxedMetadataTrack;
textTrack: DemuxedUserdataTrack;
}
export interface DemuxedTrack {
type: string;
id: number;
pid: number;
inputTimeScale: number;
sequenceNumber: number;
samples:
| AudioSample[]
| VideoSample[]
| MetadataSample[]
| UserdataSample[]
| Uint8Array;
timescale?: number;
container?: string;
dropped: number;
duration?: number;
pesData?: ElementaryStreamData | null;
codec?: string;
}
export interface PassthroughTrack extends DemuxedTrack {
sampleDuration: number;
samples: Uint8Array;
timescale: number;
duration: number;
codec: string;
supplemental: string | undefined;
}
export interface DemuxedAudioTrack extends DemuxedTrack {
type: 'audio';
segmentCodec: 'aac' | 'ac3' | 'mp3';
config?: number[] | Uint8Array;
samplerate?: number;
channelCount?: number;
manifestCodec?: string;
parsedCodec?: string;
samples: AudioSample[];
}
export type DemuxedAAC = DemuxedAudioTrack & {
segmentCodec: 'aac';
samples: AACAudioSample[];
};
export type DemuxedAC3 = DemuxedAudioTrack & {
segmentCodec: 'ac3';
config: Uint8Array;
};
export interface DemuxedVideoTrackBase extends DemuxedTrack {
width?: number;
height?: number;
pixelRatio?: [number, number];
audFound?: boolean;
vps?: Uint8Array[];
pps?: Uint8Array[];
sps?: Uint8Array[];
naluState?: number;
segmentCodec?: string;
manifestCodec?: string;
samples: VideoSample[] | Uint8Array;
params?: object;
}
export interface DemuxedVideoTrack extends DemuxedVideoTrackBase {
type: 'video';
segmentCodec: 'avc' | 'hevc';
samples: VideoSample[];
pixelRatio: [number, number];
width: number;
height: number;
}
export type DemuxedAVC1 = DemuxedVideoTrack & {
segmentCodec: 'avc';
pps: Uint8Array[];
sps: Uint8Array[];
};
export type DemuxedHEVC = DemuxedVideoTrack & {
segmentCodec: 'hevc';
params: {
general_profile_space: number;
general_tier_flag: number;
general_profile_idc: number;
general_profile_compatibility_flags: number[];
general_constraint_indicator_flags: number[];
general_level_idc: number;
min_spatial_segmentation_idc: number;
parallelismType: number;
chroma_format_idc: number;
bit_depth_luma_minus8: number;
bit_depth_chroma_minus8: number;
frame_rate: { fps: string; fixed: boolean };
temporal_id_nested: number;
num_temporal_layers: number;
};
pps: Uint8Array[];
sps: Uint8Array[];
vps: Uint8Array;
};
export interface DemuxedMetadataTrack extends DemuxedTrack {
samples: MetadataSample[];
}
export interface DemuxedUserdataTrack extends DemuxedTrack {
samples: UserdataSample[];
}
export enum MetadataSchema {
audioId3 = 'org.id3',
dateRange = 'com.apple.quicktime.HLS',
emsg = 'https://aomedia.org/emsg/ID3',
misbklv = 'urn:misb:KLV:bin:1910.1',
}
export interface MetadataSample {
pts: number;
dts: number;
duration: number;
len?: number;
data: Uint8Array;
type: MetadataSchema;
}
export interface UserdataSample {
pts: number;
bytes?: Uint8Array;
type?: number;
payloadType?: number;
uuid?: string;
userData?: string;
userDataBytes?: Uint8Array;
}
export interface VideoSample {
dts: number;
pts: number;
key: boolean;
frame: boolean;
units: VideoSampleUnit[];
length: number;
}
export interface VideoSampleUnit {
data: Uint8Array;
type: number;
state?: number;
}
export type AudioSample = {
unit: Uint8Array;
pts: number;
};
export type AACAudioSample = {
unit: Uint8Array<ArrayBuffer>;
};
export type AudioFrame = {
sample: AudioSample;
length: number;
missing: number;
};
export interface ElementaryStreamData {
data: Uint8Array[];
size: number;
}
export interface KeyData {
method: string;
key: Uint8Array<ArrayBuffer>;
iv: Uint8Array<ArrayBuffer>;
}
+544
View File
@@ -0,0 +1,544 @@
import type {
AttachMediaSourceData,
BaseTrackSet,
BufferCreatedTrackSet,
MediaOverrides,
ParsedTrack,
SourceBufferName,
SourceBufferTrackSet,
} from './buffer';
import type { MetadataSample, UserdataSample } from './demuxer';
import type {
HdcpLevel,
HlsUrlParameters,
Level,
LevelAttributes,
LevelParsed,
VariableMap,
} from './level';
import type {
Loader,
LoaderContext,
LoaderResponse,
LoaderStats,
PlaylistLevelType,
PlaylistLoaderContext,
} from './loader';
import type { MediaPlaylist, MediaPlaylistType } from './media-playlist';
import type { ChunkMetadata } from './transmuxer';
import type { SteeringManifest } from '../controller/content-steering-controller';
import type { IErrorAction } from '../controller/error-controller';
import type { HlsAssetPlayer } from '../controller/interstitial-player';
import type {
InterstitialScheduleDurations,
InterstitialScheduleItem,
} from '../controller/interstitials-schedule';
import type { ErrorDetails, ErrorTypes } from '../errors';
import type { HlsListeners } from '../events';
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type {
AssetListJSON,
InterstitialAssetItem,
InterstitialEvent,
InterstitialEventWithAssetList,
} from '../loader/interstitial-event';
import type { KeyLoaderInfo } from '../loader/key-loader';
import type { LevelDetails } from '../loader/level-details';
import type { LevelKey } from '../loader/level-key';
import type { LoadStats } from '../loader/load-stats';
import type { AttrList } from '../utils/attr-list';
import type { BufferInfo } from '../utils/buffer-helper';
export interface MediaAttachingData {
media: HTMLMediaElement;
mediaSource?: MediaSource | null;
tracks?: SourceBufferTrackSet;
overrides?: MediaOverrides;
}
export interface MediaAttachedData {
media: HTMLMediaElement;
mediaSource?: MediaSource;
}
export interface MediaDetachingData {
transferMedia?: AttachMediaSourceData | null;
}
export interface MediaDetachedData {
transferMedia?: AttachMediaSourceData | null;
}
export interface MediaEndedData {
stalled: boolean;
}
export interface BufferCodecsData {
video?: ParsedTrack;
audio?: ParsedTrack;
audiovideo?: ParsedTrack;
tracks?: BaseTrackSet;
}
export interface BufferCreatedData {
tracks: BufferCreatedTrackSet;
}
export interface BufferAppendingData {
type: SourceBufferName;
frag: Fragment;
part: Part | null;
chunkMeta: ChunkMetadata;
offset?: number | undefined;
parent: PlaylistLevelType;
data: Uint8Array<ArrayBuffer>;
}
export interface BufferAppendedData {
type: SourceBufferName;
frag: Fragment;
part: Part | null;
chunkMeta: ChunkMetadata;
parent: PlaylistLevelType;
timeRanges: Partial<Record<SourceBufferName, TimeRanges>>;
}
export interface BufferEOSData {
type?: SourceBufferName;
}
export interface BufferFlushingData {
startOffset: number;
endOffset: number;
endOffsetSubtitles?: number;
type: SourceBufferName | null;
}
export interface BufferFlushedData {
type: SourceBufferName;
}
export interface ManifestLoadingData {
url: string;
}
export type ContentSteeringOptions = {
uri: string;
pathwayId: string;
};
export interface ManifestLoadedData {
audioTracks: MediaPlaylist[];
captions?: MediaPlaylist[];
contentSteering: ContentSteeringOptions | null;
levels: LevelParsed[];
networkDetails: any;
sessionData: Record<string, AttrList> | null;
sessionKeys: LevelKey[] | null;
startTimeOffset: number | null;
stats: LoaderStats;
subtitles?: MediaPlaylist[];
url: string;
variableList: VariableMap | null;
}
export interface ManifestParsedData {
levels: Level[];
audioTracks: MediaPlaylist[];
subtitleTracks: MediaPlaylist[];
sessionData: Record<string, AttrList> | null;
sessionKeys: LevelKey[] | null;
firstLevel: number;
stats: LoaderStats;
audio: boolean;
video: boolean;
altAudio: boolean;
}
export interface LevelSwitchingData {
level: number;
attrs: LevelAttributes;
details: LevelDetails | undefined;
bitrate: number;
averageBitrate: number;
maxBitrate: number;
realBitrate: number;
width: number;
height: number;
codecSet: string;
audioCodec: string | undefined;
videoCodec: string | undefined;
audioGroups: (string | undefined)[] | undefined;
subtitleGroups: (string | undefined)[] | undefined;
loaded: { bytes: number; duration: number } | undefined;
loadError: number;
fragmentError: number;
name: string | undefined;
id: number;
uri: string;
// Deprecated (retained for backwards compatibility)
url: string[];
urlId: 0;
audioGroupIds: (string | undefined)[] | undefined;
textGroupIds: (string | undefined)[] | undefined;
}
export interface LevelSwitchedData {
level: number;
}
export interface TrackLoadingData {
id: number;
groupId: string;
track: MediaPlaylist;
url: string;
deliveryDirectives: HlsUrlParameters | null;
}
export interface LevelLoadingData {
id: number;
level: number;
levelInfo: Level;
pathwayId: string | undefined;
url: string;
deliveryDirectives: HlsUrlParameters | null;
}
export interface TrackLoadedData {
details: LevelDetails;
id: number;
groupId: string;
networkDetails: any;
stats: LoaderStats;
deliveryDirectives: HlsUrlParameters | null;
track: MediaPlaylist;
}
export interface LevelLoadedData {
details: LevelDetails;
id: number;
level: number;
levelInfo: Level;
networkDetails: any;
stats: LoaderStats;
deliveryDirectives: HlsUrlParameters | null;
withoutMultiVariant?: boolean;
}
export interface LevelUpdatedData {
details: LevelDetails;
level: number;
}
export interface AudioTrackUpdatedData {
details: LevelDetails;
id: number;
groupId: string;
}
export interface SubtitleTrackUpdatedData {
details: LevelDetails;
id: number;
groupId: string;
}
export interface LevelPTSUpdatedData {
details: LevelDetails;
level: Level;
drift: number;
type: string;
frag: Fragment;
start: number;
end: number;
}
export interface AudioTrackSwitchingData extends MediaPlaylist {}
export interface AudioTrackSwitchedData extends MediaPlaylist {}
export interface AudioTrackLoadedData extends TrackLoadedData {}
export interface AudioTracksUpdatedData {
audioTracks: MediaPlaylist[];
}
export interface SubtitleTracksUpdatedData {
subtitleTracks: MediaPlaylist[];
}
export interface SubtitleTrackSwitchData {
id: number;
name?: string;
groupId?: string;
type?: MediaPlaylistType | 'main';
url?: string;
}
export interface SubtitleTrackLoadedData extends TrackLoadedData {}
export interface TrackSwitchedData {
id: number;
}
export interface SubtitleFragProcessed {
success: boolean;
frag: Fragment;
}
export interface FragChangedData {
frag: Fragment;
}
export interface FPSDropData {
currentDropped: number;
currentDecoded: number;
totalDroppedFrames: number;
}
export interface FPSDropLevelCappingData {
droppedLevel: number;
level: number;
}
export interface MaxAutoLevelUpdatedData {
autoLevelCapping: number;
levels: Level[] | null;
maxAutoLevel: number;
minAutoLevel: number;
maxHdcpLevel: HdcpLevel;
}
export interface ErrorData {
type: ErrorTypes;
details: ErrorDetails;
error: Error;
fatal: boolean;
errorAction?: IErrorAction;
buffer?: number;
bufferInfo?: BufferInfo;
bytes?: number;
chunkMeta?: ChunkMetadata;
context?: PlaylistLoaderContext;
decryptdata?: LevelKey;
event?: keyof HlsListeners | 'demuxerWorker';
frag?: Fragment;
part?: Part | null;
level?: number | undefined;
levelRetry?: boolean;
loader?: Loader<LoaderContext>;
networkDetails?: any;
stalled?: { start: number };
stats?: LoaderStats;
mimeType?: string;
reason?: string;
response?: LoaderResponse;
url?: string;
parent?: PlaylistLevelType;
sourceBufferName?: SourceBufferName;
interstitial?: InterstitialEvent;
/**
* @deprecated Use ErrorData.error
*/
err?: {
// comes from transmuxer interface
message: string;
};
}
export interface SubtitleFragProcessedData {
success: boolean;
frag: Fragment;
error?: Error;
}
export interface CuesParsedData {
type: 'captions' | 'subtitles';
cues: any;
track: string;
}
export interface NonNativeTextTrack {
_id?: string;
label: any;
kind: string;
default: boolean;
closedCaptions?: MediaPlaylist;
subtitleTrack?: MediaPlaylist;
}
export interface NonNativeTextTracksData {
tracks: Array<NonNativeTextTrack>;
}
export interface InitPTSFoundData {
id: PlaylistLevelType;
frag: MediaFragment;
initPTS: number;
timescale: number;
trackId: number;
}
export interface FragLoadingData {
frag: Fragment;
part?: Part;
targetBufferTime: number | null;
}
export interface FragLoadEmergencyAbortedData {
frag: Fragment;
part: Part | null;
stats: LoaderStats;
}
export interface FragLoadedData {
frag: Fragment;
part: Part | null;
payload: ArrayBuffer;
networkDetails: unknown;
}
export interface PartsLoadedData {
frag: Fragment;
part: Part | null;
partsLoaded?: FragLoadedData[];
}
export interface FragDecryptedData {
frag: Fragment;
payload: ArrayBuffer;
stats: {
tstart: number;
tdecrypt: number;
};
}
export interface FragParsingInitSegmentData {}
export interface FragParsingUserdataData {
id: string;
frag: Fragment;
details: LevelDetails;
samples: UserdataSample[];
}
export interface FragParsingMetadataData {
id: string;
frag: Fragment;
details: LevelDetails;
samples: MetadataSample[];
}
export interface FragParsedData {
frag: Fragment;
part: Part | null;
}
export interface FragBufferedData {
stats: LoadStats;
frag: Fragment;
part: Part | null;
id: string;
}
export interface LevelsUpdatedData {
levels: Array<Level>;
}
export interface KeyLoadingData {
frag: Fragment;
}
export interface KeyLoadedData {
frag: Fragment;
keyInfo: KeyLoaderInfo;
}
export interface BackBufferData {
bufferEnd: number;
}
/**
* @deprecated Use BackBufferData
*/
export interface LiveBackBufferData extends BackBufferData {}
export interface SteeringManifestLoadedData {
steeringManifest: SteeringManifest;
url: string;
}
export interface AssetListLoadingData {
event: InterstitialEventWithAssetList;
}
export interface AssetListLoadedData {
event: InterstitialEventWithAssetList;
assetListResponse: AssetListJSON;
networkDetails: any;
}
export interface InterstitialsUpdatedData {
events: InterstitialEvent[];
schedule: InterstitialScheduleItem[];
durations: InterstitialScheduleDurations;
removedIds: string[];
}
export interface InterstitialsBufferedToBoundaryData {
events: InterstitialEvent[];
schedule: InterstitialScheduleItem[];
bufferingIndex: number;
playingIndex: number;
}
export interface InterstitialAssetPlayerCreatedData {
asset: InterstitialAssetItem;
assetListIndex: number;
assetListResponse?: AssetListJSON;
event: InterstitialEvent;
player: HlsAssetPlayer;
}
export interface InterstitialStartedData {
event: InterstitialEvent;
schedule: InterstitialScheduleItem[];
scheduleIndex: number;
}
export interface InterstitialEndedData {
event: InterstitialEvent;
schedule: InterstitialScheduleItem[];
scheduleIndex: number;
}
export interface InterstitialAssetStartedData {
asset: InterstitialAssetItem;
assetListIndex: number;
event: InterstitialEvent;
schedule: InterstitialScheduleItem[];
scheduleIndex: number;
player: HlsAssetPlayer;
}
export interface InterstitialAssetEndedData {
asset: InterstitialAssetItem;
assetListIndex: number;
event: InterstitialEvent;
schedule: InterstitialScheduleItem[];
scheduleIndex: number;
player: HlsAssetPlayer;
}
export type InterstitialAssetErrorData = {
asset: InterstitialAssetItem | null;
assetListIndex: number;
event: InterstitialEvent | null;
schedule: InterstitialScheduleItem[] | null;
scheduleIndex: number;
player: HlsAssetPlayer | null;
} & ErrorData;
export interface InterstitialsPrimaryResumed {
schedule: InterstitialScheduleItem[];
scheduleIndex: number;
}
+23
View File
@@ -0,0 +1,23 @@
import type { SourceBufferName } from './buffer';
import type { FragLoadedData } from './events';
import type { MediaFragment } from '../loader/fragment';
export interface FragmentEntity {
body: MediaFragment;
// appendedPTS is the latest buffered presentation time within the fragment's time range.
// It is used to determine: which fragment is appended at any given position, and hls.currentLevel.
appendedPTS: number | null;
loaded: FragLoadedData | null;
buffered: boolean;
range: { [key in SourceBufferName]: FragmentBufferedRange };
}
export interface FragmentTimeRange {
startPTS: number;
endPTS: number;
}
export interface FragmentBufferedRange {
time: Array<FragmentTimeRange>;
partial: boolean;
}
Generated Vendored Executable
+268
View File
@@ -0,0 +1,268 @@
import type { MediaPlaylist } from './media-playlist';
import type { LevelDetails } from '../loader/level-details';
import type { AttrList } from '../utils/attr-list';
import type { MediaDecodingInfo } from '../utils/mediacapabilities-helper';
export interface LevelParsed extends CodecsParsed {
attrs: LevelAttributes;
bitrate: number;
details?: LevelDetails;
height?: number;
id?: number;
name: string;
supplemental?: CodecsParsed;
url: string;
width?: number;
}
export interface CodecsParsed {
audioCodec?: string;
videoCodec?: string;
textCodec?: string;
unknownCodecs?: string[];
}
export interface LevelAttributes extends AttrList {
'ALLOWED-CPC'?: string;
AUDIO?: string;
'AVERAGE-BANDWIDTH'?: string;
BANDWIDTH?: string;
'CLOSED-CAPTIONS'?: string;
CODECS?: string;
'FRAME-RATE'?: string;
'HDCP-LEVEL'?: 'TYPE-0' | 'TYPE-1' | 'NONE';
'PATHWAY-ID'?: string;
RESOLUTION?: string;
SCORE?: string;
'STABLE-VARIANT-ID'?: string;
SUBTITLES?: string;
'SUPPLEMENTAL-CODECS'?: string;
VIDEO?: string;
'VIDEO-RANGE'?: VideoRange;
}
export const HdcpLevels = ['NONE', 'TYPE-0', 'TYPE-1', null] as const;
export type HdcpLevel = (typeof HdcpLevels)[number];
export function isHdcpLevel(value: any): value is HdcpLevel {
return HdcpLevels.indexOf(value) > -1;
}
export const VideoRangeValues = ['SDR', 'PQ', 'HLG'] as const;
export type VideoRange = (typeof VideoRangeValues)[number];
export function isVideoRange(value: any): value is VideoRange {
return !!value && VideoRangeValues.indexOf(value) > -1;
}
export type VariableMap = Record<string, string>;
export const enum HlsSkip {
No = '',
Yes = 'YES',
v2 = 'v2',
}
export function getSkipValue(details: LevelDetails): HlsSkip {
const { canSkipUntil, canSkipDateRanges, age } = details;
// A Client SHOULD NOT request a Playlist Delta Update unless it already
// has a version of the Playlist that is no older than one-half of the Skip Boundary.
// @see: https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis#section-6.3.7
const playlistRecentEnough = age < canSkipUntil / 2;
if (canSkipUntil && playlistRecentEnough) {
if (canSkipDateRanges) {
return HlsSkip.v2;
}
return HlsSkip.Yes;
}
return HlsSkip.No;
}
export class HlsUrlParameters {
msn?: number;
part?: number;
skip?: HlsSkip;
constructor(msn?: number, part?: number, skip?: HlsSkip) {
this.msn = msn;
this.part = part;
this.skip = skip;
}
addDirectives(uri: string): string | never {
const url: URL = new self.URL(uri);
if (this.msn !== undefined) {
url.searchParams.set('_HLS_msn', this.msn.toString());
}
if (this.part !== undefined) {
url.searchParams.set('_HLS_part', this.part.toString());
}
if (this.skip) {
url.searchParams.set('_HLS_skip', this.skip);
}
return url.href;
}
}
export class Level {
public readonly _attrs: LevelAttributes[];
public readonly audioCodec: string | undefined;
public readonly bitrate: number;
public readonly codecSet: string;
public readonly url: string[];
public readonly frameRate: number;
public readonly height: number;
public readonly id: number;
public readonly name: string;
public readonly supplemental: CodecsParsed | undefined;
public readonly videoCodec: string | undefined;
public readonly width: number;
public details?: LevelDetails;
public fragmentError: number = 0;
public loadError: number = 0;
public loaded?: { bytes: number; duration: number };
public realBitrate: number = 0;
public supportedPromise?: Promise<MediaDecodingInfo>;
public supportedResult?: MediaDecodingInfo;
private _avgBitrate: number = 0;
private _audioGroups?: (string | undefined)[];
private _subtitleGroups?: (string | undefined)[];
// Deprecated (retained for backwards compatibility)
private readonly _urlId: number = 0;
constructor(data: LevelParsed | MediaPlaylist) {
this.url = [data.url];
this._attrs = [data.attrs];
this.bitrate = data.bitrate;
if (data.details) {
this.details = data.details;
}
this.id = data.id || 0;
this.name = data.name;
this.width = data.width || 0;
this.height = data.height || 0;
this.frameRate = data.attrs.optionalFloat('FRAME-RATE', 0);
this._avgBitrate = data.attrs.decimalInteger('AVERAGE-BANDWIDTH');
this.audioCodec = data.audioCodec;
this.videoCodec = data.videoCodec;
this.codecSet = [data.videoCodec, data.audioCodec]
.filter((c) => !!c)
.map((s: string) => s.substring(0, 4))
.join(',');
if ('supplemental' in data) {
this.supplemental = data.supplemental;
const supplementalVideo = data.supplemental?.videoCodec;
if (supplementalVideo && supplementalVideo !== data.videoCodec) {
this.codecSet += `,${supplementalVideo.substring(0, 4)}`;
}
}
this.addGroupId('audio', data.attrs.AUDIO);
this.addGroupId('text', data.attrs.SUBTITLES);
}
get maxBitrate(): number {
return Math.max(this.realBitrate, this.bitrate);
}
get averageBitrate(): number {
return this._avgBitrate || this.realBitrate || this.bitrate;
}
get attrs(): LevelAttributes {
return this._attrs[0];
}
get codecs(): string {
return this.attrs.CODECS || '';
}
get pathwayId(): string {
return this.attrs['PATHWAY-ID'] || '.';
}
get videoRange(): VideoRange {
return this.attrs['VIDEO-RANGE'] || 'SDR';
}
get score(): number {
return this.attrs.optionalFloat('SCORE', 0);
}
get uri(): string {
return this.url[0] || '';
}
hasAudioGroup(groupId: string | undefined): boolean {
return hasGroup(this._audioGroups, groupId);
}
hasSubtitleGroup(groupId: string | undefined): boolean {
return hasGroup(this._subtitleGroups, groupId);
}
get audioGroups(): (string | undefined)[] | undefined {
return this._audioGroups;
}
get subtitleGroups(): (string | undefined)[] | undefined {
return this._subtitleGroups;
}
addGroupId(type: string, groupId: string | undefined) {
if (!groupId) {
return;
}
if (type === 'audio') {
let audioGroups = this._audioGroups;
if (!audioGroups) {
audioGroups = this._audioGroups = [];
}
if (audioGroups.indexOf(groupId) === -1) {
audioGroups.push(groupId);
}
} else if (type === 'text') {
let subtitleGroups = this._subtitleGroups;
if (!subtitleGroups) {
subtitleGroups = this._subtitleGroups = [];
}
if (subtitleGroups.indexOf(groupId) === -1) {
subtitleGroups.push(groupId);
}
}
}
// Deprecated methods (retained for backwards compatibility)
get urlId(): number {
return 0;
}
set urlId(value: number) {}
get audioGroupIds(): (string | undefined)[] | undefined {
return this.audioGroups ? [this.audioGroupId] : undefined;
}
get textGroupIds(): (string | undefined)[] | undefined {
return this.subtitleGroups ? [this.textGroupId] : undefined;
}
get audioGroupId(): string | undefined {
return this.audioGroups?.[0];
}
get textGroupId(): string | undefined {
return this.subtitleGroups?.[0];
}
addFallback() {}
}
function hasGroup(
groups: (string | undefined)[] | undefined,
groupId: string | undefined,
): boolean {
if (!groupId || !groups) {
return false;
}
return groups.indexOf(groupId) !== -1;
}
+197
View File
@@ -0,0 +1,197 @@
import type { LoaderConfig } from '../config';
import type { HlsUrlParameters, Level } from './level';
import type { MediaPlaylist } from './media-playlist';
import type { Fragment } from '../loader/fragment';
import type { Part } from '../loader/fragment';
import type { KeyLoaderInfo } from '../loader/key-loader';
import type { LevelDetails } from '../loader/level-details';
export interface LoaderContext {
// target URL
url: string;
// loader response type (arraybuffer or default response type for playlist)
responseType: string;
// headers
headers?: Record<string, string>;
// start byte range offset
rangeStart?: number;
// end byte range offset
rangeEnd?: number;
// true if onProgress should report partial chunk of loaded content
progressData?: boolean;
}
export interface FragmentLoaderContext extends LoaderContext {
frag: Fragment;
part: Part | null;
resetIV?: boolean;
}
export interface KeyLoaderContext extends LoaderContext {
keyInfo: KeyLoaderInfo;
frag: Fragment;
}
export interface LoaderConfiguration {
// LoaderConfig policy that overrides required settings
loadPolicy: LoaderConfig;
/**
* @deprecated use LoaderConfig timeoutRetry and errorRetry maxNumRetry
*/
// Max number of load retries
maxRetry: number;
/**
* @deprecated use LoaderConfig maxTimeToFirstByteMs and maxLoadTimeMs
*/
// Timeout after which `onTimeOut` callback will be triggered
// when loading has not finished after that delay
timeout: number;
/**
* @deprecated use LoaderConfig timeoutRetry and errorRetry retryDelayMs
*/
// Delay between an I/O error and following connection retry (ms).
// This to avoid spamming the server
retryDelay: number;
/**
* @deprecated use LoaderConfig timeoutRetry and errorRetry maxRetryDelayMs
*/
// max connection retry delay (ms)
maxRetryDelay: number;
// When streaming progressively, this is the minimum chunk size required to emit a PROGRESS event
highWaterMark?: number;
}
export interface LoaderResponse {
url: string;
data?: string | ArrayBuffer | Object;
// Errors can include HTTP status code and error message
// Successful responses should include status code 200
code?: number;
text?: string;
}
export interface LoaderStats {
aborted: boolean;
loaded: number;
retry: number;
total: number;
chunkCount: number;
bwEstimate: number;
loading: HlsProgressivePerformanceTiming;
parsing: HlsPerformanceTiming;
buffering: HlsProgressivePerformanceTiming;
}
export interface HlsPerformanceTiming {
start: number;
end: number;
}
export interface HlsChunkPerformanceTiming extends HlsPerformanceTiming {
executeStart: number;
executeEnd: number;
}
export interface HlsProgressivePerformanceTiming extends HlsPerformanceTiming {
first: number;
}
export type LoaderOnSuccess<T extends LoaderContext> = (
response: LoaderResponse,
stats: LoaderStats,
context: T,
networkDetails: any,
) => void;
export type LoaderOnProgress<T extends LoaderContext> = (
stats: LoaderStats,
context: T,
data: string | ArrayBuffer,
networkDetails: any,
) => void;
export type LoaderOnError<T extends LoaderContext> = (
error: {
// error status code
code: number;
// error description
text: string;
},
context: T,
networkDetails: any,
stats: LoaderStats,
) => void;
export type LoaderOnTimeout<T extends LoaderContext> = (
stats: LoaderStats,
context: T,
networkDetails: any,
) => void;
export type LoaderOnAbort<T extends LoaderContext> = (
stats: LoaderStats,
context: T,
networkDetails: any,
) => void;
export interface LoaderCallbacks<T extends LoaderContext> {
onSuccess: LoaderOnSuccess<T>;
onError: LoaderOnError<T>;
onTimeout: LoaderOnTimeout<T>;
onAbort?: LoaderOnAbort<T>;
onProgress?: LoaderOnProgress<T>;
}
export interface Loader<T extends LoaderContext> {
destroy(): void;
abort(): void;
load(
context: T,
config: LoaderConfiguration,
callbacks: LoaderCallbacks<T>,
): void;
/**
* `getCacheAge()` is called by hls.js to get the duration that a given object
* has been sitting in a cache proxy when playing live. If implemented,
* this should return a value in seconds.
*
* For HTTP based loaders, this should return the contents of the "age" header.
*
* @returns time object being lodaded
*/
getCacheAge?: () => number | null;
getResponseHeader?: (name: string) => string | null;
context: T | null;
stats: LoaderStats;
}
export const enum PlaylistContextType {
MANIFEST = 'manifest',
LEVEL = 'level',
AUDIO_TRACK = 'audioTrack',
SUBTITLE_TRACK = 'subtitleTrack',
}
export const enum PlaylistLevelType {
MAIN = 'main',
AUDIO = 'audio',
SUBTITLE = 'subtitle',
}
export interface PlaylistLoaderContext extends LoaderContext {
type: PlaylistContextType;
// the level index to load
level: number | null;
// level or track id from LevelLoadingData / TrackLoadingData
id: number | null;
// Media Playlist Group ID
groupId?: string;
// Content Steering Pathway ID (or undefined for default Pathway ".")
pathwayId?: string;
// internal representation of a parsed m3u8 level playlist
levelDetails?: LevelDetails;
// Blocking playlist request delivery directives (or null id none were added to playlist url
deliveryDirectives: HlsUrlParameters | null;
// Reference to level or track object in hls.levels, hls.allAudioTracks, or hls.allSubtitleTracks (null when loading MVP)
levelOrTrack: Level | MediaPlaylist | null;
}
+90
View File
@@ -0,0 +1,90 @@
import type { Level, VideoRange } from './level';
import type { PlaylistLevelType } from './loader';
import type { LevelDetails } from '../loader/level-details';
import type { AttrList } from '../utils/attr-list';
export type AudioPlaylistType = 'AUDIO';
export type MainPlaylistType = AudioPlaylistType | 'VIDEO';
export type SubtitlePlaylistType = 'SUBTITLES' | 'CLOSED-CAPTIONS';
export type MediaPlaylistType = MainPlaylistType | SubtitlePlaylistType;
export type MediaSelection = {
[PlaylistLevelType.MAIN]: Level;
[PlaylistLevelType.AUDIO]?: MediaPlaylist;
[PlaylistLevelType.SUBTITLE]?: MediaPlaylist;
};
export type VideoSelectionOption = {
preferHDR?: boolean;
allowedVideoRanges?: Array<VideoRange>;
videoCodec?: string;
};
export type AudioSelectionOption = {
lang?: string;
assocLang?: string;
characteristics?: string;
channels?: string;
name?: string;
audioCodec?: string;
groupId?: string;
default?: boolean;
};
export type SubtitleSelectionOption = {
id?: number;
lang?: string;
assocLang?: string;
characteristics?: string;
name?: string;
groupId?: string;
default?: boolean;
forced?: boolean;
};
// audioTracks, captions and subtitles returned by `M3U8Parser.parseMasterPlaylistMedia`
export interface MediaPlaylist {
attrs: MediaAttributes;
audioCodec?: string;
autoselect: boolean; // implicit false if not present
bitrate: number;
channels?: string;
characteristics?: string;
details?: LevelDetails;
height?: number;
default: boolean; // implicit false if not present
forced: boolean; // implicit false if not present
groupId: string; // required in HLS playlists
id: number; // incrementing number to track media playlists
instreamId?: string;
lang?: string;
assocLang?: string;
name: string;
textCodec?: string;
unknownCodecs?: string[];
// 'main' is a custom type added to signal a audioCodec in main track?; see playlist-loader~L310
type: MediaPlaylistType | 'main';
url: string;
videoCodec?: string;
width?: number;
}
export interface MediaAttributes extends AttrList {
'ASSOC-LANGUAGE'?: string;
AUTOSELECT?: 'YES' | 'NO';
CHANNELS?: string;
CHARACTERISTICS?: string;
DEFAULT?: 'YES' | 'NO';
FORCED?: 'YES' | 'NO';
'GROUP-ID': string;
'INSTREAM-ID'?: string;
LANGUAGE?: string;
NAME: string;
'PATHWAY-ID'?: string;
'STABLE-RENDITION-ID'?: string;
TYPE?: 'AUDIO' | 'VIDEO' | 'SUBTITLES' | 'CLOSED-CAPTIONS';
URI?: string;
}
+104
View File
@@ -0,0 +1,104 @@
import type { SourceBufferName } from './buffer';
import type {
DemuxedAudioTrack,
DemuxedMetadataTrack,
DemuxedUserdataTrack,
DemuxedVideoTrack,
DemuxedVideoTrackBase,
MetadataSample,
UserdataSample,
} from './demuxer';
import type { PlaylistLevelType } from './loader';
import type { TrackSet } from './track';
import type { DecryptData } from '../loader/level-key';
import type { TimestampOffset } from '../utils/timescale-conversion';
export interface Remuxer {
remux(
audioTrack: DemuxedAudioTrack,
videoTrack: DemuxedVideoTrackBase,
id3Track: DemuxedMetadataTrack,
textTrack: DemuxedUserdataTrack,
timeOffset: number,
accurateTimeOffset: boolean,
flush: boolean,
playlistType: PlaylistLevelType,
): RemuxerResult;
resetInitSegment(
initSegment: Uint8Array | undefined,
audioCodec: string | undefined,
videoCodec: string | undefined,
decryptdata: DecryptData | null,
): void;
resetTimeStamp(defaultInitPTS: TimestampOffset | null): void;
resetNextTimestamp(): void;
destroy(): void;
}
export interface RemuxedTrack {
data1: Uint8Array<ArrayBuffer>;
data2?: Uint8Array<ArrayBuffer>;
startPTS: number;
endPTS: number;
startDTS: number;
endDTS: number;
type: SourceBufferName;
hasAudio: boolean;
hasVideo: boolean;
independent?: boolean;
firstKeyFrame?: number;
firstKeyFramePTS?: number;
nb: number;
transferredData1?: ArrayBuffer;
transferredData2?: ArrayBuffer;
dropped?: number;
encrypted?: boolean;
}
export interface RemuxedMetadata {
samples: MetadataSample[];
}
export interface RemuxedUserdata {
samples: UserdataSample[];
}
export type Mp4SampleFlags = {
isLeading: 0;
isDependedOn: 0;
hasRedundancy: 0;
degradPrio: 0;
dependsOn: 1 | 2;
isNonSync: 0 | 1;
};
export type Mp4Sample = {
size: number;
duration: number;
cts: number;
flags: Mp4SampleFlags;
};
export type RemuxedAudioTrackSamples = DemuxedAudioTrack & {
samples: Mp4Sample[];
};
export type RemuxedVideoTrackSamples = DemuxedVideoTrack & {
samples: Mp4Sample[];
};
export interface RemuxerResult {
audio?: RemuxedTrack;
video?: RemuxedTrack;
text?: RemuxedUserdata;
id3?: RemuxedMetadata;
initSegment?: InitSegmentData;
independent?: boolean;
}
export interface InitSegmentData {
tracks?: TrackSet;
initPTS: number | undefined;
timescale: number | undefined;
trackId: number | undefined;
}
+12
View File
@@ -0,0 +1,12 @@
import type { BaseTrack } from './buffer';
export interface TrackSet {
audio?: Track;
video?: Track;
audiovideo?: Track;
}
export interface Track extends BaseTrack {
buffer?: SourceBuffer; // eslint-disable-line no-restricted-globals
initSegment?: Uint8Array;
}
+46
View File
@@ -0,0 +1,46 @@
import type { SourceBufferName } from './buffer';
import type { HlsChunkPerformanceTiming } from './loader';
import type { RemuxerResult } from './remuxer';
export interface TransmuxerResult {
remuxResult: RemuxerResult;
chunkMeta: ChunkMetadata;
}
export class ChunkMetadata {
public readonly level: number;
public readonly sn: number;
public readonly part: number;
public readonly id: number;
public readonly size: number;
public readonly partial: boolean;
public readonly transmuxing: HlsChunkPerformanceTiming =
getNewPerformanceTiming();
public readonly buffering: {
[key in SourceBufferName]: HlsChunkPerformanceTiming;
} = {
audio: getNewPerformanceTiming(),
video: getNewPerformanceTiming(),
audiovideo: getNewPerformanceTiming(),
};
constructor(
level: number,
sn: number,
id: number,
size = 0,
part = -1,
partial = false,
) {
this.level = level;
this.sn = sn;
this.id = id;
this.size = size;
this.part = part;
this.partial = partial;
}
}
function getNewPerformanceTiming(): HlsChunkPerformanceTiming {
return { start: 0, executeStart: 0, executeEnd: 0, end: 0 };
}
+6
View File
@@ -0,0 +1,6 @@
export type Tail<T extends any[]> = ((...t: T) => any) extends (
_: any,
...tail: infer U
) => any
? U
: [];
+9
View File
@@ -0,0 +1,9 @@
export type VTTCCs = {
ccOffset: number;
presentationOffset: number;
[key: number]: {
start: number;
prevCC: number;
new: boolean;
};
};
+22
View File
@@ -0,0 +1,22 @@
export function arrayValuesMatch(
a: (string | number)[] | Uint8Array,
b: (string | number)[] | Uint8Array,
): boolean {
if (a.length === b.length) {
return !a.some((value: string | number, i: number) => value !== b[i]);
}
return false;
}
export function optionalArrayValuesMatch(
a: (string | number)[] | Uint8Array | null | undefined,
b: (string | number)[] | Uint8Array | null | undefined,
): boolean {
if (!a && !b) {
return true;
}
if (!a || !b) {
return false;
}
return arrayValuesMatch(a, b);
}
Generated Vendored Executable
+192
View File
@@ -0,0 +1,192 @@
import { logger } from './logger';
import { substituteVariables } from './variable-substitution';
import type { LevelDetails } from '../loader/level-details';
import type { ParsedMultivariantPlaylist } from '../loader/m3u8-parser';
const DECIMAL_RESOLUTION_REGEX = /^(\d+)x(\d+)$/;
const ATTR_LIST_REGEX = /(.+?)=(".*?"|.*?)(?:,|$)/g;
// adapted from https://github.com/kanongil/node-m3u8parse/blob/master/attrlist.js
export class AttrList {
[key: string]: any;
constructor(
attrs: string | Record<string, any>,
parsed?: Pick<
ParsedMultivariantPlaylist | LevelDetails,
'variableList' | 'hasVariableRefs' | 'playlistParsingError'
>,
) {
if (typeof attrs === 'string') {
attrs = AttrList.parseAttrList(attrs, parsed);
}
Object.assign(this, attrs);
}
get clientAttrs(): string[] {
return Object.keys(this).filter((attr) => attr.substring(0, 2) === 'X-');
}
decimalInteger(attrName: string): number {
const intValue = parseInt(this[attrName], 10);
if (intValue > Number.MAX_SAFE_INTEGER) {
return Infinity;
}
return intValue;
}
hexadecimalInteger(attrName: string) {
if (this[attrName]) {
let stringValue = (this[attrName] || '0x').slice(2);
stringValue = (stringValue.length & 1 ? '0' : '') + stringValue;
const value = new Uint8Array(stringValue.length / 2);
for (let i = 0; i < stringValue.length / 2; i++) {
value[i] = parseInt(stringValue.slice(i * 2, i * 2 + 2), 16);
}
return value;
}
return null;
}
hexadecimalIntegerAsNumber(attrName: string): number {
const intValue = parseInt(this[attrName], 16);
if (intValue > Number.MAX_SAFE_INTEGER) {
return Infinity;
}
return intValue;
}
decimalFloatingPoint(attrName: string): number {
return parseFloat(this[attrName]);
}
optionalFloat(attrName: string, defaultValue: number): number {
const value = this[attrName];
return value ? parseFloat(value) : defaultValue;
}
enumeratedString(attrName: string): string | undefined {
return this[attrName];
}
enumeratedStringList<T extends { [key: string]: boolean }>(
attrName: string,
dict: T,
): { [key in keyof T]: boolean } {
const attrValue = this[attrName];
return (attrValue ? attrValue.split(/[ ,]+/) : []).reduce(
(result: { [key in keyof T]: boolean }, identifier: string) => {
result[identifier.toLowerCase() as keyof T] = true;
return result;
},
dict,
);
}
bool(attrName: string): boolean {
return this[attrName] === 'YES';
}
decimalResolution(attrName: string):
| {
width: number;
height: number;
}
| undefined {
const res = DECIMAL_RESOLUTION_REGEX.exec(this[attrName]);
if (res === null) {
return undefined;
}
return {
width: parseInt(res[1], 10),
height: parseInt(res[2], 10),
};
}
static parseAttrList(
input: string,
parsed?: Pick<
ParsedMultivariantPlaylist | LevelDetails,
'variableList' | 'hasVariableRefs' | 'playlistParsingError'
>,
): Record<string, string> {
let match: RegExpExecArray | null;
const attrs = {};
const quote = '"';
ATTR_LIST_REGEX.lastIndex = 0;
while ((match = ATTR_LIST_REGEX.exec(input)) !== null) {
const name = match[1].trim();
let value = match[2];
const quotedString =
value.indexOf(quote) === 0 &&
value.lastIndexOf(quote) === value.length - 1;
let hexadecimalSequence = false;
if (quotedString) {
value = value.slice(1, -1);
} else {
switch (name) {
case 'IV':
case 'SCTE35-CMD':
case 'SCTE35-IN':
case 'SCTE35-OUT':
hexadecimalSequence = true;
}
}
if (parsed && (quotedString || hexadecimalSequence)) {
if (__USE_VARIABLE_SUBSTITUTION__) {
value = substituteVariables(parsed, value);
}
} else if (!hexadecimalSequence && !quotedString) {
switch (name) {
case 'CLOSED-CAPTIONS':
if (value === 'NONE') {
break;
}
// falls through
case 'ALLOWED-CPC':
case 'CLASS':
case 'ASSOC-LANGUAGE':
case 'AUDIO':
case 'BYTERANGE':
case 'CHANNELS':
case 'CHARACTERISTICS':
case 'CODECS':
case 'DATA-ID':
case 'END-DATE':
case 'GROUP-ID':
case 'ID':
case 'IMPORT':
case 'INSTREAM-ID':
case 'KEYFORMAT':
case 'KEYFORMATVERSIONS':
case 'LANGUAGE':
case 'NAME':
case 'PATHWAY-ID':
case 'QUERYPARAM':
case 'RECENTLY-REMOVED-DATERANGES':
case 'SERVER-URI':
case 'STABLE-RENDITION-ID':
case 'STABLE-VARIANT-ID':
case 'START-DATE':
case 'SUBTITLES':
case 'SUPPLEMENTAL-CODECS':
case 'URI':
case 'VALUE':
case 'VIDEO':
case 'X-ASSET-LIST':
case 'X-ASSET-URI':
// Since we are not checking tag:attribute combination, just warn rather than ignoring attribute
logger.warn(`${input}: attribute ${name} is missing quotes`);
// continue;
}
}
attrs[name] = value;
}
return attrs;
}
}
+46
View File
@@ -0,0 +1,46 @@
type BinarySearchComparison<T> = (candidate: T) => -1 | 0 | 1;
const BinarySearch = {
/**
* Searches for an item in an array which matches a certain condition.
* This requires the condition to only match one item in the array,
* and for the array to be ordered.
*
* @param list The array to search.
* @param comparisonFn
* Called and provided a candidate item as the first argument.
* Should return:
* > -1 if the item should be located at a lower index than the provided item.
* > 1 if the item should be located at a higher index than the provided item.
* > 0 if the item is the item you're looking for.
*
* @returns the object if found, otherwise returns null
*/
search: function <T>(
list: T[],
comparisonFn: BinarySearchComparison<T>,
): T | null {
let minIndex: number = 0;
let maxIndex: number = list.length - 1;
let currentIndex: number | null = null;
let currentElement: T | null = null;
while (minIndex <= maxIndex) {
currentIndex = ((minIndex + maxIndex) / 2) | 0;
currentElement = list[currentIndex];
const comparisonResult = comparisonFn(currentElement);
if (comparisonResult > 0) {
minIndex = currentIndex + 1;
} else if (comparisonResult < 0) {
maxIndex = currentIndex - 1;
} else {
return currentElement;
}
}
return null;
},
};
export default BinarySearch;
+173
View File
@@ -0,0 +1,173 @@
/**
* Provides methods dealing with buffer length retrieval for example.
*
* In general, a helper around HTML5 MediaElement TimeRanges gathered from `buffered` property.
*
* Also @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/buffered
*/
import { logger } from './logger';
export type BufferTimeRange = {
start: number;
end: number;
};
export type Bufferable = {
buffered: TimeRanges;
};
export type BufferInfo = {
len: number;
start: number;
end: number;
nextStart?: number;
buffered?: BufferTimeRange[];
bufferedIndex: number;
};
const noopBuffered: TimeRanges = {
length: 0,
start: () => 0,
end: () => 0,
};
export class BufferHelper {
/**
* Return true if `media`'s buffered include `position`
*/
static isBuffered(media: Bufferable, position: number): boolean {
if (media) {
const buffered = BufferHelper.getBuffered(media);
for (let i = buffered.length; i--; ) {
if (position >= buffered.start(i) && position <= buffered.end(i)) {
return true;
}
}
}
return false;
}
static bufferedRanges(media: Bufferable | null): BufferTimeRange[] {
if (media) {
const timeRanges = BufferHelper.getBuffered(media);
return BufferHelper.timeRangesToArray(timeRanges);
}
return [];
}
static timeRangesToArray(timeRanges: TimeRanges): BufferTimeRange[] {
const buffered: BufferTimeRange[] = [];
for (let i = 0; i < timeRanges.length; i++) {
buffered.push({ start: timeRanges.start(i), end: timeRanges.end(i) });
}
return buffered;
}
static bufferInfo(
media: Bufferable | null,
pos: number,
maxHoleDuration: number,
): BufferInfo {
if (media) {
const buffered = BufferHelper.bufferedRanges(media);
if (buffered.length) {
return BufferHelper.bufferedInfo(buffered, pos, maxHoleDuration);
}
}
return { len: 0, start: pos, end: pos, bufferedIndex: -1 };
}
static bufferedInfo(
buffered: BufferTimeRange[],
pos: number,
maxHoleDuration: number,
): BufferInfo {
pos = Math.max(0, pos);
// sort on buffer.start/smaller end (IE does not always return sorted buffered range)
if (buffered.length > 1) {
buffered.sort((a, b) => a.start - b.start || b.end - a.end);
}
let bufferedIndex: number = -1;
let buffered2: BufferTimeRange[] = [];
if (maxHoleDuration) {
// there might be some small holes between buffer time range
// consider that holes smaller than maxHoleDuration are irrelevant and build another
// buffer time range representations that discards those holes
for (let i = 0; i < buffered.length; i++) {
if (pos >= buffered[i].start && pos <= buffered[i].end) {
bufferedIndex = i;
}
const buf2len = buffered2.length;
if (buf2len) {
const buf2end = buffered2[buf2len - 1].end;
// if small hole (value between 0 or maxHoleDuration ) or overlapping (negative)
if (buffered[i].start - buf2end < maxHoleDuration) {
// merge overlapping time ranges
// update lastRange.end only if smaller than item.end
// e.g. [ 1, 15] with [ 2,8] => [ 1,15] (no need to modify lastRange.end)
// whereas [ 1, 8] with [ 2,15] => [ 1,15] ( lastRange should switch from [1,8] to [1,15])
if (buffered[i].end > buf2end) {
buffered2[buf2len - 1].end = buffered[i].end;
}
} else {
// big hole
buffered2.push(buffered[i]);
}
} else {
// first value
buffered2.push(buffered[i]);
}
}
} else {
buffered2 = buffered;
}
let bufferLen = 0;
let nextStart: number | undefined;
// bufferStart and bufferEnd are buffer boundaries around current playback position (pos)
let bufferStart: number = pos;
let bufferEnd: number = pos;
for (let i = 0; i < buffered2.length; i++) {
const start = buffered2[i].start;
const end = buffered2[i].end;
// logger.log('buf start/end:' + buffered.start(i) + '/' + buffered.end(i));
if (bufferedIndex === -1 && pos >= start && pos <= end) {
bufferedIndex = i;
}
if (pos + maxHoleDuration >= start && pos < end) {
// play position is inside this buffer TimeRange, retrieve end of buffer position and buffer length
bufferStart = start;
bufferEnd = end;
bufferLen = bufferEnd - pos;
} else if (pos + maxHoleDuration < start) {
nextStart = start;
break;
}
}
return {
len: bufferLen,
start: bufferStart || 0,
end: bufferEnd || 0,
nextStart,
buffered,
bufferedIndex,
};
}
/**
* Safe method to get buffered property.
* SourceBuffer.buffered may throw if SourceBuffer is removed from it's MediaSource
*/
static getBuffered(media: Bufferable): TimeRanges {
try {
return media.buffered || noopBuffered;
} catch (e) {
logger.log('failed to get media.buffered', e);
return noopBuffered;
}
}
}
+1413
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
import { appendUint8Array } from './mp4-tools';
export default class Chunker {
private chunkSize: number;
public cache: Uint8Array | null = null;
constructor(chunkSize = Math.pow(2, 19)) {
this.chunkSize = chunkSize;
}
public push(data: Uint8Array): Array<Uint8Array> {
const { cache, chunkSize } = this;
const result: Array<Uint8Array> = [];
let temp: Uint8Array | null = null;
if (cache?.length) {
temp = appendUint8Array(cache, data);
this.cache = null;
} else {
temp = data;
}
if (temp.length < chunkSize) {
this.cache = temp;
return result;
}
if (temp.length > chunkSize) {
let offset = 0;
const len = temp.length;
while (offset < len - chunkSize) {
result.push(temp.slice(offset, offset + chunkSize));
offset += chunkSize;
}
this.cache = temp.slice(offset);
} else {
result.push(temp);
}
return result;
}
}
+314
View File
@@ -0,0 +1,314 @@
import { getMediaSource } from './mediasource-helper';
import { isHEVC } from './mp4-tools';
export const userAgentHevcSupportIsInaccurate = () => {
return /\(Windows.+Firefox\//i.test(navigator.userAgent);
};
// from http://mp4ra.org/codecs.html
// values indicate codec selection preference (lower is higher priority)
export const sampleEntryCodesISO = {
audio: {
a3ds: 1,
'ac-3': 0.95,
'ac-4': 1,
alac: 0.9,
alaw: 1,
dra1: 1,
'dts+': 1,
'dts-': 1,
dtsc: 1,
dtse: 1,
dtsh: 1,
'ec-3': 0.9,
enca: 1,
fLaC: 0.9, // MP4-RA listed codec entry for FLAC
flac: 0.9, // legacy browser codec name for FLAC
FLAC: 0.9, // some manifests may list "FLAC" with Apple's tools
g719: 1,
g726: 1,
m4ae: 1,
mha1: 1,
mha2: 1,
mhm1: 1,
mhm2: 1,
mlpa: 1,
mp4a: 1,
'raw ': 1,
Opus: 1,
opus: 1, // browsers expect this to be lowercase despite MP4RA says 'Opus'
samr: 1,
sawb: 1,
sawp: 1,
sevc: 1,
sqcp: 1,
ssmv: 1,
twos: 1,
ulaw: 1,
},
video: {
avc1: 1,
avc2: 1,
avc3: 1,
avc4: 1,
avcp: 1,
av01: 0.8,
dav1: 0.8,
drac: 1,
dva1: 1,
dvav: 1,
dvh1: 0.7,
dvhe: 0.7,
encv: 1,
hev1: 0.75,
hvc1: 0.75,
mjp2: 1,
mp4v: 1,
mvc1: 1,
mvc2: 1,
mvc3: 1,
mvc4: 1,
resv: 1,
rv60: 1,
s263: 1,
svc1: 1,
svc2: 1,
'vc-1': 1,
vp08: 1,
vp09: 0.9,
},
text: {
stpp: 1,
wvtt: 1,
},
} as const;
export type CodecType = 'audio' | 'video';
export function isCodecType(codec: string, type: CodecType): boolean {
const typeCodes = sampleEntryCodesISO[type];
return !!typeCodes && !!typeCodes[codec.slice(0, 4)];
}
export function areCodecsMediaSourceSupported(
codecs: string,
type: CodecType,
preferManagedMediaSource = true,
): boolean {
return !codecs
.split(',')
.some(
(codec) =>
!isCodecMediaSourceSupported(codec, type, preferManagedMediaSource),
);
}
function isCodecMediaSourceSupported(
codec: string,
type: CodecType,
preferManagedMediaSource = true,
): boolean {
const MediaSource = getMediaSource(preferManagedMediaSource);
return MediaSource?.isTypeSupported(mimeTypeForCodec(codec, type)) ?? false;
}
export function mimeTypeForCodec(codec: string, type: CodecType): string {
return `${type}/mp4;codecs=${codec}`;
}
export function videoCodecPreferenceValue(
videoCodec: string | undefined,
): number {
if (videoCodec) {
const fourCC = videoCodec.substring(0, 4);
return sampleEntryCodesISO.video[fourCC];
}
return 2;
}
export function codecsSetSelectionPreferenceValue(codecSet: string): number {
const limitedHevcSupport = userAgentHevcSupportIsInaccurate();
return codecSet.split(',').reduce((num, fourCC) => {
const lowerPriority = limitedHevcSupport && isHEVC(fourCC);
const preferenceValue = lowerPriority
? 9
: sampleEntryCodesISO.video[fourCC];
if (preferenceValue) {
return (preferenceValue * 2 + num) / (num ? 3 : 2);
}
return (sampleEntryCodesISO.audio[fourCC] + num) / (num ? 2 : 1);
}, 0);
}
interface CodecNameCache {
flac?: string;
opus?: string;
}
const CODEC_COMPATIBLE_NAMES: CodecNameCache = {};
type LowerCaseCodecType = 'flac' | 'opus';
function getCodecCompatibleNameLower(
lowerCaseCodec: LowerCaseCodecType,
preferManagedMediaSource = true,
): string {
if (CODEC_COMPATIBLE_NAMES[lowerCaseCodec]) {
return CODEC_COMPATIBLE_NAMES[lowerCaseCodec]!;
}
const codecsToCheck = {
// Idealy fLaC and Opus would be first (spec-compliant) but
// some browsers will report that fLaC is supported then fail.
// see: https://bugs.chromium.org/p/chromium/issues/detail?id=1422728
flac: ['flac', 'fLaC', 'FLAC'],
opus: ['opus', 'Opus'],
// Replace audio codec info if browser does not support mp4a.40.34,
// and demuxer can fallback to 'audio/mpeg' or 'audio/mp4;codecs="mp3"'
'mp4a.40.34': ['mp3'],
}[lowerCaseCodec];
for (let i = 0; i < codecsToCheck.length; i++) {
if (
isCodecMediaSourceSupported(
codecsToCheck[i],
'audio',
preferManagedMediaSource,
)
) {
CODEC_COMPATIBLE_NAMES[lowerCaseCodec] = codecsToCheck[i];
return codecsToCheck[i];
} else if (
codecsToCheck[i] === 'mp3' &&
getMediaSource(preferManagedMediaSource)?.isTypeSupported('audio/mpeg')
) {
return '';
}
}
return lowerCaseCodec;
}
const AUDIO_CODEC_REGEXP = /flac|opus|mp4a\.40\.34/i;
export function getCodecCompatibleName(
codec: string,
preferManagedMediaSource = true,
): string {
return codec.replace(AUDIO_CODEC_REGEXP, (m) =>
getCodecCompatibleNameLower(
m.toLowerCase() as LowerCaseCodecType,
preferManagedMediaSource,
),
);
}
export function replaceVideoCodec(
originalCodecs: string | undefined,
newVideoCodec: string | undefined,
): string | undefined {
const codecs: string[] = [];
if (originalCodecs) {
const allCodecs = originalCodecs.split(',');
for (let i = 0; i < allCodecs.length; i++) {
if (!isCodecType(allCodecs[i], 'video')) {
codecs.push(allCodecs[i]);
}
}
}
if (newVideoCodec) {
codecs.push(newVideoCodec);
}
return codecs.join(',');
}
export function pickMostCompleteCodecName(
parsedCodec: string | undefined,
levelCodec: string | undefined,
): string | undefined {
// Parsing of mp4a codecs strings in mp4-tools from media is incomplete as of d8c6c7a
// so use level codec is parsed codec is unavailable or incomplete
if (
parsedCodec &&
(parsedCodec.length > 4 ||
['ac-3', 'ec-3', 'alac', 'fLaC', 'Opus'].indexOf(parsedCodec) !== -1)
) {
if (
isCodecSupportedAsType(parsedCodec, 'audio') ||
isCodecSupportedAsType(parsedCodec, 'video')
) {
return parsedCodec;
}
}
if (levelCodec) {
const levelCodecs = levelCodec.split(',');
if (levelCodecs.length > 1) {
if (parsedCodec) {
for (let i = levelCodecs.length; i--; ) {
if (levelCodecs[i].substring(0, 4) === parsedCodec.substring(0, 4)) {
return levelCodecs[i];
}
}
}
return levelCodecs[0];
}
}
return levelCodec || parsedCodec;
}
function isCodecSupportedAsType(codec: string, type: CodecType): boolean {
return isCodecType(codec, type) && isCodecMediaSourceSupported(codec, type);
}
export function convertAVC1ToAVCOTI(videoCodecs: string): string {
// Convert avc1 codec string from RFC-4281 to RFC-6381 for MediaSource.isTypeSupported
// Examples: avc1.66.30 to avc1.42001e and avc1.77.30,avc1.66.30 to avc1.4d001e,avc1.42001e.
const codecs = videoCodecs.split(',');
for (let i = 0; i < codecs.length; i++) {
const avcdata = codecs[i].split('.');
// only convert codec strings starting with avc1 (Examples: avc1.64001f,dvh1.05.07)
if (avcdata.length > 2 && avcdata[0] === 'avc1') {
codecs[i] = `avc1.${parseInt(avcdata[1]).toString(16)}${(
'000' + parseInt(avcdata[2]).toString(16)
).slice(-4)}`;
}
}
return codecs.join(',');
}
export function fillInMissingAV01Params(videoCodec: string): string {
// Used to fill in incomplete AV1 playlist CODECS strings for mediaCapabilities.decodingInfo queries
if (videoCodec.startsWith('av01.')) {
const av1params = videoCodec.split('.');
const placeholders = ['0', '111', '01', '01', '01', '0'];
for (let i = av1params.length; i > 4 && i < 10; i++) {
av1params[i] = placeholders[i - 4];
}
return av1params.join('.');
}
return videoCodec;
}
export interface TypeSupported {
mpeg: boolean;
mp3: boolean;
ac3: boolean;
}
export function getM2TSSupportedAudioTypes(
preferManagedMediaSource: boolean,
): TypeSupported {
const MediaSource = getMediaSource(preferManagedMediaSource) || {
isTypeSupported: () => false,
};
return {
mpeg: MediaSource.isTypeSupported('audio/mpeg'),
mp3: MediaSource.isTypeSupported('audio/mp4; codecs="mp3"'),
ac3: __USE_M2TS_ADVANCED_CODECS__
? MediaSource.isTypeSupported('audio/mp4; codecs="ac-3"')
: false,
};
}
export function getCodecsForMimeType(mimeType: string): string {
return mimeType.replace(/^.+codecs=["']?([^"']+).*$/, '$1');
}
+96
View File
@@ -0,0 +1,96 @@
import { addCueToTrack } from './texttrack-utils';
import { fixLineBreaks } from './vttparser';
import { generateCueId } from './webvtt-parser';
import type { CaptionScreen, Row } from './cea-608-parser';
const WHITESPACE_CHAR = /\s/;
export interface CuesInterface {
newCue(
track: TextTrack | null,
startTime: number,
endTime: number,
captionScreen: CaptionScreen,
): VTTCue[];
}
const Cues: CuesInterface = {
newCue(
track: TextTrack | null,
startTime: number,
endTime: number,
captionScreen: CaptionScreen,
): VTTCue[] {
const result: VTTCue[] = [];
let row: Row;
// the type data states this is VTTCue, but it can potentially be a TextTrackCue on old browsers
let cue: VTTCue;
let indenting: boolean;
let indent: number;
let text: string;
const Cue = (self.VTTCue || self.TextTrackCue) as any;
for (let r = 0; r < captionScreen.rows.length; r++) {
row = captionScreen.rows[r];
indenting = true;
indent = 0;
text = '';
if (!row.isEmpty()) {
for (let c = 0; c < row.chars.length; c++) {
if (WHITESPACE_CHAR.test(row.chars[c].uchar) && indenting) {
indent++;
} else {
text += row.chars[c].uchar;
indenting = false;
}
}
// To be used for cleaning-up orphaned roll-up captions
row.cueStartTime = startTime;
// Give a slight bump to the endTime if it's equal to startTime to avoid a SyntaxError in IE
if (startTime === endTime) {
endTime += 0.0001;
}
if (indent >= 16) {
indent--;
} else {
indent++;
}
const cueText = fixLineBreaks(text.trim());
const id = generateCueId(startTime, endTime, cueText);
// If this cue already exists in the track do not push it
if (!track?.cues?.getCueById(id)) {
cue = new Cue(startTime, endTime, cueText);
cue.id = id;
cue.line = r + 1;
cue.align = 'left';
// Clamp the position between 10 and 80 percent (CEA-608 PAC indent code)
// https://dvcs.w3.org/hg/text-tracks/raw-file/default/608toVTT/608toVTT.html#positioning-in-cea-608
// Firefox throws an exception and captions break with out of bounds 0-100 values
cue.position = 10 + Math.min(80, Math.floor((indent * 8) / 32) * 10);
result.push(cue);
}
}
}
if (track && result.length) {
// Sort bottom cues in reverse order so that they render in line order when overlapping in Chrome
result.sort((cueA, cueB) => {
if (cueA.line === 'auto' || cueB.line === 'auto') {
return 0;
}
if (cueA.line > 8 && cueB.line > 8) {
return cueB.line - cueA.line;
}
return cueA.line - cueB.line;
});
result.forEach((cue) => addCueToTrack(track, cue));
}
return result;
},
};
export default Cues;
+159
View File
@@ -0,0 +1,159 @@
import { adjustSliding } from './level-helper';
import { logger } from './logger';
import type { Fragment } from '../loader/fragment';
import type { LevelDetails } from '../loader/level-details';
export function findFirstFragWithCC(
fragments: Fragment[],
cc: number,
): Fragment | null {
for (let i = 0, len = fragments.length; i < len; i++) {
if (fragments[i]?.cc === cc) {
return fragments[i];
}
}
return null;
}
export function shouldAlignOnDiscontinuities(
refDetails: LevelDetails | undefined,
details: LevelDetails,
): refDetails is LevelDetails & boolean {
if (refDetails) {
if (
details.startCC < refDetails.endCC &&
details.endCC > refDetails.startCC
) {
return true;
}
}
return false;
}
function adjustFragmentStart(frag: Fragment, sliding: number) {
const start = frag.start + sliding;
frag.startPTS = start;
frag.setStart(start);
frag.endPTS = start + frag.duration;
}
export function adjustSlidingStart(sliding: number, details: LevelDetails) {
// Update segments
const fragments = details.fragments;
for (let i = 0, len = fragments.length; i < len; i++) {
adjustFragmentStart(fragments[i], sliding);
}
// Update LL-HLS parts at the end of the playlist
if (details.fragmentHint) {
adjustFragmentStart(details.fragmentHint, sliding);
}
details.alignedSliding = true;
}
/**
* Using the parameters of the last level, this function computes PTS' of the new fragments so that they form a
* contiguous stream with the last fragments.
* The PTS of a fragment lets Hls.js know where it fits into a stream - by knowing every PTS, we know which fragment to
* download at any given time. PTS is normally computed when the fragment is demuxed, so taking this step saves us time
* and an extra download.
* @param lastLevel
* @param details
*/
export function alignStream(
switchDetails: LevelDetails | undefined,
details: LevelDetails,
) {
if (!switchDetails) {
return;
}
alignDiscontinuities(details, switchDetails);
if (!details.alignedSliding) {
// If the PTS wasn't figured out via discontinuity sequence that means there was no CC increase within the level.
// Aligning via Program Date Time should therefore be reliable, since PDT should be the same within the same
// discontinuity sequence.
alignMediaPlaylistByPDT(details, switchDetails);
}
if (!details.alignedSliding && !details.skippedSegments) {
// Try to align on sn so that we pick a better start fragment.
// Do not perform this on playlists with delta updates as this is only to align levels on switch
// and adjustSliding only adjusts fragments after skippedSegments.
adjustSliding(switchDetails, details, false);
}
}
/**
* Ajust the start of fragments in `details` by the difference in time between fragments of the latest
* shared discontinuity sequence change.
* @param lastLevel - The details of the last loaded level
* @param details - The details of the new level
*/
export function alignDiscontinuities(
details: LevelDetails,
refDetails: LevelDetails | undefined,
) {
if (!shouldAlignOnDiscontinuities(refDetails, details)) {
return;
}
const targetCC = Math.min(refDetails.endCC, details.endCC);
const refFrag = findFirstFragWithCC(refDetails.fragments, targetCC);
const frag = findFirstFragWithCC(details.fragments, targetCC);
if (!refFrag || !frag) {
return;
}
logger.log(`Aligning playlist at start of dicontinuity sequence ${targetCC}`);
const delta = refFrag.start - frag.start;
adjustSlidingStart(delta, details);
}
/**
* Ensures appropriate time-alignment between renditions based on PDT.
* This function assumes the timelines represented in `refDetails` are accurate, including the PDTs
* for the last discontinuity sequence number shared by both playlists when present,
* and uses the "wallclock"/PDT timeline as a cross-reference to `details`, adjusting the presentation
* times/timelines of `details` accordingly.
* Given the asynchronous nature of fetches and initial loads of live `main` and audio/subtitle tracks,
* the primary purpose of this function is to ensure the "local timelines" of audio/subtitle tracks
* are aligned to the main/video timeline, using PDT as the cross-reference/"anchor" that should
* be consistent across playlists, per the HLS spec.
* @param details - The details of the rendition you'd like to time-align (e.g. an audio rendition).
* @param refDetails - The details of the reference rendition with start and PDT times for alignment.
*/
export function alignMediaPlaylistByPDT(
details: LevelDetails,
refDetails: LevelDetails,
) {
if (!details.hasProgramDateTime || !refDetails.hasProgramDateTime) {
return;
}
const fragments = details.fragments;
const refFragments = refDetails.fragments;
if (!fragments.length || !refFragments.length) {
return;
}
// Calculate a delta to apply to all fragments according to the delta in PDT times and start times
// of a fragment in the reference details, and a fragment in the target details of the same discontinuity.
// If a fragment of the same discontinuity was not found use the middle fragment of both.
let refFrag: Fragment | null | undefined;
let frag: Fragment | null | undefined;
const targetCC = Math.min(refDetails.endCC, details.endCC);
if (refDetails.startCC < targetCC && details.startCC < targetCC) {
refFrag = findFirstFragWithCC(refFragments, targetCC);
frag = findFirstFragWithCC(fragments, targetCC);
}
if (!refFrag || !frag) {
refFrag = refFragments[Math.floor(refFragments.length / 2)];
frag =
findFirstFragWithCC(fragments, refFrag.cc) ||
fragments[Math.floor(fragments.length / 2)];
}
const refPDT = refFrag.programDateTime;
const targetPDT = frag.programDateTime;
if (!refPDT || !targetPDT) {
return;
}
const delta = (targetPDT - refPDT) / 1000 - (frag.start - refFrag.start);
adjustSlidingStart(delta, details);
}

Some files were not shown because too many files have changed in this diff Show More