From 16583a428bdafc20d3ea60e4321fd47615d7fa01 Mon Sep 17 00:00:00 2001 From: Yury Yarashevich Date: Fri, 14 Mar 2025 19:56:49 +0100 Subject: [PATCH] #7064: Check integrity of MPEG-TS video stream. --- api-extractor/report/hls.js.api.md | 1 + docs/API.md | 13 ++++ src/config.ts | 2 + src/demux/tsdemuxer.ts | 96 +++++++++++++++++++++++++++++- 4 files changed, 110 insertions(+), 2 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 2d780afb20b..4d792bbc06e 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -4886,6 +4886,7 @@ export interface TransmuxerResult { // @public (undocumented) export type TSDemuxerConfig = { forceKeyFrameOnDiscontinuity: boolean; + handleMpegTsVideoIntegrityErrors: 'process' | 'skip'; }; // Warning: (ae-missing-release-tag) "UriReplacement" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/docs/API.md b/docs/API.md index 88938b4077d..067b6dc31de 100644 --- a/docs/API.md +++ b/docs/API.md @@ -112,6 +112,7 @@ See [API Reference](https://hlsjs-dev.video-dev.org/api-docs/) for a complete li - [`stretchShortVideoTrack`](#stretchshortvideotrack) - [`maxAudioFramesDrift`](#maxaudioframesdrift) - [`forceKeyFrameOnDiscontinuity`](#forcekeyframeondiscontinuity) + - [`handleMpegTsVideoIntegrityErrors`](#handlempegtsvideointegrityerrors) - [`abrEwmaFastLive`](#abrewmafastlive) - [`abrEwmaSlowLive`](#abrewmaslowlive) - [`abrEwmaFastVoD`](#abrewmafastvod) @@ -472,6 +473,7 @@ var config = { stretchShortVideoTrack: false, maxAudioFramesDrift: 1, forceKeyFrameOnDiscontinuity: true, + handleMpegTsVideoIntegrityErrors: 'process', abrEwmaFastLive: 3.0, abrEwmaSlowLive: 9.0, abrEwmaFastVoD: 3.0, @@ -1489,6 +1491,17 @@ Setting this parameter to false can also generate decoding weirdness when switch parameter should be a boolean +### `handleMpegTsVideoIntegrityErrors` + +(default: `'process'`) + +Controls how corrupted video data is handled based on MPEG-TS integrity checks. + +- `'process'` (default): Continues processing corrupted data, which may lead to decoding errors. +- `'skip'`: Discards corrupted video data to prevent potential playback issues. + +This parameter accepts a string with possible values: `'process'` | `'skip'`. + ### `abrEwmaFastLive` (default: `3.0`) diff --git a/src/config.ts b/src/config.ts index 4627baa5797..46ac4b6e263 100644 --- a/src/config.ts +++ b/src/config.ts @@ -272,6 +272,7 @@ export type TimelineControllerConfig = { export type TSDemuxerConfig = { forceKeyFrameOnDiscontinuity: boolean; + handleMpegTsVideoIntegrityErrors: 'process' | 'skip'; }; export type HlsConfig = { @@ -421,6 +422,7 @@ export const hlsDefaultConfig: HlsConfig = { stretchShortVideoTrack: false, // used by mp4-remuxer maxAudioFramesDrift: 1, // used by mp4-remuxer forceKeyFrameOnDiscontinuity: true, // used by ts-demuxer + handleMpegTsVideoIntegrityErrors: 'process', // used by ts-demuxer abrEwmaFastLive: 3, // used by abr-controller abrEwmaSlowLive: 9, // used by abr-controller abrEwmaFastVoD: 3, // used by abr-controller diff --git a/src/demux/tsdemuxer.ts b/src/demux/tsdemuxer.ts index 863e0baadc1..630ffd49638 100644 --- a/src/demux/tsdemuxer.ts +++ b/src/demux/tsdemuxer.ts @@ -72,6 +72,7 @@ class TSDemuxer implements Demuxer { private aacOverFlow: AudioFrame | null = null; private remainderData: Uint8Array | null = null; private videoParser: BaseVideoParser | null; + private videoIntegrityChecker: PacketsIntegrityChecker | null = null; constructor( observer: HlsEventEmitter, @@ -182,6 +183,10 @@ class TSDemuxer implements Demuxer { this._videoTrack = TSDemuxer.createTrack('video') as DemuxedVideoTrack; this._videoTrack.duration = trackDuration; + this.videoIntegrityChecker = + this.config.handleMpegTsVideoIntegrityErrors === 'skip' + ? new PacketsIntegrityChecker(this.logger) + : null; this._audioTrack = TSDemuxer.createTrack( 'audio', trackDuration, @@ -228,6 +233,7 @@ class TSDemuxer implements Demuxer { let pes: PES | null; const videoTrack = this._videoTrack as DemuxedVideoTrack; + const videoIntegrityChecker = this.videoIntegrityChecker; const audioTrack = this._audioTrack as DemuxedAudioTrack; const id3Track = this._id3Track as DemuxedMetadataTrack; const textTrack = this._txtTrack as DemuxedUserdataTrack; @@ -291,7 +297,11 @@ class TSDemuxer implements Demuxer { switch (pid) { case videoPid: if (stt) { - if (videoData && (pes = parsePES(videoData, this.logger))) { + if ( + videoData && + !videoIntegrityChecker?.isCorrupted && + (pes = parsePES(videoData, this.logger)) + ) { this.readyVideoParser(videoTrack.segmentCodec); if (this.videoParser !== null) { this.videoParser.parsePES(videoTrack, textTrack, pes, false); @@ -299,7 +309,9 @@ class TSDemuxer implements Demuxer { } videoData = { data: [], size: 0 }; + videoIntegrityChecker?.reset(videoPid); } + videoIntegrityChecker?.handlePacket(data.subarray(start)); if (videoData) { videoData.data.push(data.subarray(offset, start + PACKET_LENGTH)); videoData.size += start + PACKET_LENGTH - offset; @@ -464,9 +476,14 @@ class TSDemuxer implements Demuxer { const videoData = videoTrack.pesData; const audioData = audioTrack.pesData; const id3Data = id3Track.pesData; + const videoIntegrityChecker = this.videoIntegrityChecker; // try to parse last PES packets let pes: PES | null; - if (videoData && (pes = parsePES(videoData, this.logger))) { + if ( + videoData && + !videoIntegrityChecker?.isCorrupted && + (pes = parsePES(videoData, this.logger)) + ) { this.readyVideoParser(videoTrack.segmentCodec); if (this.videoParser !== null) { this.videoParser.parsePES( @@ -590,6 +607,8 @@ class TSDemuxer implements Demuxer { this._id3Track = this._txtTrack = undefined; + + this.videoIntegrityChecker = null; } private parseAACPES(track: DemuxedAudioTrack, pes: PES) { @@ -1050,4 +1069,77 @@ function parsePES(stream: ElementaryStreamData, logger: ILogger): PES | null { return null; } +// See FFMpeg for reference: https://github.com/FFmpeg/FFmpeg/blob/e4c8e80a2efee275f2a10fcf0424c9fc1d86e309/libavformat/mpegts.c#L2811-L2834 +class PacketsIntegrityChecker { + private readonly logger: ILogger; + + private pid: number = 0; + private lastContinuityCounter = -1; + private integrityState: 'ok' | 'tei-bit' | 'cc-failed' = 'ok'; + + constructor(logger: ILogger) { + this.logger = logger; + } + + public get isCorrupted(): boolean { + return this.integrityState !== 'ok'; + } + + public reset(pid: number) { + this.pid = pid; + this.lastContinuityCounter = -1; + this.integrityState = 'ok'; + } + + public handlePacket(data: Uint8Array) { + if (data.byteLength < 4) { + return; + } + + const pid = parsePID(data, 0); + if (pid !== this.pid) { + this.logger.debug(`Packet PID mismatch, expected ${this.pid} got ${pid}`); + return; + } + + const adaptationFieldControl = (data[3] & 0x30) >> 4; + if (adaptationFieldControl === 0) { + return; + } + const continuityCounter = data[3] & 0xf; + + const lastContinuityCounter = this.lastContinuityCounter; + this.lastContinuityCounter = continuityCounter; + + const hasPayload = (adaptationFieldControl & 0b01) != 0; + const hasAdaptation = (adaptationFieldControl & 0b10) != 0; + const isDiscontinuity = + hasAdaptation && data[4] != 0 && (data[5] & 0x80) != 0; + + if (isDiscontinuity) { + return; + } + if (lastContinuityCounter < 0) { + return; + } + + const expectedContinuityCounter = hasPayload + ? (lastContinuityCounter + 1) & 0x0f + : lastContinuityCounter; + if (continuityCounter !== expectedContinuityCounter) { + this.logger.warn( + `MPEG-TS Continuity Counter check failed for PID='${pid}', CC=${continuityCounter}, Expected-CC=${expectedContinuityCounter} Last-CC=${lastContinuityCounter}`, + ); + this.integrityState = 'cc-failed'; + return; + } + + if ((data[1] & 0x80) !== 0) { + this.logger.warn(`MPEG-TS Packet had TEI flag set for PID='${pid}'`); + this.integrityState = 'tei-bit'; + return; + } + } +} + export default TSDemuxer;