webm-muxer

webm-muxer - JavaScript WebM multiplexer

The WebCodecs API provides low-level access to media codecs, but provides no way of actually packaging (multiplexing) the encoded media into a playable file. This project implements a WebM/Matroska multiplexer in pure TypeScript, which is high-quality, fast and tiny, and supports video, audio and subtitles as well as live-streaming.

Demo: Muxing into a file

Demo: Streaming

Note: If you’re looking to create MP4 files, check out mp4-muxer, the sister library to webm-muxer.

Quick start

The following is an example for a common usage of this library:

import { Muxer, ArrayBufferTarget } from 'webm-muxer';

let muxer = new Muxer({
    target: new ArrayBufferTarget(),
    video: {
        codec: 'V_VP9',
        width: 1280,
        height: 720
    }
});

let videoEncoder = new VideoEncoder({
    output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
    error: e => console.error(e)
});
videoEncoder.configure({
    codec: 'vp09.00.10.08',
    width: 1280,
    height: 720,
    bitrate: 1e6
});

/* Encode some frames... */

await videoEncoder.flush();
muxer.finalize();

let { buffer } = muxer.target; // Buffer contains final WebM file

Motivation

This library was created to power the in-game video renderer of the browser game Marble Blast Web - here you can find a video completely rendered by it and muxed with this library. Previous efforts at in-browser WebM muxing, such as webm-writer-js or webm-muxer.js, were either lacking in functionality or were way too heavy in terms of byte size, which prompted the creation of this library.

Installation

Using NPM, simply install this package using

npm install webm-muxer

You can import all exported classes like so:

import * as WebMMuxer from 'webm-muxer';
// Or, using CommonJS:
const WebMMuxer = require('webm-muxer');

Alternatively, you can simply include the library as a script in your HTML, which will add a WebMMuxer object, containing all the exported classes, to the global object, like so:

<script src="build/webm-muxer.js"></script>

Usage

Initialization

For each WebM file you wish to create, create an instance of Muxer like so:

import { Muxer } from 'webm-muxer';

let muxer = new Muxer(options);

The available options are defined by the following interface:

interface MuxerOptions {
    target:
        | ArrayBufferTarget
        | StreamTarget
        | FileSystemWritableFileStreamTarget,

    video?: {
        codec: string,
        width: number,
        height: number,
        frameRate?: number, // Optional, adds metadata to the file
        alpha?: boolean // If the video contains transparency data
    },

    audio?: {
        codec: string,
        numberOfChannels: number,
        sampleRate: number,
        bitDepth?: number // Mainly necessary for PCM-coded audio
    },

    subtitles?: {
        codec: string
    },

    streaming?: boolean,

    type?: 'webm' | 'matroska',

    firstTimestampBehavior?: 'strict' | 'offset' | 'permissive'
}

Codecs officially supported by WebM are:
Video: V_VP8, V_VP9, V_AV1
Audio: A_OPUS, A_VORBIS
Subtitles: S_TEXT/WEBVTT

target

This option specifies where the data created by the muxer will be written. The options are:

Muxing media chunks

Then, with VideoEncoder and AudioEncoder set up, send encoded chunks to the muxer using the following methods:

addVideoChunk(
    chunk: EncodedVideoChunk,
    meta?: EncodedVideoChunkMetadata,
    timestamp?: number
): void;

addAudioChunk(
    chunk: EncodedAudioChunk,
    meta?: EncodedAudioChunkMetadata,
    timestamp?: number
): void;

Both methods accept an optional, third argument timestamp (microseconds) which, if specified, overrides the timestamp property of the passed-in chunk.

The metadata comes from the second parameter of the output callback given to the VideoEncoder or AudioEncoder’s constructor and needs to be passed into the muxer, like so:

let videoEncoder = new VideoEncoder({
    output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
    error: e => console.error(e)
});
videoEncoder.configure(/* ... */);

Should you have obtained your encoded media data from a source other than the WebCodecs API, you can use these following methods to directly send your raw data to the muxer:

addVideoChunkRaw(
    data: Uint8Array,
    type: 'key' | 'delta',
    timestamp: number, // In microseconds
    meta?: EncodedVideoChunkMetadata
): void;

addAudioChunkRaw(
    data: Uint8Array,
    type: 'key' | 'delta',
    timestamp: number, // In microseconds
    meta?: EncodedAudioChunkMetadata
): void;

Finishing up

When encoding is finished and all the encoders have been flushed, call finalize on the Muxer instance to finalize the WebM file:

muxer.finalize();

When using an ArrayBufferTarget, the final buffer will be accessible through it:

let { buffer } = muxer.target;

When using a FileSystemWritableFileStreamTarget, make sure to close the stream after calling finalize:

await fileStream.close();

Details

Video key frame frequency

Canonical WebM files can only have a maximum Matroska Cluster length of 32.768 seconds, and each cluster must begin with a video key frame. You therefore need to tell your VideoEncoder to encode a VideoFrame as a key frame at least every 32 seconds, otherwise your WebM file will be incorrect. You can do this by doing:

videoEncoder.encode(frame, { keyFrame: true });

Media chunk buffering

When muxing a file with a video and an audio track, it is important that the individual chunks inside the WebM file be stored in monotonically increasing time. This does mean, however, that the multiplexer must buffer chunks of one medium if the other medium has not yet encoded chunks up to that timestamp. For example, should you first encode all your video frames and then encode the audio afterwards, the multiplexer will have to hold all those video frames in memory until the audio chunks 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. So, when muxing a multimedia file, make sure it is somewhat limited in size or the chunks are encoded in a somewhat interleaved way (like is the case for live media).

Subtitles

This library supports adding a subtitle track to a file. Like video and audio, subtitles also need to be encoded before they can be added to the muxer. To do this, this library exports its own SubtitleEncoder class with a WebCodecs-like API. Currently, it only supports encoding WebVTT files.

Here’s a full example using subtitles:

import { Muxer, SubtitleEncoder, ArrayBufferTarget } from 'webm-muxer';

let muxer = new Muxer({
    target: new ArrayBufferTarget(),
    subtitles: {
        codec: 'S_TEXT/WEBVTT'
    },
    // ....
});

let subtitleEncoder = new SubtitleEncoder({
    output: (chunk, meta) => muxer.addSubtitleChunk(chunk, meta),
    error: e => console.error(e)
});
subtitleEncoder.configure({
    codec: 'webvtt'
});

let simpleWebvttFile =
`WEBVTT

00:00:00.000 --> 00:00:10.000
Example entry 1: Hello <b>world</b>.
`;
subtitleEncoder.encode(simpleWebvttFile);

// ...

muxer.finalize();

You do not need to encode an entire WebVTT file in one go; you can encode individual cues or any number of them at once. Just make sure that the preamble (the part before the first cue) is the first thing to be encoded.

Size “limits”

This library can mux WebM files up to a total size of ~4398 GB and with a Matroska Cluster size of ~34 GB.

Implementation & development

WebM files are a subset of the more general Matroska media container format. Matroska in turn uses a format known as EBML (think of it like binary XML) to structure its file. This project therefore implements a simple EBML writer to create the Matroska elements needed to form a WebM file. Many thanks to webm-writer-js for being the inspiration for most of the core EBML writing code.

For development, clone this repository, install everything with npm install, then run npm run watch to bundle the code into the build directory. Run npm run check to run the TypeScript type checker, and npm run lint to run ESLint.