diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index b9325c66dd9..176ebd293b8 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -726,6 +726,7 @@ export type BufferControllerConfig = { frontBufferFlushThreshold: number; liveDurationInfinity: boolean; liveBackBufferLength: number | null; + maxAppendSize: number; }; // Warning: (ae-missing-release-tag) "BufferCreatedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/config.ts b/src/config.ts index a6906e648c6..9c533e5eb0d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,6 +69,7 @@ export type BufferControllerConfig = { * @deprecated use backBufferLength */ liveBackBufferLength: number | null; + maxAppendSize: number; }; export type CapLevelControllerConfig = { @@ -392,6 +393,7 @@ export const hlsDefaultConfig: HlsConfig = { maxBufferLength: 30, // used by stream-controller backBufferLength: Infinity, // used by buffer-controller frontBufferFlushThreshold: Infinity, + maxAppendSize: Infinity, // used by buffer-controller startOnSegmentBoundary: false, // used by stream-controller nextAudioTrackBufferFlushForwardOffset: 0.25, // used by stream-controller maxBufferSize: 60 * 1000 * 1000, // used by stream-controller diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index 31640e9ae8d..24a395639e0 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -22,6 +22,7 @@ import { isCompatibleTrackChange, isManagedMediaSource, } from '../utils/mediasource-helper'; +import { splitAppendData } from '../utils/mp4-tools'; import { stringify } from '../utils/safe-json-stringify'; import type { FragmentTracker } from './fragment-tracker'; import type { HlsConfig } from '../config'; @@ -779,8 +780,30 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe event: Events.BUFFER_APPENDING, eventData: BufferAppendingData, ) { + const { data, type } = eventData; + const { maxAppendSize } = this.hls.config; + + // Split large fMP4 segments into smaller chunks to avoid QuotaExceededError. + // Each chunk becomes a separate operation with full error handling. + if (isFinite(maxAppendSize) && data.byteLength > maxAppendSize) { + const chunks = splitAppendData(data, maxAppendSize); + if (chunks.length > 1) { + this.log( + `Splitting large ${type} append (sn:${eventData.frag.sn}, ` + + `${(data.byteLength / 1e6).toFixed(1)}MB) into ${chunks.length} chunks`, + ); + for (let i = 0; i < chunks.length; i++) { + this.onBufferAppending(event, { + ...eventData, + data: chunks[i], + }); + } + return; + } + } + const { tracks } = this; - const { data, type, parent, frag, part, chunkMeta, offset } = eventData; + const { parent, frag, part, chunkMeta, offset } = eventData; const chunkStats = chunkMeta.buffering[type]; const { sn, cc } = frag; const bufferAppendingStart = self.performance.now(); diff --git a/src/utils/mp4-tools.ts b/src/utils/mp4-tools.ts index 4d6d836db2f..c526b4af5e2 100644 --- a/src/utils/mp4-tools.ts +++ b/src/utils/mp4-tools.ts @@ -41,6 +41,119 @@ export function readUint32(buffer: Uint8Array, offset: number): number { return val < 0 ? 4294967296 + val : val; } +/** + * Read the size of an fMP4 box at the given offset, handling both standard + * and 64-bit extended sizes. Returns the box size in bytes, or 0 if the + * box header is invalid or truncated. + */ +export function readMp4BoxSize( + data: Uint8Array, + pos: number, + end: number, +): number { + if (pos + 8 > end) { + return 0; + } + const size = readUint32(data, pos); + if (size === 0) { + return end - pos; + } + if (size === 1 && pos + 16 <= end) { + const hi = readUint32(data, pos + 8); + return hi > 0 ? end - pos : readUint32(data, pos + 12); + } + if (size < 8 || pos + size > end) { + return 0; + } + return size; +} + +/** + * Try to split data at fMP4 top-level box boundaries into chunks of at + * most maxBytes. Returns null if box-boundary splitting cannot produce + * chunks that all fit within maxBytes (e.g. a single giant mdat box). + */ +export function splitAtBoxBoundaries( + data: Uint8Array, + maxBytes: number, +): Uint8Array[] | null { + if (data.byteLength < 8) { + return null; + } + const chunks: Uint8Array[] = []; + const end = data.byteLength; + let chunkStart = 0; + let pos = 0; + + while (pos < end) { + const boxSize = readMp4BoxSize(data, pos, end); + if (boxSize === 0) { + break; + } + if (pos > chunkStart && pos - chunkStart + boxSize > maxBytes) { + chunks.push( + new Uint8Array( + data.buffer, + data.byteOffset + chunkStart, + pos - chunkStart, + ), + ); + chunkStart = pos; + } + pos += boxSize; + } + + if (chunkStart < end) { + chunks.push( + new Uint8Array( + data.buffer, + data.byteOffset + chunkStart, + end - chunkStart, + ), + ); + } + + const allFit = + chunks.length > 1 && !chunks.some((c) => c.byteLength > maxBytes); + return allFit ? chunks : null; +} + +/** + * Split a large buffer into chunks of at most maxBytes. + * Tries to split at fMP4 top-level box boundaries first. If the data + * contains a single box larger than maxBytes (e.g. a giant mdat), falls + * back to naive byte splitting — MSE SourceBuffer handles reassembly of + * partial boxes internally. + */ +export function splitAppendData( + data: Uint8Array, + maxBytes: number, +): Uint8Array[] { + if (data.byteLength <= maxBytes) { + return [data]; + } + + const boxChunks = splitAtBoxBoundaries(data, maxBytes); + if (boxChunks) { + return boxChunks; + } + + // Fallback: naive byte splitting. MSE SourceBuffer handles partial box + // reassembly across sequential appendBuffer() calls. + const chunks: Uint8Array[] = []; + const end = data.byteLength; + for (let offset = 0; offset < end; offset += maxBytes) { + chunks.push( + new Uint8Array( + data.buffer, + data.byteOffset + offset, + Math.min(maxBytes, end - offset), + ), + ); + } + return chunks; +} + export function readUint64(buffer: Uint8Array, offset: number) { let result = readUint32(buffer, offset); result *= Math.pow(2, 32);