Update docs a bunch, fix some typos

This commit is contained in:
Vanilagy
2026-04-15 14:36:53 +02:00
parent 606ee87822
commit 7080b79a2a
16 changed files with 508 additions and 116 deletions
+34
View File
@@ -134,6 +134,7 @@ type ConversionVideoOptions = {
>; >;
processedWidth?: number; processedWidth?: number;
processedHeight?: number; processedHeight?: number;
group?: OutputTrackGroup | OutputTrackGroup[];
}; };
type MaybePromise<T> = T | Promise<T>; type MaybePromise<T> = T | Promise<T>;
@@ -334,6 +335,23 @@ const conversion = await Conversion.init({
For documentation about the properties of video and audio tracks, refer to [Reading track metadata](./reading-media-files#reading-track-metadata). For documentation about the properties of video and audio tracks, refer to [Reading track metadata](./reading-media-files#reading-track-metadata).
## Track fan-out
You can also set (or return) an array of options for a single input track, which causes Mediabunny to create one output track per entry, or, in other words, multiple output tracks from one input track (fan-out).
This is useful for producing multiple renditions at different qualities, e.g. for [HLS](./output-formats#hls):
```ts
const conversion = await Conversion.init({
input,
output,
video: [
{ height: 1080, bitrate: QUALITY_HIGH },
{ height: 720, bitrate: QUALITY_MEDIUM },
{ height: 480, bitrate: QUALITY_LOW },
],
});
```
## Trimming ## Trimming
Use the `trim` property in the conversion options to extract only a section of the input file into the output file: Use the `trim` property in the conversion options to extract only a section of the input file into the output file:
@@ -418,6 +436,21 @@ const conversion = await Conversion.init({
}); });
``` ```
## Selecting tracks
Use the `tracks` option to control which input tracks are considered for conversion:
```ts
const conversion = await Conversion.init({
input,
output,
tracks: 'primary', // Only the primary video and audio tracks
});
```
Accepted values are `'all'` or `'primary'`. The default is `'all'`, unless the input format is HLS, then it is `'primary'`. This is because HLS typically has multiple renditions of a track, and converting all of them is (usually) not desired.
For a more granular selection, use the `discard` field on the tracks.
## Discarded tracks ## Discarded tracks
If an input track is excluded from the output file, it is considered *discarded*. The list of discarded tracks can be accessed after initializing a `Conversion`: If an input track is excluded from the output file, it is considered *discarded*. The list of discarded tracks can be accessed after initializing a `Conversion`:
@@ -466,3 +499,4 @@ On the flip side, you can always query which input tracks made it into the outpu
const conversion = await Conversion.init({ input, output }); const conversion = await Conversion.init({ input, output });
conversion.utilizedTracks; // => InputTrack[] conversion.utilizedTracks; // => InputTrack[]
``` ```
A track may appear multiple times in this list when [fan-out](#fan-out) produces multiple output tracks from it.
+2
View File
@@ -33,6 +33,7 @@ import {
ADTS, // ADTS input format singleton ADTS, // ADTS input format singleton
FLAC, // FLAC input format singleton FLAC, // FLAC input format singleton
MPEG_TS, // MPEG-TS input format singleton MPEG_TS, // MPEG-TS input format singleton
HLS, // HLS input format singleton
} from 'mediabunny'; } from 'mediabunny';
``` ```
@@ -82,6 +83,7 @@ In addition to singletons, input format classes are structured hierarchically:
- `AdtsInputFormat` - `AdtsInputFormat`
- `FlacInputFormat` - `FlacInputFormat`
- `MpegTsInputFormat` - `MpegTsInputFormat`
- `HlsInputFormat`
This means you can also perform input format checks using `instanceof` instead of `===` comparisons. For example: This means you can also perform input format checks using `instanceof` instead of `===` comparisons. For example:
```ts ```ts
+1
View File
@@ -13,6 +13,7 @@ Here's a long list of stuff this library does:
- Hardware-accelerated decoding & encoding (via the WebCodecs API) - Hardware-accelerated decoding & encoding (via the WebCodecs API)
- Support for multiple video, audio and subtitle tracks - Support for multiple video, audio and subtitle tracks
- Read & write support for many container formats (.mp4, .mov, .webm, .mkv, .mp3, .wav, .ogg, .aac, .flac, .ts), including variations such as MP4 with Fast Start, fragmented MP4, streamable Matroska, transparent WebM, etc. - Read & write support for many container formats (.mp4, .mov, .webm, .mkv, .mp3, .wav, .ogg, .aac, .flac, .ts), including variations such as MP4 with Fast Start, fragmented MP4, streamable Matroska, transparent WebM, etc.
- Read & write support for HLS, both VOD and live
- Support for 25 different codecs - Support for 25 different codecs
- Lazy, optimized, on-demand file reading - Lazy, optimized, on-demand file reading
- Input and output streaming, arbitrary file size support - Input and output streaming, arbitrary file size support
+19
View File
@@ -72,6 +72,25 @@ Packets may appear out-of-order in the file, meaning the order in which they are
- **Presentation order:** The order in which the data is to be presented; sorted by timestamp. - **Presentation order:** The order in which the data is to be presented; sorted by timestamp.
- **Decode order:** The order in which packets must be decoded; not always sorted by timestamp. - **Decode order:** The order in which packets must be decoded; not always sorted by timestamp.
### Live tracks
Some tracks (like in HLS) may be *live*, meaning new media data is still being produced. Check it like this:
```ts
await track.isLive();
```
Mediabunny's mental model for live tracks is simple: it treats them like any other track, just one where the media data at the end is not available yet.
This means that any reading operation that attempts to read media data from the "unavailable" section at the end will wait until the media data becomes available or the live stream ends. Mediabunny calls this the "live wait".
Sometimes, you may want to *skip* the live wait and pretend like the media data ends at the currently-known latest point. For this, you can pass the `skipLiveWait: true` option to all media sinks. For example:
```ts
import { EncodedPacketSink } from 'mediabunny';
const packetSink = new EncodedPacketSink(track);
const lastPacket = await packetSink.getPacket(Infinity, { skipLiveWait: true });
```
## General sinks ## General sinks
There is one media sink which can be used with any `InputTrack`: There is one media sink which can be used with any `InputTrack`:
+38 -1
View File
@@ -57,6 +57,19 @@ type VideoEncodingConfig = {
contentHint?: string; contentHint?: string;
sizeChangeBehavior?: 'deny' | 'passThrough' | 'fill' | 'contain' | 'cover'; sizeChangeBehavior?: 'deny' | 'passThrough' | 'fill' | 'contain' | 'cover';
transform?: {
width?: number;
height?: number;
fit?: 'fill' | 'contain' | 'cover';
rotate?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
frameRate?: number;
process?: (sample: VideoSample) => MaybePromise<
CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null
>;
force?: boolean;
};
onEncodedPacket?: ( onEncodedPacket?: (
packet: EncodedPacket, packet: EncodedPacket,
meta: EncodedVideoChunkMetadata | undefined meta: EncodedVideoChunkMetadata | undefined
@@ -78,7 +91,19 @@ type VideoEncodingConfig = {
- `hardwareAcceleration`: A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`. - `hardwareAcceleration`: A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`.
- `scalabilityMode`: An encoding scalability mode identifier as defined by [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*). - `scalabilityMode`: An encoding scalability mode identifier as defined by [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*).
- `contentHint`: An encoding video content hint as defined by [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints). - `contentHint`: An encoding video content hint as defined by [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints).
- `sizeChangeBehavior`: Video frames may change size overtime. This field controls the behavior in case this happens. Defaults to `'deny'`. - `sizeChangeBehavior`: Video frames may change size over time. This field controls the behavior in case this happens. Defaults to `'deny'`.
- `transform`: Optional transformations to apply to the video frames before they are passed to the encoder.
- `width`: The width in pixels to resize the frames to. If `height` is not set, it will be deduced automatically based on aspect ratio.
- `height`: The height in pixels to resize the frames to. If `width` is not set, it will be deduced automatically based on aspect ratio.
- `fit`: The fitting algorithm in case both `width` and `height` are set. To avoid ambiguity, this field must not be set when `sizeChangeBehavior` is `'fill'`, `'contain'` or `'deny'`, since `sizeChangeBehavior` already determines the fitting algorithm.
- `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
- `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to letterboxing.
- `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
- `rotate`: The clockwise rotation by which to rotate the frames. Rotation is applied before resizing.
- `crop`: Specifies the rectangular region of the frames to crop to. The crop region will automatically be clamped to the dimensions of the frame. Cropping is performed after rotation but before resizing.
- `frameRate`: The frame rate in hertz to normalize the video frame stream to.
- `process`: Allows for custom user-defined processing of video frames, e.g. for applying overlays, color transformations, or timestamp modifications. Will be called for each video frame after transformations and frame rate corrections. Must return a `VideoSample` or a `CanvasImageSource`, an array of them, or `null` for dropping the frame. When non-timestamped data is returned, the timestamp and duration from the input sample will be used.
- `force`: Forces every video frame through the transformation step even if no transformation properties are defined. This can be used, for example, to bake rotation into the encoded video frames.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress. - `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string. - `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
@@ -92,6 +117,14 @@ type AudioEncodingConfig = {
bitrateMode?: 'constant' | 'variable'; bitrateMode?: 'constant' | 'variable';
fullCodecString?: string; fullCodecString?: string;
transform?: {
numberOfChannels?: number;
sampleRate?: number;
process?: (sample: AudioSample) => MaybePromise<
AudioSample | AudioSample[] | null
>;
};
onEncodedPacket?: ( onEncodedPacket?: (
packet: EncodedPacket, packet: EncodedPacket,
meta: EncodedAudioChunkMetadata | undefined meta: EncodedAudioChunkMetadata | undefined
@@ -105,6 +138,10 @@ type AudioEncodingConfig = {
- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities). - `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
- `bitrateMode`: Can be used to control constant vs. variable bitrate. - `bitrateMode`: Can be used to control constant vs. variable bitrate.
- `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [Mediabunny Codec Registry](/codec-registry/overview). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically. - `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [Mediabunny Codec Registry](/codec-registry/overview). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `transform`: Optional transformations to apply to the audio samples before they are passed to the encoder.
- `numberOfChannels`: The desired number of output channels to up/downmix to.
- `sampleRate`: The desired output sample rate in hertz to resample to.
- `process`: Allows for custom user-defined processing of audio samples, e.g. for applying audio effects or timestamp modifications. Called for each audio sample after resampling and remixing. Must return an `AudioSample`, an array of them, or `null` for dropping the sample.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress. - `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string. - `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
+106 -1
View File
@@ -124,6 +124,25 @@ const output = new Output({
The available options are the same `IsobmffOutputFormatOptions` used by [MP4](#mp4). The available options are the same `IsobmffOutputFormatOptions` used by [MP4](#mp4).
## CMAF
This output format creates a single Common Media Application Format (CMAF) segment (.m4s).
```ts
import { Output, CmafOutputFormat } from 'mediabunny';
const output = new Output({
format: new CmafOutputFormat(options),
initTarget: new BufferTarget(), // For example
// ...
});
```
A CMAF segment requires a separate init segment, which is written to the target specified by [`OutputOptions.initTarget`](../api/OutputOptions#inittarget).
The available options are the same `IsobmffOutputFormatOptions` used by [MP4](#mp4), but with the `fastStart` option removed and with `minimumFragmentDuration` defaulting to `Infinity`.
CMAF is mainly intended for use as the segment format of [HLS](#hls).
## WebM ## WebM
This output format creates WebM files. This output format creates WebM files.
@@ -339,4 +358,90 @@ type MpegTsOutputFormatOptions = {
}; };
``` ```
- `onPacket`\ - `onPacket`\
Will be called for each 188-byte Transport Stream packet that is written. Will be called for each 188-byte Transport Stream packet that is written.
## HLS
This output format creates media files compatible with the HTTP Live Streaming (HLS) protocol. 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_.
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 `BaseTrackMetadata.isRelativeToUnixEpoch` to `true` for all output tracks.
```ts
import { Output, PathedTarget, MpegTsOutputFormat } from 'mediabunny';
const output = new Output({
format: new HlsOutputFormat(options),
target: new PathedTarget('master.m3u8', ({ path }) => {
// Return a target
}),
});
```
::: info
This format ensures [append-only writing](#append-only-writing) for master and media playlists; for media segments it depends on the media segment format.
:::
The following options are available:
```ts
export type HlsOutputFormatOptions = {
segmentFormat: OutputFormat | OutputFormat[];
targetDuration?: number;
singleFilePerPlaylist?: boolean;
live?: boolean;
maxLiveSegmentCount?: number;
getPlaylistPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<FilePath>;
getSegmentPath?: (info: HlsOutputSegmentInfo) => MaybePromise<FilePath>;
getInitPath?: (info: HlsOutputPlaylistInfo) => MaybePromise<FilePath>;
onMaster?: (content: string) => unknown;
onPlaylist?: (content: string, info: HlsOutputPlaylistInfo) => unknown;
onSegment?: (target: Target, info: HlsOutputSegmentInfo) => unknown;
onInit?: (target: Target, info: HlsOutputPlaylistInfo) => unknown;
};
```
- `segmentFormat`\
**Required.** Specifies the file format of each media segment. Not all formats are supported by all players; prefer sticking to the most commonly used ones: `MpegTsOutputFormat`, `CmafOutputFormat`, `AdtsOutputFormat`, and `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.
- `targetDuration`\
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 [`VideoEncodingConfig.keyFrameInterval`](../api/VideoEncodingConfig#keyframeinterval).
- `singleFilePerPlaylist`\
Whether to bundle all media segments for a playlist into a single file. Individual segments are then extracted via range requests.
- `live`\
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.
- `maxLiveSegmentCount`\
When in live mode, this controls the maximum number of segments contained in each playlist. Defaults to `Infinity`, meaning playlists continually grow in size.
- `getPlaylistPath`\
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.
- `getSegmentPath`\
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 `singleFilePerPlaylist` is true, it defaults to `'segments-{n}{ext}'` instead.
- `getInitPath`\
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 `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).
- `onMaster`\
Called whenever the master playlist is written.
- `onPlaylist`\
Called whenever a media playlist is written.
- `onSegment`\
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.
- `onInit`\
Called when a media playlist is initialized, before any segments have been written. In single-file mode, this function is never called.
+154 -3
View File
@@ -40,10 +40,26 @@ Reading operations will throw an error if the file format could not be recognize
Simply creating an instance of `Input` will perform zero reads and is practically free. The file will only be read once data is requested. Simply creating an instance of `Input` will perform zero reads and is practically free. The file will only be read once data is requested.
::: :::
For convenience, `createInputFrom` automatically constructs an `Input` along with the matching source for a given value:
```ts
import { createInputFrom, ALL_FORMATS } from 'mediabunny';
const input = createInputFrom(file, ALL_FORMATS);
const input = createInputFrom(arrayBuffer, ALL_FORMATS);
const input = createInputFrom('https://example.com/video.mp4', ALL_FORMATS);
const input = createInputFrom('./video.mp4', ALL_FORMATS); // Uses the file system server-side, fetch client-side
```
## Reading file metadata ## Reading file metadata
With our instance of `Input` created, you can now start reading file-level metadata. With our instance of `Input` created, you can now start reading file-level metadata.
You can check if Mediabunny can read the file:
```ts
await input.canRead();
```
You can query the concrete format of the file like this: You can query the concrete format of the file like this:
```ts ```ts
await input.getFormat(); // => Mp4InputFormat await input.getFormat(); // => Mp4InputFormat
@@ -60,6 +76,18 @@ await input.computeDuration(); // => 1905.4615
``` ```
More specifically, the duration is defined as the maximum end timestamp across all tracks. More specifically, the duration is defined as the maximum end timestamp across all tracks.
If you only need an approximate duration and want to avoid expensive scanning operations, you can read it directly from file metadata (where available):
```ts
await input.getDurationFromMetadata(); // => number | null
```
This resolves to `null` if the file doesn't expose its duration as metadata.
Both `computeDuration` and `getDurationFromMetadata` will not resolve if the underlying media is live (because the duration is not yet known!). To get the duration *up to the known point* (the live edge), do this:
```ts
await input.computeDuration(undefined, { skipLiveWait: true });
await input.getDurationFromMetadata(undefined, { skipLiveWait: true });
```
Since not all media files begin at time zero, you can also retrieve the *starting timestamp* of the media file in seconds: Since not all media files begin at time zero, you can also retrieve the *starting timestamp* of the media file in seconds:
```ts ```ts
await input.getFirstTimestamp(); // => 0.0 await input.getFirstTimestamp(); // => 0.0
@@ -73,6 +101,8 @@ For more info, see [`MetadataTags`](../api/MetadataTags).
## Reading track metadata ## Reading track metadata
### Extracting tracks
You can extract the list of all media tracks in the file like so: You can extract the list of all media tracks in the file like so:
```ts ```ts
await input.getTracks(); // => InputTrack[] await input.getTracks(); // => InputTrack[]
@@ -91,6 +121,30 @@ await input.getPrimaryAudioTrack(); // => InputAudioTrack | null
Subtitle tracks are currently not supported for reading. Subtitle tracks are currently not supported for reading.
::: :::
These methods accept an optional [`InputTrackQuery`](../api/InputTrackQuery) parameter for filtering and sorting tracks. This query system is especially useful for inputs with many tracks such as HLS playlists. The helpers `asc`, `desc`, and `prefer` make it easy to express sorting logic.
```ts
import { desc, prefer } from 'mediabunny';
// Get the highest-resolution video track:
await input.getPrimaryVideoTrack({
sortBy: async track => [
desc(await track.getDisplayWidth()),
desc(await track.getBitrate()), // If resolution matches, prefer the highest bitrate
],
});
// Get only English audio tracks:
await input.getAudioTracks({
filter: async track => await track.getLanguageCode() === 'eng',
});
// Get an English track but only if one exists:
await input.getAudioTracks({
sortBy: async track => prefer(await track.getLanguageCode() === 'eng'),
});
```
### Common track metadata ### Common track metadata
Once you have an `InputTrack`, you can start extracting metadata from it. Once you have an `InputTrack`, you can start extracting metadata from it.
@@ -119,6 +173,21 @@ await track.getName(); // => string | null
// Information about the intended usage of the track // Information about the intended usage of the track
// (default, commentary, hearing-impaired, visually-impaired, etc.) // (default, commentary, hearing-impaired, visually-impaired, etc.)
await track.getDisposition(); // TrackDisposition await track.getDisposition(); // TrackDisposition
// The track's peak bitrate, if exposed by the file metadata:
await track.getBitrate(); // => number | null
// The track's average bitrate, if exposed by the file metadata:
await track.getAverageBitrate(); // => number | null
// Whether all packets in the track are key packets:
await track.hasOnlyKeyPackets(); // => boolean
// Whether the track is currently live (meaning new media data is still being added):
await track.isLive(); // => boolean
// If the track is live, returns the interval in seconds at which new data is expected:
await track.getLiveRefreshInterval(); // => number | null
``` ```
#### Codec information #### Codec information
@@ -151,6 +220,11 @@ await track.computeDuration(); // => 1902.4615
``` ```
Analogous to the `Input`'s duration, this is identical to the end timestamp of the last sample. A track's duration may be shorter than the `Input`'s total duration if the `Input` has multiple tracks which differ in length. Analogous to the `Input`'s duration, this is identical to the end timestamp of the last sample. A track's duration may be shorter than the `Input`'s total duration if the `Input` has multiple tracks which differ in length.
You can also retrieve the approximate duration based on metadata in the file:
```ts
await track.getDurationFromMetadata(); // => number | null
```
You can also retrieve the track's *start timestamp* in seconds: You can also retrieve the track's *start timestamp* in seconds:
```ts ```ts
await track.getFirstTimestamp(); // => 0.041666666666666664 await track.getFirstTimestamp(); // => 0.041666666666666664
@@ -177,6 +251,11 @@ $$ \frac{k}{x},\quad k \in \mathbb{Z} $$
This field only gives an upper bound on a track's frame rate. To get a track's actual frame rate based on its samples, compute its [packet statistics](#packet-statistics). This field only gives an upper bound on a track's frame rate. To get a track's actual frame rate based on its samples, compute its [packet statistics](#packet-statistics).
::: :::
Some tracks (especially live tracks) have timestamps which are relative to the Unix epoch (Jan 1 1970, midnight UTC). In other words, their timestamps *are* Unix timestamps. This allows you to map the media data to a definitive point in wall-clock time. To see if this is the case, use:
```ts
await track.isRelativeToUnixEpoch(); // => boolean
```
#### Packet statistics #### Packet statistics
You can query aggregate statistics about a track's encoded packets: You can query aggregate statistics about a track's encoded packets:
@@ -309,6 +388,34 @@ For example, here's the decoder configuration for an AAC audio track:
} }
``` ```
### Track pairability
Mediabunny has a concept of "track pairability". Two different tracks are considered _pairable_ if they are compatible with each other, meaning they can be presented together. For example, in a normal file, the video track can be paired with the audio track. If it has multiple audio tracks, the video track can be paired with each one of them, but the audio tracks cannot be paired with each other; they're not intended to be played at the same time.
This concept is needed to model the more complex track configurations found in many-track formats such as HLS. Here, an audio track may be pairable with all video tracks or just one; it depends on the master playlist. These inter-track relations are exactly what track pairability describes.
To see if two tracks are pairable, do:
```ts
const canPair = trackA.canBePairedWith(trackB);
```
Each track also provides utilities that let you find its pairable tracks easily:
```ts
// These return an array of tracks
await track.getPairableTracks();
await track.getPairableVideoTracks();
await track.getPairableAudioTracks();
// These return one track or null
await track.getPrimaryPairableVideoTrack();
await track.getPrimaryPairableAudioTrack();
// These resolve to booleans:
await track.hasPairableTrack();
await track.hasPairableVideoTrack();
await track.hasPairableAudioTrack();
```
## Reading media data ## Reading media data
Mediabunny has the concept of *media sinks*, which are the way to read media data from an `InputTrack`. Media sinks differ in their API and in their level of abstraction, meaning you can pick whichever sink best fits your use case. Mediabunny has the concept of *media sinks*, which are the way to read media data from an `InputTrack`. Media sinks differ in their API and in their level of abstraction, meaning you can pick whichever sink best fits your use case.
@@ -443,11 +550,20 @@ Using `using` is recommended over `const` if you only need the `Input` momentari
The _input source_ determines where the `Input` reads data from. The _input source_ determines where the `Input` reads data from.
All sources have an `onread` callback property you can set to inspect which areas of the file are being read: All sources have a `read` event you can use to inspect which areas of the file are being read:
```ts ```ts
source.onread = (start, end) => { source.on('read', ({ start, end }) => {
console.log(`Reading byte range [${start}, ${end})`); console.log(`Reading byte range [${start}, ${end})`);
}; });
```
You can derive a `RangedSource` representing only a sub-section of an existing source via `slice`:
```ts
// A source over only the first 1024 bytes:
const sliced = source.slice(0, 1024);
// A source that starts 8192 bytes into the original source:
const sliced2 = source.slice(8192);
``` ```
--- ---
@@ -714,4 +830,39 @@ recorder.onstop = async () => {
recorder.start(1000); recorder.start(1000);
setTimeout(() => recorder.stop(), 10_000); // Stop recording after 10s setTimeout(() => recorder.stop(), 10_000); // Stop recording after 10s
```
## Pathed (multi-file) sources
Some media formats reference more than one file. For example, an [HLS](./input-formats) stream consists of a master playlist that points to one or more media playlists, each of which in turn references many media segment files. To read this kind of multi-file media, Mediabunny needs a way to resolve those file paths into [input sources](#input-sources). You can do this using `PathedSource`.
A `PathedSource` wraps a *root path* (the entry file of the media) together with a callback that produces a `Source` for each requested file path:
```ts
import { Input, HLS, PathedSource, UrlSource } from 'mediabunny';
const input = new Input({
formats: [HLS],
source: new PathedSource(
'https://example.com/stream/master.m3u8',
({ path, isRoot }) => new UrlSource(path),
),
});
```
The callback is called once per requested file (lazily, only when needed) and receives a `SourceRequest`:
```ts
type SourceRequest = {
path: FilePath; // The requested file path
isRoot: boolean; // Whether the requested file is the root file
};
```
You can return either a `Source` or a [`SourceRef`](../api/SourceRef). The kind of `Source` you create inside the callback is up to you - use `UrlSource` for streams served over HTTP, `FilePathSource` for files on disk, `BufferSource` for files in memory, or any other source type (or mix of them) that fits.
## Init inputs
Some file formats contain track initialization info in a *separate* file; CMAF is one example. To supply these to Mediabunny, load the initialization file as a separate `Input` and then pass it as an `initInput`:
```ts
const initInput = createInputFrom('init.mp4', ALL_FORMATS);
const input = createInputFrom('data.mp4', ALL_FORMATS, { initInput });
``` ```
@@ -6,6 +6,7 @@ Mediabunny supports many commonly used media container formats, all of which are
- ISOBMFF-based formats (.mp4, .m4v, .m4a, ...) - ISOBMFF-based formats (.mp4, .m4v, .m4a, ...)
- QuickTime File Format (.mov) - QuickTime File Format (.mov)
- Segmented MP4 (CMAF) (.m4s)
- Matroska (.mkv) - Matroska (.mkv)
- WebM (.webm) - WebM (.webm)
- Ogg (.ogg) - Ogg (.ogg)
@@ -14,6 +15,7 @@ Mediabunny supports many commonly used media container formats, all of which are
- ADTS (.aac) - ADTS (.aac)
- FLAC (.flac) - FLAC (.flac)
- MPEG Transport Stream (.ts) - MPEG Transport Stream (.ts)
- HLS (.m3u8)
## Codecs ## Codecs
+63
View File
@@ -40,6 +40,8 @@ output.addSubtitleTrack(subtitleSource);
For each track you want to add, you'll need to create a unique [media source](./media-sources) for it. You'll be able to add media data to the output via these media sources. A media source can only ever be used for one output track. For each track you want to add, you'll need to create a unique [media source](./media-sources) for it. You'll be able to add media data to the output via these media sources. A media source can only ever be used for one output track.
These methods return the newly created `OutputTrack` instance.
Optionally, you can specify additional track metadata when adding tracks: Optionally, you can specify additional track metadata when adding tracks:
```ts ```ts
// This specifies that the video track should be rotated by 90 degrees // This specifies that the video track should be rotated by 90 degrees
@@ -107,6 +109,40 @@ output.addAudioTrack(audioSource);
Adding tracks to an `Output` will throw if the track is not compatible with the output format. Be sure to respect the [properties](./output-formats#format-properties) of the output format when adding tracks. Adding tracks to an `Output` will throw if the track is not compatible with the output format. Be sure to respect the [properties](./output-formats#format-properties) of the output format when adding tracks.
::: :::
### Track groups & pairability
To control output track pairability (which tracks can be presented with which tracks), Mediabunny uses a concept called "track groups". Tracks are assigned to zero or more groups, and their group membership determines with which other tracks they can be paired. This system allows for the common pairability patterns to be described easily.
For typical file formats, configuring track groups is not necessary and does nothing. It is relevant for configuring many-track formats such as HLS, where track groups affect the structure of the master playlist.
Two output tracks are considered pairable if at least one of these is true:
- They are part of the same group but have a different type (video, audio, subtitle)
- They are in two different groups that have been paired with each other
Create track groups like this:
```ts
import { OutputTrackGroup } from 'mediabunny';
const groupA = new OutputTrackGroup();
const groupB = new OutputTrackGroup();
```
You can optionally pair groups like this:
```ts
// After this, any track in group A can be paired with any track in group B
groupA.pairWith(groupB); // Automatically pairs B with A as well (symmetric operation)
```
Assign tracks to groups during track registration:
```ts
output.addVideoTrack(videoSource, { group: groupA });
output.addAudioTrack(audioSource, { group: [groupA, groupB] });
```
By default, when not specified, every track will be assigned to `Output.defaultTrackGroup`. This in turn means that the default track pairing rules are:
- Tracks of different type (video, audio, subtitle) can always be paired with each other
- No two tracks of the same type (e.g. two audio tracks) can be paired with each other
## Setting metadata tags ## Setting metadata tags
Mediabunny lets you write additional descriptive metadata tags to an output file, such as title, artist, or cover art: Mediabunny lets you write additional descriptive metadata tags to an output file, such as title, artist, or cover art:
@@ -396,6 +432,33 @@ const output = new Output({
}); });
``` ```
## Pathed (multi-file) targets
Some output formats write more than one file. For example, an [HLS](./output-formats#hls) output produces a master playlist alongside one or more media playlists and many media segment files. To write this kind of multi-file output, Mediabunny needs a way to resolve the paths it wants to write into [output targets](#output-targets). You can use `PathedTarget` for that.
A `PathedTarget` wraps a *root path* (the entry file of the media) together with a callback that produces a `Target` for each requested file path:
```ts
import { Output, PathedTarget, FilePathTarget, HlsOutputFormat } from 'mediabunny';
const output = new Output({
format: new HlsOutputFormat({ /* ... */ }),
target: new PathedTarget(
'master.m3u8',
({ path, isRoot }) => new FilePathTarget(`/output/${path}`),
),
});
```
The callback is called once per file the format wants to write and receives a `TargetRequest`:
```ts
type TargetRequest = {
path: FilePath; // The requested file path
isRoot: boolean; // Whether the requested file is the root file
};
```
The kind of `Target` you create inside the callback is up to you - use `FilePathTarget` for files on disk, `StreamTarget` to upload chunks to a server, or any other target type that fits.
## Packet buffering ## Packet buffering
Some [output formats](./output-formats) require *packet buffering* for multi-track outputs. Packet buffering occurs because the `Output` must wait for data from all tracks for a given timestamp to continue writing data. For example, should you first encode all your video frames and then encode the audio afterward, the `Output` will have to hold all of the video frames in memory until the audio packets start coming in. This might lead to memory exhaustion should your video be very long. When there is only one media track, this issue does not arise. Some [output formats](./output-formats) require *packet buffering* for multi-track outputs. Packet buffering occurs because the `Output` must wait for data from all tracks for a given timestamp to continue writing data. For example, should you first encode all your video frames and then encode the audio afterward, the `Output` will have to hold all of the video frames in memory until the audio packets start coming in. This might lead to memory exhaustion should your video be very long. When there is only one media track, this issue does not arise.
+70 -103
View File
@@ -666,14 +666,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
const variableName = declaration.name.getText(); const variableName = declaration.name.getText();
// Get variable description from JSDoc // Get variable description from JSDoc
const jsDocComment = ts.getJSDocCommentsAndTags(declaration)[0]; const description = extractJsDocDescription(declaration, {
let description = ''; tagHandling: 'filterAll',
if (jsDocComment && ts.isJSDoc(jsDocComment)) { transform: text => processLinkTags(text, variableName),
const commentText = jsDocComment.comment; });
if (typeof commentText === 'string') {
description = processLinkTags(commentText.trim(), variableName);
}
}
// Check if it's a function type // Check if it's a function type
const variableType = typeChecker.getTypeAtLocation(declaration); const variableType = typeChecker.getTypeAtLocation(declaration);
@@ -779,42 +775,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
let typeParameters: string | null = null; let typeParameters: string | null = null;
// Get class description from JSDoc (or from superclass if none) // Get class description from JSDoc (or from superclass if none)
let description = ''; let description = extractJsDocDescription(declaration, {
const jsDocComment = ts.getJSDocCommentsAndTags(declaration)[0]; tagHandling: 'filterAll',
transform: text => processLinkTags(text, className),
if (jsDocComment && ts.isJSDoc(jsDocComment)) { });
// First try to get the comment from the parsed JSDoc
const commentText = jsDocComment.comment;
if (typeof commentText === 'string' && commentText.trim()) {
description = processLinkTags(commentText.trim(), className);
} else {
// If no comment text, extract from raw source text
const sourceFile = declaration.getSourceFile();
const sourceText = sourceFile.getFullText();
const start = jsDocComment.getStart();
const end = jsDocComment.getEnd();
const rawJsDoc = sourceText.substring(start, end);
// Extract the content between /** and */
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
if (match && match[1]) {
const content = match[1]
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
// Filter out @tags but keep the description
const lines = content.split('\n');
const descLines = lines.filter(line => !line.trim().startsWith('@'));
const rawDesc = descLines.join('\n').trim();
if (rawDesc) {
description = processLinkTags(rawDesc, className);
}
}
}
}
// If no description, check superclass (only for classes/interfaces) // If no description, check superclass (only for classes/interfaces)
if (!description && (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) { if (!description && (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) {
@@ -923,37 +887,12 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
// Helper to get JSDoc description with superclass fallback (recursive) // Helper to get JSDoc description with superclass fallback (recursive)
const getDescriptionWithFallback = (member: ts.ClassElement | ts.TypeElement, memberName: string): string => { const getDescriptionWithFallback = (member: ts.ClassElement | ts.TypeElement, memberName: string): string => {
const jsDoc = ts.getJSDocCommentsAndTags(member)[0]; const ownDescription = extractJsDocDescription(member, {
if (jsDoc && ts.isJSDoc(jsDoc)) { tagHandling: 'filterAll',
// First try the parsed comment transform: text => processLinkTags(text, className),
if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) { });
return processLinkTags(jsDoc.comment.trim(), className); if (ownDescription) {
} else { return ownDescription;
// If no parsed comment, extract from raw source (same logic as class descriptions)
const sourceFile = member.getSourceFile();
const sourceText = sourceFile.getFullText();
const start = jsDoc.getStart();
const end = jsDoc.getEnd();
const rawJsDoc = sourceText.substring(start, end);
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
if (match && match[1]) {
const content = match[1]
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
// Filter out @tags but keep the description
const lines = content.split('\n');
const descLines = lines.filter(line => !line.trim().startsWith('@'));
const rawDesc = descLines.join('\n').trim();
if (rawDesc) {
return processLinkTags(rawDesc, className);
}
}
}
} }
// Recursively check superclass hierarchy for this member's documentation // Recursively check superclass hierarchy for this member's documentation
@@ -1907,43 +1846,71 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
} }
}; };
// Helper to get the full description text from a JSDoc comment, handling inline tags. // Shared helper for extracting a JSDoc description, handling inline tags via raw-source fallback.
const getFullJSDocDescription = (node: ts.Node): string => { // - `tagHandling: 'stopAtFirst'` matches the behavior used by property-level descriptions: if any
// @-tag line is encountered, all subsequent lines are dropped (including any trailing description).
// - `tagHandling: 'filterAll'` matches the behavior used by class/variable top-level descriptions:
// every @-tag line is filtered out individually, preserving interleaved description lines.
// - `transform` is applied to the final non-empty description (e.g. to process {@link} tags).
const extractJsDocDescription = (
node: ts.Node,
opts: { tagHandling: 'stopAtFirst' | 'filterAll'; transform?: (text: string) => string },
): string => {
const jsDoc = ts.getJSDocCommentsAndTags(node)[0]; const jsDoc = ts.getJSDocCommentsAndTags(node)[0];
if (!jsDoc || !ts.isJSDoc(jsDoc)) return ''; if (!jsDoc || !ts.isJSDoc(jsDoc)) {
return '';
// If it's a simple string, just return it.
if (typeof jsDoc.comment === 'string') {
return jsDoc.comment.trim();
} }
// If it's a structured comment (with inline tags), get the raw text. const transform = opts.transform ?? ((t: string) => t);
// If the parsed comment is a non-empty string (no inline tags), use it directly.
// When the parsed comment is an empty string or a structured NodeArray (inline tags like
// {@link}), fall through to raw-source extraction. For empty-string comments this is safe:
// the raw block can only contain @-tags, so raw extraction also yields empty.
if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) {
return transform(jsDoc.comment.trim());
}
// Structured comment (contains inline tags like {@link}); extract description from raw source.
const sourceFile = node.getSourceFile(); const sourceFile = node.getSourceFile();
const sourceText = sourceFile.getFullText(); const sourceText = sourceFile.getFullText();
const start = jsDoc.getStart(); const rawJsDoc = sourceText.substring(jsDoc.getStart(), jsDoc.getEnd());
const end = jsDoc.getEnd();
const rawJsDoc = sourceText.substring(start, end);
// Extract the content between /** and */
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s); const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
if (match && match[1]) { if (!match || !match[1]) {
const content = match[1] return '';
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
// Filter out @-tags (like @param, @returns) to keep only the main description
const lines = content.split('\n');
const descLines = [];
for (const line of lines) {
if (line.trim().startsWith('@')) break; // Stop at the first @-tag
descLines.push(line);
}
return descLines.join('\n').trim();
} }
return ''; const content = match[1]
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
const lines = content.split('\n');
let descLines: string[];
if (opts.tagHandling === 'stopAtFirst') {
descLines = [];
for (const line of lines) {
if (line.trim().startsWith('@')) {
break;
}
descLines.push(line);
}
} else {
descLines = lines.filter(line => !line.trim().startsWith('@'));
}
const rawDesc = descLines.join('\n').trim();
if (!rawDesc) {
return '';
}
return transform(rawDesc);
};
// Helper to get the full description text from a JSDoc comment, handling inline tags.
const getFullJSDocDescription = (node: ts.Node): string => {
return extractJsDocDescription(node, { tagHandling: 'stopAtFirst' });
}; };
const main = () => { const main = () => {
+1 -1
View File
@@ -174,7 +174,7 @@ export type ConversionVideoOptions = {
*/ */
rotate?: Rotation; rotate?: Rotation;
/** /**
* Defaults to `true`. When enabaled, Mediabunny will use the rotation metadata in the output file to perform video * Defaults to `true`. When enabled, Mediabunny will use the rotation metadata in the output file to perform video
* rotation whenever possible. Set this field to `false` if you want to ensure the output file does not make use of * rotation whenever possible. Set this field to `false` if you want to ensure the output file does not make use of
* rotation metadata and that any rotation is baked into the video frames directly. * rotation metadata and that any rotation is baked into the video frames directly.
*/ */
+11 -3
View File
@@ -693,8 +693,8 @@ export type CreateInputFromOptions =
* function automatically chooses the correct underlying {@link Source} based on the type of the data passed in. * function automatically chooses the correct underlying {@link Source} based on the type of the data passed in.
* *
* Legal data types are `ArrayBuffer`, `SharedArrayBuffer`, `ArrayBufferView`, `Blob` (and, by extension, `File`), * Legal data types are `ArrayBuffer`, `SharedArrayBuffer`, `ArrayBufferView`, `Blob` (and, by extension, `File`),
* `ReadableStream<Uint8Array>`, `string` (representing either a URL or a local file path), `URL`, and `Request`. Local * `ReadableStream<Uint8Array>`, `string` (representing either a URL or a local file path), `URL`, `Request`, `Source`
* file paths require a Node-like server-side environment with access to the file system. * and `PathedSource`. Local file paths require a Node-like server-side environment with access to the file system.
* *
* The available options are the union of the options for each {@link Source}. Check the sources to see which field * The available options are the union of the options for each {@link Source}. Check the sources to see which field
* applies to which source. * applies to which source.
@@ -706,7 +706,7 @@ export type CreateInputFromOptions =
* @public * @public
*/ */
export const createInputFrom = ( export const createInputFrom = (
data: AllowSharedBufferSource | Blob | ReadableStream<Uint8Array> | string | URL | Request, data: AllowSharedBufferSource | Blob | ReadableStream<Uint8Array> | string | URL | Request | Source | PathedSource,
formats: InputFormat[], formats: InputFormat[],
options: CreateInputFromOptions = {}, options: CreateInputFromOptions = {},
): Input => { ): Input => {
@@ -719,6 +719,14 @@ export const createInputFrom = (
const { initInput, ...sourceOptions } = options; const { initInput, ...sourceOptions } = options;
if (data instanceof Source || data instanceof PathedSource) {
return new Input({
formats,
source: data,
initInput,
});
}
if ( if (
data instanceof ArrayBuffer data instanceof ArrayBuffer
|| (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer) || (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer)
+1 -1
View File
@@ -59,7 +59,7 @@ export type PacketRetrievalOptions = {
metadataOnly?: boolean; metadataOnly?: boolean;
/** /**
* When set to true, key packets will be verified upon retrieval by looking into the packet's bitstream. * When set to `true`, key packets will be verified upon retrieval by looking into the packet's bitstream.
* If not enabled, the packet types will be determined solely by what's stored in the containing file and may be * If not enabled, the packet types will be determined solely by what's stored in the containing file and may be
* incorrect, potentially leading to decoder errors. Since determining a packet's actual type requires looking into * incorrect, potentially leading to decoder errors. Since determining a packet's actual type requires looking into
* its data, this option cannot be enabled together with `metadataOnly`. * its data, this option cannot be enabled together with `metadataOnly`.
+1 -1
View File
@@ -952,7 +952,7 @@ export const simplifyRational = (rational: Rational): Rational => {
* @public * @public
*/ */
export type Rectangle = { export type Rectangle = {
/** The distance in pixels to the left edge of the rectangle . */ /** The distance in pixels to the left edge of the rectangle. */
left: number; left: number;
/** The distance in pixels to the top edge of the rectangle. */ /** The distance in pixels to the top edge of the rectangle. */
top: number; top: number;
+3
View File
@@ -1283,6 +1283,9 @@ export type HlsOutputFormatOptions = {
* - One .m3u8 file for each playlist, each containing a list of media segments. * - One .m3u8 file for each playlist, each containing a list of media segments.
* - Many media segments, containing the actual media data. * - 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 * @group Output formats
* @public * @public
*/ */
+2 -2
View File
@@ -492,7 +492,7 @@ export type UrlSourceOptions = {
* failed. If the function returns `null`, no more retries will be made. * failed. If the function returns `null`, no more retries will be made.
* *
* By default, it uses an exponential backoff algorithm that never gives up unless * By default, it uses an exponential backoff algorithm that never gives up unless
* a CORS error is suspected (`fetch()` did reject, `navigator.onLine` is true and origin is different) * a CORS error is suspected (`fetch()` did reject, `navigator.onLine` is true and origin is different).
*/ */
getRetryDelay?: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null; getRetryDelay?: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null;
@@ -2187,7 +2187,7 @@ export class RangedSource extends Source {
* @group Input sources * @group Input sources
* @public * @public
*/ */
export class PathedSource<S extends Source> { export class PathedSource<S extends Source = Source> {
/** Creates a new {@link PathedSource} from a root path and a callback. */ /** Creates a new {@link PathedSource} from a root path and a callback. */
constructor( constructor(
/** The path that points to the root file; the entry file of the media. */ /** The path that points to the root file; the entry file of the media. */