Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export type BufferControllerConfig = {
* @deprecated use backBufferLength
*/
liveBackBufferLength: number | null;
maxAppendSize: number;
};

export type CapLevelControllerConfig = {
Expand Down Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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],
Comment on lines +796 to +798

@robwalch robwalch Apr 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BUFFER_APPENDING event data's chunkMeta should specify the chunk "id" (index) to aid in tracking append progress:

const segment: BufferAppendingData = {
type: data.type,
frag,
part,
chunkMeta,
offset,
parent: frag.type,
data: buffer,
};
this.hls.trigger(Events.BUFFER_APPENDING, segment);

    const chunkMeta = new ChunkMetadata(
      frag.level,
      frag.sn,
      i, // chunk ID should match index of split data
      payload.byteLength
    );

});
}
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();
Expand Down
113 changes: 113 additions & 0 deletions src/utils/mp4-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer>,
maxBytes: number,
): Uint8Array<ArrayBuffer>[] | null {
if (data.byteLength < 8) {
return null;
}
const chunks: Uint8Array<ArrayBuffer>[] = [];
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<ArrayBuffer>,
maxBytes: number,
): Uint8Array<ArrayBuffer>[] {
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<ArrayBuffer>[] = [];
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);
Expand Down
Loading