UNPKG

mediabunny

Version:

Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.

773 lines (772 loc) 26.7 kB
/*! * Copyright (c) 2026-present, Vanilagy and contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { AdtsMuxer } from './adts/adts-muxer.js'; import { AUDIO_CODECS, NON_PCM_AUDIO_CODECS, PCM_AUDIO_CODECS, SUBTITLE_CODECS, VIDEO_CODECS, } from './codec.js'; import { FlacMuxer } from './flac/flac-muxer.js'; import { IsobmffMuxer } from './isobmff/isobmff-muxer.js'; import { MatroskaMuxer } from './matroska/matroska-muxer.js'; import { Mp3Muxer } from './mp3/mp3-muxer.js'; import { OggMuxer } from './ogg/ogg-muxer.js'; import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer.js'; import { WaveMuxer } from './wave/wave-muxer.js'; import { HlsMuxer } from './hls/hls-muxer.js'; import { HLS_MIME_TYPE } from './hls/hls-misc.js'; import { toArray } from './misc.js'; /** * Base class representing an output media file format. * @group Output formats * @public */ export class OutputFormat { /** Returns a list of video codecs that this output format can contain. */ getSupportedVideoCodecs() { return this.getSupportedCodecs() .filter(codec => VIDEO_CODECS.includes(codec)); } /** Returns a list of audio codecs that this output format can contain. */ getSupportedAudioCodecs() { return this.getSupportedCodecs() .filter(codec => AUDIO_CODECS.includes(codec)); } /** Returns a list of subtitle codecs that this output format can contain. */ getSupportedSubtitleCodecs() { return this.getSupportedCodecs() .filter(codec => SUBTITLE_CODECS.includes(codec)); } /** @internal */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _codecUnsupportedHint(codec) { return ''; } } /** * Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files. * @group Output formats * @public */ export class IsobmffOutputFormat extends OutputFormat { /** Internal constructor. */ constructor(options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (options.fastStart !== undefined && ![false, 'in-memory', 'reserve', 'fragmented'].includes(options.fastStart)) { throw new TypeError('options.fastStart, when provided, must be false, \'in-memory\', \'reserve\', or \'fragmented\'.'); } if (options.minimumFragmentDuration !== undefined && (!Number.isFinite(options.minimumFragmentDuration) || options.minimumFragmentDuration < 0)) { throw new TypeError('options.minimumFragmentDuration, when provided, must be a non-negative number.'); } if (options.onFtyp !== undefined && typeof options.onFtyp !== 'function') { throw new TypeError('options.onFtyp, when provided, must be a function.'); } if (options.onMoov !== undefined && typeof options.onMoov !== 'function') { throw new TypeError('options.onMoov, when provided, must be a function.'); } if (options.onMdat !== undefined && typeof options.onMdat !== 'function') { throw new TypeError('options.onMdat, when provided, must be a function.'); } if (options.onMoof !== undefined && typeof options.onMoof !== 'function') { throw new TypeError('options.onMoof, when provided, must be a function.'); } if (options.metadataFormat !== undefined && !['mdir', 'mdta', 'udta', 'auto'].includes(options.metadataFormat)) { throw new TypeError('options.metadataFormat, when provided, must be either \'auto\', \'mdir\', \'mdta\', or \'udta\'.'); } super(); this._options = options; } getSupportedTrackCounts() { const max = 2 ** 32 - 1; // Have fun reaching this one return { video: { min: 0, max }, audio: { min: 0, max }, subtitle: { min: 0, max }, total: { min: 1, max }, }; } get supportsVideoRotationMetadata() { return true; } get supportsTimestampedMediaData() { return true; } /** @internal */ _createMuxer(output) { return new IsobmffMuxer(output, this); } } /** * MPEG-4 Part 14 (MP4) file format. Supports most codecs. * @group Output formats * @public */ export class Mp4OutputFormat extends IsobmffOutputFormat { /** Creates a new {@link Mp4OutputFormat} configured with the specified `options`. */ constructor(options) { super(options); } /** @internal */ get _name() { return 'MP4'; } get fileExtension() { return '.mp4'; } get mimeType() { return 'video/mp4'; } getSupportedCodecs() { return [ ...VIDEO_CODECS, ...NON_PCM_AUDIO_CODECS, // These are supported via ISO/IEC 23003-5: 'pcm-s16', 'pcm-s16be', 'pcm-s24', 'pcm-s24be', 'pcm-s32', 'pcm-s32be', 'pcm-f32', 'pcm-f32be', 'pcm-f64', 'pcm-f64be', ...SUBTITLE_CODECS, ]; } /** @internal */ _codecUnsupportedHint(codec) { if (new MovOutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MOV will grant support for this codec.'; } return ''; } } /** * Creates a single Common Media Application Format (CMAF) segment. An init segment will be written to the * {@link Target} specified in {@link OutputOptions.initTarget}. Supports most codecs. * @group Output formats * @public */ export class CmafOutputFormat extends IsobmffOutputFormat { /** Creates a new {@link CmafOutputFormat} configured with the specified `options`. */ constructor(options) { super(options); } /** @internal */ get _name() { return 'CMAF'; } get fileExtension() { return '.m4s'; } get mimeType() { return 'video/mp4'; } getSupportedCodecs() { return [ ...VIDEO_CODECS, ...NON_PCM_AUDIO_CODECS, // These are supported via ISO/IEC 23003-5: 'pcm-s16', 'pcm-s16be', 'pcm-s24', 'pcm-s24be', 'pcm-s32', 'pcm-s32be', 'pcm-f32', 'pcm-f32be', 'pcm-f64', 'pcm-f64be', ...SUBTITLE_CODECS, ]; } } /** * QuickTime File Format (QTFF), often called MOV. Supports all video and audio codecs, but not subtitle codecs. * @group Output formats * @public */ export class MovOutputFormat extends IsobmffOutputFormat { /** Creates a new {@link MovOutputFormat} configured with the specified `options`. */ constructor(options) { super(options); } /** @internal */ get _name() { return 'MOV'; } get fileExtension() { return '.mov'; } get mimeType() { return 'video/quicktime'; } getSupportedCodecs() { return [ ...VIDEO_CODECS, ...AUDIO_CODECS, ]; } /** @internal */ _codecUnsupportedHint(codec) { if (new Mp4OutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MP4 will grant support for this codec.'; } return ''; } } /** * Matroska file format. * * Supports writing transparent video. For a video track to be marked as transparent, the first packet added must * contain alpha side data. * * @group Output formats * @public */ export class MkvOutputFormat extends OutputFormat { /** Creates a new {@link MkvOutputFormat} configured with the specified `options`. */ constructor(options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (options.appendOnly !== undefined && typeof options.appendOnly !== 'boolean') { throw new TypeError('options.appendOnly, when provided, must be a boolean.'); } if (options.minimumClusterDuration !== undefined && (!Number.isFinite(options.minimumClusterDuration) || options.minimumClusterDuration < 0)) { throw new TypeError('options.minimumClusterDuration, when provided, must be a non-negative number.'); } if (options.onEbmlHeader !== undefined && typeof options.onEbmlHeader !== 'function') { throw new TypeError('options.onEbmlHeader, when provided, must be a function.'); } if (options.onSegmentHeader !== undefined && typeof options.onSegmentHeader !== 'function') { throw new TypeError('options.onHeader, when provided, must be a function.'); } if (options.onCluster !== undefined && typeof options.onCluster !== 'function') { throw new TypeError('options.onCluster, when provided, must be a function.'); } super(); this._options = options; } /** @internal */ _createMuxer(output) { return new MatroskaMuxer(output, this); } /** @internal */ get _name() { return 'Matroska'; } getSupportedTrackCounts() { const max = 127; return { video: { min: 0, max }, audio: { min: 0, max }, subtitle: { min: 0, max }, total: { min: 1, max }, }; } get fileExtension() { return '.mkv'; } get mimeType() { return 'video/x-matroska'; } getSupportedCodecs() { return [ ...VIDEO_CODECS, ...NON_PCM_AUDIO_CODECS, ...PCM_AUDIO_CODECS.filter(codec => !['pcm-s8', 'pcm-f32be', 'pcm-f64be', 'ulaw', 'alaw'].includes(codec)), ...SUBTITLE_CODECS, ]; } get supportsVideoRotationMetadata() { // While it technically does support it with ProjectionPoseRoll, many players appear to ignore this value return false; } get supportsTimestampedMediaData() { return true; } } /** * WebM file format, based on Matroska. * * Supports writing transparent video. For a video track to be marked as transparent, the first packet added must * contain alpha side data. * * @group Output formats * @public */ export class WebMOutputFormat extends MkvOutputFormat { /** Creates a new {@link WebMOutputFormat} configured with the specified `options`. */ constructor(options) { super(options); } getSupportedCodecs() { return [ ...VIDEO_CODECS.filter(codec => ['vp8', 'vp9', 'av1'].includes(codec)), ...AUDIO_CODECS.filter(codec => ['opus', 'vorbis'].includes(codec)), ...SUBTITLE_CODECS, ]; } /** @internal */ get _name() { return 'WebM'; } get fileExtension() { return '.webm'; } get mimeType() { return 'video/webm'; } /** @internal */ _codecUnsupportedHint(codec) { if (new MkvOutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MKV will grant support for this codec.'; } return ''; } } /** * MP3 file format. * @group Output formats * @public */ export class Mp3OutputFormat extends OutputFormat { /** Creates a new {@link Mp3OutputFormat} configured with the specified `options`. */ constructor(options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (options.xingHeader !== undefined && typeof options.xingHeader !== 'boolean') { throw new TypeError('options.xingHeader, when provided, must be a boolean.'); } if (options.onXingFrame !== undefined && typeof options.onXingFrame !== 'function') { throw new TypeError('options.onXingFrame, when provided, must be a function.'); } super(); this._options = options; } /** @internal */ _createMuxer(output) { return new Mp3Muxer(output, this); } /** @internal */ get _name() { return 'MP3'; } getSupportedTrackCounts() { return { video: { min: 0, max: 0 }, audio: { min: 1, max: 1 }, subtitle: { min: 0, max: 0 }, total: { min: 1, max: 1 }, }; } get fileExtension() { return '.mp3'; } get mimeType() { return 'audio/mpeg'; } getSupportedCodecs() { return ['mp3']; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * WAVE file format, based on RIFF. * @group Output formats * @public */ export class WavOutputFormat extends OutputFormat { /** Creates a new {@link WavOutputFormat} configured with the specified `options`. */ constructor(options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (options.large !== undefined && typeof options.large !== 'boolean') { throw new TypeError('options.large, when provided, must be a boolean.'); } if (options.metadataFormat !== undefined && !['info', 'id3'].includes(options.metadataFormat)) { throw new TypeError('options.metadataFormat, when provided, must be either \'info\' or \'id3\'.'); } if (options.onHeader !== undefined && typeof options.onHeader !== 'function') { throw new TypeError('options.onHeader, when provided, must be a function.'); } super(); this._options = options; } /** @internal */ _createMuxer(output) { return new WaveMuxer(output, this); } /** @internal */ get _name() { return 'WAVE'; } getSupportedTrackCounts() { return { video: { min: 0, max: 0 }, audio: { min: 1, max: 1 }, subtitle: { min: 0, max: 0 }, total: { min: 1, max: 1 }, }; } get fileExtension() { return '.wav'; } get mimeType() { return 'audio/wav'; } getSupportedCodecs() { return [ ...PCM_AUDIO_CODECS.filter(codec => ['pcm-s16', 'pcm-s24', 'pcm-s32', 'pcm-f32', 'pcm-u8', 'ulaw', 'alaw'].includes(codec)), ]; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * Ogg file format. * @group Output formats * @public */ export class OggOutputFormat extends OutputFormat { /** Creates a new {@link OggOutputFormat} configured with the specified `options`. */ constructor(options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (options.maximumPageDuration !== undefined && (!Number.isFinite(options.maximumPageDuration) || options.maximumPageDuration <= 0)) { throw new TypeError('options.maximumPageDuration, when provided, must be a positive number.'); } if (options.onPage !== undefined && typeof options.onPage !== 'function') { throw new TypeError('options.onPage, when provided, must be a function.'); } super(); this._options = options; } /** @internal */ _createMuxer(output) { return new OggMuxer(output, this); } /** @internal */ get _name() { return 'Ogg'; } getSupportedTrackCounts() { const max = 2 ** 32; // Have fun reaching this one return { video: { min: 0, max: 0 }, audio: { min: 0, max }, subtitle: { min: 0, max: 0 }, total: { min: 1, max }, }; } get fileExtension() { return '.ogg'; } get mimeType() { return 'application/ogg'; } getSupportedCodecs() { return [ ...AUDIO_CODECS.filter(codec => ['vorbis', 'opus'].includes(codec)), ]; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * ADTS file format. * @group Output formats * @public */ export class AdtsOutputFormat extends OutputFormat { /** Creates a new {@link AdtsOutputFormat} configured with the specified `options`. */ constructor(options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (options.onFrame !== undefined && typeof options.onFrame !== 'function') { throw new TypeError('options.onFrame, when provided, must be a function.'); } super(); this._options = options; } /** @internal */ _createMuxer(output) { return new AdtsMuxer(output, this); } /** @internal */ get _name() { return 'ADTS'; } getSupportedTrackCounts() { return { video: { min: 0, max: 0 }, audio: { min: 1, max: 1 }, subtitle: { min: 0, max: 0 }, total: { min: 1, max: 1 }, }; } get fileExtension() { return '.aac'; } get mimeType() { return 'audio/aac'; } getSupportedCodecs() { return ['aac']; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * FLAC file format. * @group Output formats * @public */ export class FlacOutputFormat extends OutputFormat { /** Creates a new {@link FlacOutputFormat} configured with the specified `options`. */ constructor(options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (options.appendOnly !== undefined && typeof options.appendOnly !== 'boolean') { throw new TypeError('options.appendOnly, when provided, must be a boolean.'); } super(); this._options = options; } /** @internal */ _createMuxer(output) { return new FlacMuxer(output, this); } /** @internal */ get _name() { return 'FLAC'; } getSupportedTrackCounts() { return { video: { min: 0, max: 0 }, audio: { min: 1, max: 1 }, subtitle: { min: 0, max: 0 }, total: { min: 1, max: 1 }, }; } get fileExtension() { return '.flac'; } get mimeType() { return 'audio/flac'; } getSupportedCodecs() { return ['flac']; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * MPEG Transport Stream file format. * @group Output formats * @public */ export class MpegTsOutputFormat extends OutputFormat { /** Creates a new {@link MpegTsOutputFormat} configured with the specified `options`. */ constructor(options = {}) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (options.onPacket !== undefined && typeof options.onPacket !== 'function') { throw new TypeError('options.onPacket, when provided, must be a function.'); } super(); this._options = options; } /** @internal */ _createMuxer(output) { return new MpegTsMuxer(output, this); } /** @internal */ get _name() { return 'MPEG-TS'; } getSupportedTrackCounts() { const maxVideo = 16; // Stream IDs 0xE0-0xEF const maxAudio = 32; const maxTotal = maxVideo + maxAudio; return { video: { min: 0, max: maxVideo }, audio: { min: 0, max: maxAudio }, subtitle: { min: 0, max: 0 }, total: { min: 1, max: maxTotal }, }; } get fileExtension() { return '.ts'; } get mimeType() { return 'video/MP2T'; } getSupportedCodecs() { return [ ...VIDEO_CODECS.filter(codec => ['avc', 'hevc'].includes(codec)), ...AUDIO_CODECS.filter(codec => ['aac', 'mp3', 'ac3', 'eac3'].includes(codec)), ]; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return true; } } /** * HTTP Live Streaming (HLS) output format. HLS media is represented by a set of .m3u8 playlist files and media segment * files, meaning this format writes out multiple files, requiring the use of a _pathed Output_ * ({@link OutputOptions.target} must be a {@link PathedTarget}). * * This output format creates the following files: * - A master playlist .m3u8 file, containing the list of available playlists. A master playlist is always emitted, * written to the root path. * - One .m3u8 file for each playlist, each containing a list of media segments. * - Many media segments, containing the actual media data. * * To emit media playlists that use the `#EXT-X-PROGRAM-DATE-TIME` tag to map segment timestamps to real-world time, * set {@link BaseTrackMetadata.isRelativeToUnixEpoch} to `true` for all tracks. * * @group Output formats * @public */ export class HlsOutputFormat extends OutputFormat { /** Creates a new {@link HlsOutputFormat} configured with the specified `options`. */ constructor(options) { if (!options || typeof options !== 'object') { throw new TypeError('options must be an object.'); } if (!(options.segmentFormat instanceof OutputFormat) && (!Array.isArray(options.segmentFormat) || options.segmentFormat.length === 0 || !options.segmentFormat.every(format => format instanceof OutputFormat))) { throw new TypeError('options.segmentFormat must be an OutputFormat or a non-empty array of OutputFormat instances.'); } if (options.targetDuration !== undefined && (typeof options.targetDuration !== 'number' || options.targetDuration <= 0)) { throw new TypeError('options.targetDuration, when provided, must be a positive number.'); } if (options.singleFilePerPlaylist !== undefined && typeof options.singleFilePerPlaylist !== 'boolean') { throw new TypeError('options.singleFilePerPlaylist, when provided, must be a boolean.'); } if (options.live !== undefined && typeof options.live !== 'boolean') { throw new TypeError('options.live, when provided, must be a boolean.'); } if (options.maxLiveSegmentCount !== undefined && (typeof options.maxLiveSegmentCount !== 'number' || options.maxLiveSegmentCount < 1 || (Number.isFinite(options.maxLiveSegmentCount) && !Number.isInteger(options.maxLiveSegmentCount)))) { throw new TypeError('options.maxLiveSegmentCount, when provided, must be a positive integer or Infinity.'); } if (options.getPlaylistPath !== undefined && typeof options.getPlaylistPath !== 'function') { throw new TypeError('options.getPlaylistPath, when provided, must be a function.'); } if (options.getSegmentPath !== undefined && typeof options.getSegmentPath !== 'function') { throw new TypeError('options.getSegmentPath, when provided, must be a function.'); } if (options.getInitPath !== undefined && typeof options.getInitPath !== 'function') { throw new TypeError('options.getInitPath, when provided, must be a function.'); } if (options.onMaster !== undefined && typeof options.onMaster !== 'function') { throw new TypeError('options.onMaster, when provided, must be a function.'); } if (options.onPlaylist !== undefined && typeof options.onPlaylist !== 'function') { throw new TypeError('options.onPlaylist, when provided, must be a function.'); } if (options.onSegment !== undefined && typeof options.onSegment !== 'function') { throw new TypeError('options.onSegment, when provided, must be a function.'); } if (options.onInit !== undefined && typeof options.onInit !== 'function') { throw new TypeError('options.onInit, when provided, must be a function.'); } if (options.onSegmentPopped !== undefined && typeof options.onSegmentPopped !== 'function') { throw new TypeError('options.onSegmentPopped, when provided, must be a function.'); } super(); this._options = options; } /** @internal */ _createMuxer(output) { return new HlsMuxer(output, this); } /** @internal */ get _name() { return 'HTTP Live Streaming (HLS)'; } get fileExtension() { return '.m3u8'; } get mimeType() { return HLS_MIME_TYPE; } getSupportedCodecs() { const uniqueCodecs = new Set(toArray(this._options.segmentFormat).flatMap(x => x.getSupportedCodecs())); return [...uniqueCodecs]; } getSupportedTrackCounts() { let supportsVideo = false; let supportsAudio = false; let supportsSubtitle = false; for (const format of toArray(this._options.segmentFormat)) { const trackCounts = format.getSupportedTrackCounts(); supportsVideo ||= trackCounts.video.max > 0; supportsAudio ||= trackCounts.audio.max > 0; supportsSubtitle ||= trackCounts.subtitle.max > 0; } return { video: { min: 0, max: supportsVideo ? Infinity : 0 }, audio: { min: 0, max: supportsAudio ? Infinity : 0 }, subtitle: { min: 0, max: 0 }, // Currently disabled total: { min: 1, max: Infinity }, }; } get supportsVideoRotationMetadata() { return toArray(this._options.segmentFormat).some(format => format.supportsVideoRotationMetadata); } get supportsTimestampedMediaData() { return true; // I guess?? } /** @internal */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _codecUnsupportedHint(codec) { return ` Using different segment formats may grant support for this codec.`; } }