From 96d0d9fb226af5b11f7d433d24e8d6faa2fea8b2 Mon Sep 17 00:00:00 2001 From: StringWeaver <55291103+StringWeaver@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:55:02 +0800 Subject: [PATCH] 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 <1696106+Vanilagy@users.noreply.github.com> --- src/isobmff/isobmff-boxes.ts | 14 +++++++------ src/isobmff/isobmff-muxer.ts | 29 ++++++++++++++++++++++++++- test/node/isobmff-muxer.test.ts | 35 +++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index f09c170..21f69d5 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -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, [ - mvhd(muxer.creationTime, muxer.trackDatas), - ...muxer.trackDatas.map(x => trak(x, muxer.creationTime)), - muxer.isFragmented ? mvex(muxer.trackDatas) : null, - udta(muxer), -]); +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 = ( diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts index 42c6ce8..c9baf86 100644 --- a/src/isobmff/isobmff-muxer.ts +++ b/src/isobmff/isobmff-muxer.ts @@ -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; diff --git a/test/node/isobmff-muxer.test.ts b/test/node/isobmff-muxer.test.ts index 8d495e4..e6594da 100644 --- a/test/node/isobmff-muxer.test.ts +++ b/test/node/isobmff-muxer.test.ts @@ -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); +});