From 217b383f12ef00a5c148871878c65dbf48fffe56 Mon Sep 17 00:00:00 2001 From: Vanilagy <1696106+Vanilagy@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:00:42 +0100 Subject: [PATCH] Add read/write support for track disposition information --- dev/demux.html | 6 ++ docs/guide/reading-media-files.md | 4 ++ docs/guide/writing-media-files.md | 1 + src/adts/adts-demuxer.ts | 7 +++ src/codec-data.ts | 2 +- src/conversion.ts | 8 ++- src/demuxer.ts | 2 +- src/flac/flac-demuxer.ts | 8 ++- src/flac/flac-muxer.ts | 2 +- src/id3.ts | 2 +- src/index.ts | 3 +- src/input-track.ts | 7 +++ src/isobmff/isobmff-boxes.ts | 9 ++- src/isobmff/isobmff-demuxer.ts | 16 ++++-- src/isobmff/isobmff-reader.ts | 2 +- src/matroska/ebml.ts | 4 ++ src/matroska/matroska-demuxer.ts | 46 ++++++++++++++-- src/matroska/matroska-muxer.ts | 20 ++++++- src/{tags.ts => metadata.ts} | 59 ++++++++++++++++++++ src/mp3/mp3-demuxer.ts | 8 ++- src/mp3/mp3-muxer.ts | 2 +- src/ogg/ogg-demuxer.ts | 8 ++- src/output.ts | 7 ++- src/wave/wave-demuxer.ts | 8 ++- src/wave/wave-muxer.ts | 2 +- test/node/disposition.test.ts | 92 +++++++++++++++++++++++++++++++ test/node/metadata-tags.test.ts | 2 +- 27 files changed, 307 insertions(+), 30 deletions(-) rename src/{tags.ts => metadata.ts} (81%) create mode 100644 test/node/disposition.test.ts diff --git a/dev/demux.html b/dev/demux.html index c0c8346..8d02ae1 100644 --- a/dev/demux.html +++ b/dev/demux.html @@ -14,12 +14,18 @@ source: new Mediabunny.BlobSource(file), }); + console.log(await input.getTracks()); + + /* + return; + const audioTrack = await input.getPrimaryAudioTrack(); const sink = new Mediabunny.EncodedPacketSink(audioTrack); for await (const packet of sink.packets()) { console.log(packet.timestamp, packet.duration, packet.type) } + */ /* const audioTrack = await input.getPrimaryAudioTrack(); diff --git a/docs/guide/reading-media-files.md b/docs/guide/reading-media-files.md index 8457088..dab7aec 100644 --- a/docs/guide/reading-media-files.md +++ b/docs/guide/reading-media-files.md @@ -106,6 +106,10 @@ track.languageCode; // => string // A user-defined name for this track. track.name; // => string + +// Information about the intended usage of the track +// (default, commentary, hearing-impaired, visually-impaired, etc.) +track.disposition; // TrackDisposition ``` #### Codec information diff --git a/docs/guide/writing-media-files.md b/docs/guide/writing-media-files.md index c9ef8ed..6882eb4 100644 --- a/docs/guide/writing-media-files.md +++ b/docs/guide/writing-media-files.md @@ -56,6 +56,7 @@ output.addVideoTrack(videoSource, { output.addAudioTrack(audioSourceEng, { language: 'eng', // ISO 639-2/T language code name: 'Developer Commentary', // Sets a user-defined track name + disposition: { commentary: true }, // Sets additional flags in the file }); output.addAudioTrack(audioSourceGer, { language: 'ger', diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts index 209365e..3295a9a 100644 --- a/src/adts/adts-demuxer.ts +++ b/src/adts/adts-demuxer.ts @@ -21,6 +21,7 @@ import { } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { readBytes, Reader } from '../reader'; +import { DEFAULT_TRACK_DISPOSITION } from '../metadata'; import { FrameHeader, MAX_FRAME_HEADER_SIZE, MIN_FRAME_HEADER_SIZE, readFrameHeader } from './adts-reader'; const SAMPLES_PER_AAC_FRAME = 1024; @@ -188,6 +189,12 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking { return sampleRate; } + getDisposition() { + return { + ...DEFAULT_TRACK_DISPOSITION, + }; + } + async getDecoderConfig(): Promise { assert(this.demuxer.firstFrameHeader); diff --git a/src/codec-data.ts b/src/codec-data.ts index 13888f2..eef04ec 100644 --- a/src/codec-data.ts +++ b/src/codec-data.ts @@ -24,7 +24,7 @@ import { toUint8Array, } from './misc'; import { PacketType } from './packet'; -import { MetadataTags } from './tags'; +import { MetadataTags } from './metadata'; // References for AVC/HEVC code: // ISO 14496-15 diff --git a/src/conversion.ts b/src/conversion.ts index 2771f7c..33bbd4d 100644 --- a/src/conversion.ts +++ b/src/conversion.ts @@ -48,7 +48,7 @@ import { import { Output, TrackType } from './output'; import { Mp4OutputFormat } from './output-format'; import { AudioSample, clampCropRectangle, validateCropRectangle, VideoSample } from './sample'; -import { MetadataTags, validateMetadataTags } from './tags'; +import { MetadataTags, validateMetadataTags } from './metadata'; import { NullTarget } from './target'; /** @@ -1184,9 +1184,10 @@ export class Conversion { this.output.addVideoTrack(videoSource, { frameRate: trackOptions.frameRate, - // TEMP: This condition can be removed when all demuxers properly homogenize to BCP47 in v2 + // TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2 languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined, name: track.name ?? undefined, + disposition: track.disposition, rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output }); this._addedCounts.video++; @@ -1423,9 +1424,10 @@ export class Conversion { } this.output.addAudioTrack(audioSource, { - // TEMP: This condition can be removed when all demuxers properly homogenize to BCP47 in v2 + // TODO: This condition can be removed when all demuxers properly homogenize to BCP47 in v2 languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined, name: track.name ?? undefined, + disposition: track.disposition, }); this._addedCounts.audio++; this._totalTrackCount++; diff --git a/src/demuxer.ts b/src/demuxer.ts index f654dab..5b53fb2 100644 --- a/src/demuxer.ts +++ b/src/demuxer.ts @@ -8,7 +8,7 @@ import { Input } from './input'; import { InputTrack } from './input-track'; -import { MetadataTags } from './tags'; +import { MetadataTags } from './metadata'; export abstract class Demuxer { input: Input; diff --git a/src/flac/flac-demuxer.ts b/src/flac/flac-demuxer.ts index 4303fd4..e362f44 100644 --- a/src/flac/flac-demuxer.ts +++ b/src/flac/flac-demuxer.ts @@ -28,7 +28,7 @@ import { readU32Be, readU8, } from '../reader'; -import { MetadataTags } from '../tags'; +import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { calculateCrc8, readBlockSize, @@ -565,6 +565,12 @@ class FlacAudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.audioInfo.sampleRate; } + getDisposition() { + return { + ...DEFAULT_TRACK_DISPOSITION, + }; + } + async getFirstTimestamp() { return 0; } diff --git a/src/flac/flac-muxer.ts b/src/flac/flac-muxer.ts index 782234a..b444f0b 100644 --- a/src/flac/flac-muxer.ts +++ b/src/flac/flac-muxer.ts @@ -20,7 +20,7 @@ import { Output, OutputAudioTrack } from '../output'; import { FlacOutputFormat } from '../output-format'; import { EncodedPacket } from '../packet'; import { FileSlice, readBytes } from '../reader'; -import { AttachedImage, metadataTagsAreEmpty } from '../tags'; +import { AttachedImage, metadataTagsAreEmpty } from '../metadata'; import { Writer } from '../writer'; import { readBlockSize, diff --git a/src/id3.ts b/src/id3.ts index f231176..15db50b 100644 --- a/src/id3.ts +++ b/src/id3.ts @@ -7,7 +7,7 @@ */ import { decodeSynchsafe, encodeSynchsafe } from '../shared/mp3-misc'; -import { MetadataTags } from './tags'; +import { MetadataTags } from './metadata'; import { coalesceIndex, textDecoder, diff --git a/src/index.ts b/src/index.ts index e3abe7a..f5dbdf1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -202,6 +202,7 @@ export { AttachedImage, RichImageData, AttachedFile, -} from './tags'; + TrackDisposition, +} from './metadata'; // 🐡🦔 diff --git a/src/input-track.ts b/src/input-track.ts index 25b5100..8c137fc 100644 --- a/src/input-track.ts +++ b/src/input-track.ts @@ -14,6 +14,7 @@ import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink'; import { assert, Rotation } from './misc'; import { TrackType } from './output'; import { EncodedPacket, PacketType } from './packet'; +import { TrackDisposition } from './metadata'; /** * Contains aggregate statistics about the encoded packets of a track. @@ -36,6 +37,7 @@ export interface InputTrackBacking { getName(): string | null; getLanguageCode(): string; getTimeResolution(): number; + getDisposition(): TrackDisposition; getFirstTimestamp(): Promise; computeDuration(): Promise; @@ -128,6 +130,11 @@ export abstract class InputTrack { return this._backing.getTimeResolution(); } + /** The track's disposition, i.e. information about its intended usage. */ + get disposition() { + return this._backing.getDisposition(); + } + /** * Returns the start timestamp of the first packet of this track, in seconds. While often near zero, this value * may be positive or even negative. A negative starting timestamp means the track's timing has been offset. Samples diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index f2fccc4..b69ac5e 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -44,7 +44,7 @@ import { Sample, } from './isobmff-muxer'; import { parseOpusIdentificationHeader } from '../codec-data'; -import { MetadataTags, RichImageData } from '../tags'; +import { MetadataTags, RichImageData } from '../metadata'; export class IsobmffBoxWriter { private helper = new Uint8Array(8); @@ -421,7 +421,12 @@ export const tkhd = ( matrix = IDENTITY_MATRIX; } - return fullBox('tkhd', +needsU64, 3, [ + let flags = 0x2; // Track in movie + if (trackData.track.metadata.disposition?.default !== false) { + flags |= 0x1; // Track enabled + } + + return fullBox('tkhd', +needsU64, flags, [ u32OrU64(creationTime), // Creation time u32OrU64(creationTime), // Modification time u32(trackData.track.id), // Track ID diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts index aa34771..b76fd85 100644 --- a/src/isobmff/isobmff-demuxer.ts +++ b/src/isobmff/isobmff-demuxer.ts @@ -86,13 +86,13 @@ import { readU8, readAscii, } from '../reader'; -import { MetadataTags, RichImageData } from '../tags'; +import { DEFAULT_TRACK_DISPOSITION, MetadataTags, RichImageData, TrackDisposition } from '../metadata'; type InternalTrack = { id: number; demuxer: IsobmffDemuxer; inputTrack: InputTrack | null; - isDefault: boolean; + disposition: TrackDisposition; timescale: number; durationInMovieTimescale: number; durationInMediaTimescale: number; @@ -317,7 +317,7 @@ export class IsobmffDemuxer extends Demuxer { this.readContiguousBoxes(this.moovSlice); // Put default tracks first - this.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault)); + this.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default)); for (const track of this.tracks) { // Modify the edit list offset based on the previous segment durations. They are in different @@ -655,7 +655,9 @@ export class IsobmffDemuxer extends Demuxer { id: -1, demuxer: this, inputTrack: null, - isDefault: false, + disposition: { + ...DEFAULT_TRACK_DISPOSITION, + }, info: null, timescale: -1, durationInMovieTimescale: -1, @@ -703,7 +705,7 @@ export class IsobmffDemuxer extends Demuxer { // Spec says disabled tracks are to be treated like they don't exist, but in practice, they are treated // more like non-default tracks. const trackEnabled = !!(flags & 0x1); - track.isDefault = trackEnabled; + track.disposition.default = trackEnabled; // Skip over creation & modification time to reach the track ID if (version === 0) { @@ -2350,6 +2352,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking { return this.internalTrack.timescale; } + getDisposition() { + return this.internalTrack.disposition; + } + async computeDuration() { const lastPacket = await this.getPacket(Infinity, { metadataOnly: true }); return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0); diff --git a/src/isobmff/isobmff-reader.ts b/src/isobmff/isobmff-reader.ts index 67e0ab8..2579fc9 100644 --- a/src/isobmff/isobmff-reader.ts +++ b/src/isobmff/isobmff-reader.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { RichImageData } from '../tags'; +import { RichImageData } from '../metadata'; import { textDecoder } from '../misc'; import { FileSlice, readAscii, readBytes, readI32Be, readU16Be, readU32Be, readU64Be, readU8 } from '../reader'; diff --git a/src/matroska/ebml.ts b/src/matroska/ebml.ts index 5097b77..d695405 100644 --- a/src/matroska/ebml.ts +++ b/src/matroska/ebml.ts @@ -88,6 +88,10 @@ export enum EBMLId { FlagEnabled = 0xb9, FlagDefault = 0x88, FlagForced = 0x55aa, + FlagOriginal = 0x55ae, + FlagHearingImpaired = 0x55ab, + FlagVisualImpaired = 0x55ac, + FlagCommentary = 0x55af, FlagLacing = 0x9c, Name = 0x536e, Language = 0x22b59c, diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts index 4c69c35..0cf97d7 100644 --- a/src/matroska/matroska-demuxer.ts +++ b/src/matroska/matroska-demuxer.ts @@ -31,7 +31,7 @@ import { InputVideoTrack, InputVideoTrackBacking, } from '../input-track'; -import { AttachedFile, MetadataTags } from '../tags'; +import { AttachedFile, DEFAULT_TRACK_DISPOSITION, MetadataTags, TrackDisposition } from '../metadata'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, @@ -188,7 +188,7 @@ type InternalTrack = { }[]; cuePoints: CuePoint[]; - isDefault: boolean; + disposition: TrackDisposition; inputTrack: InputTrack | null; codecId: string | null; codecPrivate: Uint8Array | null; @@ -537,7 +537,7 @@ export class MatroskaDemuxer extends Demuxer { } // Put default tracks first - this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault)); + this.currentSegment.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default)); // Now, let's distribute the cue points to the tracks const idToTrack = new Map(this.currentSegment.tracks.map(x => [x.id, x])); @@ -989,7 +989,9 @@ export class MatroskaDemuxer extends Demuxer { clusterPositionCache: [], cuePoints: [], - isDefault: false, + disposition: { + ...DEFAULT_TRACK_DISPOSITION, + }, inputTrack: null, codecId: null, codecPrivate: null, @@ -1154,7 +1156,37 @@ export class MatroskaDemuxer extends Demuxer { case EBMLId.FlagDefault: { if (!this.currentTrack) break; - this.currentTrack.isDefault = !!readUnsignedInt(slice, size); + this.currentTrack.disposition.default = !!readUnsignedInt(slice, size); + }; break; + + case EBMLId.FlagForced: { + if (!this.currentTrack) break; + + this.currentTrack.disposition.forced = !!readUnsignedInt(slice, size); + }; break; + + case EBMLId.FlagOriginal: { + if (!this.currentTrack) break; + + this.currentTrack.disposition.original = !!readUnsignedInt(slice, size); + }; break; + + case EBMLId.FlagHearingImpaired: { + if (!this.currentTrack) break; + + this.currentTrack.disposition.hearingImpaired = !!readUnsignedInt(slice, size); + }; break; + + case EBMLId.FlagVisualImpaired: { + if (!this.currentTrack) break; + + this.currentTrack.disposition.visuallyImpaired = !!readUnsignedInt(slice, size); + }; break; + + case EBMLId.FlagCommentary: { + if (!this.currentTrack) break; + + this.currentTrack.disposition.commentary = !!readUnsignedInt(slice, size); }; break; case EBMLId.CodecID: { @@ -1847,6 +1879,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking { return this.internalTrack.segment.timestampFactor; } + getDisposition() { + return this.internalTrack.disposition; + } + async getFirstPacket(options: PacketRetrievalOptions) { return this.performClusterLookup( null, diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index deff3e0..920eab8 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -63,7 +63,7 @@ import { Muxer } from '../muxer'; import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { parseOpusIdentificationHeader } from '../codec-data'; -import { AttachedFile } from '../tags'; +import { AttachedFile } from '../metadata'; const MIN_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ -(2 ** 15); const MAX_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ 2 ** 15 - 1; @@ -304,6 +304,24 @@ export class MatroskaMuxer extends Muxer { { id: EBMLId.TrackNumber, data: trackData.track.id }, { id: EBMLId.TrackUID, data: trackData.track.id }, { id: EBMLId.TrackType, data: TRACK_TYPE_MAP[trackData.type] }, + trackData.track.metadata.disposition?.default === false + ? { id: EBMLId.FlagDefault, data: 0 } + : null, + trackData.track.metadata.disposition?.forced + ? { id: EBMLId.FlagForced, data: 1 } + : null, + trackData.track.metadata.disposition?.hearingImpaired + ? { id: EBMLId.FlagHearingImpaired, data: 1 } + : null, + trackData.track.metadata.disposition?.visuallyImpaired + ? { id: EBMLId.FlagVisualImpaired, data: 1 } + : null, + trackData.track.metadata.disposition?.original + ? { id: EBMLId.FlagOriginal, data: 1 } + : null, + trackData.track.metadata.disposition?.commentary + ? { id: EBMLId.FlagCommentary, data: 1 } + : null, { id: EBMLId.FlagLacing, data: 0 }, { id: EBMLId.Language, data: trackData.track.metadata.languageCode ?? UNDETERMINED_LANGUAGE }, { id: EBMLId.CodecID, data: codecId }, diff --git a/src/tags.ts b/src/metadata.ts similarity index 81% rename from src/tags.ts rename to src/metadata.ts index 9055efc..dc1d166 100644 --- a/src/tags.ts +++ b/src/metadata.ts @@ -259,3 +259,62 @@ export const metadataTagsAreEmpty = (tags: MetadataTags) => { && tags.comment === undefined && (tags.raw === undefined || Object.keys(tags.raw).length === 0); }; + +/** + * Specifies a track's disposition, i.e. information about its intended usage. + * @public + * @group Miscellaneous + */ +export type TrackDisposition = { + /** + * Indicates that this track is eligible for automatic selection by a player; that it is the main track among other, + * non-default tracks of the same type. + */ + default: boolean; + /** + * Indicates that players should always display this track by default, even if it goes against the user's default + * preferences. For example, a subtitle track only containing translations of foreign-language audio. + */ + forced: boolean; + /** Indicates that this track is in the content's original language. */ + original: boolean; + /** Indicates that this track contains commentary. */ + commentary: boolean; + /** Indicates that this track is intended for hearing-impaired users. */ + hearingImpaired: boolean; + /** Indicates that this track is intended for visually-impaired users. */ + visuallyImpaired: boolean; +}; + +export const DEFAULT_TRACK_DISPOSITION: TrackDisposition = { + default: true, + forced: false, + original: false, + commentary: false, + hearingImpaired: false, + visuallyImpaired: false, +}; + +export const validateTrackDisposition = (disposition: Partial) => { + if (!disposition || typeof disposition !== 'object') { + throw new TypeError('disposition must be an object.'); + } + if (disposition.default !== undefined && typeof disposition.default !== 'boolean') { + throw new TypeError('disposition.default must be a boolean.'); + } + if (disposition.forced !== undefined && typeof disposition.forced !== 'boolean') { + throw new TypeError('disposition.forced must be a boolean.'); + } + if (disposition.original !== undefined && typeof disposition.original !== 'boolean') { + throw new TypeError('disposition.original must be a boolean.'); + } + if (disposition.commentary !== undefined && typeof disposition.commentary !== 'boolean') { + throw new TypeError('disposition.commentary must be a boolean.'); + } + if (disposition.hearingImpaired !== undefined && typeof disposition.hearingImpaired !== 'boolean') { + throw new TypeError('disposition.hearingImpaired must be a boolean.'); + } + if (disposition.visuallyImpaired !== undefined && typeof disposition.visuallyImpaired !== 'boolean') { + throw new TypeError('disposition.visuallyImpaired must be a boolean.'); + } +}; diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts index a8333b5..fe654d8 100644 --- a/src/mp3/mp3-demuxer.ts +++ b/src/mp3/mp3-demuxer.ts @@ -10,7 +10,7 @@ import { AudioCodec } from '../codec'; import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; -import { MetadataTags } from '../tags'; +import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { PacketRetrievalOptions } from '../media-sink'; import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; @@ -257,6 +257,12 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking { return this.demuxer.firstFrameHeader.sampleRate; } + getDisposition() { + return { + ...DEFAULT_TRACK_DISPOSITION, + }; + } + async getDecoderConfig(): Promise { assert(this.demuxer.firstFrameHeader); diff --git a/src/mp3/mp3-muxer.ts b/src/mp3/mp3-muxer.ts index dd310da..f2c86e8 100644 --- a/src/mp3/mp3-muxer.ts +++ b/src/mp3/mp3-muxer.ts @@ -7,7 +7,7 @@ */ import { assert, toDataView } from '../misc'; -import { metadataTagsAreEmpty } from '../tags'; +import { metadataTagsAreEmpty } from '../metadata'; import { Muxer } from '../muxer'; import { Output, OutputAudioTrack } from '../output'; import { Mp3OutputFormat } from '../output-format'; diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts index 556e3af..c5164b9 100644 --- a/src/ogg/ogg-demuxer.ts +++ b/src/ogg/ogg-demuxer.ts @@ -12,7 +12,7 @@ import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; -import { MetadataTags } from '../tags'; +import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { assert, AsyncMutex, @@ -463,6 +463,12 @@ class OggAudioTrackBacking implements InputAudioTrackBacking { return UNDETERMINED_LANGUAGE; } + getDisposition() { + return { + ...DEFAULT_TRACK_DISPOSITION, + }; + } + async getFirstTimestamp() { return 0; } diff --git a/src/output.ts b/src/output.ts index 6379388..4046c1f 100644 --- a/src/output.ts +++ b/src/output.ts @@ -7,7 +7,7 @@ */ import { AsyncMutex, isIso639Dash2LanguageCode, Rotation } from './misc'; -import { MetadataTags, validateMetadataTags } from './tags'; +import { MetadataTags, TrackDisposition, validateMetadataTags, validateTrackDisposition } from './metadata'; import { Muxer } from './muxer'; import { OutputFormat } from './output-format'; import { AudioSource, MediaSource, SubtitleSource, VideoSource } from './media-source'; @@ -74,6 +74,8 @@ export type BaseTrackMetadata = { languageCode?: string; /** A user-defined name for this track, like "English" or "Director Commentary". */ name?: string; + /** The track's disposition, i.e. information about its intended usage. */ + disposition?: Partial; /** * The maximum amount of encoded packets that will be added to this track. Setting this field provides the muxer * with an additional signal that it can use to preallocate space in the file. @@ -129,6 +131,9 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => { if (metadata.name !== undefined && typeof metadata.name !== 'string') { throw new TypeError('metadata.name, when provided, must be a string.'); } + if (metadata.disposition !== undefined) { + validateTrackDisposition(metadata.disposition); + } if ( metadata.maximumPacketCount !== undefined && (!Number.isInteger(metadata.maximumPacketCount) || metadata.maximumPacketCount < 0) diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts index c552827..56b5db5 100644 --- a/src/wave/wave-demuxer.ts +++ b/src/wave/wave-demuxer.ts @@ -11,7 +11,7 @@ import { Demuxer } from '../demuxer'; import { Input } from '../input'; import { InputAudioTrack, InputAudioTrackBacking } from '../input-track'; import { PacketRetrievalOptions } from '../media-sink'; -import { MetadataTags } from '../tags'; +import { DEFAULT_TRACK_DISPOSITION, MetadataTags } from '../metadata'; import { assert, UNDETERMINED_LANGUAGE } from '../misc'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { readAscii, readBytes, Reader, readU16, readU32, readU64 } from '../reader'; @@ -407,6 +407,12 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking { return UNDETERMINED_LANGUAGE; } + getDisposition() { + return { + ...DEFAULT_TRACK_DISPOSITION, + }; + } + async getFirstTimestamp() { return 0; } diff --git a/src/wave/wave-muxer.ts b/src/wave/wave-muxer.ts index 63c8f96..74b6abf 100644 --- a/src/wave/wave-muxer.ts +++ b/src/wave/wave-muxer.ts @@ -15,7 +15,7 @@ import { Writer } from '../writer'; import { EncodedPacket } from '../packet'; import { WavOutputFormat } from '../output-format'; import { assert, assertNever, isIso88591Compatible, keyValueIterator } from '../misc'; -import { MetadataTags, metadataTagsAreEmpty } from '../tags'; +import { MetadataTags, metadataTagsAreEmpty } from '../metadata'; import { Id3V2Writer } from '../id3'; export class WaveMuxer extends Muxer { diff --git a/test/node/disposition.test.ts b/test/node/disposition.test.ts new file mode 100644 index 0000000..a949121 --- /dev/null +++ b/test/node/disposition.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from 'vitest'; +import { Output } from '../../src/output.js'; +import { MkvOutputFormat } from '../../src/output-format.js'; +import { BufferTarget } from '../../src/target.js'; +import { EncodedVideoPacketSource } from '../../src/media-source.js'; +import { EncodedPacket } from '../../src/packet.js'; +import { Input } from '../../src/input.js'; +import { BufferSource } from '../../src/source.js'; +import { ALL_FORMATS } from '../../src/input-format.js'; + +test('Default track disposition', async () => { + const output = new Output({ + format: new MkvOutputFormat(), + target: new BufferTarget(), + }); + + const source = new EncodedVideoPacketSource('avc'); + output.addVideoTrack(source); + + await output.start(); + await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 1), { + decoderConfig: { + codec: 'avc1.123456', + codedWidth: 1920, + codedHeight: 1080, + }, + }); + + await output.finalize(); + + using input = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const track = (await input.getPrimaryVideoTrack())!; + + expect(track.disposition).toEqual({ + default: true, + forced: false, + original: false, + hearingImpaired: false, + visionImpaired: false, + commentary: false, + }); +}); + +test('Customized track disposition', async () => { + const output = new Output({ + format: new MkvOutputFormat(), + target: new BufferTarget(), + }); + + const source = new EncodedVideoPacketSource('avc'); + output.addVideoTrack(source, { + disposition: { + default: false, + forced: true, + original: true, + hearingImpaired: true, + visuallyImpaired: true, + commentary: true, + }, + }); + + await output.start(); + await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 1), { + decoderConfig: { + codec: 'avc1.123456', + codedWidth: 1920, + codedHeight: 1080, + }, + }); + + await output.finalize(); + + using input = new Input({ + source: new BufferSource(output.target.buffer!), + formats: ALL_FORMATS, + }); + + const track = (await input.getPrimaryVideoTrack())!; + + expect(track.disposition).toEqual({ + default: false, + forced: true, + original: true, + hearingImpaired: true, + visionImpaired: true, + commentary: true, + }); +}); diff --git a/test/node/metadata-tags.test.ts b/test/node/metadata-tags.test.ts index ae7ceb2..d9e50c2 100644 --- a/test/node/metadata-tags.test.ts +++ b/test/node/metadata-tags.test.ts @@ -15,7 +15,7 @@ import { EncodedPacket } from '../../src/packet.js'; import { Input } from '../../src/input.js'; import { BufferSource, FilePathSource } from '../../src/source.js'; import { ALL_FORMATS } from '../../src/input-format.js'; -import { AttachedFile, MetadataTags } from '../../src/tags.js'; +import { AttachedFile, MetadataTags } from '../../src/metadata.js'; import path from 'node:path'; import { AudioCodec, buildAudioCodecString } from '../../src/codec.js'; import { Conversion } from '../../src/conversion.js';