Explicitly model the supported codecs per output format

This commit is contained in:
Vanilagy
2025-01-02 21:03:17 +01:00
parent 2471f55f20
commit 74369ee108
7 changed files with 154 additions and 42 deletions
+1 -1
View File
@@ -86,7 +86,7 @@
bitrate: 1e6
});
let audioSource = new Metamuxer.AudioBufferSource({
codec: 'opus',
codec: 'flac',
bitrate: 128e3,
});
let subtitleSource = new Metamuxer.TextSubtitleSource('webvtt');
+1 -1
View File
@@ -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',
+9 -4
View File
@@ -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<VideoCodec, (trackData: IsobmffVi
av1: av1C,
};
const AUDIO_CODEC_TO_BOX_NAME: Record<AudioCodec, string> = {
const AUDIO_CODEC_TO_BOX_NAME: Partial<Record<AudioCodec, string>> = {
aac: 'mp4a',
opus: 'Opus',
};
const AUDIO_CODEC_TO_CONFIGURATION_BOX: Record<AudioCodec, (trackData: IsobmffAudioTrackData) => Box | null> = {
const AUDIO_CODEC_TO_CONFIGURATION_BOX: Partial<
Record<AudioCodec, (trackData: IsobmffAudioTrackData) => Box | null>
> = {
aac: esds,
opus: dOps,
};
+6 -32
View File
@@ -101,7 +101,7 @@ type MatroskaVideoTrackData = MatroskaTrackData & { type: 'video' };
type MatroskaAudioTrackData = MatroskaTrackData & { type: 'audio' };
type MatroskaSubtitleTrackData = MatroskaTrackData & { type: 'subtitle' };
const CODEC_STRING_MAP: Record<VideoCodec | AudioCodec | SubtitleCodec, string> = {
const CODEC_STRING_MAP: Partial<Record<VideoCodec | AudioCodec | SubtitleCodec, string>> = {
avc: 'V_MPEG4/ISO/AVC',
hevc: 'V_MPEGH/ISO/HEVC',
vp8: 'V_VP8',
@@ -109,6 +109,7 @@ const CODEC_STRING_MAP: Record<VideoCodec | AudioCodec | SubtitleCodec, string>
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),
-2
View File
@@ -24,8 +24,6 @@ export abstract class Muxer {
abstract addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata): Promise<void>;
abstract finalize(): Promise<void>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
beforeTrackAdd(track: OutputTrack) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onTrackClose(track: OutputTrack) {}
+84 -1
View File
@@ -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 '';
}
}
}
+53 -1
View File
@@ -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;