Workaround Chrome silent coded frame eviction by removing later segments from buffer when PTS overlap is detected

Resolves #6777
This commit is contained in:
Rob Walch
2026-04-16 20:31:12 -07:00
parent 20431ad730
commit 2a1961a8f6
12 changed files with 167 additions and 66 deletions
+1 -1
View File
@@ -2038,7 +2038,7 @@ export class FragmentTracker implements ComponentAPI {
addAsGap(frag: MediaFragment): void;
// (undocumented)
destroy(): void;
detectEvictedFragments(elementaryStream: SourceBufferName, timeRange: TimeRanges, playlistType: PlaylistLevelType, appendedPart?: Part | null, removeAppending?: boolean): void;
detectEvictedFragments(elementaryStream: SourceBufferName, timeRange: TimeRanges, playlistType: PlaylistLevelType, appendedFrag?: MediaFragment | null, appendedPart?: Part | null, removeAppending?: boolean): void;
detectPartialFragments(data: FragBufferedData): void;
// (undocumented)
fragBuffered(frag: MediaFragment, force?: true): void;
+1
View File
@@ -419,6 +419,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
bufferedTimeRanges,
playlistType,
null,
null,
true,
);
}
+29 -6
View File
@@ -5,6 +5,7 @@ import {
type MediaFragment,
type Part,
} from '../loader/fragment';
import { userAgentChromeVersion } from '../utils/user-agent';
import type Hls from '../hls';
import type { SourceBufferName } from '../types/buffer';
import type { ComponentAPI } from '../types/component-api';
@@ -154,6 +155,7 @@ export class FragmentTracker implements ComponentAPI {
elementaryStream: SourceBufferName,
timeRange: TimeRanges,
playlistType: PlaylistLevelType,
appendedFrag?: MediaFragment | null,
appendedPart?: Part | null,
removeAppending?: boolean,
) {
@@ -165,18 +167,19 @@ export class FragmentTracker implements ComponentAPI {
const appendedPartSn = appendedPart?.fragment.sn || -1;
Object.keys(this.fragments).forEach((key) => {
const fragmentEntity = this.fragments[key];
if (!fragmentEntity) {
if (!fragmentEntity || !this.hls) {
return;
}
if (appendedPartSn >= fragmentEntity.body.sn) {
const frag = fragmentEntity.body;
if (appendedPartSn >= frag.sn) {
return;
}
if (
!fragmentEntity.buffered &&
(!fragmentEntity.loaded || removeAppending)
) {
if (fragmentEntity.body.type === playlistType) {
this.removeFragment(fragmentEntity.body);
if (frag.type === playlistType) {
this.removeFragment(frag);
}
return;
}
@@ -185,7 +188,7 @@ export class FragmentTracker implements ComponentAPI {
return;
}
if (esData.time.length === 0) {
this.removeFragment(fragmentEntity.body);
this.removeFragment(frag);
return;
}
esData.time.some((time: FragmentTimeRange) => {
@@ -196,10 +199,29 @@ export class FragmentTracker implements ComponentAPI {
);
if (isNotBuffered) {
// Unregister partial fragment as it needs to load again to be reused
this.removeFragment(fragmentEntity.body);
this.removeFragment(frag);
}
return isNotBuffered;
});
// Flush forward buffer in Chrome when PTS overlap detected
// Workaround https://github.com/video-dev/hls.js/issues/6777 (https://issues.chromium.org/u/1/issues/336839131)
if (appendedFrag && appendedFrag !== frag && userAgentChromeVersion()) {
const endPTS = appendedFrag.endPTS;
const otherStartPTS = frag.startPTS;
if (endPTS && otherStartPTS && fragmentEntity.range.video) {
const diff = otherStartPTS - endPTS;
// overlap is no more than 1/10s
if (diff < 0 && diff > -0.1) {
this.removeFragment(frag);
this.hls.trigger(Events.BUFFER_FLUSHING, {
// pad removal start to avoid accedental removal of appendedFrag
startOffset: endPTS + 0.004,
endOffset: Infinity,
type: 'video',
});
}
}
}
});
}
@@ -236,6 +258,7 @@ export class FragmentTracker implements ComponentAPI {
elementaryStream,
timeRange,
playlistType,
frag,
part,
);
});
+6 -3
View File
@@ -16,6 +16,10 @@ import {
removeEventListener,
} from '../utils/event-listener-helper';
import { useAlternateAudio } from '../utils/rendition-helper';
import {
userAgentFirefoxVersion,
userAgentIsAndroidLike,
} from '../utils/user-agent';
import type { FragmentTracker } from './fragment-tracker';
import type Hls from '../hls';
import type { Fragment, MediaFragment } from '../loader/fragment';
@@ -1428,7 +1432,6 @@ export default class StreamController
audioCodec = 'mp4a.40.5';
}
// Handle `audioCodecSwitch`
const ua = navigator.userAgent.toLowerCase();
if (this.audioCodecSwitch) {
if (audioCodec) {
if (audioCodec.indexOf('mp4a.40.5') !== -1) {
@@ -1445,7 +1448,7 @@ export default class StreamController
audioMetadata &&
'channelCount' in audioMetadata &&
(audioMetadata.channelCount || 1) !== 1 &&
ua.indexOf('firefox') === -1
!userAgentFirefoxVersion()
) {
audioCodec = 'mp4a.40.5';
}
@@ -1454,7 +1457,7 @@ export default class StreamController
if (
audioCodec &&
audioCodec.indexOf('mp4a.40.5') !== -1 &&
ua.indexOf('android') !== -1 &&
userAgentIsAndroidLike() &&
audio.container !== 'audio/mpeg'
) {
// Exclude mpeg audio
+2 -7
View File
@@ -1,10 +1,9 @@
/**
* MPEG parser helper
*/
import { userAgentChromeVersion } from '../../utils/user-agent';
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,
@@ -115,11 +114,7 @@ export function parseHeader(data: Uint8Array, offset: number) {
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 chromeVersion = userAgentChromeVersion();
const needChromeFix = !!chromeVersion && chromeVersion <= 87;
if (
+14 -16
View File
@@ -9,6 +9,10 @@ import {
timestampToString,
toMsFromMpegTsClock,
} from '../utils/timescale-conversion';
import {
userAgentChromeVersion,
userAgentSafariVersion,
} from '../utils/user-agent';
import type { HlsConfig } from '../config';
import type { HlsEventEmitter } from '../events';
import type { ChunkMetadata } from '../hls';
@@ -43,9 +47,6 @@ const MPEG_AUDIO_SAMPLE_PER_FRAME = 1152;
const AC3_SAMPLES_PER_FRAME = 1536;
const MPEG_TS_PTS_ROLLOVER = 8589934592; // 2^33
let chromeVersion: number | null = null;
let safariWebkitVersion: number | null = null;
function createMp4Sample(
isKeyframe: boolean,
duration: number,
@@ -96,16 +97,6 @@ export default class MP4Remuxer extends Logger implements Remuxer {
this.config = config;
this.typeSupported = typeSupported;
this.ISGenerated = false;
if (chromeVersion === null) {
const userAgent = navigator.userAgent || '';
const result = userAgent.match(/Chrome\/(\d+)/i);
chromeVersion = result ? parseInt(result[1]) : 0;
}
if (safariWebkitVersion === null) {
const result = navigator.userAgent.match(/Safari\/(\d+)/i);
safariWebkitVersion = result ? parseInt(result[1]) : 0;
}
}
destroy() {
@@ -563,7 +554,7 @@ export default class MP4Remuxer extends Logger implements Remuxer {
inputSamples[0].pts -
normalizePts(inputSamples[0].dts, inputSamples[0].pts);
if (
chromeVersion &&
userAgentChromeVersion() &&
nextVideoTs !== null &&
Math.abs(pts - cts - (nextVideoTs + initTime)) < 15000
) {
@@ -635,7 +626,7 @@ export default class MP4Remuxer extends Logger implements Remuxer {
if (
!foundOverlap ||
nextVideoPts >= inputSamples[0].pts ||
chromeVersion
userAgentChromeVersion()
) {
firstDTS = nextVideoPts;
const firstPTS = inputSamples[0].pts - delta;
@@ -832,6 +823,7 @@ export default class MP4Remuxer extends Logger implements Remuxer {
}
if (outputSamples.length) {
const chromeVersion = userAgentChromeVersion();
if (chromeVersion) {
if (chromeVersion < 70) {
// Chrome workaround, mark first sample as being a Random Access Point (keyframe) to avoid sourcebuffer append issue
@@ -840,7 +832,7 @@ export default class MP4Remuxer extends Logger implements Remuxer {
flags.dependsOn = 2;
flags.isNonSync = 0;
}
} else if (safariWebkitVersion) {
} else if (userAgentSafariVersion()) {
// Fix for "CNN special report, with CC" in test-streams (Safari browser only)
// Ignore DTS when frame durations are irregular. Safari MSE does not handle this leading to gaps.
if (
@@ -913,6 +905,12 @@ export default class MP4Remuxer extends Logger implements Remuxer {
nb: outputSamples.length,
dropped: track.dropped,
};
// For troubleshooting duplicates of https://github.com/video-dev/hls.js/issues/6374
// console.log(
// `#6374 segment ${chunkMeta.sn} timeOffset: ${timeOffset} dts: ${firstDTS}-${endDTS} pts: ${minPTS}-${maxPTS} cts[0]=${
// outputSamples[0].cts
// } cts[${outputSamples.length - 1}]=${outputSamples[outputSamples.length - 1].cts} sample-duration: ${mp4SampleDuration} timescale: ${timeScale}`,
// );
track.samples = [];
track.dropped = 0;
return data;
+28 -9
View File
@@ -390,11 +390,29 @@ class PassThroughRemuxer extends Logger implements Remuxer {
initSegment.trackId = initPTS.trackId;
}
const startTime = decodeTime - initPTS.baseTime / initPTS.timescale;
const endTime = startTime + duration;
const startDTS = decodeTime - initPTS.baseTime / initPTS.timescale;
const endDTS = startDTS + duration;
const startPTS =
baseOffsetSamples?.ptsMin !== undefined
? baseOffsetSamples.ptsMin / baseOffsetSamples.timescale -
initPTS.baseTime / initPTS.timescale
: startDTS;
const endPTS = baseOffsetSamples?.ptsMax
? baseOffsetSamples.ptsMax / baseOffsetSamples.timescale -
initPTS.baseTime / initPTS.timescale
: endDTS;
// For troubleshooting duplicates of https://github.com/video-dev/hls.js/issues/6777
// if (videoSampleTimestamps) {
// console.log(
// `#6777 segment ${chunkMeta.sn}: dts: ${videoSampleTimestamps.start}-${videoSampleTimestamps.start + videoSampleTimestamps.duration} pts: ${videoSampleTimestamps.ptsMin}-${
// videoSampleTimestamps.ptsMax
// }`,
// );
// }
if (duration > 0) {
this.lastEndTime = endTime;
this.lastEndTime = endDTS;
} else {
this.warn('Duration parsed from mp4 should be greater than zero');
this.resetNextTimestamp();
@@ -407,10 +425,10 @@ class PassThroughRemuxer extends Logger implements Remuxer {
const track: RemuxedTrack = {
data1,
data2,
startPTS: startTime,
startDTS: startTime,
endPTS: endTime,
endDTS: endTime,
startPTS,
startDTS,
endPTS,
endDTS,
type,
hasAudio,
hasVideo,
@@ -421,13 +439,14 @@ class PassThroughRemuxer extends Logger implements Remuxer {
result.audio = hasAudio && !hasVideo ? track : undefined;
result.video = hasVideo ? track : undefined;
const isVideoContiguous = this.isVideoContiguous;
const videoSampleCount = videoSampleTimestamps?.sampleCount;
if (videoSampleCount) {
const firstKeyFrame = videoSampleTimestamps.keyFrameIndex;
const independent = firstKeyFrame !== -1;
track.nb = videoSampleCount;
track.dropped =
firstKeyFrame === 0 || this.isVideoContiguous
firstKeyFrame === 0 || isVideoContiguous
? 0
: independent
? firstKeyFrame
@@ -439,7 +458,7 @@ class PassThroughRemuxer extends Logger implements Remuxer {
(videoSampleTimestamps.keyFrameStart - initPTS.baseTime) /
initPTS.timescale;
}
if (!this.isVideoContiguous) {
if (!isVideoContiguous) {
result.independent = independent;
}
this.isVideoContiguous ||= independent;
+1 -4
View File
@@ -1,9 +1,6 @@
import { getMediaSource } from './mediasource-helper';
import { isHEVC } from './mp4-tools';
export const userAgentHevcSupportIsInaccurate = () => {
return /\(Windows.+Firefox\//i.test(navigator.userAgent);
};
import { userAgentHevcSupportIsInaccurate } from './user-agent';
// from http://mp4ra.org/codecs.html
// values indicate codec selection preference (lower is higher priority)
+1 -1
View File
@@ -2,9 +2,9 @@ import {
fillInMissingAV01Params,
getCodecsForMimeType,
mimeTypeForCodec,
userAgentHevcSupportIsInaccurate,
} from './codecs';
import { isHEVC } from './mp4-tools';
import { userAgentHevcSupportIsInaccurate } from './user-agent';
import type { AudioTracksByGroup } from './rendition-helper';
import type { Level, VideoRange } from '../types/level';
import type { AudioSelectionOption } from '../types/media-playlist';
+31 -14
View File
@@ -712,10 +712,11 @@ type TrackFragmentRun = {
samples: TrackFragmentRunSample[];
};
export type TrackTimes = {
cts?: number;
duration: number;
keyFrameIndex?: number;
keyFrameStart?: number;
ptsMin?: number;
ptsMax?: number;
start: number;
sampleCount: number;
trun: TrackFragmentRun[];
@@ -852,10 +853,8 @@ export function getSampleData(
};
trackTimes.trun.push(fragRun);
}
let size;
for (let ix = 0; ix < sampleCount; ix++) {
const sample: TrackFragmentRunSample = {
size: 0,
};
if (sampleDurationPresent) {
sampleDuration = readUint32(trun, offset);
offset += 4;
@@ -863,17 +862,18 @@ export function getSampleData(
sampleDuration = defaultSampleDuration;
}
if (sampleSizePresent) {
sample.size = readUint32(trun, offset);
size = readUint32(trun, offset);
offset += 4;
} else {
sample.size = defaultSampleSize;
size = defaultSampleSize;
}
sampleOffset += sample.size;
sampleOffset += size;
if (sampleOffset <= eof) {
samples[ix] = sample;
let flags;
let cts = 0;
if (sampleFlagsPresent) {
const isNonSyncSample = trun[offset + 1] & 0x01;
sample.flags = {
flags = {
isNonSync: isNonSyncSample ? 1 : 0,
dependsOn: (trun[offset] & 0x03) === 1 ? 1 : 2,
};
@@ -887,13 +887,30 @@ export function getSampleData(
}
if (sampleCompositionTimeOffsetPresent) {
const version = trun[0];
if (version === 0) {
sample.cts = readUint32(trun, offset);
} else {
sample.cts = readSint32(trun, offset);
}
cts =
version === 0
? readUint32(trun, offset)
: readSint32(trun, offset);
offset += 4;
}
const pts = sampleDTS + cts;
if (
!Number.isFinite(trackTimes.ptsMin) ||
pts < trackTimes.ptsMin!
) {
trackTimes.ptsMin = pts;
}
if (
!Number.isFinite(trackTimes.ptsMax) ||
pts + sampleDuration > trackTimes.ptsMax!
) {
trackTimes.ptsMax = pts + sampleDuration;
}
samples[ix] = {
cts,
flags,
size,
};
}
sampleDTS += sampleDuration;
rawDuration += sampleDuration;
+2 -5
View File
@@ -1,4 +1,5 @@
import { logger } from './logger';
import { userAgentIsIOSLike } from './user-agent';
import type { MediaPlaylist } from '../hls';
// This is a replacement of the native addTextTrack method.
@@ -28,11 +29,7 @@ export function createTrackNode(
}
export function getTrackKind(track: MediaPlaylist): TextTrackKind | 'forced' {
if (
track.forced &&
(navigator.vendor.includes('Apple') ||
/iPhone|iPad|iPod/.test(navigator.userAgent))
) {
if (track.forced && userAgentIsIOSLike()) {
return 'forced';
} else if (
track.characteristics &&
+51
View File
@@ -0,0 +1,51 @@
const ua = getNavigator('userAgent');
const vendor = getNavigator('vendor');
let chromeVersion: number | undefined;
let firefoxVersion: number | undefined;
let safariVersion: number | undefined;
function getNavigator(field: string) {
try {
return navigator[field] || '';
} catch (e) {
/* no-op */
}
return '';
}
export function userAgentSafariVersion(): number {
if (safariVersion === undefined) {
const result = ua.match(/Safari\/(\d+)/i);
safariVersion = result ? parseInt(result[1]) : 0;
}
return safariVersion;
}
export function userAgentChromeVersion(): number {
if (chromeVersion === undefined) {
const result = ua.match(/Chrome\/(\d+)/i);
chromeVersion = result ? parseInt(result[1]) : 0;
}
return chromeVersion;
}
export function userAgentFirefoxVersion(): number {
if (firefoxVersion === undefined) {
const result = ua.match(/Firefox\/(\d+)/i);
firefoxVersion = result ? parseInt(result[1]) : 0;
}
return firefoxVersion;
}
export function userAgentHevcSupportIsInaccurate() {
return /\(Windows.+Firefox\//i.test(ua);
}
export function userAgentIsIOSLike() {
return vendor.indexOf('Apple') > -1 || /iPhone|iPad|iPod/.test(ua);
}
export function userAgentIsAndroidLike() {
return /Android/.test(ua);
}