UNPKG

mediabunny

Version:

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

1,426 lines (1,246 loc) 42.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'; import { AUDIO_CODECS, AudioCodec, MediaCodec, NON_PCM_AUDIO_CODECS, PCM_AUDIO_CODECS, SUBTITLE_CODECS, SubtitleCodec, VIDEO_CODECS, VideoCodec, } from './codec'; import { FlacMuxer } from './flac/flac-muxer'; import { IsobmffMuxer } from './isobmff/isobmff-muxer'; import { MatroskaMuxer } from './matroska/matroska-muxer'; import { MediaSource } from './media-source'; import { Mp3Muxer } from './mp3/mp3-muxer'; import { Muxer } from './muxer'; import { OggMuxer } from './ogg/ogg-muxer'; import { Output, OutputTrack, TrackType } from './output'; import { MpegTsMuxer } from './mpeg-ts/mpeg-ts-muxer'; import { WaveMuxer } from './wave/wave-muxer'; import { HlsMuxer } from './hls/hls-muxer'; import { HLS_MIME_TYPE } from './hls/hls-misc'; import { MaybePromise, FilePath, toArray } from './misc'; import { Target } from './target'; /** * Specifies an inclusive range of integers. * @group Miscellaneous * @public */ export type InclusiveIntegerRange = { /** The integer cannot be less than this. */ min: number; /** The integer cannot be greater than this. */ max: number; }; /** * Specifies the number of tracks (for each track type and in total) that an output format supports. * @group Output formats * @public */ export type TrackCountLimits = { [K in TrackType]: InclusiveIntegerRange; } & { /** Specifies the overall allowed range of track counts for the output format. */ total: InclusiveIntegerRange; }; /** * Base class representing an output media file format. * @group Output formats * @public */ export abstract class OutputFormat { /** @internal */ abstract _createMuxer(output: Output): Muxer; /** @internal */ abstract get _name(): string; /** The file extension used by this output format, beginning with a dot. */ abstract get fileExtension(): string; /** The base MIME type of the output format. */ abstract get mimeType(): string; /** Returns a list of media codecs that this output format can contain. */ abstract getSupportedCodecs(): MediaCodec[]; /** Returns the number of tracks that this output format supports. */ abstract getSupportedTrackCounts(): TrackCountLimits; /** Whether this output format supports video rotation metadata. */ abstract get supportsVideoRotationMetadata(): boolean; /** * Whether this output format's tracks store timestamped media data. When `true`, the timestamps of added packets * will be respected, allowing things like gaps in media data or non-zero start times. When `false`, the format's * media data implicitly starts at zero and follows an implicit sequential timing from there, using the intrinsic * durations of the media data. */ abstract get supportsTimestampedMediaData(): boolean; /** Returns a list of video codecs that this output format can contain. */ getSupportedVideoCodecs() { return this.getSupportedCodecs() .filter(codec => (VIDEO_CODECS as readonly string[]).includes(codec)) as VideoCodec[]; } /** Returns a list of audio codecs that this output format can contain. */ getSupportedAudioCodecs() { return this.getSupportedCodecs() .filter(codec => (AUDIO_CODECS as readonly string[]).includes(codec)) as AudioCodec[]; } /** Returns a list of subtitle codecs that this output format can contain. */ getSupportedSubtitleCodecs() { return this.getSupportedCodecs() .filter(codec => (SUBTITLE_CODECS as readonly string[]).includes(codec)) as SubtitleCodec[]; } /** @internal */ // eslint-disable-next-line @typescript-eslint/no-unused-vars _codecUnsupportedHint(codec: MediaCodec) { return ''; } } /** * ISOBMFF-specific output options. * @group Output formats * @public */ export type IsobmffOutputFormatOptions = { /** * Controls the placement of metadata in the file. Placing metadata at the start of the file is known as "Fast * Start", which results in better playback at the cost of more required processing or memory. * * Use `false` to disable Fast Start, placing the metadata at the end of the file. Fastest and uses the least * memory. * * Use `'in-memory'` to produce a file with Fast Start by keeping all media chunks in memory until the file is * finalized. This produces a high-quality and compact output at the cost of a more expensive finalization step and * higher memory requirements. Data will be written monotonically (in order) when this option is set. * * Use `'reserve'` to reserve space at the start of the file into which the metadata will be written later. This * produces a file with Fast Start but requires knowledge about the expected length of the file beforehand. When * using this option, you must set the {@link BaseTrackMetadata.maximumPacketCount} field in the track metadata * for all tracks. * * Use `'fragmented'` to place metadata at the start of the file by creating a fragmented file (fMP4). In a * fragmented file, chunks of media and their metadata are written to the file in "fragments", eliminating the need * to put all metadata in one place. Fragmented files are useful for streaming contexts, as each fragment can be * played individually without requiring knowledge of the other fragments. Furthermore, they remain lightweight to * create even for very large files, as they don't require all media to be kept in memory. However, fragmented files * are not as widely and wholly supported as regular MP4/MOV files. Data will be written monotonically (in order) * when this option is set. * * When this field is not defined, either `false` or `'in-memory'` will be used, automatically determined based on * the type of output target used. */ fastStart?: false | 'in-memory' | 'reserve' | 'fragmented'; /** * When using `fastStart: 'fragmented'`, this field controls the minimum duration of each fragment, in seconds. * New fragments will only be created when the current fragment is longer than this value. Defaults to 1 second. */ minimumFragmentDuration?: number; /** * The metadata format to use for writing metadata tags. * * - `'auto'` (default): Behaves like `'mdir'` for MP4 and like `'udta'` for QuickTime, matching FFmpeg's default * behavior. * - `'mdir'`: Write tags into `moov/udta/meta` using the 'mdir' handler format. * - `'mdta'`: Write tags into `moov/udta/meta` using the 'mdta' handler format, equivalent to FFmpeg's * `use_metadata_tags` flag. This allows for custom keys of arbitrary length. * - `'udta'`: Write tags directly into `moov/udta`. */ metadataFormat?: 'auto' | 'mdir' | 'mdta' | 'udta'; /** * Will be called once the ftyp (File Type) box of the output file has been written. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onFtyp?: (data: Uint8Array, position: number) => unknown; /** * Will be called once the moov (Movie) box of the output file has been written. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onMoov?: (data: Uint8Array, position: number) => unknown; /** * Will be called for each finalized mdat (Media Data) box of the output file. Usage of this callback is not * recommended when not using `fastStart: 'fragmented'`, as there will be one monolithic mdat box which might * require large amounts of memory. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onMdat?: (data: Uint8Array, position: number) => unknown; /** * Will be called for each finalized moof (Movie Fragment) box of the output file. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. * @param timestamp - The start timestamp of the fragment in seconds. */ onMoof?: (data: Uint8Array, position: number, timestamp: number) => unknown; }; /** * Format representing files compatible with the ISO base media file format (ISOBMFF), like MP4 or MOV files. * @group Output formats * @public */ export abstract class IsobmffOutputFormat extends OutputFormat { /** @internal */ _options: IsobmffOutputFormatOptions; /** Internal constructor. */ constructor(options: IsobmffOutputFormatOptions = {}) { 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(): TrackCountLimits { 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: 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?: IsobmffOutputFormatOptions) { super(options); } /** @internal */ get _name() { return 'MP4'; } get fileExtension() { return '.mp4'; } get mimeType() { return 'video/mp4'; } getSupportedCodecs(): MediaCodec[] { 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 */ override _codecUnsupportedHint(codec: MediaCodec) { if (new MovOutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MOV will grant support for this codec.'; } return ''; } } /** * CMAF-specific output options. * @group Output formats * @public */ export type CmafOutputFormatOptions = Omit<IsobmffOutputFormatOptions, 'fastStart'> & { /** * Controls the minimum duration of each fragment, in seconds. New fragments will only be created when the current * fragment is longer than this value. Defaults to `Infinity`, meaning the file will contain only one fragment. */ minimumFragmentDuration?: number; }; /** * 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?: CmafOutputFormatOptions) { super(options); } /** @internal */ get _name() { return 'CMAF'; } get fileExtension() { return '.m4s'; } get mimeType() { return 'video/mp4'; } getSupportedCodecs(): MediaCodec[] { 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?: IsobmffOutputFormatOptions) { super(options); } /** @internal */ get _name() { return 'MOV'; } get fileExtension() { return '.mov'; } get mimeType() { return 'video/quicktime'; } getSupportedCodecs(): MediaCodec[] { return [ ...VIDEO_CODECS, ...AUDIO_CODECS, ]; } /** @internal */ override _codecUnsupportedHint(codec: MediaCodec) { if (new Mp4OutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MP4 will grant support for this codec.'; } return ''; } } /** * Matroska-specific output options. * @group Output formats * @public */ export type MkvOutputFormatOptions = { /** * Configures the output to only append new data at the end, useful for live-streaming the file as it's being * created. When enabled, some features such as storing duration and seeking will be disabled or impacted, so don't * use this option when you want to write out a clean file for later use. */ appendOnly?: boolean; /** * This field controls the minimum duration of each Matroska cluster, in seconds. New clusters will only be created * when the current cluster is longer than this value. Defaults to 1 second. */ minimumClusterDuration?: number; /** * Will be called once the EBML header of the output file has been written. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onEbmlHeader?: (data: Uint8Array, position: number) => void; /** * Will be called once the header part of the Matroska Segment element has been written. The header data includes * the Segment element and everything inside it, up to (but excluding) the first Matroska Cluster. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onSegmentHeader?: (data: Uint8Array, position: number) => unknown; /** * Will be called for each finalized Matroska Cluster of the output file. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. * @param timestamp - The start timestamp of the cluster in seconds. */ onCluster?: (data: Uint8Array, position: number, timestamp: number) => unknown; }; /** * 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 { /** @internal */ _options: MkvOutputFormatOptions; /** Creates a new {@link MkvOutputFormat} configured with the specified `options`. */ constructor(options: MkvOutputFormatOptions = {}) { 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: Output) { return new MatroskaMuxer(output, this); } /** @internal */ get _name() { return 'Matroska'; } getSupportedTrackCounts(): TrackCountLimits { 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(): MediaCodec[] { 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-specific output options. * @group Output formats * @public */ export type WebMOutputFormatOptions = MkvOutputFormatOptions; /** * 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?: MkvOutputFormatOptions) { super(options); } override getSupportedCodecs(): MediaCodec[] { return [ ...VIDEO_CODECS.filter(codec => ['vp8', 'vp9', 'av1'].includes(codec)), ...AUDIO_CODECS.filter(codec => ['opus', 'vorbis'].includes(codec)), ...SUBTITLE_CODECS, ]; } /** @internal */ override get _name() { return 'WebM'; } override get fileExtension() { return '.webm'; } override get mimeType() { return 'video/webm'; } /** @internal */ override _codecUnsupportedHint(codec: MediaCodec) { if (new MkvOutputFormat().getSupportedCodecs().includes(codec)) { return ' Switching to MKV will grant support for this codec.'; } return ''; } } /** * MP3-specific output options. * @group Output formats * @public */ export type Mp3OutputFormatOptions = { /** * Controls whether the Xing header, which contains additional metadata as well as an index, is written to the start * of the MP3 file. When disabled, the writing process becomes append-only. Defaults to `true`. */ xingHeader?: boolean; /** * Will be called once the Xing metadata frame is finalized. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onXingFrame?: (data: Uint8Array, position: number) => unknown; }; /** * MP3 file format. * @group Output formats * @public */ export class Mp3OutputFormat extends OutputFormat { /** @internal */ _options: Mp3OutputFormatOptions; /** Creates a new {@link Mp3OutputFormat} configured with the specified `options`. */ constructor(options: Mp3OutputFormatOptions = {}) { 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: Output) { return new Mp3Muxer(output, this); } /** @internal */ get _name() { return 'MP3'; } getSupportedTrackCounts(): TrackCountLimits { 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(): MediaCodec[] { return ['mp3']; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * WAVE-specific output options. * @group Output formats * @public */ export type WavOutputFormatOptions = { /** * When enabled, an RF64 file will be written, allowing for file sizes to exceed 4 GiB, which is otherwise not * possible for regular WAVE files. */ large?: boolean; /** * The metadata format to use for writing metadata tags. * * - `'info'` (default): Writes metadata into a RIFF INFO LIST chunk, the default way to contain metadata tags * within WAVE. Only allows for a limited subset of tags to be written. * - `'id3'`: Writes metadata into an ID3 chunk. Non-default, but used by many taggers in practice. Allows for a * much larger and richer set of tags to be written. */ metadataFormat?: 'info' | 'id3'; /** * Will be called once the file header is written. The header consists of the RIFF header, the format chunk, * metadata chunks, and the start of the data chunk (with a placeholder size of 0). */ onHeader?: (data: Uint8Array, position: number) => unknown; }; /** * WAVE file format, based on RIFF. * @group Output formats * @public */ export class WavOutputFormat extends OutputFormat { /** @internal */ _options: WavOutputFormatOptions; /** Creates a new {@link WavOutputFormat} configured with the specified `options`. */ constructor(options: WavOutputFormatOptions = {}) { 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: Output) { return new WaveMuxer(output, this); } /** @internal */ get _name() { return 'WAVE'; } getSupportedTrackCounts(): TrackCountLimits { 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(): MediaCodec[] { 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-specific output options. * @group Output formats * @public */ export type OggOutputFormatOptions = { /** * The maximum duration of each Ogg page, in seconds. This is useful for streaming contexts where more frequent page * output is desired. By default, pages are only flushed when they exceed a certain size. */ maximumPageDuration?: number; /** * Will be called for each Ogg page that is written. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. * @param source - The {@link MediaSource} backing the page's logical bitstream (track). */ onPage?: (data: Uint8Array, position: number, source: MediaSource) => unknown; }; /** * Ogg file format. * @group Output formats * @public */ export class OggOutputFormat extends OutputFormat { /** @internal */ _options: OggOutputFormatOptions; /** Creates a new {@link OggOutputFormat} configured with the specified `options`. */ constructor(options: OggOutputFormatOptions = {}) { 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: Output) { return new OggMuxer(output, this); } /** @internal */ get _name() { return 'Ogg'; } getSupportedTrackCounts(): TrackCountLimits { 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(): MediaCodec[] { return [ ...AUDIO_CODECS.filter(codec => ['vorbis', 'opus'].includes(codec)), ]; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * ADTS-specific output options. * @group Output formats * @public */ export type AdtsOutputFormatOptions = { /** * Will be called for each ADTS frame that is written. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onFrame?: (data: Uint8Array, position: number) => unknown; }; /** * ADTS file format. * @group Output formats * @public */ export class AdtsOutputFormat extends OutputFormat { /** @internal */ _options: AdtsOutputFormatOptions; /** Creates a new {@link AdtsOutputFormat} configured with the specified `options`. */ constructor(options: AdtsOutputFormatOptions = {}) { 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: Output) { return new AdtsMuxer(output, this); } /** @internal */ get _name() { return 'ADTS'; } getSupportedTrackCounts(): TrackCountLimits { 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(): MediaCodec[] { return ['aac']; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * FLAC-specific output options. * @group Output formats * @public */ export type FlacOutputFormatOptions = { /** * Configures the output to only append new data at the end, useful for live-streaming the file as it's being * created. When enabled, the STREAMINFO block will not be finalized with accurate min/max block sizes, frame sizes, * or total sample count, so don't use this option when you want to write out a clean file for later use. */ appendOnly?: boolean; /** * Will be called for each FLAC frame that is written. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onFrame?: (data: Uint8Array, position: number) => unknown; }; /** * FLAC file format. * @group Output formats * @public */ export class FlacOutputFormat extends OutputFormat { /** @internal */ _options: FlacOutputFormatOptions; /** Creates a new {@link FlacOutputFormat} configured with the specified `options`. */ constructor(options: FlacOutputFormatOptions = {}) { 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: Output) { return new FlacMuxer(output, this); } /** @internal */ get _name() { return 'FLAC'; } getSupportedTrackCounts(): TrackCountLimits { 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(): MediaCodec[] { return ['flac']; } get supportsVideoRotationMetadata() { return false; } get supportsTimestampedMediaData() { return false; } } /** * MPEG-TS-specific output options. * @group Output formats * @public */ export type MpegTsOutputFormatOptions = { /** * Will be called for each 188-byte Transport Stream packet that is written. * * @param data - The raw bytes. * @param position - The byte offset of the data in the file. */ onPacket?: (data: Uint8Array, position: number) => unknown; }; /** * MPEG Transport Stream file format. * @group Output formats * @public */ export class MpegTsOutputFormat extends OutputFormat { /** @internal */ _options: MpegTsOutputFormatOptions; /** Creates a new {@link MpegTsOutputFormat} configured with the specified `options`. */ constructor(options: MpegTsOutputFormatOptions = {}) { 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: Output) { return new MpegTsMuxer(output, this); } /** @internal */ get _name() { return 'MPEG-TS'; } getSupportedTrackCounts(): TrackCountLimits { 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(): MediaCodec[] { 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; } } /** * Info about an HLS media playlist. * @group Output formats * @public */ export type HlsOutputPlaylistInfo = { /** The 1-based index of the media playlist in the master playlist. */ n: number; /** The output tracks contained in this playlist. */ tracks: OutputTrack[]; /** The format of the media segments in this playlist. */ segmentFormat: OutputFormat; }; /** * Info about an HLS media segment. * @group Output formats * @public */ export type HlsOutputSegmentInfo = { /** The 1-based index of the segment in the containing media playlist. */ n: number; /** If the segment is a single file, meaning it is a single segment file that covers the entire playlist. */ isSingleFile: boolean; /** The format of the media segment. */ format: OutputFormat; /** The media playlist to which this segment belongs. */ playlist: HlsOutputPlaylistInfo; }; /** * HLS-specific output options. * @group Output formats * @public */ export type HlsOutputFormatOptions = { /** * Specifies the file format of each media segment. Not all formats are supported by all players; prefer sticking * to the most commonly used ones: {@link MpegTsOutputFormat}, {@link CmafOutputFormat}, {@link AdtsOutputFormat}, * and {@link Mp3OutputFormat}. * * When an array of formats is specified, for each playlist, the first format that can contain all of the playlist's * tracks is chosen. This allows you to, for example, package audio into .aac files and video into .ts files. */ segmentFormat: OutputFormat | OutputFormat[]; /** * Specifies the target (max) duration in seconds for each media segment, defaulting to 2 seconds. * * Mediabunny will try not to emit media segments longer than the target duration, but it is forced to if key frames * are provided with a longer period than the target duration. Therefore, make sure to encode a key frame at least * every `targetDuration` seconds to guarantee segment length, controllable via * {@link VideoEncodingConfig.keyFrameInterval}. */ targetDuration?: number; /** * Whether to bundle all media segments for a playlist into a single file. Individual segments are then extracted * via range requests. */ singleFilePerPlaylist?: boolean; /** * If `true`, the muxer will be in "live mode", continuously emitting updated playlists as new segments are created. * The master playlist will be emitted as soon as all playlists have been emitted at least once, and will continue * to be emitted each time a segment is finalized to further refine the accuracy of the `BANDWIDTH` attribute. * * When `false` (the default), all playlists will only be emitted once, upon output finalization. */ live?: boolean; /** * When in live mode, this controls the maximum number of segments contained in each playlist. Defaults to * `Infinity`, meaning playlists continually grow in size. */ maxLiveSegmentCount?: number; /** * Returns the file path for a given media playlist. If the returned path is relative, it is relative to the root * path. * * Defaults to `'playlist-{n}.m3u8'`, where `n` is the 1-based index of the media playlist in the master playlist. */ getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<FilePath>; /** * Returns the file path for a given media segment. If the returned path is relative, it is relative to the path * of the containing playlist. * * Defaults to `'segment-{n}-{k}{ext}'`, where `n` is the 1-based index of the containing media playlist in the * master playlist, `k` is the 1-based index of the segment in its playlist, and `ext` is the file extension of the * segment format (including the leading dot). * * If {@link HlsOutputFormatOptions.singleFilePerPlaylist} is true, it defaults to `'segments-{n}{ext}'` instead. */ getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise<FilePath>; /** * Returns the file path for a given media init segment. If the returned path is relative, it is relative to the * path of the containing playlist. * * Only necessary for segment formats that require an init file, such as {@link CmafOutputFormat}. * * Defaults to `'init-{n}{ext}'`, where `n` is the 1-based index of the containing media playlist in the master * playlist and `ext` is the file extension of the segment format (including the leading dot). */ getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<FilePath>; /** Called whenever the master playlist is written. */ onMaster?: (content: string) => unknown; /** Called whenever a media playlist is written. */ onPlaylist?: (content: string, info: HlsOutputPlaylistInfo) => unknown; /** * Called whenever a media segment has been fully written. In single-file mode, this function will only be called * once when the playlist is finalized. */ onSegment?: (target: Target, info: HlsOutputSegmentInfo) => unknown; /** * Called when a media playlist is initialized, before any segments have been written. In single-file mode, this * function is never called. */ onInit?: (target: Target, info: HlsOutputPlaylistInfo) => unknown; /** * Called when a media segment is removed from the start of a media playlist due to * {@link HlsOutputFormatOptions.maxLiveSegmentCount}. Will not be called when * {@link HlsOutputFormatOptions.singleFilePerPlaylist} is `true`. */ onSegmentPopped?: (path: string, info: HlsOutputSegmentInfo) => unknown; }; /** * 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 { /** @internal */ _options: HlsOutputFormatOptions; /** Creates a new {@link HlsOutputFormat} configured with the specified `options`. */ constructor(options: HlsOutputFormatOptions) { 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: Output): Muxer { return new HlsMuxer(output, this); } /** @internal */ get _name() { return 'HTTP Live Streaming (HLS)'; } get fileExtension() { return '.m3u8'; } get mimeType() { return HLS_MIME_TYPE; } getSupportedCodecs(): MediaCodec[] { const uniqueCodecs = new Set(toArray(this._options.segmentFormat).flatMap(x => x.getSupportedCodecs())); return [...uniqueCodecs]; } getSupportedTrackCounts(): TrackCountLimits { 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(): boolean { return toArray(this._options.segmentFormat).some(format => format.supportsVideoRotationMetadata); } get supportsTimestampedMediaData(): boolean { return true; // I guess?? } /** @internal */ // eslint-disable-next-line @typescript-eslint/no-unused-vars override _codecUnsupportedHint(codec: MediaCodec): string { return ` Using different segment formats may grant support for this codec.`; } }