diff --git a/dev/demux.html b/dev/demux.html
index 6a6cb94..5138016 100644
--- a/dev/demux.html
+++ b/dev/demux.html
@@ -19,6 +19,8 @@
const videoTrack = await input.getPrimaryVideoTrack();
console.log(await videoTrack.getFirstTimestamp(), await videoTrack.computeDuration());
+ console.log(videoTrack.name);
+
/*
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
diff --git a/dev/mux.html b/dev/mux.html
index e2ae1c2..ad0eeaf 100644
--- a/dev/mux.html
+++ b/dev/mux.html
@@ -45,6 +45,7 @@
format = new Mediabunny.MkvOutputFormat({ minimumClusterDuration: 2 });
format = new Mediabunny.WavOutputFormat();
format = new Mediabunny.MkvOutputFormat();
+ format = new Mediabunny.MovOutputFormat();
let target = new Mediabunny.BufferTarget();
/*
@@ -125,8 +126,8 @@
});
let subtitleSource = new Mediabunny.TextSubtitleSource('webvtt');
- output.addVideoTrack(videoSource, { languageCode: 'eng' });
- output.addAudioTrack(audioSource);
+ output.addVideoTrack(videoSource, { languageCode: 'eng', name: 'Mononoké' });
+ output.addAudioTrack(audioSource, { name: 'Yooo' });
//output.addSubtitleTrack(subtitleSource);
output.start();
@@ -206,5 +207,5 @@ Testing... <00:17.350>One... <00:18.125>Two...
await output.finalize();
console.log(target);
- //download(new Blob([target.buffer]), 'test' + format.fileExtension);
+ download(new Blob([target.buffer]), 'test' + format.fileExtension);
\ No newline at end of file
diff --git a/docs/guide/reading-media-files.md b/docs/guide/reading-media-files.md
index 8034861..996b647 100644
--- a/docs/guide/reading-media-files.md
+++ b/docs/guide/reading-media-files.md
@@ -97,6 +97,9 @@ track.isAudioTrack(); // => boolean
// Retrieve the track's language as an ISO 639-2/T language code.
// Resolves to 'und' (undetermined) if the language isn't known.
track.languageCode; // => string
+
+// A user-defined name for this track.
+track.name; // => string
```
#### Codec information
diff --git a/docs/guide/writing-media-files.md b/docs/guide/writing-media-files.md
index 00fa8e8..5a20e6a 100644
--- a/docs/guide/writing-media-files.md
+++ b/docs/guide/writing-media-files.md
@@ -55,6 +55,7 @@ output.addVideoTrack(videoSource, {
// This adds two audio tracks; one in English and one in German.
output.addAudioTrack(audioSourceEng, {
language: 'eng', // ISO 639-2/T language code
+ name: 'Developer Commentary', // Sets a user-defined track name
});
output.addAudioTrack(audioSourceGer, {
language: 'ger',
diff --git a/src/adts/adts-demuxer.ts b/src/adts/adts-demuxer.ts
index b29cbdd..05deace 100644
--- a/src/adts/adts-demuxer.ts
+++ b/src/adts/adts-demuxer.ts
@@ -156,6 +156,10 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
+ getName() {
+ return null;
+ }
+
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
diff --git a/src/input-track.ts b/src/input-track.ts
index 2e56fec..a433462 100644
--- a/src/input-track.ts
+++ b/src/input-track.ts
@@ -30,6 +30,7 @@ export type PacketStats = {
export interface InputTrackBacking {
getId(): number;
getCodec(): MediaCodec | null;
+ getName(): string | null;
getLanguageCode(): string;
getTimeResolution(): number;
getFirstTimestamp(): Promise;
@@ -89,6 +90,11 @@ export abstract class InputTrack {
return this._backing.getLanguageCode();
}
+ /** A user-defined name for this track. */
+ get name() {
+ return this._backing.getName();
+ }
+
/**
* A positive number x such that all timestamps and durations of all packets of this track are
* integer multiples of 1/x.
diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts
index 5da915a..e25824a 100644
--- a/src/isobmff/isobmff-boxes.ts
+++ b/src/isobmff/isobmff-boxes.ts
@@ -31,6 +31,7 @@ import {
import { formatSubtitleTimestamp } from '../subtitles';
import { Writer } from '../writer';
import {
+ getTrackMetadata,
GLOBAL_TIMESCALE,
intoTimescale,
IsobmffAudioTrackData,
@@ -378,10 +379,72 @@ export const mvhd = (
* independent of the other tracks in the movie and carries its own temporal and spatial information. Each Track Box
* contains its associated Media Box.
*/
-export const trak = (trackData: IsobmffTrackData, creationTime: number) => box('trak', undefined, [
- tkhd(trackData, creationTime),
- mdia(trackData, creationTime),
-]);
+export const trak = (trackData: IsobmffTrackData, creationTime: number) => {
+ const trackMetadata = getTrackMetadata(trackData);
+
+ return box('trak', undefined, [
+ tkhd(trackData, creationTime),
+ mdia(trackData, creationTime),
+ trackMetadata.name !== undefined
+ ? box('udta', undefined, [
+ box('©nam', [
+ ...textEncoder.encode(trackMetadata.name),
+ ]),
+ ])
+ : null,
+ ]);
+};
+
+/*
+const meta = (trackData: IsobmffTrackData) => {
+ const trackMetadata = getTrackMetadata(trackData);
+
+ if (trackData.muxer.isQuickTime) {
+ const keyMap: Record = {
+ name: 'com.apple.quicktime.title',
+ };
+
+ return box('meta', undefined, [
+ hdlr(false, 'mdta', ''),
+ fullBox('keys', 0, 0, [
+ u32(Object.keys(trackMetadata).length),
+ ], Object.keys(trackMetadata).map(key =>
+ box('mdta', [
+ ascii(keyMap[key as keyof IsobmffMetadata]), // Key name
+ ]),
+ )),
+ box('ilst', undefined, Object.values(trackMetadata)
+ .map((value, i) => box(u32(i + 1).map(x => String.fromCharCode(x)).join(''), undefined, [
+ data(value),
+ ]))),
+ ]);
+ } else {
+ const keyMap: Record = {
+ name: '©nam',
+ };
+
+ return fullBox('meta', 0, 0, undefined, [
+ hdlr(false, 'mdir', ''),
+ box('ilst', undefined, Object.entries(trackMetadata)
+ .map(([key, value]) => box(keyMap[key as keyof IsobmffMetadata], undefined, [
+ data(value),
+ ]))),
+ ]);
+ }
+};
+
+const data = (value: unknown) => {
+ if (typeof value === 'string') {
+ return box('data', [
+ u32(1), // Type indicator (UTF-8)
+ u32(0), // Locale indicator
+ ...textEncoder.encode(value),
+ ]);
+ }
+
+ throw new Error('Unhandled data type.');
+};
+*/
/** Track Header Box: Specifies the characteristics of a single track within a movie. */
export const tkhd = (
@@ -425,7 +488,7 @@ export const tkhd = (
/** Media Box: Describes and define a track's media type and sample data. */
export const mdia = (trackData: IsobmffTrackData, creationTime: number) => box('mdia', undefined, [
mdhd(trackData, creationTime),
- hdlr(trackData),
+ hdlr(true, TRACK_TYPE_TO_COMPONENT_SUBTYPE[trackData.type], TRACK_TYPE_TO_HANDLER_NAME[trackData.type]),
minf(trackData),
]);
@@ -471,14 +534,14 @@ const TRACK_TYPE_TO_HANDLER_NAME: Record = {
subtitle: 'MediabunnyTextHandler',
};
-/** Handler Reference Box: Specifies the media handler component that is to be used to interpret the media's data. */
-export const hdlr = (trackData: IsobmffTrackData) => fullBox('hdlr', 0, 0, [
- ascii('mhlr'), // Component type
- ascii(TRACK_TYPE_TO_COMPONENT_SUBTYPE[trackData.type]), // Component subtype
+/** Handler Reference Box. */
+export const hdlr = (hasComponentType: boolean, handlerType: string, name: string) => fullBox('hdlr', 0, 0, [
+ hasComponentType ? ascii('mhlr') : u32(0), // Component type
+ ascii(handlerType), // Component subtype
u32(0), // Component manufacturer
u32(0), // Component flags
u32(0), // Component flags mask
- ascii(TRACK_TYPE_TO_HANDLER_NAME[trackData.type], true), // Component name
+ ascii(name, true), // Component name
]);
/**
diff --git a/src/isobmff/isobmff-demuxer.ts b/src/isobmff/isobmff-demuxer.ts
index cfafef7..1920049 100644
--- a/src/isobmff/isobmff-demuxer.ts
+++ b/src/isobmff/isobmff-demuxer.ts
@@ -56,6 +56,7 @@ import {
normalizeRotation,
Bitstream,
insertSorted,
+ textDecoder,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { Reader } from '../reader';
@@ -70,6 +71,7 @@ type InternalTrack = {
durationInMovieTimescale: number;
durationInMediaTimescale: number;
rotation: Rotation;
+ name: string | null;
languageCode: string;
sampleTableByteOffset: number;
sampleTable: SampleTable | null;
@@ -596,7 +598,8 @@ export class IsobmffDemuxer extends Demuxer {
case 'minf':
case 'dinf':
case 'mfra':
- case 'edts': {
+ case 'edts':
+ case 'udta': {
this.readContiguousBoxes(boxInfo.contentSize);
}; break;
@@ -625,6 +628,7 @@ export class IsobmffDemuxer extends Demuxer {
durationInMovieTimescale: -1,
durationInMediaTimescale: -1,
rotation: 0,
+ name: null,
languageCode: UNDETERMINED_LANGUAGE,
sampleTableByteOffset: -1,
sampleTable: null,
@@ -1919,6 +1923,16 @@ export class IsobmffDemuxer extends Demuxer {
this.currentFragment.implicitBaseDataOffset = currentOffset;
}; break;
+
+ // These appear in udta:
+ case '©nam':
+ case 'name': {
+ if (!this.currentTrack) {
+ break;
+ }
+
+ this.currentTrack.name = textDecoder.decode(this.metadataReader.readBytes(boxInfo.contentSize));
+ }; break;
}
this.metadataReader.pos = boxEndPos;
@@ -1943,6 +1957,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
throw new Error('Not implemented on base class.');
}
+ getName() {
+ return this.internalTrack.name;
+ }
+
getLanguageCode() {
return this.internalTrack.languageCode;
}
diff --git a/src/isobmff/isobmff-muxer.ts b/src/isobmff/isobmff-muxer.ts
index 2d35091..1ffd0c8 100644
--- a/src/isobmff/isobmff-muxer.ts
+++ b/src/isobmff/isobmff-muxer.ts
@@ -117,6 +117,21 @@ export type IsobmffVideoTrackData = IsobmffTrackData & { type: 'video' };
export type IsobmffAudioTrackData = IsobmffTrackData & { type: 'audio' };
export type IsobmffSubtitleTrackData = IsobmffTrackData & { type: 'subtitle' };
+export type IsobmffMetadata = {
+ name?: string;
+};
+
+export const getTrackMetadata = (trackData: IsobmffTrackData) => {
+ const metadata: IsobmffMetadata = {};
+ const track = trackData.track as OutputTrack;
+
+ if (track.metadata.name !== undefined) {
+ metadata.name = track.metadata.name;
+ }
+
+ return metadata;
+};
+
export const intoTimescale = (timeInSeconds: number, timescale: number, round = true) => {
const value = timeInSeconds * timescale;
return round ? Math.round(value) : value;
diff --git a/src/matroska/ebml.ts b/src/matroska/ebml.ts
index 4a2d560..21f599b 100644
--- a/src/matroska/ebml.ts
+++ b/src/matroska/ebml.ts
@@ -7,13 +7,22 @@
*/
import { MediaCodec } from '../codec';
+import { assertNever, textDecoder, textEncoder } from '../misc';
import { Reader } from '../reader';
import { Writer } from '../writer';
export interface EBMLElement {
id: number;
size?: number;
- data: number | string | Uint8Array | EBMLFloat32 | EBMLFloat64 | EBMLSignedInt | (EBML | null)[];
+ data:
+ | number
+ | string
+ | Uint8Array
+ | EBMLFloat32
+ | EBMLFloat64
+ | EBMLSignedInt
+ | EBMLUnicodeString
+ | (EBML | null)[];
}
export type EBML = EBMLElement | Uint8Array | (EBML | null)[];
@@ -45,6 +54,10 @@ export class EBMLSignedInt {
}
}
+export class EBMLUnicodeString {
+ constructor(public value: string) {}
+}
+
/** Defines some of the EBML IDs used by Matroska files. */
export enum EBMLId {
EBML = 0x1a45dfa3,
@@ -73,6 +86,7 @@ export enum EBMLId {
FlagDefault = 0x88,
FlagForced = 0x55aa,
FlagLacing = 0x9c,
+ Name = 0x536e,
Language = 0x22b59c,
CodecID = 0x86,
CodecPrivate = 0x63a2,
@@ -371,6 +385,12 @@ export class EBMLWriter {
const size = data.size ?? measureSignedInt(data.data.value);
this.writeVarInt(size);
this.writeSignedInt(data.data.value, size);
+ } else if (data.data instanceof EBMLUnicodeString) {
+ const bytes = textEncoder.encode(data.data.value);
+ this.writeVarInt(bytes.length);
+ this.writer.write(bytes);
+ } else {
+ assertNever(data.data);
}
}
}
@@ -514,6 +534,19 @@ export class EBMLReader {
return String.fromCharCode(...new Uint8Array(view.buffer, offset, strLength));
}
+ readUnicodeString(length: number) {
+ const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
+ this.pos += length;
+
+ // Actual string length might be shorter due to null terminators
+ let strLength = 0;
+ while (strLength < length && view.getUint8(offset + strLength) !== 0) {
+ strLength += 1;
+ }
+
+ return textDecoder.decode(new Uint8Array(view.buffer, offset, strLength));
+ }
+
readElementId() {
const size = this.readVarIntSize();
if (size === null) {
diff --git a/src/matroska/matroska-demuxer.ts b/src/matroska/matroska-demuxer.ts
index c72c92b..194d35b 100644
--- a/src/matroska/matroska-demuxer.ts
+++ b/src/matroska/matroska-demuxer.ts
@@ -145,6 +145,7 @@ type InternalTrack = {
codecId: string | null;
codecPrivate: Uint8Array | null;
defaultDuration: number | null;
+ name: string | null;
languageCode: string;
info:
| null
@@ -836,6 +837,7 @@ export class MatroskaDemuxer extends Demuxer {
codecId: null,
codecPrivate: null,
defaultDuration: null,
+ name: null,
languageCode: UNDETERMINED_LANGUAGE,
info: null,
};
@@ -1004,6 +1006,12 @@ export class MatroskaDemuxer extends Demuxer {
= this.currentTrack.segment.timestampFactor * reader.readUnsignedInt(size) / 1e9;
}; break;
+ case EBMLId.Name: {
+ if (!this.currentTrack) break;
+
+ this.currentTrack.name = reader.readUnicodeString(size);
+ }; break;
+
case EBMLId.Language: {
if (!this.currentTrack) break;
@@ -1261,6 +1269,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
+ getName() {
+ return this.internalTrack.name;
+ }
+
getLanguageCode() {
return this.internalTrack.languageCode;
}
diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts
index c4bb52e..0e46954 100644
--- a/src/matroska/matroska-muxer.ts
+++ b/src/matroska/matroska-muxer.ts
@@ -29,6 +29,7 @@ import {
EBMLFloat64,
EBMLId,
EBMLSignedInt,
+ EBMLUnicodeString,
EBMLWriter,
} from './ebml';
import { buildMatroskaMimeType } from './matroska-misc';
@@ -272,6 +273,9 @@ export class MatroskaMuxer extends Muxer {
{ id: EBMLId.CodecID, data: codecId },
{ id: EBMLId.CodecDelay, data: 0 },
{ id: EBMLId.SeekPreRoll, data: seekPreRollNs },
+ trackData.track.metadata.name !== undefined
+ ? { id: EBMLId.Name, data: new EBMLUnicodeString(trackData.track.metadata.name) }
+ : null,
(trackData.type === 'video' ? this.videoSpecificTrackInfo(trackData) : null),
(trackData.type === 'audio' ? this.audioSpecificTrackInfo(trackData) : null),
(trackData.type === 'subtitle' ? this.subtitleSpecificTrackInfo(trackData) : null),
diff --git a/src/misc.ts b/src/misc.ts
index debf8e5..94fdd65 100644
--- a/src/misc.ts
+++ b/src/misc.ts
@@ -173,6 +173,7 @@ export const toDataView = (source: AllowSharedBufferSource) => {
}
};
+export const textDecoder = new TextDecoder();
export const textEncoder = new TextEncoder();
const invertObject = (object: Record) => {
diff --git a/src/mp3/mp3-demuxer.ts b/src/mp3/mp3-demuxer.ts
index c987d2e..315d431 100644
--- a/src/mp3/mp3-demuxer.ts
+++ b/src/mp3/mp3-demuxer.ts
@@ -168,6 +168,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
+ getName() {
+ return null;
+ }
+
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
diff --git a/src/ogg/ogg-demuxer.ts b/src/ogg/ogg-demuxer.ts
index 708e8c4..f0a6b5c 100644
--- a/src/ogg/ogg-demuxer.ts
+++ b/src/ogg/ogg-demuxer.ts
@@ -442,6 +442,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
};
}
+ getName() {
+ return null;
+ }
+
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
diff --git a/src/output.ts b/src/output.ts
index e45b9b79..92c0d0f 100644
--- a/src/output.ts
+++ b/src/output.ts
@@ -67,6 +67,8 @@ export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' };
export type BaseTrackMetadata = {
/** The three-letter, ISO 639-2/T language code specifying the language of this track. */
languageCode?: string;
+ /** A user-defined name for this track, like "English" or "Director Commentary". */
+ name?: string;
};
/**
@@ -99,7 +101,10 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => {
throw new TypeError('metadata must be an object.');
}
if (metadata.languageCode !== undefined && !isIso639Dash2LanguageCode(metadata.languageCode)) {
- throw new TypeError('metadata.languageCode must be a three-letter, ISO 639-2/T language code.');
+ throw new TypeError('metadata.languageCode, when provided, must be a three-letter, ISO 639-2/T language code.');
+ }
+ if (metadata.name !== undefined && typeof metadata.name !== 'string') {
+ throw new TypeError('metadata.name, when provided, must be a string.');
}
};
diff --git a/src/wave/wave-demuxer.ts b/src/wave/wave-demuxer.ts
index b70a464..df7a7f3 100644
--- a/src/wave/wave-demuxer.ts
+++ b/src/wave/wave-demuxer.ts
@@ -255,6 +255,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.audioInfo.sampleRate;
}
+ getName() {
+ return null;
+ }
+
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}