Fix tkhd issue for Apple ecosystem compatibility (#391)

* fix: ensure at least one track per type is enabled for Apple ecosystem compatibility

* Add ensureOneEnabledTrack() to ISOBMFF muxer

* Add test case

---------

Co-authored-by: Vanilagy <[email protected]>
This commit is contained in:
StringWeaver
2026-06-01 22:55:02 +02:00
committed by GitHub
co-authored by Vanilagy
parent c6c768d93e
commit 96d0d9fb22
3 changed files with 71 additions and 7 deletions
+4 -2
View File
@@ -378,12 +378,14 @@ export const free = (size: number): Box => ({ type: 'free', size });
* Movie Box: Used to specify the information that defines a movie - that is, the information that allows
* an application to interpret the sample data that is stored elsewhere.
*/
export const moov = (muxer: IsobmffMuxer) => box('moov', undefined, [
export const moov = (muxer: IsobmffMuxer) => {
return box('moov', undefined, [
mvhd(muxer.creationTime, muxer.trackDatas),
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
muxer.isFragmented ? mvex(muxer.trackDatas) : null,
udta(muxer),
]);
]);
};
/** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */
export const mvhd = (
+28 -1
View File
@@ -22,7 +22,7 @@ import {
vtte,
} from './isobmff-boxes';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack, TrackType } from '../output';
import { Writer } from '../writer';
import { BufferTarget } from '../target';
import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc';
@@ -1183,6 +1183,8 @@ export class IsobmffMuxer extends Muxer {
boxWriter.writer.startTrackingWrites();
}
this.ensureOneEnabledTrack();
// Write the moov box now that we have all decoder configs
const movieBox = moov(this);
boxWriter.writeBox(movieBox);
@@ -1299,6 +1301,8 @@ export class IsobmffMuxer extends Muxer {
if (this.allTracksAreKnown()) {
if (!this.mdat) {
this.ensureOneEnabledTrack();
// We finally know all tracks, let's reserve space for the moov box
const moovBox = moov(this);
const moovSize = this.boxWriter.measureBox(moovBox);
@@ -1389,11 +1393,34 @@ export class IsobmffMuxer extends Muxer {
release();
}
ensureOneEnabledTrack() {
// If no track of a given type is enabled, force the first one to be enabled. Otherwise the video won't play in
// players like QuickTime.
// https://github.com/Vanilagy/mediabunny/pull/391
for (const type of ['video', 'audio', 'subtitle'] as TrackType[]) {
const tracks = this.trackDatas.filter(t => t.type === type);
if (tracks.length === 0) {
continue;
}
const hasEnabled = tracks.some(t => t.track.metadata.disposition?.default !== false);
if (!hasEnabled) {
const firstTrack = tracks[0]!;
firstTrack.track.metadata.disposition = {
...firstTrack.track.metadata.disposition,
default: true,
};
}
}
}
/** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
async finalize() {
const release = await this.mutex.acquire();
this.allTracksKnown.resolve();
this.ensureOneEnabledTrack();
for (const trackData of this.trackDatas) {
trackData.closed = true;
+35
View File
@@ -352,3 +352,38 @@ test('PCM audio, no silence padding with approximate timestamps', async () => {
expect(frameCount).toBe(expectedFrameCount);
});
// https://github.com/Vanilagy/mediabunny/pull/391
test('At least one track is enabled even if all are added disabled', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const meta = { decoderConfig: { codec: 'vp8', codedWidth: 1280, codedHeight: 720 } };
const source1 = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source1, { disposition: { default: false } });
const source2 = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source2, { disposition: { default: false } });
await output.start();
await source1.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1), meta);
await source2.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1), meta);
await output.finalize();
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const tracks = await input.getVideoTracks();
expect(tracks.length).toBe(2);
// Even though both tracks were added disabled, the muxer forces the first one to be enabled
expect((await tracks[0]!.getDisposition()).default).toBe(true);
expect((await tracks[1]!.getDisposition()).default).toBe(false);
});