diff --git a/dev/index.html b/dev/index.html index 69808ee..dde4b8c 100644 --- a/dev/index.html +++ b/dev/index.html @@ -86,7 +86,7 @@ bitrate: 1e6 }); let audioSource = new Metamuxer.AudioBufferSource({ - codec: 'opus', + codec: 'flac', bitrate: 128e3, }); let subtitleSource = new Metamuxer.TextSubtitleSource('webvtt'); diff --git a/src/codec.ts b/src/codec.ts index 3aa1171..367e2f5 100644 --- a/src/codec.ts +++ b/src/codec.ts @@ -19,7 +19,6 @@ export const VIDEO_CODECS = [ 'vp9', 'av1', ] as const; -/** @public */ export const PCM_CODECS = [ 'pcm-u8', 'pcm-s8', @@ -32,6 +31,7 @@ export const PCM_CODECS = [ 'pcm-f32', 'pcm-f32be', ] as const; +/** @public */ export const AUDIO_CODECS = [ 'aac', 'mp3', diff --git a/src/isobmff/isobmff-boxes.ts b/src/isobmff/isobmff-boxes.ts index e6258be..49f3cbe 100644 --- a/src/isobmff/isobmff-boxes.ts +++ b/src/isobmff/isobmff-boxes.ts @@ -520,8 +520,11 @@ export const stsd = (trackData: IsobmffTrackData) => { trackData, ); } else if (trackData.type === 'audio') { + const boxName = AUDIO_CODEC_TO_BOX_NAME[trackData.track.source._codec]; + assert(boxName); + sampleDescription = soundSampleDescription( - AUDIO_CODEC_TO_BOX_NAME[trackData.track.source._codec], + boxName, trackData, ); } else if (trackData.type === 'subtitle') { @@ -690,7 +693,7 @@ export const soundSampleDescription = ( u16(0), // Packet size u32(2 ** 16 * trackData.info.sampleRate), // Sample rate ], [ - AUDIO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source._codec](trackData), + AUDIO_CODEC_TO_CONFIGURATION_BOX[trackData.track.source._codec]!(trackData), ]); /** MPEG-4 Elementary Stream Descriptor Box. */ @@ -1086,12 +1089,14 @@ const VIDEO_CODEC_TO_CONFIGURATION_BOX: Record = { +const AUDIO_CODEC_TO_BOX_NAME: Partial> = { aac: 'mp4a', opus: 'Opus', }; -const AUDIO_CODEC_TO_CONFIGURATION_BOX: Record Box | null> = { +const AUDIO_CODEC_TO_CONFIGURATION_BOX: Partial< + Record Box | null> +> = { aac: esds, opus: dOps, }; diff --git a/src/matroska/matroska-muxer.ts b/src/matroska/matroska-muxer.ts index 83316f4..0cd8740 100644 --- a/src/matroska/matroska-muxer.ts +++ b/src/matroska/matroska-muxer.ts @@ -101,7 +101,7 @@ type MatroskaVideoTrackData = MatroskaTrackData & { type: 'video' }; type MatroskaAudioTrackData = MatroskaTrackData & { type: 'audio' }; type MatroskaSubtitleTrackData = MatroskaTrackData & { type: 'subtitle' }; -const CODEC_STRING_MAP: Record = { +const CODEC_STRING_MAP: Partial> = { avc: 'V_MPEG4/ISO/AVC', hevc: 'V_MPEGH/ISO/HEVC', vp8: 'V_VP8', @@ -109,6 +109,7 @@ const CODEC_STRING_MAP: Record av1: 'V_AV1', aac: 'A_AAC', // TODO is this even correct opus: 'A_OPUS', + vorbis: 'A_VORBIS', webvtt: 'S_TEXT/WEBVTT', }; @@ -330,36 +331,6 @@ export class MatroskaMuxer extends Muxer { } } - override beforeTrackAdd(track: OutputTrack) { - if (!(this.format instanceof WebMOutputFormat)) { - return; - } - - if (track.type === 'video') { - if (!['vp8', 'vp9', 'av1'].includes(track.source._codec)) { - throw new Error( - `WebM only supports VP8, VP9 and AV1 as video codecs. Switching to MKV removes this restriction.`, - ); - } - } else if (track.type === 'audio') { - if (!['opus', 'vorbis'].includes(track.source._codec)) { - throw new Error( - `WebM only supports Opus and Vorbis as audio codecs. Switching to MKV removes this restriction.`, - ); - } - } else if (track.type === 'subtitle') { - if (track.source._codec !== 'webvtt') { - throw new Error( - `WebM only supports WebVTT as subtitle codec. Switching to MKV removes this restriction.`, - ); - } - } else { - throw new Error( - 'WebM only supports video, audio and subtitle tracks. Switching to MKV removes this restriction.', - ); - } - } - async start() { const release = await this.mutex.acquire(); @@ -434,11 +405,14 @@ export class MatroskaMuxer extends Muxer { this.tracksElement = tracksElement; for (const trackData of this.trackDatas) { + const codecId = CODEC_STRING_MAP[trackData.track.source._codec]; + assert(codecId); + tracksElement.data.push({ id: EBMLId.TrackEntry, data: [ { id: EBMLId.TrackNumber, data: trackData.track.id }, { id: EBMLId.TrackUID, data: trackData.track.id }, { id: EBMLId.TrackType, data: TRACK_TYPE_MAP[trackData.type] }, - { id: EBMLId.CodecID, data: CODEC_STRING_MAP[trackData.track.source._codec] }, + { id: EBMLId.CodecID, data: codecId }, (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/muxer.ts b/src/muxer.ts index 393b86f..4c940d3 100644 --- a/src/muxer.ts +++ b/src/muxer.ts @@ -24,8 +24,6 @@ export abstract class Muxer { abstract addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata): Promise; abstract finalize(): Promise; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - beforeTrackAdd(track: OutputTrack) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars onTrackClose(track: OutputTrack) {} diff --git a/src/output-format.ts b/src/output-format.ts index efe9da6..68eeccd 100644 --- a/src/output-format.ts +++ b/src/output-format.ts @@ -1,3 +1,4 @@ +import { AUDIO_CODECS, MediaCodec, SUBTITLE_CODECS, VIDEO_CODECS } from './codec'; import { IsobmffMuxer } from './isobmff/isobmff-muxer'; import { MatroskaMuxer } from './matroska/matroska-muxer'; import { Muxer } from './muxer'; @@ -7,6 +8,28 @@ import { Output } from './output'; export abstract class OutputFormat { /** @internal */ abstract _createMuxer(output: Output): Muxer; + + /** @internal */ + abstract _getName(): string; + abstract getSupportedCodecs(): MediaCodec[]; + + getSupportedVideoCodecs() { + return this.getSupportedCodecs().filter(codec => (VIDEO_CODECS as readonly string[]).includes(codec)); + } + + getSupportedAudioCodecs() { + return this.getSupportedCodecs().filter(codec => (AUDIO_CODECS as readonly string[]).includes(codec)); + } + + getSupportedSubtitleCodecs() { + return this.getSupportedCodecs().filter(codec => (SUBTITLE_CODECS as readonly string[]).includes(codec)); + } + + /** @internal */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _codecUnsupportedHint(codec: MediaCodec) { + return ''; + } } /** @public */ @@ -36,6 +59,23 @@ export class Mp4OutputFormat extends OutputFormat { override _createMuxer(output: Output) { return new IsobmffMuxer(output, this); } + + /** @internal */ + _getName() { + return 'MP4'; + } + + getSupportedCodecs() { + return Mp4OutputFormat.getSupportedCodecs(); + } + + static getSupportedCodecs(): MediaCodec[] { + return [ + 'avc', 'hevc', 'vp8', 'vp9', 'av1', + 'aac', 'opus', + 'webvtt', + ]; + } } /** @public */ @@ -65,10 +105,53 @@ export class MkvOutputFormat extends OutputFormat { override _createMuxer(output: Output) { return new MatroskaMuxer(output, this); } + + /** @internal */ + _getName() { + return 'Matroska'; + } + + getSupportedCodecs() { + return MkvOutputFormat.getSupportedCodecs(); + } + + static getSupportedCodecs(): MediaCodec[] { + return [ + 'avc', 'hevc', 'vp8', 'vp9', 'av1', + 'aac', 'opus', 'vorbis', + 'webvtt', + ]; + } } /** @public */ export type WebMOutputFormatOptions = MkvOutputFormatOptions; /** @public */ -export class WebMOutputFormat extends MkvOutputFormat {} +export class WebMOutputFormat extends MkvOutputFormat { + override getSupportedCodecs() { + return WebMOutputFormat.getSupportedCodecs(); + } + + /** @internal */ + override _getName() { + return 'WebM'; + } + + static override getSupportedCodecs(): MediaCodec[] { + return [ + 'vp8', 'vp9', 'av1', + 'opus', 'vorbis', + 'webvtt', + ]; + } + + /** @internal */ + override _codecUnsupportedHint(codec: MediaCodec) { + if (MkvOutputFormat.getSupportedCodecs().includes(codec)) { + return ' Switching to MKV will grant support for this codec.'; + } else { + return ''; + } + } +} diff --git a/src/output.ts b/src/output.ts index 64fadef..2458332 100644 --- a/src/output.ts +++ b/src/output.ts @@ -44,6 +44,10 @@ export type SubtitleTrackMetadata = {}; /** @public */ export class Output { + /** @internal */ + _format: OutputFormat; + /** @internal */ + _target: Target; /** @internal */ _muxer: Muxer; /** @internal */ @@ -75,6 +79,9 @@ export class Output { } options.target._output = this; + this._format = options.format; + this._target = options.target; + this._writer = options.target._createWriter(); this._muxer = options.format._createMuxer(this); } @@ -145,7 +152,52 @@ export class Output { metadata, } as OutputTrack; - this._muxer.beforeTrackAdd(track); + if (track.type === 'video') { + const supportedVideoCodecs = this._format.getSupportedVideoCodecs(); + + if (supportedVideoCodecs.length === 0) { + throw new Error( + `${this._format._getName()} does not support video tracks.` + + this._format._codecUnsupportedHint(track.source._codec), + ); + } else if (!supportedVideoCodecs.includes(track.source._codec)) { + throw new Error( + `Codec '${track.source._codec}' cannot be contained within ${this._format._getName()}. Supported` + + ` video codecs are: ${supportedVideoCodecs.map(codec => `'${codec}'`).join(', ')}.` + + this._format._codecUnsupportedHint(track.source._codec), + ); + } + } else if (track.type === 'audio') { + const supportedAudioCodecs = this._format.getSupportedAudioCodecs(); + + if (supportedAudioCodecs.length === 0) { + throw new Error( + `${this._format._getName()} does not support audio tracks.` + + this._format._codecUnsupportedHint(track.source._codec), + ); + } else if (!supportedAudioCodecs.includes(track.source._codec)) { + throw new Error( + `Codec '${track.source._codec}' cannot be contained within ${this._format._getName()}. Supported` + + ` audio codecs are: ${supportedAudioCodecs.map(codec => `'${codec}'`).join(', ')}.` + + this._format._codecUnsupportedHint(track.source._codec), + ); + } + } else if (track.type === 'subtitle') { + const supportedSubtitleCodecs = this._format.getSupportedSubtitleCodecs(); + + if (supportedSubtitleCodecs.length === 0) { + throw new Error( + `${this._format._getName()} does not support subtitle tracks.` + + this._format._codecUnsupportedHint(track.source._codec), + ); + } else if (!supportedSubtitleCodecs.includes(track.source._codec)) { + throw new Error( + `Codec '${track.source._codec}' cannot be contained within ${this._format._getName()}. Supported` + + ` subtitle codecs are: ${supportedSubtitleCodecs.map(codec => `'${codec}'`).join(', ')}.` + + this._format._codecUnsupportedHint(track.source._codec), + ); + } + } this._tracks.push(track); source._connectedTrack = track;