Skip to content

Commit fdbdf11

Browse files
authored
fix: handle QuickTime wave/esds in mp4a codec derivation and fragmented init (#550)
* fix: resolve nested wave esds when deriving mp4a codec * fix: normalize mp4a wave esds in init segment for MSE compatibility
1 parent 4199d94 commit fdbdf11

9 files changed

Lines changed: 279 additions & 24 deletions

File tree

.changeset/bright-waves-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"mp4box": patch
3+
---
4+
5+
fix: include nested QuickTime wave esds when deriving mp4a codec strings

.changeset/quiet-segments-smile.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"mp4box": patch
3+
---
4+
5+
fix: normalize QuickTime wave esds in fragmented init segments for MSE compatibility
6+
7+
By default, segmentation writes MSE-compatible `mp4a.esds` sample entries when source
8+
QuickTime files store AAC decoder config under `mp4a.wave.esds`. This behavior can be
9+
disabled with `setSegmentOptions(..., { normalizeAudioSampleEntriesForMSE: false })`
10+
to preserve nested QuickTime `wave.esds` sample entries in initialization segments.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ Indicates that the track with the given `track_id` should be segmented, with the
240240

241241
- **nbSamples**: Number, representing the number of frames per segment, i.e. the time between 2 callbacks to onSegment. If not enough data is received to form a segment, received samples are kept. If not provided, the default is 1000.
242242
- **rapAlignement**: boolean, indicating if segments should start with a RAP. If not provided, the default is true.
243+
- **normalizeAudioSampleEntriesForMSE**: boolean, indicating if QuickTime-style audio sample entries should be normalized in initialization segments for Media Source Extensions compatibility. If not provided, the default is true. Set to false to preserve nested QuickTime `wave.esds` sample entries.
243244

244245
```javascript
245246
mp4boxfile.setSegmentOptions(1, sb, { nbSamples: 1000 });

entries/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export interface FragmentedTrack<TUser> {
113113
nb_samples_per_fragment: number;
114114
size_per_segment: number;
115115
rapAlignement: boolean;
116+
normalizeAudioSampleEntriesForMSE?: boolean;
116117
state: {
117118
lastFragmentSampleNumber: number;
118119
lastSegmentSampleNumber: number;

src/boxes/qt/wave.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { ContainerBox } from '#/containerBox';
2+
import { esdsBox } from '#/boxes/esds';
23

34
export class waveBox extends ContainerBox {
45
static override readonly fourcc = 'wave' as const;
56
box_name = 'siDecompressionParamBox' as const;
7+
esds: esdsBox;
68
}

src/boxes/sampleentries/sampleentry.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { av1CBox } from '#/boxes/av1C';
22
import { avcCBox } from '#/boxes/avcC';
33
import { sinfBox } from '#/boxes/defaults';
44
import { esdsBox } from '#/boxes/esds';
5+
import { waveBox } from '#/boxes/qt/wave';
56
import { hvcCBox } from '#/boxes/hvcC';
67
import { lvcCBox } from '#/boxes/lvcC';
78
import { vpcCBox } from '#/boxes/vpcC';
@@ -384,12 +385,14 @@ export class mp4aSampleEntry extends AudioSampleEntry {
384385

385386
esds: esdsBox;
386387
esdss: Array<esdsBox>;
388+
wave: waveBox;
387389

388390
getCodec() {
389391
const baseCodec = super.getCodec();
390-
if (this.esds && this.esds.esd) {
391-
const oti = this.esds.esd.getOTI();
392-
const dsi = this.esds.esd.getAudioConfig();
392+
const esds = this.esds ?? this.wave?.esds;
393+
if (esds && esds.esd) {
394+
const oti = esds.esd.getOTI();
395+
const dsi = esds.esd.getAudioConfig();
393396
return baseCodec + '.' + decimalToHex(oti) + (dsi ? '.' + dsi : '');
394397
} else {
395398
return baseCodec;

src/isofile.ts

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { mehdBox } from '#/boxes/mehd';
3030
import { metaBox } from '#/boxes/meta';
3131
import { mfhdBox } from '#/boxes/mfhd';
3232
import { mvhdBox } from '#/boxes/mvhd';
33-
import { stppSampleEntry } from '#/boxes/sampleentries';
33+
import { mp4aSampleEntry, stppSampleEntry } from '#/boxes/sampleentries';
3434
import {
3535
AudioSampleEntry,
3636
HintSampleEntry,
@@ -245,10 +245,15 @@ export class ISOFile<TSegmentUser = unknown, TSampleUser = unknown> {
245245
nbSamplesPerFragment: number;
246246
sizePerSegment: number;
247247
rapAlignement: boolean;
248+
normalizeAudioSampleEntriesForMSE: boolean;
248249
}>,
249250
) {
250251
// Destructure and provide defaults for optional properties
251-
const { sizePerSegment = Number.MAX_SAFE_INTEGER, rapAlignement = true } = opts;
252+
const {
253+
sizePerSegment = Number.MAX_SAFE_INTEGER,
254+
rapAlignement = true,
255+
normalizeAudioSampleEntriesForMSE = true,
256+
} = opts;
252257

253258
// Set defaults for sample counts
254259
let nbSamples = opts.nbSamples ?? opts.nbSamplesPerFragment ?? 1000;
@@ -292,6 +297,7 @@ export class ISOFile<TSegmentUser = unknown, TSampleUser = unknown> {
292297
nb_samples_per_fragment: nbSamplesPerFragment,
293298
size_per_segment: sizePerSegment,
294299
rapAlignement,
300+
normalizeAudioSampleEntriesForMSE,
295301
state: {
296302
lastFragmentSampleNumber: 0,
297303
lastSegmentSampleNumber: 0,
@@ -1261,29 +1267,45 @@ export class ISOFile<TSegmentUser = unknown, TSampleUser = unknown> {
12611267
* Modify the file and create the initialization segment
12621268
* @bundle isofile-write.js
12631269
*/
1264-
static writeInitializationSegment(ftyp: ftypBox, moov: moovBox, total_duration: number) {
1270+
static writeInitializationSegment(
1271+
ftyp: ftypBox,
1272+
moov: moovBox,
1273+
total_duration: number,
1274+
normalizeAudioSampleEntryTrackIds?: Set<number>,
1275+
) {
12651276
Log.debug('ISOFile', 'Generating initialization segment');
12661277

12671278
const stream = new DataStream();
12681279
ftyp.write(stream);
12691280

1270-
/* we can now create the new mvex box */
1271-
const mvex = moov.addBox(new mvexBox());
1272-
if (total_duration) {
1273-
const mehd = mvex.addBox(new mehdBox());
1274-
mehd.fragment_duration = total_duration;
1275-
}
1281+
const restoreCallbacks = ISOFile.normalizeAudioSampleEntriesForMSEFragmentedInit(
1282+
moov.traks,
1283+
normalizeAudioSampleEntryTrackIds,
1284+
);
1285+
1286+
try {
1287+
/* we can now create the new mvex box */
1288+
const mvex = moov.addBox(new mvexBox());
1289+
if (total_duration) {
1290+
const mehd = mvex.addBox(new mehdBox());
1291+
mehd.fragment_duration = total_duration;
1292+
}
12761293

1277-
// Add trex boxes for each track
1278-
for (let i = 0; i < moov.traks.length; i++) {
1279-
const trex = mvex.addBox(new trexBox());
1280-
trex.track_id = moov.traks[i].tkhd.track_id;
1281-
trex.default_sample_description_index = 1;
1282-
trex.default_sample_duration = moov.traks[i].samples[0]?.duration ?? 0;
1283-
trex.default_sample_size = 0;
1284-
trex.default_sample_flags = 1 << 16;
1294+
// Add trex boxes for each track
1295+
for (let i = 0; i < moov.traks.length; i++) {
1296+
const trex = mvex.addBox(new trexBox());
1297+
trex.track_id = moov.traks[i].tkhd.track_id;
1298+
trex.default_sample_description_index = 1;
1299+
trex.default_sample_duration = moov.traks[i].samples[0]?.duration ?? 0;
1300+
trex.default_sample_size = 0;
1301+
trex.default_sample_flags = 1 << 16;
1302+
}
1303+
moov.write(stream);
1304+
} finally {
1305+
for (let i = restoreCallbacks.length - 1; i >= 0; i--) {
1306+
restoreCallbacks[i]();
1307+
}
12851308
}
1286-
moov.write(stream);
12871309

12881310
return stream.buffer;
12891311
}
@@ -1304,6 +1326,51 @@ export class ISOFile<TSegmentUser = unknown, TSampleUser = unknown> {
13041326
return stream;
13051327
}
13061328

1329+
/** @bundle isofile-write.js */
1330+
private static normalizeAudioSampleEntriesForMSEFragmentedInit(
1331+
traks: Array<trakBox>,
1332+
normalizeAudioSampleEntryTrackIds?: Set<number>,
1333+
) {
1334+
const restoreCallbacks: Array<() => void> = [];
1335+
1336+
for (const trak of traks) {
1337+
if (!normalizeAudioSampleEntryTrackIds?.has(trak.tkhd.track_id)) {
1338+
continue;
1339+
}
1340+
1341+
for (const sampleEntry of trak.mdia.minf.stbl.stsd?.entries ?? []) {
1342+
if (!(sampleEntry instanceof mp4aSampleEntry)) {
1343+
continue;
1344+
}
1345+
1346+
const esds = sampleEntry.wave?.esds;
1347+
1348+
if (sampleEntry.esds || !esds) {
1349+
continue;
1350+
}
1351+
1352+
const previousEsds = sampleEntry.esds;
1353+
const previousWave = sampleEntry.wave;
1354+
const previousBoxes = sampleEntry.boxes;
1355+
1356+
restoreCallbacks.push(() => {
1357+
sampleEntry.esds = previousEsds;
1358+
sampleEntry.wave = previousWave;
1359+
sampleEntry.boxes = previousBoxes;
1360+
});
1361+
1362+
const boxesWithoutWave = Array.isArray(sampleEntry.boxes)
1363+
? sampleEntry.boxes.filter(box => box?.type !== 'wave' && box?.type !== 'esds')
1364+
: [];
1365+
sampleEntry.esds = esds;
1366+
sampleEntry.boxes = [...boxesWithoutWave, esds];
1367+
sampleEntry.wave = undefined;
1368+
}
1369+
}
1370+
1371+
return restoreCallbacks;
1372+
}
1373+
13071374
/** @bundle isofile-write.js */
13081375
initializeSegmentation(mode?: 'combined'): SegmentationInitialization<TSegmentUser>;
13091376
initializeSegmentation(
@@ -1339,6 +1406,11 @@ export class ISOFile<TSegmentUser = unknown, TSampleUser = unknown> {
13391406
}
13401407

13411408
const fragmentDuration = this.moov?.mvex?.mehd.fragment_duration;
1409+
const normalizeAudioSampleEntryTrackIds = new Set(
1410+
this.fragmentedTracks
1411+
.filter(track => track.normalizeAudioSampleEntriesForMSE !== false)
1412+
.map(track => track.id),
1413+
);
13421414

13431415
if (mode === 'per-track') {
13441416
return tracksToInitialize.map(({ id, user, trak }) => {
@@ -1349,7 +1421,12 @@ export class ISOFile<TSegmentUser = unknown, TSampleUser = unknown> {
13491421
return {
13501422
id,
13511423
user,
1352-
buffer: ISOFile.writeInitializationSegment(this.ftyp, moov, fragmentDuration),
1424+
buffer: ISOFile.writeInitializationSegment(
1425+
this.ftyp,
1426+
moov,
1427+
fragmentDuration,
1428+
normalizeAudioSampleEntryTrackIds,
1429+
),
13531430
};
13541431
});
13551432
}
@@ -1363,7 +1440,12 @@ export class ISOFile<TSegmentUser = unknown, TSampleUser = unknown> {
13631440

13641441
return {
13651442
tracks: tracksToInitialize.map(({ id, user }) => ({ id, user })),
1366-
buffer: ISOFile.writeInitializationSegment(this.ftyp, moov, fragmentDuration),
1443+
buffer: ISOFile.writeInitializationSegment(
1444+
this.ftyp,
1445+
moov,
1446+
fragmentDuration,
1447+
normalizeAudioSampleEntryTrackIds,
1448+
),
13671449
};
13681450
}
13691451

tests/codec.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { mp4aSampleEntry } from '../src/boxes/sampleentries/sampleentry';
2+
import type { esdsBox } from '../src/boxes/esds';
3+
import type { waveBox } from '../src/boxes/qt/wave';
4+
5+
describe('Codec strings', () => {
6+
const makeEsds = () =>
7+
({
8+
esd: {
9+
getOTI: () => 0x40,
10+
getAudioConfig: () => 2,
11+
},
12+
}) as esdsBox;
13+
14+
it('should include AAC object type from nested QuickTime wave esds', () => {
15+
const entry = new mp4aSampleEntry();
16+
entry.wave = { esds: makeEsds() } as waveBox;
17+
18+
expect(entry.getCodec()).toBe('mp4a.40.2');
19+
});
20+
21+
it('should keep bare mp4a when no esds is present', () => {
22+
const entry = new mp4aSampleEntry();
23+
24+
expect(entry.getCodec()).toBe('mp4a');
25+
});
26+
});

0 commit comments

Comments
 (0)