Add read/write support for track disposition information

This commit is contained in:
Vanilagy
2025-11-13 11:00:42 +01:00
parent 2c96ec0f1b
commit 217b383f12
27 changed files with 307 additions and 30 deletions
+7
View File
@@ -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<AudioDecoderConfig> {
assert(this.demuxer.firstFrameHeader);
+1 -1
View File
@@ -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
+5 -3
View File
@@ -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++;
+1 -1
View File
@@ -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;
+7 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -7,7 +7,7 @@
*/
import { decodeSynchsafe, encodeSynchsafe } from '../shared/mp3-misc';
import { MetadataTags } from './tags';
import { MetadataTags } from './metadata';
import {
coalesceIndex,
textDecoder,
+2 -1
View File
@@ -202,6 +202,7 @@ export {
AttachedImage,
RichImageData,
AttachedFile,
} from './tags';
TrackDisposition,
} from './metadata';
// 🐡🦔
+7
View File
@@ -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<number>;
computeDuration(): Promise<number>;
@@ -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
+7 -2
View File
@@ -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
+11 -5
View File
@@ -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);
+1 -1
View File
@@ -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';
+4
View File
@@ -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,
+41 -5
View File
@@ -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,
+19 -1
View File
@@ -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 },
+59
View File
@@ -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<TrackDisposition>) => {
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.');
}
};
+7 -1
View File
@@ -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<AudioDecoderConfig> {
assert(this.demuxer.firstFrameHeader);
+1 -1
View File
@@ -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';
+7 -1
View File
@@ -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;
}
+6 -1
View File
@@ -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<TrackDisposition>;
/**
* 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)
+7 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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 {