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; }