├── .gitignore
├── .gitattributes
├── test
├── CantinaBand60.wav
├── test.html
└── test.js
├── src
├── index.ts
├── misc.ts
├── target.ts
├── ebml.ts
├── subtitles.ts
├── writer.ts
└── muxer.ts
├── tsconfig.json
├── .github
├── FUNDING.yml
└── workflows
│ └── release.yml
├── demo
├── index.html
├── style.css
└── script.js
├── demo-streaming
├── style.css
├── index.html
└── script.js
├── LICENSE
├── README.md
├── .eslintrc.js
├── package.json
└── MIGRATION-GUIDE.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .vscode
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | build/* linguist-generated
--------------------------------------------------------------------------------
/test/CantinaBand60.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Vanilagy/webm-muxer/HEAD/test/CantinaBand60.wav
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { Muxer } from './muxer';
2 | export { SubtitleEncoder } from './subtitles';
3 | export { ArrayBufferTarget, StreamTarget, FileSystemWritableFileStreamTarget } from './target';
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2021",
4 | "strict": true,
5 | "strictNullChecks": false,
6 | "noImplicitAny": true,
7 | "noImplicitOverride": true
8 | },
9 | "include": [
10 | "src/**/*",
11 | "build/**/*.ts"
12 | ]
13 | }
--------------------------------------------------------------------------------
/test/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Test
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: vanilagy
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/src/misc.ts:
--------------------------------------------------------------------------------
1 | export const readBits = (bytes: Uint8Array, start: number, end: number) => {
2 | let result = 0;
3 |
4 | for (let i = start; i < end; i++) {
5 | let byteIndex = Math.floor(i / 8);
6 | let byte = bytes[byteIndex];
7 | let bitIndex = 0b111 - (i & 0b111);
8 | let bit = (byte & (1 << bitIndex)) >> bitIndex;
9 |
10 | result <<= 1;
11 | result |= bit;
12 | }
13 |
14 | return result;
15 | };
16 |
17 | export const writeBits = (bytes: Uint8Array, start: number, end: number, value: number) => {
18 | for (let i = start; i < end; i++) {
19 | let byteIndex = Math.floor(i / 8);
20 | let byte = bytes[byteIndex];
21 | let bitIndex = 0b111 - (i & 0b111);
22 |
23 | byte &= ~(1 << bitIndex);
24 | byte |= ((value & (1 << (end - i - 1))) >> (end - i - 1)) << bitIndex;
25 | bytes[byteIndex] = byte;
26 | }
27 | };
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | WebM muxer demo
8 |
9 |
10 |
11 |
12 |
13 |
14 | WebM muxer demo - draw something!
15 | The live canvas state and your microphone input will be recorded
and muxed into a WebM file.
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/demo/style.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | margin: 0;
3 | width: 100%;
4 | height: 100%;
5 | background: #0d1117;
6 | color: white;
7 | font-family: monospace;
8 | }
9 |
10 | body {
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 | }
15 |
16 | * {
17 | user-select: none;
18 | }
19 |
20 | main {
21 | width: 640px;
22 | }
23 |
24 | h1 {
25 | margin: 0;
26 | font-weight: normal;
27 | text-align: center;
28 | margin-bottom: 10px;
29 | }
30 |
31 | h2 {
32 | margin: 0;
33 | font-weight: normal;
34 | text-align: center;
35 | font-size: 14px;
36 | margin-bottom: 20px;
37 | }
38 |
39 | canvas {
40 | border-radius: 10px;
41 | outline: 3px solid rgb(202, 202, 202);
42 | }
43 |
44 | #controls {
45 | margin-bottom: 20px;
46 | display: flex;
47 | justify-content: center;
48 | height: 38px;
49 | }
50 |
51 | button {
52 | font-size: 20px;
53 | padding: 5px 8px;
54 | }
55 |
56 | p {
57 | margin: 0;
58 | text-align: center;
59 | margin-top: 20px;
60 | height: 20px;
61 | }
--------------------------------------------------------------------------------
/demo-streaming/style.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | margin: 0;
3 | width: 100%;
4 | height: 100%;
5 | background: #0d1117;
6 | color: white;
7 | font-family: monospace;
8 | }
9 |
10 | body {
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 | }
15 |
16 | * {
17 | user-select: none;
18 | }
19 |
20 | main {
21 | width: 640px;
22 | }
23 |
24 | h1 {
25 | margin: 0;
26 | font-weight: normal;
27 | text-align: center;
28 | margin-bottom: 10px;
29 | }
30 |
31 | h2 {
32 | margin: 0;
33 | font-weight: normal;
34 | text-align: center;
35 | font-size: 14px;
36 | margin-bottom: 20px;
37 | }
38 |
39 | canvas {
40 | border-radius: 10px;
41 | outline: 3px solid rgb(202, 202, 202);
42 | }
43 |
44 | #controls {
45 | margin-bottom: 20px;
46 | display: flex;
47 | justify-content: center;
48 | height: 38px;
49 | }
50 |
51 | button {
52 | font-size: 20px;
53 | padding: 5px 8px;
54 | }
55 |
56 | p {
57 | margin: 0;
58 | text-align: center;
59 | margin-top: 20px;
60 | height: 20px;
61 | }
--------------------------------------------------------------------------------
/demo-streaming/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | WebM muxer streaming demo
8 |
9 |
10 |
11 |
12 |
13 |
14 | WebM muxer streaming demo - record your camera and microphone!
15 | Your camera and your microphone input will be recorded,
muxed into a WebM stream and shown in the <video> element below.
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Vanilagy
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ⚠️ This library is deprecated ⚠️
2 |
3 | webm-muxer has been deprecated in favor of [Mediabunny](https://github.com/Vanilagy/mediabunny), which entirely supersedes it. Mediabunny ships with an even better, easier-to-use, faster and more feature-rich WebM multiplexer, as well as muxers for many other formats, demuxers, WebCodecs abstractions and more, while keeping the bundle size tiny thanks to a tree-shakable design.
4 |
5 | webm-muxer is no longer being maintained and will not receive any new features or bug fixes.
6 |
7 | ## I just want to create a WebM file.
8 |
9 | [Mediabunny](https://github.com/Vanilagy/mediabunny) has got you covered. Refer to the following documents to jump right in:
10 |
11 | - [Guide: Writing media files](https://mediabunny.dev/guide/writing-media-files)
12 | - [Quick start: Create new media files](https://mediabunny.dev/guide/quick-start#create-new-media-files)
13 |
14 | ## My code already uses webm-muxer. What should I do?
15 |
16 | No worries. You can easily [migrate to Mediabunny](./MIGRATION-GUIDE.md) in 10 minutes.
17 |
18 | If you still need the docs for webm-muxer, you can find them [here](https://github.com/Vanilagy/webm-muxer/blob/a77076a0f5c8245bbc5d387eb5aaaf065cd84f90/README.md).
19 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | permissions:
8 | contents: read
9 |
10 | jobs:
11 | release:
12 | name: Release
13 | runs-on: ubuntu-latest
14 | permissions:
15 | contents: read
16 | id-token: write
17 | steps:
18 | - uses: actions/checkout@v4
19 | - uses: actions/setup-node@v4
20 | with:
21 | node-version: "20"
22 | registry-url: https://registry.npmjs.org
23 |
24 | # Get the 'version' field out of the package.json file
25 | - name: Get package.json version
26 | id: package-json-version
27 | run: echo "version=v$(cat package.json | jq '.version' --raw-output)" >> $GITHUB_OUTPUT
28 |
29 | # Abort if the version in the package.json file doesn't match the tag name of the release
30 | - name: Check package.json version against tag name
31 | if: steps.package-json-version.outputs.version != github.event.release.tag_name
32 | uses: actions/github-script@v3
33 | with:
34 | script: core.setFailed('Release tag does not match package.json version!')
35 |
36 | - name: Install dependencies
37 | run: npm ci
38 |
39 | - name: Build the package
40 | run: npm run build
41 |
42 | - name: Create Publish to npm
43 | run: npm publish --provenance
44 | env:
45 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
46 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | 'env': {
3 | 'browser': true,
4 | 'es2021': true,
5 | 'node': true
6 | },
7 | 'extends': [
8 | 'eslint:recommended',
9 | 'plugin:@typescript-eslint/recommended'
10 | ],
11 | 'parser': '@typescript-eslint/parser',
12 | 'parserOptions': {
13 | 'ecmaVersion': 13,
14 | 'sourceType': 'module'
15 | },
16 | 'plugins': [
17 | '@typescript-eslint'
18 | ],
19 | 'ignorePatterns': ['build/*.js', 'build/*.mjs', 'build.js'],
20 | 'rules': {
21 | 'prefer-const': ['off'],
22 | '@typescript-eslint/no-explicit-any': ['off'],
23 | 'indent': ['error', 'tab', { 'SwitchCase': 1, 'flatTernaryExpressions': true }],
24 | 'no-useless-escape': ['off'],
25 | '@typescript-eslint/semi': ['error', 'always'],
26 | '@typescript-eslint/no-empty-function': ['off'],
27 | 'no-constant-condition': ['error', { 'checkLoops': false }],
28 | 'no-cond-assign': ['off'],
29 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
30 | 'no-async-promise-executor': ['off'],
31 | '@typescript-eslint/no-this-alias': ['off'],
32 | 'no-empty': ['error', { 'allowEmptyCatch': true }],
33 | 'no-trailing-spaces': ['warn'],
34 | 'eqeqeq': ['error', 'always'],
35 | 'no-warning-comments': ['warn', { 'terms': ['todo', 'fixme', 'temp'] }],
36 | 'no-fallthrough': ['off'],
37 | 'max-len': ['warn', { 'code': 120, 'tabWidth': 4 }],
38 | 'no-undef': ['off'],
39 | '@typescript-eslint/comma-dangle': ['warn'],
40 | '@typescript-eslint/quotes': ['warn', 'single', { avoidEscape: true, allowTemplateLiterals: true }]
41 | }
42 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "webm-muxer",
3 | "version": "5.1.4",
4 | "description": "WebM multiplexer in pure TypeScript with support for WebCodecs API, video & audio.",
5 | "main": "./build/webm-muxer.js",
6 | "module": "./build/webm-muxer.mjs",
7 | "types": "./build/webm-muxer.d.ts",
8 | "exports": {
9 | "types": "./build/webm-muxer.d.ts",
10 | "import": "./build/webm-muxer.mjs",
11 | "require": "./build/webm-muxer.js"
12 | },
13 | "files": [
14 | "README.md",
15 | "package.json",
16 | "LICENSE",
17 | "build/webm-muxer.js",
18 | "build/webm-muxer.mjs",
19 | "build/webm-muxer.d.ts"
20 | ],
21 | "scripts": {
22 | "build": "node build.js",
23 | "watch": "node build.js --watch",
24 | "check": "npx tsc --noEmit --skipLibCheck",
25 | "lint": "npx eslint src demo build"
26 | },
27 | "repository": {
28 | "type": "git",
29 | "url": "git+https://github.com/Vanilagy/webm-muxer.git"
30 | },
31 | "author": "Vanilagy",
32 | "license": "MIT",
33 | "bugs": {
34 | "url": "https://github.com/Vanilagy/webm-muxer/issues"
35 | },
36 | "homepage": "https://github.com/Vanilagy/webm-muxer#readme",
37 | "dependencies": {
38 | "@types/dom-webcodecs": "^0.1.4",
39 | "@types/wicg-file-system-access": "^2020.9.5"
40 | },
41 | "devDependencies": {
42 | "@types/node": "^18.11.3",
43 | "@typescript-eslint/eslint-plugin": "^5.48.1",
44 | "@typescript-eslint/parser": "^5.48.1",
45 | "esbuild": "^0.15.12",
46 | "eslint": "^8.32.0",
47 | "typescript": "^4.8.4"
48 | },
49 | "keywords": [
50 | "webm",
51 | "muxer",
52 | "muxing",
53 | "multiplexer",
54 | "video",
55 | "audio",
56 | "subtitles",
57 | "webvtt",
58 | "media",
59 | "webcodecs"
60 | ]
61 | }
62 |
--------------------------------------------------------------------------------
/src/target.ts:
--------------------------------------------------------------------------------
1 | const isTarget = Symbol('isTarget');
2 | export abstract class Target {
3 | // If we didn't add this symbol, then {} would be assignable to Target
4 | [isTarget]: true;
5 | }
6 |
7 | export class ArrayBufferTarget extends Target {
8 | buffer: ArrayBuffer = null;
9 | }
10 |
11 | export class StreamTarget extends Target {
12 | constructor(public options: {
13 | onData?: (data: Uint8Array, position: number) => void,
14 | onHeader?: (data: Uint8Array, position: number) => void,
15 | onCluster?: (data: Uint8Array, position: number, timestamp: number) => void,
16 | chunked?: boolean,
17 | chunkSize?: number
18 | }) {
19 | super();
20 |
21 | if (typeof options !== 'object') {
22 | throw new TypeError('StreamTarget requires an options object to be passed to its constructor.');
23 | }
24 | if (options.onData) {
25 | if (typeof options.onData !== 'function') {
26 | throw new TypeError('options.onData, when provided, must be a function.');
27 | }
28 | if (options.onData.length < 2) {
29 | // Checking the amount of parameters here is an important validation step as it catches a common error
30 | // where people do not respect the position argument.
31 | throw new TypeError(
32 | 'options.onData, when provided, must be a function that takes in at least two arguments (data and '
33 | + 'position). Ignoring the position argument, which specifies the byte offset at which the data is '
34 | + 'to be written, can lead to broken outputs.'
35 | );
36 | }
37 | }
38 | if (options.onHeader && typeof options.onHeader !== 'function') {
39 | throw new TypeError('options.onHeader, when provided, must be a function.');
40 | }
41 | if (options.onCluster && typeof options.onCluster !== 'function') {
42 | throw new TypeError('options.onCluster, when provided, must be a function.');
43 | }
44 | if (options.chunked !== undefined && typeof options.chunked !== 'boolean') {
45 | throw new TypeError('options.chunked, when provided, must be a boolean.');
46 | }
47 | if (options.chunkSize !== undefined && (!Number.isInteger(options.chunkSize) || options.chunkSize < 1024)) {
48 | throw new TypeError('options.chunkSize, when provided, must be an integer and not smaller than 1024.');
49 | }
50 | }
51 | }
52 |
53 | export class FileSystemWritableFileStreamTarget extends Target {
54 | constructor(
55 | public stream: FileSystemWritableFileStream,
56 | public options?: { chunkSize?: number }
57 | ) {
58 | super();
59 |
60 | if (!(stream instanceof FileSystemWritableFileStream)) {
61 | throw new TypeError('FileSystemWritableFileStreamTarget requires a FileSystemWritableFileStream instance.');
62 | }
63 | if (options !== undefined && typeof options !== 'object') {
64 | throw new TypeError("FileSystemWritableFileStreamTarget's options, when provided, must be an object.");
65 | }
66 | if (options) {
67 | if (options.chunkSize !== undefined && (!Number.isInteger(options.chunkSize) || options.chunkSize <= 0)) {
68 | throw new TypeError('options.chunkSize, when provided, must be a positive integer');
69 | }
70 | }
71 | }
72 | }
--------------------------------------------------------------------------------
/src/ebml.ts:
--------------------------------------------------------------------------------
1 | export interface EBMLElement {
2 | id: number,
3 | size?: number,
4 | data: number | string | Uint8Array | EBMLFloat32 | EBMLFloat64 | EBML[]
5 | }
6 |
7 | export type EBML = EBMLElement | Uint8Array | EBML[];
8 |
9 | /** Wrapper around a number to be able to differentiate it in the writer. */
10 | export class EBMLFloat32 {
11 | value: number;
12 |
13 | constructor(value: number) {
14 | this.value = value;
15 | }
16 | }
17 |
18 | /** Wrapper around a number to be able to differentiate it in the writer. */
19 | export class EBMLFloat64 {
20 | value: number;
21 |
22 | constructor(value: number) {
23 | this.value = value;
24 | }
25 | }
26 |
27 | /** Defines some of the EBML IDs used by Matroska files. */
28 | export enum EBMLId {
29 | EBML = 0x1a45dfa3,
30 | EBMLVersion = 0x4286,
31 | EBMLReadVersion = 0x42f7,
32 | EBMLMaxIDLength = 0x42f2,
33 | EBMLMaxSizeLength = 0x42f3,
34 | DocType = 0x4282,
35 | DocTypeVersion = 0x4287,
36 | DocTypeReadVersion = 0x4285,
37 | SeekHead = 0x114d9b74,
38 | Seek = 0x4dbb,
39 | SeekID = 0x53ab,
40 | SeekPosition = 0x53ac,
41 | Duration = 0x4489,
42 | Info = 0x1549a966,
43 | TimestampScale = 0x2ad7b1,
44 | MuxingApp = 0x4d80,
45 | WritingApp = 0x5741,
46 | Tracks = 0x1654ae6b,
47 | TrackEntry = 0xae,
48 | TrackNumber = 0xd7,
49 | TrackUID = 0x73c5,
50 | TrackType = 0x83,
51 | CodecID = 0x86,
52 | CodecPrivate = 0x63a2,
53 | DefaultDuration = 0x23e383,
54 | Video = 0xe0,
55 | PixelWidth = 0xb0,
56 | PixelHeight = 0xba,
57 | Void = 0xec,
58 | Audio = 0xe1,
59 | SamplingFrequency = 0xb5,
60 | Channels = 0x9f,
61 | BitDepth = 0x6264,
62 | Segment = 0x18538067,
63 | SimpleBlock = 0xa3,
64 | BlockGroup = 0xa0,
65 | Block = 0xa1,
66 | BlockAdditions = 0x75a1,
67 | BlockDuration = 0x9b,
68 | Cluster = 0x1f43b675,
69 | Timestamp = 0xe7,
70 | Cues = 0x1c53bb6b,
71 | CuePoint = 0xbb,
72 | CueTime = 0xb3,
73 | CueTrackPositions = 0xb7,
74 | CueTrack = 0xf7,
75 | CueClusterPosition = 0xf1,
76 | Colour = 0x55b0,
77 | MatrixCoefficients = 0x55b1,
78 | TransferCharacteristics = 0x55ba,
79 | Primaries = 0x55bb,
80 | Range = 0x55b9,
81 | AlphaMode = 0x53c0
82 | }
83 |
84 | export const measureUnsignedInt = (value: number) => {
85 | // Force to 32-bit unsigned integer
86 | if (value < (1 << 8)) {
87 | return 1;
88 | } else if (value < (1 << 16)) {
89 | return 2;
90 | } else if (value < (1 << 24)) {
91 | return 3;
92 | } else if (value < 2**32) {
93 | return 4;
94 | } else if (value < 2**40) {
95 | return 5;
96 | } else {
97 | return 6;
98 | }
99 | };
100 |
101 | export const measureEBMLVarInt = (value: number) => {
102 | if (value < (1 << 7) - 1) {
103 | /** Top bit is set, leaving 7 bits to hold the integer, but we can't store
104 | * 127 because "all bits set to one" is a reserved value. Same thing for the
105 | * other cases below:
106 | */
107 | return 1;
108 | } else if (value < (1 << 14) - 1) {
109 | return 2;
110 | } else if (value < (1 << 21) - 1) {
111 | return 3;
112 | } else if (value < (1 << 28) - 1) {
113 | return 4;
114 | } else if (value < 2**35-1) {
115 | return 5;
116 | } else if (value < 2**42-1) {
117 | return 6;
118 | } else {
119 | throw new Error('EBML VINT size not supported ' + value);
120 | }
121 | };
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | (async () => {
2 | let sampleRate = 48000;
3 |
4 | let fileHandle = await new Promise(resolve => {
5 | window.addEventListener('click', async () => {
6 | let fileHandle = await window.showSaveFilePicker({
7 | startIn: 'videos',
8 | suggestedName: `video.webm`,
9 | types: [{
10 | description: 'Video File',
11 | accept: {'video/webm' :['.webm']}
12 | }],
13 | });
14 | resolve(fileHandle);
15 | }, { once: true });
16 | });
17 | let fileWritableStream = await fileHandle.createWritable();
18 |
19 | let writer = new WebMMuxer({
20 | target: fileWritableStream,
21 | video: {
22 | codec: 'V_VP9',
23 | width: 1280,
24 | height: 720,
25 | frameRate: 5
26 | },
27 | audio: {
28 | codec: 'A_OPUS',
29 | numberOfChannels: 1,
30 | sampleRate
31 | }
32 | });
33 |
34 | let canvas = document.createElement('canvas');
35 | canvas.setAttribute('width', '1280');
36 | canvas.setAttribute('height', '720');
37 | let ctx = canvas.getContext('2d');
38 |
39 | let videoEncoder = new VideoEncoder({
40 | output: (chunk, meta) => writer.addVideoChunk(chunk, meta),
41 | error: e => console.error(e)
42 | });
43 | videoEncoder.configure({
44 | codec: 'vp09.00.10.08',
45 | width: 1280,
46 | height: 720,
47 | bitrate: 1e6
48 | });
49 |
50 | let audioEncoder = new AudioEncoder({
51 | output: (chunk, meta) => writer.addAudioChunk(chunk, meta),
52 | error: e => console.error(e)
53 | });
54 | audioEncoder.configure({
55 | codec: 'opus',
56 | numberOfChannels: 1,
57 | sampleRate,
58 | bitrate: 32000,
59 | });
60 |
61 | let audioContext = new AudioContext();
62 | let audioBuffer = await audioContext.decodeAudioData(await (await fetch('./CantinaBand60.wav')).arrayBuffer());
63 | let length = 5;
64 | let data = new Float32Array(length * sampleRate);
65 | data.set(audioBuffer.getChannelData(0).subarray(0, data.length), 0);
66 |
67 | let audioData = new AudioData({
68 | format: 'f32',
69 | sampleRate,
70 | numberOfFrames: length * sampleRate,
71 | numberOfChannels: 1,
72 | timestamp: 0,
73 | data: data
74 | });
75 | audioEncoder.encode(audioData);
76 | audioData.close();
77 |
78 | for (let i = 0; i < length * 5; i++) {
79 | ctx.fillStyle = ['red', 'lime', 'blue', 'yellow'][Math.floor(Math.random() * 4)];
80 | ctx.fillRect(Math.random() * 1280, Math.random() * 720, Math.random() * 1280, Math.random() * 720);
81 |
82 | let videoFrame = new VideoFrame(canvas, { timestamp: i * 1000000/5 });
83 | videoEncoder.encode(videoFrame);
84 | videoFrame.close();
85 | }
86 |
87 | await Promise.allSettled([videoEncoder.flush(), audioEncoder.flush()]);
88 |
89 | let maybeBuffer = writer.finalize();
90 | console.log(maybeBuffer);
91 |
92 | await fileWritableStream.close();
93 |
94 | console.log("Done");
95 |
96 | /*
97 |
98 | let buffer = writer.target.finalize();
99 |
100 | console.log(buffer);
101 |
102 | const saveFile = (blob, filename = 'unnamed.webm') => {
103 | const a = document.createElement('a');
104 | document.body.appendChild(a);
105 | const url = window.URL.createObjectURL(blob);
106 | a.href = url;
107 | a.download = filename;
108 | a.click();
109 | setTimeout(() => {
110 | window.URL.revokeObjectURL(url);
111 | document.body.removeChild(a);
112 | }, 0);
113 | };
114 |
115 | saveFile(new Blob([buffer]));
116 |
117 | */
118 | })();
--------------------------------------------------------------------------------
/src/subtitles.ts:
--------------------------------------------------------------------------------
1 | export interface EncodedSubtitleChunk {
2 | body: Uint8Array,
3 | additions?: Uint8Array,
4 | timestamp: number,
5 | duration: number
6 | }
7 |
8 | export interface EncodedSubtitleChunkMetadata {
9 | decoderConfig?: {
10 | description: Uint8Array
11 | }
12 | }
13 |
14 | interface SubtitleEncoderOptions {
15 | output: (chunk: EncodedSubtitleChunk, metadata: EncodedSubtitleChunkMetadata) => unknown,
16 | error: (error: Error) => unknown
17 | }
18 |
19 | interface SubtitleEncoderConfig {
20 | codec: 'webvtt'
21 | }
22 |
23 | const cueBlockHeaderRegex = /(?:(.+?)\n)?((?:\d{2}:)?\d{2}:\d{2}.\d{3})\s+-->\s+((?:\d{2}:)?\d{2}:\d{2}.\d{3})/g;
24 | const preambleStartRegex = /^WEBVTT.*?\n{2}/;
25 | const timestampRegex = /(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})/;
26 | const inlineTimestampRegex = /<(?:(\d{2}):)?(\d{2}):(\d{2}).(\d{3})>/g;
27 | const textEncoder = new TextEncoder();
28 |
29 | export class SubtitleEncoder {
30 | #options: SubtitleEncoderOptions;
31 | #config: SubtitleEncoderConfig;
32 | #preambleSeen = false;
33 | #preambleBytes: Uint8Array;
34 | #preambleEmitted = false;
35 |
36 | constructor(options: SubtitleEncoderOptions) {
37 | this.#options = options;
38 | }
39 |
40 | configure(config: SubtitleEncoderConfig) {
41 | if (config.codec !== 'webvtt') {
42 | throw new Error("Codec must be 'webvtt'.");
43 | }
44 |
45 | this.#config = config;
46 | }
47 |
48 | encode(text: string) {
49 | if (!this.#config) {
50 | throw new Error('Encoder not configured.');
51 | }
52 |
53 | text = text.replace('\r\n', '\n').replace('\r', '\n');
54 |
55 | cueBlockHeaderRegex.lastIndex = 0;
56 | let match: RegExpMatchArray;
57 |
58 | if (!this.#preambleSeen) {
59 | if (!preambleStartRegex.test(text)) {
60 | let error = new Error('WebVTT preamble incorrect.');
61 | this.#options.error(error);
62 | throw error;
63 | }
64 |
65 | match = cueBlockHeaderRegex.exec(text);
66 | let preamble = text.slice(0, match?.index ?? text.length).trimEnd();
67 |
68 | if (!preamble) {
69 | let error = new Error('No WebVTT preamble provided.');
70 | this.#options.error(error);
71 | throw error;
72 | }
73 |
74 | this.#preambleBytes = textEncoder.encode(preamble);
75 | this.#preambleSeen = true;
76 |
77 | if (match) {
78 | text = text.slice(match.index);
79 | cueBlockHeaderRegex.lastIndex = 0;
80 | }
81 | }
82 |
83 | while (match = cueBlockHeaderRegex.exec(text)) {
84 | let notes = text.slice(0, match.index);
85 | let cueIdentifier = match[1] || '';
86 | let matchEnd = match.index + match[0].length;
87 | let bodyStart = text.indexOf('\n', matchEnd) + 1;
88 | let cueSettings = text.slice(matchEnd, bodyStart).trim();
89 | let bodyEnd = text.indexOf('\n\n', matchEnd);
90 | if (bodyEnd === -1) bodyEnd = text.length;
91 |
92 | let startTime = this.#parseTimestamp(match[2]);
93 | let endTime = this.#parseTimestamp(match[3]);
94 | let duration = endTime - startTime;
95 |
96 | let body = text.slice(bodyStart, bodyEnd);
97 | let additions = `${cueSettings}\n${cueIdentifier}\n${notes}`;
98 |
99 | // Replace in-body timestamps so that they're relative to the cue start time
100 | inlineTimestampRegex.lastIndex = 0;
101 | body = body.replace(inlineTimestampRegex, (match) => {
102 | let time = this.#parseTimestamp(match.slice(1, -1));
103 | let offsetTime = time - startTime;
104 |
105 | return `<${this.#formatTimestamp(offsetTime)}>`;
106 | });
107 |
108 | text = text.slice(bodyEnd).trimStart();
109 | cueBlockHeaderRegex.lastIndex = 0;
110 |
111 | let chunk: EncodedSubtitleChunk = {
112 | body: textEncoder.encode(body),
113 | additions: additions.trim() === '' ? undefined : textEncoder.encode(additions),
114 | timestamp: startTime * 1000,
115 | duration: duration * 1000
116 | };
117 |
118 | let meta: EncodedSubtitleChunkMetadata = {};
119 | if (!this.#preambleEmitted) {
120 | meta.decoderConfig = {
121 | description: this.#preambleBytes
122 | };
123 | this.#preambleEmitted = true;
124 | }
125 |
126 | this.#options.output(chunk, meta);
127 | }
128 | }
129 |
130 | #parseTimestamp(string: string) {
131 | let match = timestampRegex.exec(string);
132 | if (!match) throw new Error('Expected match.');
133 |
134 | return 60 * 60 * 1000 * Number(match[1] || '0') +
135 | 60 * 1000 * Number(match[2]) +
136 | 1000 * Number(match[3]) +
137 | Number(match[4]);
138 | }
139 |
140 | #formatTimestamp(timestamp: number) {
141 | let hours = Math.floor(timestamp / (60 * 60 * 1000));
142 | let minutes = Math.floor((timestamp % (60 * 60 * 1000)) / (60 * 1000));
143 | let seconds = Math.floor((timestamp % (60 * 1000)) / 1000);
144 | let milliseconds = timestamp % 1000;
145 |
146 | return hours.toString().padStart(2, '0') + ':' +
147 | minutes.toString().padStart(2, '0') + ':' +
148 | seconds.toString().padStart(2, '0') + '.' +
149 | milliseconds.toString().padStart(3, '0');
150 | }
151 | }
--------------------------------------------------------------------------------
/demo-streaming/script.js:
--------------------------------------------------------------------------------
1 | const streamPreview = document.querySelector('#stream-preview');
2 | const startRecordingButton = document.querySelector('#start-recording');
3 | const endRecordingButton = document.querySelector('#end-recording');
4 | const recordingStatus = document.querySelector('#recording-status');
5 |
6 | /** RECORDING & MUXING STUFF */
7 |
8 | let muxer = null;
9 | let videoEncoder = null;
10 | let audioEncoder = null;
11 | let startTime = null;
12 | let recording = false;
13 | let audioTrack = null;
14 | let videoTrack = null;
15 | let intervalId = null;
16 |
17 | const startRecording = async () => {
18 | // Check for AudioEncoder availability
19 | if (typeof AudioEncoder === 'undefined') {
20 | alert("Looks like your user agent doesn't support AudioEncoder / WebCodecs API yet.");
21 | return;
22 | }
23 | // Check for VideoEncoder availability
24 | if (typeof VideoEncoder === 'undefined') {
25 | alert("Looks like your user agent doesn't support VideoEncoder / WebCodecs API yet.");
26 | return;
27 | }
28 |
29 | startRecordingButton.style.display = 'none';
30 |
31 | // Try to get access to the user's camera and microphone
32 | try {
33 | let userMedia = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
34 | audioTrack = userMedia.getAudioTracks()[0];
35 | videoTrack = userMedia.getVideoTracks()[0];
36 | } catch (e) {}
37 | if (!audioTrack) console.warn("Couldn't acquire a user media audio track.");
38 | if (!videoTrack) console.warn("Couldn't acquire a user media video track.");
39 |
40 | let mediaSource = new MediaSource();
41 | streamPreview.src = URL.createObjectURL(mediaSource);
42 | streamPreview.play();
43 |
44 | await new Promise(resolve => mediaSource.onsourceopen = resolve);
45 |
46 | // We'll append ArrayBuffers to this as the muxer starts to spit out chunks
47 | let sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp9, opus"');
48 |
49 | endRecordingButton.style.display = 'block';
50 |
51 | let audioSampleRate = audioTrack?.getSettings().sampleRate;
52 | let audioNumberOfChannels = audioTrack?.getSettings().channelCount;
53 | let videoTrackWidth = videoTrack?.getSettings().width;
54 | let videoTrackHeight = videoTrack?.getSettings().height;
55 |
56 | // Create a WebM muxer with a video track and audio track, if available
57 | muxer = new WebMMuxer.Muxer({
58 | streaming: true,
59 | target: new WebMMuxer.StreamTarget({
60 | // Because 'streaming' is true, the buffers are contiguous and the position argument can be ignored
61 | onData: (buffer, _) => sourceBuffer.appendBuffer(buffer)
62 | }),
63 | video: videoTrack ? {
64 | codec: 'V_VP9',
65 | width: videoTrackWidth,
66 | height: videoTrackHeight,
67 | frameRate: 30
68 | } : undefined,
69 | audio: audioTrack ? {
70 | codec: 'A_OPUS',
71 | sampleRate: audioSampleRate,
72 | numberOfChannels: audioNumberOfChannels
73 | } : undefined,
74 | firstTimestampBehavior: 'offset' // Because we're directly pumping a MediaStreamTrack's data into it
75 | });
76 |
77 | // Audio track
78 | if (audioTrack) {
79 | audioEncoder = new AudioEncoder({
80 | output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
81 | error: e => console.error(e)
82 | });
83 | audioEncoder.configure({
84 | codec: 'opus',
85 | numberOfChannels: audioNumberOfChannels,
86 | sampleRate: audioSampleRate,
87 | bitrate: 64000
88 | });
89 |
90 | // Create a MediaStreamTrackProcessor to get AudioData chunks from the audio track
91 | let trackProcessor = new MediaStreamTrackProcessor({ track: audioTrack });
92 | let consumer = new WritableStream({
93 | write(audioData) {
94 | if (!recording) return;
95 | audioEncoder.encode(audioData);
96 | audioData.close();
97 | }
98 | });
99 | trackProcessor.readable.pipeTo(consumer);
100 | }
101 |
102 | // Video track
103 | if (videoTrack) {
104 | videoEncoder = new VideoEncoder({
105 | output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
106 | error: e => console.error(e)
107 | });
108 | videoEncoder.configure({
109 | codec: 'vp09.00.10.08',
110 | width: videoTrackWidth,
111 | height: videoTrackHeight,
112 | bitrate: 1e6
113 | });
114 |
115 | // Create a MediaStreamTrackProcessor to get VideoFrame chunks from the video track
116 | let frameCount = 0;
117 | const keyframeInterval = 3;
118 | let videoTrackProcessor = new MediaStreamTrackProcessor({ track: videoTrack });
119 | let videoConsumer = new WritableStream({
120 | write(videoFrame) {
121 | if (!recording) return;
122 | const isKeyframe = frameCount % keyframeInterval === 0;
123 | videoEncoder.encode(videoFrame, { keyFrame: isKeyframe });
124 | videoFrame.close();
125 |
126 | frameCount++;
127 | }
128 | });
129 | videoTrackProcessor.readable.pipeTo(videoConsumer);
130 | }
131 |
132 | startTime = document.timeline.currentTime;
133 | recording = true;
134 |
135 | intervalId = setInterval(recordingTimer, 1000/30);
136 | };
137 | startRecordingButton.addEventListener('click', startRecording);
138 |
139 | const recordingTimer = () => {
140 | let elapsedTime = document.timeline.currentTime - startTime;
141 |
142 | recordingStatus.textContent =
143 | `${elapsedTime % 1000 < 500 ? '🔴' : '⚫'} Recording - ${(elapsedTime / 1000).toFixed(1)} s`;
144 | };
145 |
146 | const stopRecording = async () => {
147 | endRecordingButton.style.display = 'none';
148 | recordingStatus.textContent = '';
149 | recording = false;
150 |
151 | clearInterval(intervalId);
152 | audioTrack?.stop();
153 | videoTrack?.stop();
154 |
155 | await videoEncoder?.flush();
156 | await audioEncoder?.flush();
157 | muxer.finalize();
158 |
159 | videoEncoder = null;
160 | audioEncoder = null;
161 | muxer = null;
162 | startTime = null;
163 |
164 | startRecordingButton.style.display = 'block';
165 | };
166 | endRecordingButton.addEventListener('click', stopRecording);
--------------------------------------------------------------------------------
/demo/script.js:
--------------------------------------------------------------------------------
1 | const canvas = document.querySelector('canvas');
2 | const ctx = canvas.getContext('2d', { desynchronized: true });
3 | const startRecordingButton = document.querySelector('#start-recording');
4 | const endRecordingButton = document.querySelector('#end-recording');
5 | const recordingStatus = document.querySelector('#recording-status');
6 |
7 | /** RECORDING & MUXING STUFF */
8 |
9 | let muxer = null;
10 | let videoEncoder = null;
11 | let audioEncoder = null;
12 | let startTime = null;
13 | let recording = false;
14 | let audioTrack = null;
15 | let intervalId = null;
16 | let lastKeyFrame = null;
17 |
18 | const startRecording = async () => {
19 | // Check for VideoEncoder availability
20 | if (typeof VideoEncoder === 'undefined') {
21 | alert("Looks like your user agent doesn't support VideoEncoder / WebCodecs API yet.");
22 | return;
23 | }
24 |
25 | startRecordingButton.style.display = 'none';
26 |
27 | // Check for AudioEncoder availability
28 | if (typeof AudioEncoder !== 'undefined') {
29 | // Try to get access to the user's microphone
30 | try {
31 | let userMedia = await navigator.mediaDevices.getUserMedia({ video: false, audio: true });
32 | audioTrack = userMedia.getAudioTracks()[0];
33 | } catch (e) {}
34 | if (!audioTrack) console.warn("Couldn't acquire a user media audio track.");
35 | } else {
36 | console.warn('AudioEncoder not available; no need to acquire a user media audio track.');
37 | }
38 |
39 | endRecordingButton.style.display = 'block';
40 |
41 | let audioSampleRate = audioTrack?.getSettings().sampleRate;
42 | let audioNumberOfChannels = audioTrack?.getSettings().channelCount;
43 |
44 | // Create a WebM muxer with a video track and maybe an audio track
45 | muxer = new WebMMuxer.Muxer({
46 | target: new WebMMuxer.ArrayBufferTarget(),
47 | video: {
48 | codec: 'V_VP9',
49 | width: canvas.width,
50 | height: canvas.height,
51 | frameRate: 30
52 | },
53 | audio: audioTrack ? {
54 | codec: 'A_OPUS',
55 | sampleRate: audioSampleRate,
56 | numberOfChannels: audioNumberOfChannels
57 | } : undefined,
58 | firstTimestampBehavior: 'offset' // Because we're directly piping a MediaStreamTrack's data into it
59 | });
60 |
61 | videoEncoder = new VideoEncoder({
62 | output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
63 | error: e => console.error(e)
64 | });
65 | videoEncoder.configure({
66 | codec: 'vp09.00.10.08',
67 | width: canvas.width,
68 | height: canvas.height,
69 | bitrate: 1e6
70 | });
71 |
72 | if (audioTrack) {
73 | audioEncoder = new AudioEncoder({
74 | output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
75 | error: e => console.error(e)
76 | });
77 | audioEncoder.configure({
78 | codec: 'opus',
79 | numberOfChannels: audioNumberOfChannels,
80 | sampleRate: audioSampleRate,
81 | bitrate: 64000
82 | });
83 |
84 | // Create a MediaStreamTrackProcessor to get AudioData chunks from the audio track
85 | let trackProcessor = new MediaStreamTrackProcessor({ track: audioTrack });
86 | let consumer = new WritableStream({
87 | write(audioData) {
88 | if (!recording) return;
89 | audioEncoder.encode(audioData);
90 | audioData.close();
91 | }
92 | });
93 | trackProcessor.readable.pipeTo(consumer);
94 | }
95 |
96 | startTime = document.timeline.currentTime;
97 | recording = true;
98 | lastKeyFrame = -Infinity;
99 |
100 | encodeVideoFrame();
101 | intervalId = setInterval(encodeVideoFrame, 1000/30);
102 | };
103 | startRecordingButton.addEventListener('click', startRecording);
104 |
105 | const encodeVideoFrame = () => {
106 | let elapsedTime = document.timeline.currentTime - startTime;
107 | let frame = new VideoFrame(canvas, {
108 | timestamp: elapsedTime * 1000
109 | });
110 |
111 | // Ensure a video key frame at least every 5 seconds
112 | let needsKeyFrame = elapsedTime - lastKeyFrame >= 5000;
113 | if (needsKeyFrame) lastKeyFrame = elapsedTime;
114 |
115 | videoEncoder.encode(frame, { keyFrame: needsKeyFrame });
116 | frame.close();
117 |
118 | recordingStatus.textContent =
119 | `${elapsedTime % 1000 < 500 ? '🔴' : '⚫'} Recording - ${(elapsedTime / 1000).toFixed(1)} s`;
120 | };
121 |
122 | const endRecording = async () => {
123 | endRecordingButton.style.display = 'none';
124 | recordingStatus.textContent = '';
125 | recording = false;
126 |
127 | clearInterval(intervalId);
128 | audioTrack?.stop();
129 |
130 | await videoEncoder?.flush();
131 | await audioEncoder?.flush();
132 | muxer.finalize();
133 |
134 | let { buffer } = muxer.target;
135 | downloadBlob(new Blob([buffer]));
136 |
137 | videoEncoder = null;
138 | audioEncoder = null;
139 | muxer = null;
140 | startTime = null;
141 | firstAudioTimestamp = null;
142 |
143 | startRecordingButton.style.display = 'block';
144 | };
145 | endRecordingButton.addEventListener('click', endRecording);
146 |
147 | const downloadBlob = (blob) => {
148 | let url = window.URL.createObjectURL(blob);
149 | let a = document.createElement('a');
150 | a.style.display = 'none';
151 | a.href = url;
152 | a.download = 'picasso.webm';
153 | document.body.appendChild(a);
154 | a.click();
155 | window.URL.revokeObjectURL(url);
156 | };
157 |
158 | /** CANVAS DRAWING STUFF */
159 |
160 | ctx.fillStyle = 'white';
161 | ctx.fillRect(0, 0, canvas.width, canvas.height);
162 |
163 | let drawing = false;
164 | let lastPos = { x: 0, y: 0 };
165 |
166 | const getRelativeMousePos = (e) => {
167 | let rect = canvas.getBoundingClientRect();
168 | return { x: e.clientX - rect.x, y: e.clientY - rect.y };
169 | };
170 |
171 | const drawLine = (from, to) => {
172 | ctx.beginPath();
173 | ctx.moveTo(from.x, from.y);
174 | ctx.lineTo(to.x, to.y);
175 | ctx.strokeStyle = 'black';
176 | ctx.lineWidth = 3;
177 | ctx.lineCap = 'round';
178 | ctx.stroke();
179 | };
180 |
181 | canvas.addEventListener('pointerdown', (e) => {
182 | if (e.button !== 0) return;
183 |
184 | drawing = true;
185 | lastPos = getRelativeMousePos(e);
186 | drawLine(lastPos, lastPos);
187 | });
188 | window.addEventListener('pointerup', () => {
189 | drawing = false;
190 | });
191 | window.addEventListener('mousemove', (e) => {
192 | if (!drawing) return;
193 |
194 | let newPos = getRelativeMousePos(e);
195 | drawLine(lastPos, newPos);
196 | lastPos = newPos;
197 | });
--------------------------------------------------------------------------------
/MIGRATION-GUIDE.md:
--------------------------------------------------------------------------------
1 | # Guide: Migrating to Mediabunny
2 |
3 | webm-muxer has been deprecated and is superseded by [Mediabunny](https://mediabunny.dev/). Mediabunny's WebM/Matroska multiplexer was originally based on the one from webm-muxer and has now evolved into a much better multiplexer:
4 |
5 | - Produces better, more correct WebM/MKV files
6 | - Support for multiple video, audio & subtitle tracks
7 | - Support for more track metadata
8 | - Pipelining & backpressure features
9 | - Improved performance
10 |
11 | And even though Mediabunny has many other features, it is built to be extremely tree-shakable and therefore will still result in a tiny bundle when only using its WebM multiplexer (11 kB vs webm-muxer's 8 kB). Thus, you should **always** prefer Mediabunny over webm-muxer - this library is now obsolete.
12 |
13 | ## Muxer migration
14 |
15 | If you wanted to perform the most direct mapping possible, the following code using webm-muxer:
16 | ```ts
17 | import { Muxer, ArrayBufferTarget } from 'webm-muxer';
18 |
19 | let muxer = new Muxer({
20 | target: new ArrayBufferTarget(),
21 | video: {
22 | codec: 'V_VP9',
23 | width: VIDEO_WIDTH,
24 | height: VIDEO_HEIGHT,
25 | frameRate: VIDEO_FRAME_RATE
26 | },
27 | audio: {
28 | codec: 'A_OPUS',
29 | numberOfChannels: AUDIO_NUMBER_OF_CHANNELS,
30 | sampleRate: AUDIO_SAMPLE_RATE
31 | },
32 | streaming: IS_STREAMING
33 | });
34 |
35 | // Assuming these are called from video/audio encoder callbacks
36 | muxer.addVideoChunk(VIDEO_CHUNK, VIDEO_CHUNK_METADATA);
37 | muxer.addAudioChunk(AUDIO_CHUNK, AUDIO_CHUNK_METADATA);
38 |
39 | muxer.finalize();
40 | ```
41 |
42 | ...maps to this code using Mediabunny:
43 | ```ts
44 | import { Output, WebMOutputFormat, BufferTarget, EncodedVideoPacketSource, EncodedAudioPacketSource, EncodedPacket } from 'mediabunny';
45 |
46 | const output = new Output({
47 | format: new WebMOutputFormat({
48 | appendOnly: IS_STREAMING
49 | }),
50 | target: new BufferTarget(),
51 | });
52 |
53 | const videoSource = new EncodedVideoPacketSource('vp9');
54 | output.addVideoTrack(videoSource, {
55 | frameRate: VIDEO_FRAME_RATE,
56 | });
57 |
58 | const audioSource = new EncodedAudioPacketSource('opus');
59 | output.addAudioTrack(audioSource);
60 |
61 | await output.start();
62 |
63 | // Assuming these are called from video/audio encoder callbacks
64 | await videoSource.add(EncodedPacket.fromEncodedChunk(VIDEO_CHUNK), VIDEO_CHUNK_METADATA);
65 | await audioSource.add(EncodedPacket.fromEncodedChunk(AUDIO_CHUNK), AUDIO_CHUNK_METADATA);
66 |
67 | await output.finalize();
68 | ```
69 |
70 | The major differences are:
71 | - `Muxer` is now `Output`: Each `Output` represents one media file. The WebM-specific options are now nested within `WebMOutputFormat`.
72 | - The `streaming` option is now `appendOnly` inside the format's options.
73 | - Tracks must be added to the `Output` after instantiating it.
74 | - `start` must be called before adding any media data, and after registering all tracks.
75 | - Adding encoded chunks is no longer a direct functionality; instead, it is enabled by the `EncodedVideoPacketSource` and `EncodedAudioPacketSource`.
76 | - Encoded chunks are now provided via Mediabunny's own [`EncodedPacket`](https://mediabunny.dev/guide/packets-and-samples#encodedpacket) class.
77 | - Media characteristics, such as width, height, channel count, or sample rate, must no longer be specified anywhere - they are deduced automatically.
78 | - Many methods must now be `await`ed; this is because Mediabunny is deeply pipelined with complex backpressure handling logic, which automatically propagates to the top-level code via promises.
79 |
80 | #### Codec migration
81 |
82 | The codec identifiers have changed from the Matroska-specific strings in webm-muxer to more generic short names in Mediabunny:
83 |
84 | | webm-muxer | Mediabunny |
85 | | :--- | :--- |
86 | | `V_VP8` | `'vp8'` |
87 | | `V_VP9` | `'vp9'` |
88 | | `V_AV1` | `'av1'` |
89 | | `A_OPUS` | `'opus'` |
90 | | `A_VORBIS` | `'vorbis'` |
91 | | `S_TEXT/WEBVTT` | `'webvtt'` |
92 |
93 | These are the codecs supported in WebM, but of course, many more codecs are supported by Mediabunny. For a full list of codecs, including those that can be contained within Matroska, check out [Codecs](https://mediabunny.dev/guide/supported-formats-and-codecs#codecs).
94 |
95 | ### But wait:
96 |
97 | Even though this direct mapping works, Mediabunny has rich, powerful abstractions around the WebCodecs API and it's very likely you can ditch your entire manual encoding stack altogether. This means you likely won't need to use `EncodedVideoPacketSource` or `EncodedAudioPacketSource` at all.
98 |
99 | To learn more, read up on [Media sources](https://mediabunny.dev/guide/media-sources).
100 |
101 | ## Target migration
102 |
103 | An `Output`'s target can be accessed via `output.target`.
104 |
105 | ### `ArrayBufferTarget`
106 |
107 | This class is simply called `BufferTarget` now. Just like `ArrayBufferTarget`, its `buffer` property is `null` before file finalization and an `ArrayBuffer` after.
108 |
109 | ### `StreamTarget`
110 |
111 | This class is still called `StreamTarget` in Mediabunny but is now based on [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) to integrate natively with the Streams API and allow for writer backpressure.
112 |
113 | The `onHeader` and `onCluster` callbacks have been moved into the format's options. The direct mapping is:
114 |
115 | ```ts
116 | import { StreamTarget } from 'webm-muxer';
117 |
118 | let target = new StreamTarget({
119 | onData: ON_DATA_CALLBACK,
120 | onHeader: ON_HEADER_CALLBACK,
121 | onCluster: ON_CLUSTER_CALLBACK,
122 | chunked: CHUNKED_OPTION,
123 | chunkSize: CHUNK_SIZE_OPTION
124 | });
125 | ```
126 | ->
127 | ```ts
128 | import { StreamTarget, WebMOutputFormat } from 'mediabunny';
129 |
130 | const format = new WebMOutputFormat({
131 | onSegmentHeader: ON_HEADER_CALLBACK,
132 | onCluster: ON_CLUSTER_CALLBACK,
133 | });
134 |
135 | const target = new StreamTarget(new WritableStream({
136 | write(chunk) {
137 | ON_DATA_CALLBACK(chunk.data, chunk.position);
138 | }
139 | }), {
140 | chunked: CHUNKED_OPTION,
141 | chunkSize: CHUNK_SIZE_OPTION,
142 | });
143 | ```
144 |
145 | ### `FileSystemWritableFileStreamTarget`
146 |
147 | This class has been removed. Instead, `StreamTarget` now naturally integrates with the File System API:
148 |
149 | ```ts
150 | import { StreamTarget } from 'mediabunny';
151 |
152 | const handle = await window.showSaveFilePicker();
153 | const writableStream = await handle.createWritable();
154 | const target = new StreamTarget(writableStream);
155 | ```
156 |
157 | With this pattern, there is now no more need to manually close the file stream - `finalize()` will automatically do it for you.
158 |
159 | ## Subtitle migration
160 |
161 | webm-muxer came with its own `SubtitleEncoder`. This is no longer needed, as Mediabunny has built-in sources that handle text-based subtitles.
162 |
163 | The old pattern:
164 | ```ts
165 | import { Muxer, SubtitleEncoder } from 'webm-muxer';
166 |
167 | let muxer = new Muxer({
168 | subtitles: {
169 | codec: 'S_TEXT/WEBVTT'
170 | },
171 | // ...
172 | });
173 | let subtitleEncoder = new SubtitleEncoder({
174 | output: (chunk, meta) => muxer.addSubtitleChunk(chunk, meta),
175 | error: e => console.error(e)
176 | });
177 | subtitleEncoder.configure({ codec: 'webvtt' });
178 | subtitleEncoder.encode(WEBVTT_TEXT);
179 | ```
180 |
181 | ...is replaced by the following pattern in Mediabunny:
182 | ```ts
183 | import { Output, TextSubtitleSource } from 'mediabunny';
184 |
185 | const output = new Output({ /* ... */ });
186 | const subtitleSource = new TextSubtitleSource('webvtt');
187 | output.addSubtitleTrack(subtitleSource);
188 |
189 | await output.start();
190 |
191 | await subtitleSource.add(WEBVTT_TEXT);
192 | ```
193 |
194 | ## Other things
195 |
196 | ### `type` option and Matroska files
197 |
198 | The `type` option has been removed. To create a general-purpose Matroska file (`.mkv`), simply use `MkvOutputFormat` instead of `WebMOutputFormat`.
199 |
200 | ### Codec support
201 |
202 | Unlike webm-muxer, Mediabunny is actually *strict* with regard to which codecs it permits within WebM files. It *only* accepts the codecs listed [here](#codec-migration). If you want to use any other codec, you *must* write a Matroska file instead.
203 |
204 | ### Adding raw data
205 |
206 | The previous `addVideoChunkRaw` and `addAudioChunkRaw` methods can now simply be emulated by creating an [`EncodedPacket`](https://mediabunny.dev/guide/packets-and-samples#encodedpacket) from the raw data and passing it to `add` on the respective track source.
207 |
208 | ### `firstTimestampBehavior`
209 |
210 | This option no longer exists. By default, timestamps behave like the old `'permissive'` option. For live media sources like [`MediaStreamVideoTrackSource`](https://mediabunny.dev/guide/media-sources#mediastreamvideotracksource), Mediabunny automatically aligns timestamps using an algorithm called `cross-track-offset`, a feature that was only available in the sister library [mp4-muxer](https://github.com/Vanilagy/mp4-muxer).
211 |
212 | ### `alpha` and `bitDepth` properties
213 |
214 | These properties have been removed from the track options for the following reasons:
215 | - **`alpha`**: The WebCodecs `VideoEncoder` does not support encoding video with an alpha channel, so the option was removed.
216 | - **`bitDepth`**: This option, relevant for PCM codecs, is now automatically determined from the codec string and does not need to be specified.
--------------------------------------------------------------------------------
/src/writer.ts:
--------------------------------------------------------------------------------
1 | import { EBML, EBMLFloat32, EBMLFloat64, measureEBMLVarInt, measureUnsignedInt } from './ebml';
2 | import { ArrayBufferTarget, FileSystemWritableFileStreamTarget, StreamTarget } from './target';
3 |
4 | export abstract class Writer {
5 | pos = 0;
6 | #helper = new Uint8Array(8);
7 | #helperView = new DataView(this.#helper.buffer);
8 |
9 | /**
10 | * Stores the position from the start of the file to where EBML elements have been written. This is used to
11 | * rewrite/edit elements that were already added before, and to measure sizes of things.
12 | */
13 | offsets = new WeakMap();
14 | /** Same as offsets, but stores position where the element's data starts (after ID and size fields). */
15 | dataOffsets = new WeakMap();
16 |
17 | /** Writes the given data to the target, at the current position. */
18 | abstract write(data: Uint8Array): void;
19 | /** Called after muxing has finished. */
20 | abstract finalize(): void;
21 |
22 | /** Sets the current position for future writes to a new one. */
23 | seek(newPos: number) {
24 | this.pos = newPos;
25 | }
26 |
27 | #writeByte(value: number) {
28 | this.#helperView.setUint8(0, value);
29 | this.write(this.#helper.subarray(0, 1));
30 | }
31 |
32 | #writeFloat32(value: number) {
33 | this.#helperView.setFloat32(0, value, false);
34 | this.write(this.#helper.subarray(0, 4));
35 | }
36 |
37 | #writeFloat64(value: number) {
38 | this.#helperView.setFloat64(0, value, false);
39 | this.write(this.#helper);
40 | }
41 |
42 | #writeUnsignedInt(value: number, width: number = measureUnsignedInt(value)) {
43 | let pos = 0;
44 |
45 | // Each case falls through:
46 | switch (width) {
47 | case 6:
48 | // Need to use division to access >32 bits of floating point var
49 | this.#helperView.setUint8(pos++, (value / 2**40) | 0);
50 | case 5:
51 | this.#helperView.setUint8(pos++, (value / 2**32) | 0);
52 | case 4:
53 | this.#helperView.setUint8(pos++, value >> 24);
54 | case 3:
55 | this.#helperView.setUint8(pos++, value >> 16);
56 | case 2:
57 | this.#helperView.setUint8(pos++, value >> 8);
58 | case 1:
59 | this.#helperView.setUint8(pos++, value);
60 | break;
61 | default:
62 | throw new Error('Bad UINT size ' + width);
63 | }
64 |
65 | this.write(this.#helper.subarray(0, pos));
66 | }
67 |
68 | writeEBMLVarInt(value: number, width: number = measureEBMLVarInt(value)) {
69 | let pos = 0;
70 |
71 | switch (width) {
72 | case 1:
73 | this.#helperView.setUint8(pos++, (1 << 7) | value);
74 | break;
75 | case 2:
76 | this.#helperView.setUint8(pos++, (1 << 6) | (value >> 8));
77 | this.#helperView.setUint8(pos++, value);
78 | break;
79 | case 3:
80 | this.#helperView.setUint8(pos++, (1 << 5) | (value >> 16));
81 | this.#helperView.setUint8(pos++, value >> 8);
82 | this.#helperView.setUint8(pos++, value);
83 | break;
84 | case 4:
85 | this.#helperView.setUint8(pos++, (1 << 4) | (value >> 24));
86 | this.#helperView.setUint8(pos++, value >> 16);
87 | this.#helperView.setUint8(pos++, value >> 8);
88 | this.#helperView.setUint8(pos++, value);
89 | break;
90 | case 5:
91 | /**
92 | * JavaScript converts its doubles to 32-bit integers for bitwise
93 | * operations, so we need to do a division by 2^32 instead of a
94 | * right-shift of 32 to retain those top 3 bits
95 | */
96 | this.#helperView.setUint8(pos++, (1 << 3) | ((value / 2**32) & 0x7));
97 | this.#helperView.setUint8(pos++, value >> 24);
98 | this.#helperView.setUint8(pos++, value >> 16);
99 | this.#helperView.setUint8(pos++, value >> 8);
100 | this.#helperView.setUint8(pos++, value);
101 | break;
102 | case 6:
103 | this.#helperView.setUint8(pos++, (1 << 2) | ((value / 2**40) & 0x3));
104 | this.#helperView.setUint8(pos++, (value / 2**32) | 0);
105 | this.#helperView.setUint8(pos++, value >> 24);
106 | this.#helperView.setUint8(pos++, value >> 16);
107 | this.#helperView.setUint8(pos++, value >> 8);
108 | this.#helperView.setUint8(pos++, value);
109 | break;
110 | default:
111 | throw new Error('Bad EBML VINT size ' + width);
112 | }
113 |
114 | this.write(this.#helper.subarray(0, pos));
115 | }
116 |
117 | // Assumes the string is ASCII
118 | #writeString(str: string) {
119 | this.write(new Uint8Array(str.split('').map(x => x.charCodeAt(0))));
120 | }
121 |
122 | writeEBML(data: EBML) {
123 | if (data === null) return;
124 |
125 | if (data instanceof Uint8Array) {
126 | this.write(data);
127 | } else if (Array.isArray(data)) {
128 | for (let elem of data) {
129 | this.writeEBML(elem);
130 | }
131 | } else {
132 | this.offsets.set(data, this.pos);
133 |
134 | this.#writeUnsignedInt(data.id); // ID field
135 |
136 | if (Array.isArray(data.data)) {
137 | let sizePos = this.pos;
138 | let sizeSize = data.size === -1 ? 1 : (data.size ?? 4);
139 |
140 | if (data.size === -1) {
141 | // Write the reserved all-one-bits marker for unknown/unbounded size.
142 | this.#writeByte(0xff);
143 | } else {
144 | this.seek(this.pos + sizeSize);
145 | }
146 |
147 | let startPos = this.pos;
148 | this.dataOffsets.set(data, startPos);
149 | this.writeEBML(data.data);
150 |
151 | if (data.size !== -1) {
152 | let size = this.pos - startPos;
153 | let endPos = this.pos;
154 | this.seek(sizePos);
155 | this.writeEBMLVarInt(size, sizeSize);
156 | this.seek(endPos);
157 | }
158 | } else if (typeof data.data === 'number') {
159 | let size = data.size ?? measureUnsignedInt(data.data);
160 | this.writeEBMLVarInt(size);
161 | this.#writeUnsignedInt(data.data, size);
162 | } else if (typeof data.data === 'string') {
163 | this.writeEBMLVarInt(data.data.length);
164 | this.#writeString(data.data);
165 | } else if (data.data instanceof Uint8Array) {
166 | this.writeEBMLVarInt(data.data.byteLength, data.size);
167 | this.write(data.data);
168 | } else if (data.data instanceof EBMLFloat32) {
169 | this.writeEBMLVarInt(4);
170 | this.#writeFloat32(data.data.value);
171 | } else if (data.data instanceof EBMLFloat64) {
172 | this.writeEBMLVarInt(8);
173 | this.#writeFloat64(data.data.value);
174 | }
175 | }
176 | }
177 | }
178 |
179 | /**
180 | * Writes to an ArrayBufferTarget. Maintains a growable internal buffer during the muxing process, which will then be
181 | * written to the ArrayBufferTarget once the muxing finishes.
182 | */
183 | export class ArrayBufferTargetWriter extends Writer {
184 | #target: ArrayBufferTarget;
185 | #buffer = new ArrayBuffer(2**16);
186 | #bytes = new Uint8Array(this.#buffer);
187 |
188 | constructor(target: ArrayBufferTarget) {
189 | super();
190 |
191 | this.#target = target;
192 | }
193 |
194 | #ensureSize(size: number) {
195 | let newLength = this.#buffer.byteLength;
196 | while (newLength < size) newLength *= 2;
197 |
198 | if (newLength === this.#buffer.byteLength) return;
199 |
200 | let newBuffer = new ArrayBuffer(newLength);
201 | let newBytes = new Uint8Array(newBuffer);
202 | newBytes.set(this.#bytes, 0);
203 |
204 | this.#buffer = newBuffer;
205 | this.#bytes = newBytes;
206 | }
207 |
208 | write(data: Uint8Array) {
209 | this.#ensureSize(this.pos + data.byteLength);
210 |
211 | this.#bytes.set(data, this.pos);
212 | this.pos += data.byteLength;
213 | }
214 |
215 | finalize() {
216 | this.#ensureSize(this.pos);
217 | this.#target.buffer = this.#buffer.slice(0, this.pos);
218 | }
219 | }
220 |
221 | export abstract class BaseStreamTargetWriter extends Writer {
222 | #trackingWrites = false;
223 | #trackedWrites: Uint8Array;
224 | #trackedStart: number;
225 | #trackedEnd: number;
226 |
227 | constructor(public target: StreamTarget) {
228 | super();
229 | }
230 |
231 | write(data: Uint8Array) {
232 | if (!this.#trackingWrites) return;
233 |
234 | // Handle negative relative write positions
235 | let pos = this.pos;
236 | if (pos < this.#trackedStart) {
237 | if (pos + data.byteLength <= this.#trackedStart) return;
238 | data = data.subarray(this.#trackedStart - pos);
239 | pos = 0;
240 | }
241 |
242 | let neededSize = pos + data.byteLength - this.#trackedStart;
243 | let newLength = this.#trackedWrites.byteLength;
244 | while (newLength < neededSize) newLength *= 2;
245 |
246 | // Check if we need to resize the buffer
247 | if (newLength !== this.#trackedWrites.byteLength) {
248 | let copy = new Uint8Array(newLength);
249 | copy.set(this.#trackedWrites, 0);
250 | this.#trackedWrites = copy;
251 | }
252 |
253 | this.#trackedWrites.set(data, pos - this.#trackedStart);
254 | this.#trackedEnd = Math.max(this.#trackedEnd, pos + data.byteLength);
255 | }
256 |
257 | startTrackingWrites() {
258 | this.#trackingWrites = true;
259 | this.#trackedWrites = new Uint8Array(2**10);
260 | this.#trackedStart = this.pos;
261 | this.#trackedEnd = this.pos;
262 | }
263 |
264 | getTrackedWrites() {
265 | if (!this.#trackingWrites) {
266 | throw new Error("Can't get tracked writes since nothing was tracked.");
267 | }
268 |
269 | let slice = this.#trackedWrites.subarray(0, this.#trackedEnd - this.#trackedStart);
270 | let result = {
271 | data: slice,
272 | start: this.#trackedStart,
273 | end: this.#trackedEnd
274 | };
275 |
276 | this.#trackedWrites = undefined;
277 | this.#trackingWrites = false;
278 |
279 | return result;
280 | }
281 | }
282 |
283 | const DEFAULT_CHUNK_SIZE = 2**24;
284 | const MAX_CHUNKS_AT_ONCE = 2;
285 |
286 | interface Chunk {
287 | start: number,
288 | written: ChunkSection[],
289 | data: Uint8Array,
290 | shouldFlush: boolean
291 | }
292 |
293 | interface ChunkSection {
294 | start: number,
295 | end: number
296 | }
297 |
298 | /**
299 | * Writes to a StreamTarget every time it is flushed, sending out all of the new data written since the
300 | * last flush. This is useful for streaming applications, like piping the output to disk.
301 | */
302 | export class StreamTargetWriter extends BaseStreamTargetWriter {
303 | #sections: {
304 | data: Uint8Array,
305 | start: number
306 | }[] = [];
307 |
308 | #lastFlushEnd = 0;
309 | #ensureMonotonicity: boolean;
310 |
311 | #chunked: boolean;
312 | #chunkSize: number;
313 | /**
314 | * The data is divided up into fixed-size chunks, whose contents are first filled in RAM and then flushed out.
315 | * A chunk is flushed if all of its contents have been written.
316 | */
317 | #chunks: Chunk[] = [];
318 |
319 | constructor(target: StreamTarget, ensureMonotonicity: boolean) {
320 | super(target);
321 |
322 | this.#ensureMonotonicity = ensureMonotonicity;
323 |
324 | this.#chunked = target.options?.chunked ?? false;
325 | this.#chunkSize = target.options?.chunkSize ?? DEFAULT_CHUNK_SIZE;
326 | }
327 |
328 | override write(data: Uint8Array) {
329 | super.write(data);
330 |
331 | this.#sections.push({
332 | data: data.slice(),
333 | start: this.pos
334 | });
335 | this.pos += data.byteLength;
336 | }
337 |
338 | flush() {
339 | if (this.#sections.length === 0) return;
340 |
341 | let chunks: {
342 | start: number,
343 | size: number,
344 | data?: Uint8Array
345 | }[] = [];
346 | let sorted = [...this.#sections].sort((a, b) => a.start - b.start);
347 |
348 | chunks.push({
349 | start: sorted[0].start,
350 | size: sorted[0].data.byteLength
351 | });
352 |
353 | // Figure out how many contiguous chunks we have
354 | for (let i = 1; i < sorted.length; i++) {
355 | let lastChunk = chunks[chunks.length - 1];
356 | let section = sorted[i];
357 |
358 | if (section.start <= lastChunk.start + lastChunk.size) {
359 | lastChunk.size = Math.max(lastChunk.size, section.start + section.data.byteLength - lastChunk.start);
360 | } else {
361 | chunks.push({
362 | start: section.start,
363 | size: section.data.byteLength
364 | });
365 | }
366 | }
367 |
368 | for (let chunk of chunks) {
369 | chunk.data = new Uint8Array(chunk.size);
370 |
371 | // Make sure to write the data in the correct order for correct overwriting
372 | for (let section of this.#sections) {
373 | // Check if the section is in the chunk
374 | if (chunk.start <= section.start && section.start < chunk.start + chunk.size) {
375 | chunk.data.set(section.data, section.start - chunk.start);
376 | }
377 | }
378 |
379 | if (this.#chunked) {
380 | this.#writeDataIntoChunks(chunk.data, chunk.start);
381 | this.#flushChunks();
382 | } else {
383 | if (this.#ensureMonotonicity && chunk.start < this.#lastFlushEnd) {
384 | throw new Error('Internal error: Monotonicity violation.');
385 | }
386 |
387 | this.target.options.onData?.(chunk.data, chunk.start);
388 | this.#lastFlushEnd = chunk.start + chunk.data.byteLength;
389 | }
390 | }
391 |
392 | this.#sections.length = 0;
393 | }
394 |
395 | #writeDataIntoChunks(data: Uint8Array, position: number) {
396 | // First, find the chunk to write the data into, or create one if none exists
397 | let chunkIndex = this.#chunks.findIndex(x => x.start <= position && position < x.start + this.#chunkSize);
398 | if (chunkIndex === -1) chunkIndex = this.#createChunk(position);
399 | let chunk = this.#chunks[chunkIndex];
400 |
401 | // Figure out how much to write to the chunk, and then write to the chunk
402 | let relativePosition = position - chunk.start;
403 | let toWrite = data.subarray(0, Math.min(this.#chunkSize - relativePosition, data.byteLength));
404 | chunk.data.set(toWrite, relativePosition);
405 |
406 | // Create a section describing the region of data that was just written to
407 | let section: ChunkSection = {
408 | start: relativePosition,
409 | end: relativePosition + toWrite.byteLength
410 | };
411 | this.#insertSectionIntoChunk(chunk, section);
412 |
413 | // Queue chunk for flushing to target if it has been fully written to
414 | if (chunk.written[0].start === 0 && chunk.written[0].end === this.#chunkSize) {
415 | chunk.shouldFlush = true;
416 | }
417 |
418 | // Make sure we don't hold too many chunks in memory at once to keep memory usage down
419 | if (this.#chunks.length > MAX_CHUNKS_AT_ONCE) {
420 | // Flush all but the last chunk
421 | for (let i = 0; i < this.#chunks.length-1; i++) {
422 | this.#chunks[i].shouldFlush = true;
423 | }
424 | this.#flushChunks();
425 | }
426 |
427 | // If the data didn't fit in one chunk, recurse with the remaining datas
428 | if (toWrite.byteLength < data.byteLength) {
429 | this.#writeDataIntoChunks(data.subarray(toWrite.byteLength), position + toWrite.byteLength);
430 | }
431 | }
432 |
433 | #insertSectionIntoChunk(chunk: Chunk, section: ChunkSection) {
434 | let low = 0;
435 | let high = chunk.written.length - 1;
436 | let index = -1;
437 |
438 | // Do a binary search to find the last section with a start not larger than `section`'s start
439 | while (low <= high) {
440 | let mid = Math.floor(low + (high - low + 1) / 2);
441 |
442 | if (chunk.written[mid].start <= section.start) {
443 | low = mid + 1;
444 | index = mid;
445 | } else {
446 | high = mid - 1;
447 | }
448 | }
449 |
450 | // Insert the new section
451 | chunk.written.splice(index + 1, 0, section);
452 | if (index === -1 || chunk.written[index].end < section.start) index++;
453 |
454 | // Merge overlapping sections
455 | while (index < chunk.written.length - 1 && chunk.written[index].end >= chunk.written[index + 1].start) {
456 | chunk.written[index].end = Math.max(chunk.written[index].end, chunk.written[index + 1].end);
457 | chunk.written.splice(index + 1, 1);
458 | }
459 | }
460 |
461 | #createChunk(includesPosition: number) {
462 | let start = Math.floor(includesPosition / this.#chunkSize) * this.#chunkSize;
463 | let chunk: Chunk = {
464 | start,
465 | data: new Uint8Array(this.#chunkSize),
466 | written: [],
467 | shouldFlush: false
468 | };
469 | this.#chunks.push(chunk);
470 | this.#chunks.sort((a, b) => a.start - b.start);
471 |
472 | return this.#chunks.indexOf(chunk);
473 | }
474 |
475 | #flushChunks(force = false) {
476 | for (let i = 0; i < this.#chunks.length; i++) {
477 | let chunk = this.#chunks[i];
478 | if (!chunk.shouldFlush && !force) continue;
479 |
480 | for (let section of chunk.written) {
481 | if (this.#ensureMonotonicity && chunk.start + section.start < this.#lastFlushEnd) {
482 | throw new Error('Internal error: Monotonicity violation.');
483 | }
484 |
485 | this.target.options.onData?.(
486 | chunk.data.subarray(section.start, section.end),
487 | chunk.start + section.start
488 | );
489 | this.#lastFlushEnd = chunk.start + section.end;
490 | }
491 | this.#chunks.splice(i--, 1);
492 | }
493 | }
494 |
495 | finalize() {
496 | if (this.#chunked) {
497 | this.#flushChunks(true);
498 | }
499 | }
500 | }
501 |
502 | /**
503 | * Essentially a wrapper around a chunked StreamTargetWriter, writing directly to disk using the File System Access API.
504 | * This is useful for large files, as available RAM is no longer a bottleneck.
505 | */
506 | export class FileSystemWritableFileStreamTargetWriter extends StreamTargetWriter {
507 | constructor(target: FileSystemWritableFileStreamTarget, ensureMonotonicity: boolean) {
508 | super(new StreamTarget({
509 | onData: (data, position) => target.stream.write({
510 | type: 'write',
511 | data,
512 | position
513 | }),
514 | chunked: true,
515 | chunkSize: target.options?.chunkSize
516 | }), ensureMonotonicity);
517 | }
518 | }
--------------------------------------------------------------------------------
/src/muxer.ts:
--------------------------------------------------------------------------------
1 | import { EBML, EBMLElement, EBMLFloat32, EBMLFloat64, EBMLId } from './ebml';
2 | import { readBits, writeBits } from './misc';
3 | import { ArrayBufferTarget, FileSystemWritableFileStreamTarget, StreamTarget, Target } from './target';
4 | import { EncodedSubtitleChunk, EncodedSubtitleChunkMetadata } from './subtitles';
5 | import {
6 | ArrayBufferTargetWriter,
7 | BaseStreamTargetWriter,
8 | FileSystemWritableFileStreamTargetWriter,
9 | StreamTargetWriter,
10 | Writer
11 | } from './writer';
12 |
13 | const VIDEO_TRACK_NUMBER = 1;
14 | const AUDIO_TRACK_NUMBER = 2;
15 | const SUBTITLE_TRACK_NUMBER = 3;
16 | const VIDEO_TRACK_TYPE = 1;
17 | const AUDIO_TRACK_TYPE = 2;
18 | const SUBTITLE_TRACK_TYPE = 17;
19 | const MAX_CHUNK_LENGTH_MS = 2**15;
20 | const CODEC_PRIVATE_MAX_SIZE = 2**13;
21 | const APP_NAME = 'https://github.com/Vanilagy/webm-muxer';
22 | const SEGMENT_SIZE_BYTES = 6;
23 | const CLUSTER_SIZE_BYTES = 5;
24 | const FIRST_TIMESTAMP_BEHAVIORS = ['strict', 'offset', 'permissive'] as const;
25 |
26 | interface MuxerOptions {
27 | target: T,
28 | video?: {
29 | codec: string,
30 | width: number,
31 | height: number
32 | frameRate?: number,
33 | alpha?: boolean
34 | },
35 | audio?: {
36 | codec: string,
37 | numberOfChannels: number,
38 | sampleRate: number,
39 | bitDepth?: number
40 | },
41 | subtitles?: {
42 | codec: string
43 | },
44 | type?: 'webm' | 'matroska',
45 | firstTimestampBehavior?: typeof FIRST_TIMESTAMP_BEHAVIORS[number],
46 | streaming?: boolean
47 | }
48 |
49 | interface InternalMediaChunk {
50 | data: Uint8Array,
51 | additions?: Uint8Array,
52 | timestamp: number,
53 | duration?: number,
54 | type: 'key' | 'delta',
55 | trackNumber: number
56 | }
57 |
58 | interface SeekHead {
59 | id: number,
60 | data: {
61 | id: number,
62 | data: ({
63 | id: number,
64 | data: Uint8Array,
65 | size?: undefined
66 | } | {
67 | id: number,
68 | size: number,
69 | data: number
70 | })[]
71 | }[]
72 | }
73 |
74 | export class Muxer {
75 | target: T;
76 |
77 | #options: MuxerOptions;
78 | #writer: Writer;
79 |
80 | #segment: EBMLElement;
81 | #segmentInfo: EBMLElement;
82 | #seekHead: SeekHead;
83 | #tracksElement: EBMLElement;
84 | #segmentDuration: EBMLElement;
85 | #colourElement: EBMLElement;
86 | #videoCodecPrivate: EBML;
87 | #audioCodecPrivate: EBML;
88 | #subtitleCodecPrivate: EBML;
89 | #cues: EBMLElement;
90 |
91 | #currentCluster: EBMLElement;
92 | #currentClusterTimestamp: number;
93 |
94 | #duration = 0;
95 | #videoChunkQueue: InternalMediaChunk[] = [];
96 | #audioChunkQueue: InternalMediaChunk[] = [];
97 | #subtitleChunkQueue: InternalMediaChunk[] = [];
98 | #firstVideoTimestamp: number;
99 | #firstAudioTimestamp: number;
100 | #lastVideoTimestamp = -1;
101 | #lastAudioTimestamp = -1;
102 | #lastSubtitleTimestamp = -1;
103 | #colorSpace: VideoColorSpaceInit;
104 | #finalized = false;
105 |
106 | constructor(options: MuxerOptions) {
107 | this.#validateOptions(options);
108 |
109 | this.#options = {
110 | type: 'webm',
111 | firstTimestampBehavior: 'strict',
112 | ...options
113 | };
114 | this.target = options.target;
115 |
116 | let ensureMonotonicity = !!this.#options.streaming;
117 |
118 | if (options.target instanceof ArrayBufferTarget) {
119 | this.#writer = new ArrayBufferTargetWriter(options.target);
120 | } else if (options.target instanceof StreamTarget) {
121 | this.#writer = new StreamTargetWriter(options.target, ensureMonotonicity);
122 | } else if (options.target instanceof FileSystemWritableFileStreamTarget) {
123 | this.#writer = new FileSystemWritableFileStreamTargetWriter(options.target, ensureMonotonicity);
124 | } else {
125 | throw new Error(`Invalid target: ${options.target}`);
126 | }
127 |
128 | this.#createFileHeader();
129 | }
130 |
131 | #validateOptions(options: MuxerOptions) {
132 | if (typeof options !== 'object') {
133 | throw new TypeError('The muxer requires an options object to be passed to its constructor.');
134 | }
135 |
136 | if (!(options.target instanceof Target)) {
137 | throw new TypeError('The target must be provided and an instance of Target.');
138 | }
139 |
140 | if (options.video) {
141 | if (typeof options.video.codec !== 'string') {
142 | throw new TypeError(`Invalid video codec: ${options.video.codec}. Must be a string.`);
143 | }
144 |
145 | if (!Number.isInteger(options.video.width) || options.video.width <= 0) {
146 | throw new TypeError(`Invalid video width: ${options.video.width}. Must be a positive integer.`);
147 | }
148 |
149 | if (!Number.isInteger(options.video.height) || options.video.height <= 0) {
150 | throw new TypeError(`Invalid video height: ${options.video.height}. Must be a positive integer.`);
151 | }
152 |
153 | if (options.video.frameRate !== undefined) {
154 | if (!Number.isFinite(options.video.frameRate) || options.video.frameRate <= 0) {
155 | throw new TypeError(
156 | `Invalid video frame rate: ${options.video.frameRate}. Must be a positive number.`
157 | );
158 | }
159 | }
160 |
161 | if (options.video.alpha !== undefined && typeof options.video.alpha !== 'boolean') {
162 | throw new TypeError(`Invalid video alpha: ${options.video.alpha}. Must be a boolean.`);
163 | }
164 | }
165 |
166 | if (options.audio) {
167 | if (typeof options.audio.codec !== 'string') {
168 | throw new TypeError(`Invalid audio codec: ${options.audio.codec}. Must be a string.`);
169 | }
170 |
171 | if (!Number.isInteger(options.audio.numberOfChannels) || options.audio.numberOfChannels <= 0) {
172 | throw new TypeError(
173 | `Invalid number of audio channels: ${options.audio.numberOfChannels}. Must be a positive integer.`
174 | );
175 | }
176 |
177 | if (!Number.isInteger(options.audio.sampleRate) || options.audio.sampleRate <= 0) {
178 | throw new TypeError(
179 | `Invalid audio sample rate: ${options.audio.sampleRate}. Must be a positive integer.`
180 | );
181 | }
182 |
183 | if (options.audio.bitDepth !== undefined) {
184 | if (!Number.isInteger(options.audio.bitDepth) || options.audio.bitDepth <= 0) {
185 | throw new TypeError(
186 | `Invalid audio bit depth: ${options.audio.bitDepth}. Must be a positive integer.`
187 | );
188 | }
189 | }
190 | }
191 |
192 | if (options.subtitles) {
193 | if (typeof options.subtitles.codec !== 'string') {
194 | throw new TypeError(`Invalid subtitles codec: ${options.subtitles.codec}. Must be a string.`);
195 | }
196 | }
197 |
198 | if (options.type !== undefined && !['webm', 'matroska'].includes(options.type)) {
199 | throw new TypeError(`Invalid type: ${options.type}. Must be 'webm' or 'matroska'.`);
200 | }
201 |
202 | if (options.firstTimestampBehavior && !FIRST_TIMESTAMP_BEHAVIORS.includes(options.firstTimestampBehavior)) {
203 | throw new TypeError(`Invalid first timestamp behavior: ${options.firstTimestampBehavior}`);
204 | }
205 |
206 | if (options.streaming !== undefined && typeof options.streaming !== 'boolean') {
207 | throw new TypeError(`Invalid streaming option: ${options.streaming}. Must be a boolean.`);
208 | }
209 | }
210 |
211 | #createFileHeader() {
212 | if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onHeader) {
213 | this.#writer.startTrackingWrites();
214 | }
215 |
216 | this.#writeEBMLHeader();
217 |
218 | if (!this.#options.streaming) {
219 | this.#createSeekHead();
220 | }
221 |
222 | this.#createSegmentInfo();
223 | this.#createCodecPrivatePlaceholders();
224 | this.#createColourElement();
225 |
226 | if (!this.#options.streaming) {
227 | this.#createTracks();
228 | this.#createSegment();
229 | } else {
230 | // We'll create these once we write out media chunks
231 | }
232 |
233 | this.#createCues();
234 |
235 | this.#maybeFlushStreamingTargetWriter();
236 | }
237 |
238 | #writeEBMLHeader() {
239 | let ebmlHeader: EBML = { id: EBMLId.EBML, data: [
240 | { id: EBMLId.EBMLVersion, data: 1 },
241 | { id: EBMLId.EBMLReadVersion, data: 1 },
242 | { id: EBMLId.EBMLMaxIDLength, data: 4 },
243 | { id: EBMLId.EBMLMaxSizeLength, data: 8 },
244 | { id: EBMLId.DocType, data: this.#options.type ?? 'webm' },
245 | { id: EBMLId.DocTypeVersion, data: 2 },
246 | { id: EBMLId.DocTypeReadVersion, data: 2 }
247 | ] };
248 | this.#writer.writeEBML(ebmlHeader);
249 | }
250 |
251 | /** Reserve 4 kiB for the CodecPrivate elements so we can write them later. */
252 | #createCodecPrivatePlaceholders() {
253 | this.#videoCodecPrivate = { id: EBMLId.Void, size: 4, data: new Uint8Array(CODEC_PRIVATE_MAX_SIZE) };
254 | this.#audioCodecPrivate = { id: EBMLId.Void, size: 4, data: new Uint8Array(CODEC_PRIVATE_MAX_SIZE) };
255 | this.#subtitleCodecPrivate = { id: EBMLId.Void, size: 4, data: new Uint8Array(CODEC_PRIVATE_MAX_SIZE) };
256 | }
257 |
258 | #createColourElement() {
259 | this.#colourElement = { id: EBMLId.Colour, data: [
260 | // All initially unspecified
261 | { id: EBMLId.MatrixCoefficients, data: 2 },
262 | { id: EBMLId.TransferCharacteristics, data: 2 },
263 | { id: EBMLId.Primaries, data: 2 },
264 | { id: EBMLId.Range, data: 0 }
265 | ] };
266 | }
267 |
268 | /**
269 | * Creates a SeekHead element which is positioned near the start of the file and allows the media player to seek to
270 | * relevant sections more easily. Since we don't know the positions of those sections yet, we'll set them later.
271 | */
272 | #createSeekHead() {
273 | const kaxCues = new Uint8Array([ 0x1c, 0x53, 0xbb, 0x6b ]);
274 | const kaxInfo = new Uint8Array([ 0x15, 0x49, 0xa9, 0x66 ]);
275 | const kaxTracks = new Uint8Array([ 0x16, 0x54, 0xae, 0x6b ]);
276 |
277 | let seekHead = { id: EBMLId.SeekHead, data: [
278 | { id: EBMLId.Seek, data: [
279 | { id: EBMLId.SeekID, data: kaxCues },
280 | { id: EBMLId.SeekPosition, size: 5, data: 0 }
281 | ] },
282 | { id: EBMLId.Seek, data: [
283 | { id: EBMLId.SeekID, data: kaxInfo },
284 | { id: EBMLId.SeekPosition, size: 5, data: 0 }
285 | ] },
286 | { id: EBMLId.Seek, data: [
287 | { id: EBMLId.SeekID, data: kaxTracks },
288 | { id: EBMLId.SeekPosition, size: 5, data: 0 }
289 | ] }
290 | ] };
291 | this.#seekHead = seekHead;
292 | }
293 |
294 | #createSegmentInfo() {
295 | let segmentDuration: EBML = { id: EBMLId.Duration, data: new EBMLFloat64(0) };
296 | this.#segmentDuration = segmentDuration;
297 |
298 | let segmentInfo: EBML = { id: EBMLId.Info, data: [
299 | { id: EBMLId.TimestampScale, data: 1e6 },
300 | { id: EBMLId.MuxingApp, data: APP_NAME },
301 | { id: EBMLId.WritingApp, data: APP_NAME },
302 | !this.#options.streaming ? segmentDuration : null
303 | ] };
304 | this.#segmentInfo = segmentInfo;
305 | }
306 |
307 | #createTracks() {
308 | let tracksElement = { id: EBMLId.Tracks, data: [] as EBML[] };
309 | this.#tracksElement = tracksElement;
310 |
311 | if (this.#options.video) {
312 | tracksElement.data.push({ id: EBMLId.TrackEntry, data: [
313 | { id: EBMLId.TrackNumber, data: VIDEO_TRACK_NUMBER },
314 | { id: EBMLId.TrackUID, data: VIDEO_TRACK_NUMBER },
315 | { id: EBMLId.TrackType, data: VIDEO_TRACK_TYPE },
316 | { id: EBMLId.CodecID, data: this.#options.video.codec },
317 | this.#videoCodecPrivate,
318 | (this.#options.video.frameRate ?
319 | { id: EBMLId.DefaultDuration, data: 1e9/this.#options.video.frameRate } :
320 | null
321 | ),
322 | { id: EBMLId.Video, data: [
323 | { id: EBMLId.PixelWidth, data: this.#options.video.width },
324 | { id: EBMLId.PixelHeight, data: this.#options.video.height },
325 | (this.#options.video.alpha ? { id: EBMLId.AlphaMode, data: 1 } : null),
326 | this.#colourElement
327 | ] }
328 | ] });
329 | }
330 |
331 | if (this.#options.audio) {
332 | this.#audioCodecPrivate = this.#options.streaming ? (this.#audioCodecPrivate || null) :
333 | { id: EBMLId.Void, size: 4, data: new Uint8Array(CODEC_PRIVATE_MAX_SIZE) };
334 |
335 | tracksElement.data.push({ id: EBMLId.TrackEntry, data: [
336 | { id: EBMLId.TrackNumber, data: AUDIO_TRACK_NUMBER },
337 | { id: EBMLId.TrackUID, data: AUDIO_TRACK_NUMBER },
338 | { id: EBMLId.TrackType, data: AUDIO_TRACK_TYPE },
339 | { id: EBMLId.CodecID, data: this.#options.audio.codec },
340 | this.#audioCodecPrivate,
341 | { id: EBMLId.Audio, data: [
342 | { id: EBMLId.SamplingFrequency, data: new EBMLFloat32(this.#options.audio.sampleRate) },
343 | { id: EBMLId.Channels, data: this.#options.audio.numberOfChannels},
344 | (this.#options.audio.bitDepth ?
345 | { id: EBMLId.BitDepth, data: this.#options.audio.bitDepth } :
346 | null
347 | )
348 | ] }
349 | ] });
350 | }
351 |
352 | if (this.#options.subtitles) {
353 | tracksElement.data.push({ id: EBMLId.TrackEntry, data: [
354 | { id: EBMLId.TrackNumber, data: SUBTITLE_TRACK_NUMBER },
355 | { id: EBMLId.TrackUID, data: SUBTITLE_TRACK_NUMBER },
356 | { id: EBMLId.TrackType, data: SUBTITLE_TRACK_TYPE },
357 | { id: EBMLId.CodecID, data: this.#options.subtitles.codec },
358 | this.#subtitleCodecPrivate
359 | ] });
360 | }
361 | }
362 |
363 | #createSegment() {
364 | let segment: EBML = {
365 | id: EBMLId.Segment,
366 | size: this.#options.streaming ? -1 : SEGMENT_SIZE_BYTES,
367 | data: [
368 | !this.#options.streaming ? this.#seekHead as EBML : null,
369 | this.#segmentInfo,
370 | this.#tracksElement
371 | ]
372 | };
373 | this.#segment = segment;
374 |
375 | this.#writer.writeEBML(segment);
376 |
377 | if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onHeader) {
378 | let { data, start } = this.#writer.getTrackedWrites(); // start should be 0
379 | this.#writer.target.options.onHeader(data, start);
380 | }
381 | }
382 |
383 | #createCues() {
384 | this.#cues = { id: EBMLId.Cues, data: [] };
385 | }
386 |
387 | #maybeFlushStreamingTargetWriter() {
388 | if (this.#writer instanceof StreamTargetWriter) {
389 | this.#writer.flush();
390 | }
391 | }
392 |
393 | get #segmentDataOffset() {
394 | return this.#writer.dataOffsets.get(this.#segment);
395 | }
396 |
397 | addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata, timestamp?: number) {
398 | if (!(chunk instanceof EncodedVideoChunk)) {
399 | throw new TypeError("addVideoChunk's first argument (chunk) must be of type EncodedVideoChunk.");
400 | }
401 | if (meta && typeof meta !== 'object') {
402 | throw new TypeError("addVideoChunk's second argument (meta), when provided, must be an object.");
403 | }
404 | if (timestamp !== undefined && (!Number.isFinite(timestamp) || timestamp < 0)) {
405 | throw new TypeError(
406 | "addVideoChunk's third argument (timestamp), when provided, must be a non-negative real number."
407 | );
408 | }
409 |
410 | let data = new Uint8Array(chunk.byteLength);
411 | chunk.copyTo(data);
412 |
413 | this.addVideoChunkRaw(data, chunk.type, timestamp ?? chunk.timestamp, meta);
414 | }
415 |
416 | addVideoChunkRaw(data: Uint8Array, type: 'key' | 'delta', timestamp: number, meta?: EncodedVideoChunkMetadata) {
417 | if (!(data instanceof Uint8Array)) {
418 | throw new TypeError("addVideoChunkRaw's first argument (data) must be an instance of Uint8Array.");
419 | }
420 | if (type !== 'key' && type !== 'delta') {
421 | throw new TypeError("addVideoChunkRaw's second argument (type) must be either 'key' or 'delta'.");
422 | }
423 | if (!Number.isFinite(timestamp) || timestamp < 0) {
424 | throw new TypeError("addVideoChunkRaw's third argument (timestamp) must be a non-negative real number.");
425 | }
426 | if (meta && typeof meta !== 'object') {
427 | throw new TypeError("addVideoChunkRaw's fourth argument (meta), when provided, must be an object.");
428 | }
429 |
430 | this.#ensureNotFinalized();
431 | if (!this.#options.video) throw new Error('No video track declared.');
432 |
433 | if (this.#firstVideoTimestamp === undefined) this.#firstVideoTimestamp = timestamp;
434 | if (meta) this.#writeVideoDecoderConfig(meta);
435 |
436 | let videoChunk = this.#createInternalChunk(data, type, timestamp, VIDEO_TRACK_NUMBER);
437 | if (this.#options.video.codec === 'V_VP9') this.#fixVP9ColorSpace(videoChunk);
438 |
439 | /**
440 | * Okay, so the algorithm used to insert video and audio blocks (if both are present) is one where we want to
441 | * insert the blocks sorted, i.e. always monotonically increasing in timestamp. This means that we can write
442 | * an audio chunk of timestamp t_a only when we have a video chunk of timestamp t_v >= t_a, and vice versa.
443 | * This means that we need to often queue up a lot of video/audio chunks and wait for their counterpart to
444 | * arrive before they are written to the file. When the video writing is finished, it is important that any
445 | * chunks remaining in the queues also be flushed to the file.
446 | */
447 |
448 | this.#lastVideoTimestamp = videoChunk.timestamp;
449 |
450 | // Write all audio chunks with a timestamp smaller than the incoming video chunk
451 | while (this.#audioChunkQueue.length > 0 && this.#audioChunkQueue[0].timestamp <= videoChunk.timestamp) {
452 | let audioChunk = this.#audioChunkQueue.shift();
453 | this.#writeBlock(audioChunk, false);
454 | }
455 |
456 | // Depending on the last audio chunk, either write the video chunk to the file or enqueue it
457 | if (!this.#options.audio || videoChunk.timestamp <= this.#lastAudioTimestamp) {
458 | this.#writeBlock(videoChunk, true);
459 | } else {
460 | this.#videoChunkQueue.push(videoChunk);
461 | }
462 |
463 | this.#writeSubtitleChunks();
464 | this.#maybeFlushStreamingTargetWriter();
465 | }
466 |
467 | /** Writes possible video decoder metadata to the file. */
468 | #writeVideoDecoderConfig(meta: EncodedVideoChunkMetadata) {
469 | if (!meta.decoderConfig) return;
470 |
471 | if (meta.decoderConfig.colorSpace) {
472 | let colorSpace = meta.decoderConfig.colorSpace;
473 | this.#colorSpace = colorSpace;
474 |
475 | this.#colourElement.data = [
476 | { id: EBMLId.MatrixCoefficients, data: {
477 | 'rgb': 1,
478 | 'bt709': 1,
479 | 'bt470bg': 5,
480 | 'smpte170m': 6
481 | }[colorSpace.matrix] },
482 | { id: EBMLId.TransferCharacteristics, data: {
483 | 'bt709': 1,
484 | 'smpte170m': 6,
485 | 'iec61966-2-1': 13
486 | }[colorSpace.transfer] },
487 | { id: EBMLId.Primaries, data: {
488 | 'bt709': 1,
489 | 'bt470bg': 5,
490 | 'smpte170m': 6
491 | }[colorSpace.primaries] },
492 | { id: EBMLId.Range, data: [1, 2][Number(colorSpace.fullRange)] }
493 | ];
494 |
495 | if (!this.#options.streaming) {
496 | let endPos = this.#writer.pos;
497 | this.#writer.seek(this.#writer.offsets.get(this.#colourElement));
498 | this.#writer.writeEBML(this.#colourElement);
499 | this.#writer.seek(endPos);
500 | }
501 | }
502 |
503 | if (meta.decoderConfig.description) {
504 | if (this.#options.streaming) {
505 | this.#videoCodecPrivate = this.#createCodecPrivateElement(meta.decoderConfig.description);
506 | } else {
507 | this.#writeCodecPrivate(this.#videoCodecPrivate, meta.decoderConfig.description);
508 | }
509 | }
510 | }
511 |
512 | /** Due to [a bug in Chromium](https://bugs.chromium.org/p/chromium/issues/detail?id=1377842), VP9 streams often
513 | * lack color space information. This method patches in that information. */
514 | // http://downloads.webmproject.org/docs/vp9/vp9-bitstream_superframe-and-uncompressed-header_v1.0.pdf
515 | #fixVP9ColorSpace(chunk: InternalMediaChunk) {
516 | if (chunk.type !== 'key') return;
517 | if (!this.#colorSpace) return;
518 |
519 | let i = 0;
520 | // Check if it's a "superframe"
521 | if (readBits(chunk.data, 0, 2) !== 0b10) return; i += 2;
522 |
523 | let profile = (readBits(chunk.data, i+1, i+2) << 1) + readBits(chunk.data, i+0, i+1); i += 2;
524 | if (profile === 3) i++;
525 |
526 | let showExistingFrame = readBits(chunk.data, i+0, i+1); i++;
527 | if (showExistingFrame) return;
528 |
529 | let frameType = readBits(chunk.data, i+0, i+1); i++;
530 | if (frameType !== 0) return; // Just to be sure
531 |
532 | i += 2;
533 |
534 | let syncCode = readBits(chunk.data, i+0, i+24); i += 24;
535 | if (syncCode !== 0x498342) return;
536 |
537 | if (profile >= 2) i++;
538 |
539 | let colorSpaceID = {
540 | 'rgb': 7,
541 | 'bt709': 2,
542 | 'bt470bg': 1,
543 | 'smpte170m': 3
544 | }[this.#colorSpace.matrix];
545 | writeBits(chunk.data, i+0, i+3, colorSpaceID);
546 | }
547 |
548 | addAudioChunk(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata, timestamp?: number) {
549 | if (!(chunk instanceof EncodedAudioChunk)) {
550 | throw new TypeError("addAudioChunk's first argument (chunk) must be of type EncodedAudioChunk.");
551 | }
552 | if (meta && typeof meta !== 'object') {
553 | throw new TypeError("addAudioChunk's second argument (meta), when provided, must be an object.");
554 | }
555 | if (timestamp !== undefined && (!Number.isFinite(timestamp) || timestamp < 0)) {
556 | throw new TypeError(
557 | "addAudioChunk's third argument (timestamp), when provided, must be a non-negative real number."
558 | );
559 | }
560 |
561 | let data = new Uint8Array(chunk.byteLength);
562 | chunk.copyTo(data);
563 |
564 | this.addAudioChunkRaw(data, chunk.type, timestamp ?? chunk.timestamp, meta);
565 | }
566 |
567 | addAudioChunkRaw(data: Uint8Array, type: 'key' | 'delta', timestamp: number, meta?: EncodedAudioChunkMetadata) {
568 | if (!(data instanceof Uint8Array)) {
569 | throw new TypeError("addAudioChunkRaw's first argument (data) must be an instance of Uint8Array.");
570 | }
571 | if (type !== 'key' && type !== 'delta') {
572 | throw new TypeError("addAudioChunkRaw's second argument (type) must be either 'key' or 'delta'.");
573 | }
574 | if (!Number.isFinite(timestamp) || timestamp < 0) {
575 | throw new TypeError("addAudioChunkRaw's third argument (timestamp) must be a non-negative real number.");
576 | }
577 | if (meta && typeof meta !== 'object') {
578 | throw new TypeError("addAudioChunkRaw's fourth argument (meta), when provided, must be an object.");
579 | }
580 |
581 | this.#ensureNotFinalized();
582 | if (!this.#options.audio) throw new Error('No audio track declared.');
583 |
584 | if (this.#firstAudioTimestamp === undefined) this.#firstAudioTimestamp = timestamp;
585 |
586 | // Write possible audio decoder metadata to the file
587 | if (meta?.decoderConfig) {
588 | if (this.#options.streaming) {
589 | this.#audioCodecPrivate = this.#createCodecPrivateElement(meta.decoderConfig.description);
590 | } else {
591 | this.#writeCodecPrivate(this.#audioCodecPrivate, meta.decoderConfig.description);
592 | }
593 | }
594 |
595 | let audioChunk = this.#createInternalChunk(data, type, timestamp, AUDIO_TRACK_NUMBER);
596 |
597 | // Algorithm explained in `addVideoChunkRaw`
598 | this.#lastAudioTimestamp = audioChunk.timestamp;
599 |
600 | while (this.#videoChunkQueue.length > 0 && this.#videoChunkQueue[0].timestamp <= audioChunk.timestamp) {
601 | let videoChunk = this.#videoChunkQueue.shift();
602 | this.#writeBlock(videoChunk, true);
603 | }
604 |
605 | if (!this.#options.video || audioChunk.timestamp <= this.#lastVideoTimestamp) {
606 | this.#writeBlock(audioChunk, !this.#options.video);
607 | } else {
608 | this.#audioChunkQueue.push(audioChunk);
609 | }
610 |
611 | this.#writeSubtitleChunks();
612 | this.#maybeFlushStreamingTargetWriter();
613 | }
614 |
615 | addSubtitleChunk(chunk: EncodedSubtitleChunk, meta: EncodedSubtitleChunkMetadata, timestamp?: number) {
616 | if (typeof chunk !== 'object' || !chunk) {
617 | throw new TypeError("addSubtitleChunk's first argument (chunk) must be an object.");
618 | } else {
619 | // We can't simply do an instanceof check, so let's check the structure itself:
620 | if (!(chunk.body instanceof Uint8Array)) {
621 | throw new TypeError('body must be an instance of Uint8Array.');
622 | }
623 | if (!Number.isFinite(chunk.timestamp) || chunk.timestamp < 0) {
624 | throw new TypeError('timestamp must be a non-negative real number.');
625 | }
626 | if (!Number.isFinite(chunk.duration) || chunk.duration < 0) {
627 | throw new TypeError('duration must be a non-negative real number.');
628 | }
629 | if (chunk.additions && !(chunk.additions instanceof Uint8Array)) {
630 | throw new TypeError('additions, when present, must be an instance of Uint8Array.');
631 | }
632 | }
633 |
634 | if (typeof meta !== 'object') {
635 | throw new TypeError("addSubtitleChunk's second argument (meta) must be an object.");
636 | }
637 |
638 | this.#ensureNotFinalized();
639 | if (!this.#options.subtitles) throw new Error('No subtitle track declared.');
640 |
641 | // Write possible subtitle decoder metadata to the file
642 | if (meta?.decoderConfig) {
643 | if (this.#options.streaming) {
644 | this.#subtitleCodecPrivate = this.#createCodecPrivateElement(meta.decoderConfig.description);
645 | } else {
646 | this.#writeCodecPrivate(this.#subtitleCodecPrivate, meta.decoderConfig.description);
647 | }
648 | }
649 |
650 | let subtitleChunk = this.#createInternalChunk(
651 | chunk.body,
652 | 'key',
653 | timestamp ?? chunk.timestamp,
654 | SUBTITLE_TRACK_NUMBER,
655 | chunk.duration,
656 | chunk.additions
657 | );
658 |
659 | this.#lastSubtitleTimestamp = subtitleChunk.timestamp;
660 | this.#subtitleChunkQueue.push(subtitleChunk);
661 |
662 | this.#writeSubtitleChunks();
663 | this.#maybeFlushStreamingTargetWriter();
664 | }
665 |
666 | #writeSubtitleChunks() {
667 | // Writing subtitle chunks is different from video and audio: A subtitle chunk will be written if it's
668 | // guaranteed that no more media chunks will be written before it, to ensure monotonicity. However, media chunks
669 | // will NOT wait for subtitle chunks to arrive, as they may never arrive, so that's how non-monotonicity can
670 | // arrive. But it should be fine, since it's all still in one cluster.
671 |
672 | let lastWrittenMediaTimestamp = Math.min(
673 | this.#options.video ? this.#lastVideoTimestamp : Infinity,
674 | this.#options.audio ? this.#lastAudioTimestamp : Infinity
675 | );
676 |
677 | let queue = this.#subtitleChunkQueue;
678 | while (queue.length > 0 && queue[0].timestamp <= lastWrittenMediaTimestamp) {
679 | this.#writeBlock(queue.shift(), !this.#options.video && !this.#options.audio);
680 | }
681 | }
682 |
683 | /** Converts a read-only external chunk into an internal one for easier use. */
684 | #createInternalChunk(
685 | data: Uint8Array,
686 | type: 'key' | 'delta',
687 | timestamp: number,
688 | trackNumber: number,
689 | duration?: number,
690 | additions?: Uint8Array
691 | ) {
692 | let adjustedTimestamp = this.#validateTimestamp(timestamp, trackNumber);
693 |
694 | let internalChunk: InternalMediaChunk = {
695 | data,
696 | additions,
697 | type,
698 | timestamp: adjustedTimestamp,
699 | duration,
700 | trackNumber
701 | };
702 |
703 | return internalChunk;
704 | }
705 |
706 | #validateTimestamp(timestamp: number, trackNumber: number) {
707 | let lastTimestamp = trackNumber === VIDEO_TRACK_NUMBER ? this.#lastVideoTimestamp :
708 | trackNumber === AUDIO_TRACK_NUMBER ? this.#lastAudioTimestamp :
709 | this.#lastSubtitleTimestamp;
710 |
711 | if (trackNumber !== SUBTITLE_TRACK_NUMBER) {
712 | let firstTimestamp = trackNumber === VIDEO_TRACK_NUMBER
713 | ? this.#firstVideoTimestamp
714 | : this.#firstAudioTimestamp;
715 |
716 | // Check first timestamp behavior
717 | if (this.#options.firstTimestampBehavior === 'strict' && lastTimestamp === -1 && timestamp !== 0) {
718 | throw new Error(
719 | `The first chunk for your media track must have a timestamp of 0 (received ${timestamp}). ` +
720 | `Non-zero first timestamps are often caused by directly piping frames or audio data ` +
721 | `from a MediaStreamTrack into the encoder. Their timestamps are typically relative to ` +
722 | `the age of the document, which is probably what you want.\n\nIf you want to offset all ` +
723 | `timestamps of a track such that the first one is zero, set firstTimestampBehavior: ` +
724 | `'offset' in the options.\nIf you want to allow non-zero first timestamps, set ` +
725 | `firstTimestampBehavior: 'permissive'.\n`
726 | );
727 | } else if (this.#options.firstTimestampBehavior === 'offset') {
728 | timestamp -= firstTimestamp;
729 | }
730 | }
731 |
732 | if (timestamp < lastTimestamp) {
733 | throw new Error(
734 | `Timestamps must be monotonically increasing (went from ${lastTimestamp} to ${timestamp}).`
735 | );
736 | }
737 |
738 | if (timestamp < 0) {
739 | throw new Error(`Timestamps must be non-negative (received ${timestamp}).`);
740 | }
741 |
742 | return timestamp;
743 | }
744 |
745 | /** Writes a block containing media data to the file. */
746 | #writeBlock(chunk: InternalMediaChunk, canCreateNewCluster: boolean) {
747 | // When streaming, we create the tracks and segment after we've received the first media chunks.
748 | // Due to the interlacing algorithm, this code will be run once we've seen one chunk from every media track.
749 | if (this.#options.streaming && !this.#tracksElement) {
750 | this.#createTracks();
751 | this.#createSegment();
752 | }
753 |
754 | let msTimestamp = Math.floor(chunk.timestamp / 1000);
755 | let relativeTimestamp = msTimestamp - this.#currentClusterTimestamp;
756 |
757 | let shouldCreateNewClusterFromKeyFrame =
758 | canCreateNewCluster &&
759 | chunk.type === 'key' &&
760 | relativeTimestamp >= 1000;
761 | let clusterWouldBeTooLong = relativeTimestamp >= MAX_CHUNK_LENGTH_MS;
762 |
763 | if (
764 | !this.#currentCluster ||
765 | shouldCreateNewClusterFromKeyFrame ||
766 | // If we hit this case, then we're sadly forced to create a chunk that starts with a delta chunk
767 | clusterWouldBeTooLong
768 | ) {
769 | this.#createNewCluster(msTimestamp);
770 | relativeTimestamp = 0;
771 | }
772 |
773 | if (relativeTimestamp < 0) {
774 | // The chunk lies outside of the current cluster
775 | return;
776 | }
777 |
778 | let prelude = new Uint8Array(4);
779 | let view = new DataView(prelude.buffer);
780 | // 0x80 to indicate it's the last byte of a multi-byte number
781 | view.setUint8(0, 0x80 | chunk.trackNumber);
782 | view.setInt16(1, relativeTimestamp, false);
783 |
784 | if (chunk.duration === undefined && !chunk.additions) {
785 | // No duration or additions, we can write out a SimpleBlock
786 | view.setUint8(3, Number(chunk.type === 'key') << 7); // Flags (keyframe flag only present for SimpleBlock)
787 |
788 | let simpleBlock = { id: EBMLId.SimpleBlock, data: [
789 | prelude,
790 | chunk.data
791 | ] };
792 | this.#writer.writeEBML(simpleBlock);
793 | } else {
794 | let msDuration = Math.floor(chunk.duration / 1000);
795 | let blockGroup = { id: EBMLId.BlockGroup, data: [
796 | { id: EBMLId.Block, data: [
797 | prelude,
798 | chunk.data
799 | ] },
800 | chunk.duration !== undefined ? { id: EBMLId.BlockDuration, data: msDuration } : null,
801 | chunk.additions ? { id: EBMLId.BlockAdditions, data: chunk.additions } : null
802 | ] };
803 | this.#writer.writeEBML(blockGroup);
804 | }
805 |
806 | this.#duration = Math.max(this.#duration, msTimestamp);
807 | }
808 |
809 | #createCodecPrivateElement(data: AllowSharedBufferSource) {
810 | return { id: EBMLId.CodecPrivate, size: 4, data: new Uint8Array(data as ArrayBuffer) };
811 | }
812 |
813 | /**
814 | * Replaces a placeholder EBML element with actual CodecPrivate data, then pads it with a Void Element of
815 | * necessary size.
816 | */
817 | #writeCodecPrivate(element: EBML, data: AllowSharedBufferSource) {
818 | let endPos = this.#writer.pos;
819 | this.#writer.seek(this.#writer.offsets.get(element));
820 |
821 | let codecPrivateElementSize = 2 + 4 + data.byteLength;
822 | let voidDataSize = CODEC_PRIVATE_MAX_SIZE - codecPrivateElementSize;
823 |
824 | if (voidDataSize < 0) {
825 | // Truncate the CodecPrivate data. This way, the file will at least still be valid.
826 | let newByteLength = data.byteLength + voidDataSize;
827 | if (data instanceof ArrayBuffer) {
828 | data = data.slice(0, newByteLength);
829 | } else {
830 | data = data.buffer.slice(0, newByteLength);
831 | }
832 | voidDataSize = 0;
833 | }
834 |
835 | element = [
836 | this.#createCodecPrivateElement(data),
837 | { id: EBMLId.Void, size: 4, data: new Uint8Array(voidDataSize) }
838 | ];
839 |
840 | this.#writer.writeEBML(element);
841 | this.#writer.seek(endPos);
842 | }
843 |
844 | /** Creates a new Cluster element to contain media chunks. */
845 | #createNewCluster(timestamp: number) {
846 | if (this.#currentCluster) {
847 | this.#finalizeCurrentCluster();
848 | }
849 |
850 | if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onCluster) {
851 | this.#writer.startTrackingWrites();
852 | }
853 |
854 | this.#currentCluster = {
855 | id: EBMLId.Cluster,
856 | size: this.#options.streaming ? -1 : CLUSTER_SIZE_BYTES,
857 | data: [
858 | { id: EBMLId.Timestamp, data: timestamp }
859 | ]
860 | };
861 | this.#writer.writeEBML(this.#currentCluster);
862 |
863 | this.#currentClusterTimestamp = timestamp;
864 |
865 | let clusterOffsetFromSegment =
866 | this.#writer.offsets.get(this.#currentCluster) - this.#segmentDataOffset;
867 |
868 | // Add a CuePoint to the Cues element for better seeking
869 | (this.#cues.data as EBML[]).push({ id: EBMLId.CuePoint, data: [
870 | { id: EBMLId.CueTime, data: timestamp },
871 | (this.#options.video ? { id: EBMLId.CueTrackPositions, data: [
872 | { id: EBMLId.CueTrack, data: VIDEO_TRACK_NUMBER },
873 | { id: EBMLId.CueClusterPosition, data: clusterOffsetFromSegment }
874 | ] } : null),
875 | (this.#options.audio ? { id: EBMLId.CueTrackPositions, data: [
876 | { id: EBMLId.CueTrack, data: AUDIO_TRACK_NUMBER },
877 | { id: EBMLId.CueClusterPosition, data: clusterOffsetFromSegment }
878 | ] } : null)
879 | ] });
880 | }
881 |
882 | #finalizeCurrentCluster() {
883 | if (!this.#options.streaming) {
884 | let clusterSize = this.#writer.pos - this.#writer.dataOffsets.get(this.#currentCluster);
885 | let endPos = this.#writer.pos;
886 |
887 | // Write the size now that we know it
888 | this.#writer.seek(this.#writer.offsets.get(this.#currentCluster) + 4);
889 | this.#writer.writeEBMLVarInt(clusterSize, CLUSTER_SIZE_BYTES);
890 | this.#writer.seek(endPos);
891 | }
892 |
893 | if (this.#writer instanceof BaseStreamTargetWriter && this.#writer.target.options.onCluster) {
894 | let { data, start } = this.#writer.getTrackedWrites();
895 | this.#writer.target.options.onCluster(data, start, this.#currentClusterTimestamp);
896 | }
897 | }
898 |
899 | /** Finalizes the file, making it ready for use. Must be called after all media chunks have been added. */
900 | finalize() {
901 | if (this.#finalized) {
902 | throw new Error('Cannot finalize a muxer more than once.');
903 | }
904 |
905 | // Flush any remaining queued chunks to the file
906 | while (this.#videoChunkQueue.length > 0) this.#writeBlock(this.#videoChunkQueue.shift(), true);
907 | while (this.#audioChunkQueue.length > 0) this.#writeBlock(this.#audioChunkQueue.shift(), true);
908 | while (this.#subtitleChunkQueue.length > 0 && this.#subtitleChunkQueue[0].timestamp <= this.#duration) {
909 | this.#writeBlock(this.#subtitleChunkQueue.shift(), false);
910 | }
911 |
912 | if (this.#currentCluster) {
913 | this.#finalizeCurrentCluster();
914 | }
915 | this.#writer.writeEBML(this.#cues);
916 |
917 | if (!this.#options.streaming) {
918 | let endPos = this.#writer.pos;
919 |
920 | // Write the Segment size
921 | let segmentSize = this.#writer.pos - this.#segmentDataOffset;
922 | this.#writer.seek(this.#writer.offsets.get(this.#segment) + 4);
923 | this.#writer.writeEBMLVarInt(segmentSize, SEGMENT_SIZE_BYTES);
924 |
925 | // Write the duration of the media to the Segment
926 | this.#segmentDuration.data = new EBMLFloat64(this.#duration);
927 | this.#writer.seek(this.#writer.offsets.get(this.#segmentDuration));
928 | this.#writer.writeEBML(this.#segmentDuration);
929 |
930 | // Fill in SeekHead position data and write it again
931 | this.#seekHead.data[0].data[1].data =
932 | this.#writer.offsets.get(this.#cues) - this.#segmentDataOffset;
933 | this.#seekHead.data[1].data[1].data =
934 | this.#writer.offsets.get(this.#segmentInfo) - this.#segmentDataOffset;
935 | this.#seekHead.data[2].data[1].data =
936 | this.#writer.offsets.get(this.#tracksElement) - this.#segmentDataOffset;
937 |
938 | this.#writer.seek(this.#writer.offsets.get(this.#seekHead));
939 | this.#writer.writeEBML(this.#seekHead);
940 |
941 | this.#writer.seek(endPos);
942 | }
943 |
944 | this.#maybeFlushStreamingTargetWriter();
945 | this.#writer.finalize();
946 |
947 | this.#finalized = true;
948 | }
949 |
950 | #ensureNotFinalized() {
951 | if (this.#finalized) {
952 | throw new Error('Cannot add new video or audio chunks after the file has been finalized.');
953 | }
954 | }
955 | }
--------------------------------------------------------------------------------