Compare commits

...
57 Commits
Author SHA1 Message Date
Vanilagy 7e546c2cc7 Fix sample closing before it reaches the custom encoder 2025-08-12 15:36:17 +02:00
Vanilagy 7e89511ca6 Bump patch 2025-08-12 10:09:34 +02:00
Vanilagy e0a4169bf8 Handle missing starting key sample in ISOBMFF 2025-08-12 09:29:57 +02:00
Vanilagy c9dcebed6f Skip zero-length AudioSamples 2025-08-12 08:53:25 +02:00
Vanilagy ae0266df52 Clarify decode order thing for encoded packet sources 2025-08-11 18:41:15 +02:00
Vanilagy 1492fd5c6f Add workaround for Chromium range request caching bug 2025-08-11 18:14:02 +02:00
Vanilagy 6d447660c2 Clarify EBML strings are ASCII 2025-08-11 16:10:43 +02:00
Vanilagy b224ac0dc6 Bump minor version 2025-08-11 03:02:25 +02:00
Vanilagy 47c955524f Rework MP3 reading pattern 2025-08-11 02:50:13 +02:00
Vanilagy 80c6fa3201 Fall back to webkitAudioContext 2025-08-11 01:51:20 +02:00
Vanilagy 11c15c2392 Add track-specific conversion options, fix esbuild race condition 2025-08-11 00:00:04 +02:00
Vanilagy 975c1c42cb First linting error yay 2025-08-10 20:06:18 +02:00
Vanilagy a9cec387d4 Extract insertSorted function 2025-08-10 19:55:21 +02:00
Vanilagy 12df436e15 Add more graceful handling of suspended AudioContext to media player example 2025-08-10 19:26:58 +02:00
Vanilagy 56ae97f994 Fix decoded frame timestamps (#49) 2025-08-10 19:26:41 +02:00
Vanilagy 5608f519a7 Add xingHeader option to Mp3OutputFormat 2025-08-10 16:04:18 +02:00
Vanilagy 230f02fffc Clarify latencyMode 2025-08-10 14:36:47 +02:00
Vanilagy 51059c4b82 Add donation link 2025-08-10 13:48:34 +02:00
Vanilagy db437bba01 Fix links in mp3-encoder's package.json 2025-08-10 04:08:22 +02:00
Vanilagy 224da29440 Adjust release script, again 2025-08-10 03:50:58 +02:00
Vanilagy c53b9a6fa1 Oops 2025-08-10 03:47:31 +02:00
Vanilagy 835143c2e2 Adjust release script, bump patch 2025-08-10 03:46:12 +02:00
David P.andGitHub f547f455af Merge pull request #50 from Vanilagy/mp3-encoder
Create @mediabunny/mp3-encoder, restructure and extend build system
2025-08-10 03:37:12 +02:00
Vanilagy 96fe4c399c Add version bump commands 2025-08-10 03:35:39 +02:00
Vanilagy 784eb89ae2 Complete package.json 2025-08-10 03:27:13 +02:00
Vanilagy ba0491de3a Circumvent flush() being called on sources when cancelling output, surface error before doing cleanup 2025-08-10 03:24:17 +02:00
Vanilagy 64f986bc1b Gotta be sure 2025-08-10 02:08:22 +02:00
Vanilagy 0b6c6ccb6c Gtfo 2025-08-10 02:07:59 +02:00
Vanilagy e1c50ef16c Clarify declaration file thing 2025-08-10 02:07:09 +02:00
Vanilagy c6227cea5a Write mp3-encoder docs 2025-08-10 02:06:09 +02:00
Vanilagy cbb2681d33 Create @mediabunny/mp3-encoder, restructure and extend build system 2025-08-10 00:45:30 +02:00
Vanilagy 7a2a23bcb0 And another one 2025-08-08 18:57:10 +02:00
Vanilagy b63d86226f Fix release workflow? 2025-08-08 18:53:06 +02:00
Vanilagy 3371813c33 Bump version to 1.5.0, update release workflow 2025-08-08 18:46:12 +02:00
Vanilagy 1fe115ac83 Implement frame rate adjustment logic for Conversion API, fix source not being closed 2025-08-08 18:39:08 +02:00
David P.andGitHub 7e42e298c5 Merge pull request #45 from yonathan06/main
Custom fps convertion WIP
2025-08-08 17:20:04 +02:00
Vanilagy 597b68b299 Add support for Matroska lacing, fix packet lookup logic 2025-08-08 17:10:03 +02:00
David P.andGitHub f5803598e8 Merge pull request #44 from frw/patch-1
fix: check string for null terminators
2025-08-08 12:16:12 +02:00
Yonatan Bendahan b98d8e58ca remove console.log 2025-08-07 12:00:29 +03:00
Yonatan Bendahan dd60ca71b3 fix lint error 2025-08-07 11:56:44 +03:00
Yonatan Bendahan 4b99fd08ca with custom fps convertion 2025-08-07 11:47:50 +03:00
Frederick Widjaja 5b238c6d0f fix: check string for null terminators 2025-08-07 11:09:44 +07:00
David P.andGitHub 2f83e07a55 Merge pull request #42 from yonathan06/main
Add "duplex: 'half'" to fetch stream example
2025-08-05 14:45:31 +02:00
Yonatan Bendahan 1aaded9241 add "duplex: 'half'" to fetch stream example 2025-08-05 15:20:12 +03:00
Vanilagy 5a6b849ff8 Fix fragmented MP4 files with a non-empty sample table 2025-08-03 22:34:38 +02:00
Vanilagy b60d4cafed Fix example blob MIME types 2025-08-01 11:11:37 +02:00
Vanilagy 95091cce9c Add special top-level Cluster logic for Matroska demuxer 2025-07-28 12:03:19 +02:00
Vanilagy 72ed933bf4 Fix discarded rotation when doing VideoSample.clone() 2025-07-28 01:50:39 +02:00
Vanilagy ef3b53ff8a Add Geef <3 2025-07-27 21:27:16 +02:00
Vanilagy ac8baa4873 Fall back to default timescale when not provided by the Matroska file 2025-07-27 13:03:43 +02:00
Vanilagy 232d1a6cd7 Bump version to 1.4.0 2025-07-27 12:20:11 +02:00
Vanilagy 50fe065852 Document API changes 2025-07-27 12:19:44 +02:00
Vanilagy d38ad22559 Big realtime playback refactor
- Added Worker- or AudioContext-based fallbacks in case MediaStreamTrackProcessor is not available
- Fixed fMP4-streamed files not playing in Safari
- Added better error handling for MediaStream sources
- Improved the live recording example to be more robust
2025-07-27 12:13:03 +02:00
Vanilagy 9cc38329f2 Fix link 2025-07-27 11:46:36 +02:00
Vanilagy 09ed583b78 Smol change 2025-07-27 00:57:57 +02:00
Vanilagy 9de5b24ec5 Add new sponsors 2025-07-27 00:56:22 +02:00
Vanilagy 03b4843c1d Fix incorrectly fixed .js imports 2025-07-26 00:50:47 +02:00
71 changed files with 5286 additions and 972 deletions
+18 -3
View File
@@ -15,6 +15,16 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Merge main into release branch
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout release
git merge origin/main --no-ff -m "Merge main into release for tag ${{ github.event.release.tag_name }}"
git push origin release
- name: Set up Node.js
uses: actions/setup-node@v4
@@ -42,9 +52,14 @@ jobs:
- name: Upload build artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts
run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
- name: Create Publish to npm
run: npm publish --provenance
- name: Publish Mediabunny to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish workspace packages to npm
run: npm publish --provenance --access public --workspaces
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+5 -2
View File
@@ -1,5 +1,8 @@
/node_modules
.vscode
node_modules
/dist
/dist-docs
.DS_Store
/docs/.vitepress/cache
/docs/.vitepress/cache
packages/mp3-encoder/dist
+12 -1
View File
@@ -22,9 +22,13 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<a href="https://diffusion.studio/" target="_blank">
<img src="./docs/public/sponsors/diffusionstudio.png" width="60" height="60" alt="Diffusion Studio">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://kino.ai/" target="_blank">
<img src="./docs/public/sponsors/kino.jpg" width="60" height="60" alt="Kino">
</a>
</div>
[Get featured](https://github.com/sponsors/Vanilagy)
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
## Features
@@ -45,10 +49,17 @@ Core features include:
### Installation
Install it via npm:
```bash
npm install mediabunny
```
Alternatively, include it directly with a script tag using one of the [builds](https://github.com/Vanilagy/mediabunny/releases). Doing so exposes a global `Mediabunny` object.
```html
<script src="mediabunny.cjs"></script>
```
Requires any JavaScript environment that can run ECMAScript 2021 or later. Mediabunny is expected to be run in modern browsers. For types, TypeScript 5.7 or later is required.
### Read file metadata
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "dist/modules/index.d.ts",
"mainEntryPointFilePath": "dist/modules/src/index.d.ts",
"bundledPackages": [],
"compiler": {},
"apiReport": {
-76
View File
@@ -1,76 +0,0 @@
import * as esbuild from 'esbuild';
import process from 'node:process';
const baseConfig = {
entryPoints: ['src/index.ts'],
bundle: true,
logLevel: 'info',
banner: {
js: `/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/`,
},
legalComments: 'none',
};
const umdConfig = {
...baseConfig,
format: 'iife',
// The following are hacks to basically make this an UMD module. No native support for that in esbuild as of today
globalName: 'Mediabunny',
footer: {
js:
`if (typeof module === "object" && typeof module.exports === "object") Object.assign(module.exports, Mediabunny)`,
},
};
const esmConfig = {
...baseConfig,
format: 'esm',
};
const ctxUmd = await esbuild.context({
...umdConfig,
outfile: 'dist/bundles/mediabunny.cjs',
});
const ctxEsm = await esbuild.context({
...esmConfig,
outfile: 'dist/bundles/mediabunny.mjs',
});
const ctxUmdMinified = await esbuild.context({
...umdConfig,
outfile: 'dist/bundles/mediabunny.min.cjs',
minify: true,
});
const ctxEsmMinified = await esbuild.context({
...esmConfig,
outfile: 'dist/bundles/mediabunny.min.mjs',
minify: true,
});
if (process.argv[2] === '--watch') {
await Promise.all([
ctxUmd.watch(),
ctxEsm.watch(),
ctxUmdMinified.watch(),
ctxEsmMinified.watch(),
]);
} else {
ctxUmd.rebuild();
ctxEsm.rebuild();
ctxUmdMinified.rebuild();
ctxEsmMinified.rebuild();
await Promise.all([
ctxUmd.dispose(),
ctxEsm.dispose(),
ctxUmdMinified.dispose(),
ctxEsmMinified.dispose(),
]);
}
Executable
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
set -e
# This script must be executed via `npm run build`
# Clear the stuff from last build
rm -rf dist
rm -rf packages/mp3-encoder/dist
# Ensure license headers on all source files
tsx scripts/ensure-license-headers.ts
# Type check & generate .js and .d.ts files
tsc -p src
tsc -p packages/mp3-encoder/src
# So that the resulting files use valid ESM imports with file extension. This only runs for the core Mediabunny as only
# it ships the individual files to npm (for tree shaking, because it's large)
npm run fix-build-import-paths
# Creates bundles for all packages
tsx scripts/bundle.ts
# Declaration file rollup and checks
api-extractor run
api-extractor run -c packages/mp3-encoder/api-extractor.json
# Checks that all symbols are documented
tsx scripts/check-docblocks.ts dist/mediabunny.d.ts
tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
# Appends stuff to the declaration files to register the global variables these libraries expose
echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts
echo 'export as namespace MediabunnyMp3Encoder;' >> packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
+13 -7
View File
@@ -1,8 +1,11 @@
<!DOCTYPE html>
<script src="../dist/bundles/mediabunny.cjs"></script>
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
<script type="module">
MediabunnyMp3Encoder.registerMp3Encoder();
const fileInput = document.createElement('input');
fileInput.type = 'file';
document.body.append(fileInput);
@@ -21,7 +24,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.Mp4OutputFormat({});
const outputFormat = new Mediabunny.Mp3OutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -38,6 +41,7 @@
target
}),
audio: {
//discard: true,
//codec: 'opus',
//bitrate: 128000,
//numberOfChannels: 1,
@@ -69,9 +73,11 @@
bitrate: 320000
},
*/
video: {
forceTranscode: true,
codec: 'av1',
video: () => ({
//frameRate: 27.123,
//width: 320,
//forceTranscode: true,
//codec: 'av1',
//discard: true,
//width: 1280,
//discard: true,
@@ -86,10 +92,10 @@
//height: 512,
//width: 200,
//height: 100,
},
}),
trim: {
start: 0,
end: 20
//start: 0,
//end: 20
},
});
console.log(conversion);
+57
View File
@@ -16,6 +16,62 @@
source
});
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: false })) {
console.log(packet);
if (packet.timestamp >= 2.4) break;
}
/*
const audioTrack = await input.getPrimaryAudioTrack();
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
console.log(await sink.getPacket(100))
*/
/*
for await (const packet of sink.packets()) {
//console.log(packet);
//if (packet.timestamp >= 2.4) break;
}
*/
/*
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.VideoSampleSink(videoTrack);
for await (const sample of sink.samples(0, 2)) {
console.log(sample.timestamp);
sample.close();
}
*/
/*
const audioTrack = await input.getPrimaryAudioTrack();
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
let thing = await sink.getFirstPacket();
while (thing) {
console.log(thing);
if (thing.timestamp >= 2.4) break;
thing = await sink.getNextPacket(thing);
}
console.log("done")
*/
/*
for await (const packet of sink.packets()) {
console.log(packet);
if (packet.timestamp >= 2.4) break;
}
*/
/*
const videoTrack = await input.getPrimaryVideoTrack();
const packetSink = new Mediabunny.EncodedPacketSink(videoTrack);
const sampleSink = new Mediabunny.VideoSampleSink(videoTrack);
@@ -29,6 +85,7 @@
}
}
console.log("don")
*/
/*
console.time()
+13 -5
View File
@@ -23,21 +23,29 @@
format: new Mediabunny.Mp4OutputFormat(),
});
if (videoTrack) {
output.addVideoTrack(new Mediabunny.MediaStreamVideoTrackSource(videoTrack, {
const source = new Mediabunny.MediaStreamVideoTrackSource(videoTrack, {
codec: 'avc',
bitrate: Mediabunny.QUALITY_MEDIUM
}));
});
source.errorPromise.catch((d) => console.log("Hello?????", d));
output.addVideoTrack(source);
}
if (audioTrack) {
output.addAudioTrack(new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
codec: 'aac',
bitrate: Mediabunny.QUALITY_MEDIUM
}));
});
source.errorPromise.catch((d) => console.log("Hello!!???", d));
output.addAudioTrack(source);
}
await output.start();
await new Promise(resolve => setTimeout(resolve, 3000));
await new Promise(resolve => setTimeout(resolve, 5000));
await output.finalize();
+6
View File
@@ -75,6 +75,12 @@ export default withMermaid({
{ text: 'Supported formats & codecs', link: '/guide/supported-formats-and-codecs' },
],
},
{
text: 'Extensions',
items: [
{ text: 'mp3-encoder', link: '/guide/extensions/mp3-encoder' },
],
},
],
socialLinks: [
+68 -28
View File
@@ -11,6 +11,7 @@ It has the following features:
- Trimming
- Video resizing & fitting
- Video rotation
- Video frame rate adjustment
- Audio resampling
- Audio up/downmixing
@@ -91,21 +92,18 @@ This automatically frees up all resources used by the conversion process.
## Video options
You can set the `video` property in the conversion options to configure the converter's behavior for video tracks:
You can set the `video` property in the conversion options to configure the converter's behavior for video tracks. The options are:
```ts
type ConversionOptions = {
// ...
video?: {
discard?: boolean;
width?: number;
height?: number;
fit?: 'fill' | 'contain' | 'cover';
rotate?: 0 | 90 | 180 | 270;
codec?: VideoCodec;
bitrate?: number | Quality;
forceTranscode?: boolean;
};
// ...
type ConversionVideoOptions = {
discard?: boolean;
width?: number;
height?: number;
fit?: 'fill' | 'contain' | 'cover';
rotate?: 0 | 90 | 180 | 270;
frameRate?: number;
codec?: VideoCodec;
bitrate?: number | Quality;
forceTranscode?: boolean;
};
```
@@ -123,7 +121,7 @@ const conversion = await Conversion.init({
```
::: info
The provided configuration will apply equally to all video tracks of the input.
The provided configuration will apply equally to all video tracks of the input. If you want to apply a separate configuration to each video track, check [track-specific options](#track-specific-options).
:::
### Discarding video
@@ -141,6 +139,12 @@ The `width`, `height` and `fit` properties control how the video is resized. If
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options).
### Adjusting frame rate
The `frameRate` property can be used to set the frame rate of the output video in Hz. If not specified, the original input frame rate will be used (which may be variable).
### Transcoding video
Use the `codec` property to control the codec of the output track. This should be set to a [codec](./supported-formats-and-codecs#video-codecs) supported by the output file, or else the track will be [discarded](#discarded-tracks).
@@ -151,19 +155,15 @@ If you want to prevent direct copying of media data and force a transcoding step
## Audio options
You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks:
You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks. The options are:
```ts
type ConversionOptions = {
// ...
audio?: {
discard?: boolean;
codec?: AudioCodec;
bitrate?: number | Quality;
numberOfChannels?: number;
sampleRate?: number;
forceTranscode?: boolean;
};
// ...
type ConversionAudioOptions = {
discard?: boolean;
codec?: AudioCodec;
bitrate?: number | Quality;
numberOfChannels?: number;
sampleRate?: number;
forceTranscode?: boolean;
};
```
@@ -180,7 +180,7 @@ const conversion = await Conversion.init({
```
::: info
The provided configuration will apply equally to all audio tracks of the input.
The provided configuration will apply equally to all audio tracks of the input. If you want to apply a separate configuration to each audio track, check [track-specific options](#track-specific-options).
:::
### Discarding audio
@@ -201,6 +201,46 @@ Use the `bitrate` property to control the bitrate of the output audio. For examp
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
## Track-specific options
You may want to configure your video and audio options differently depending on the specifics of the input track. Or, in case a media file has multiple video or audio tracks, you may want to discard only specific tracks or configure each track separately.
For this, instead of passing an object for `video` and `audio`, you can instead pass a function:
```ts
const conversion = await Conversion.init({
input,
output,
// Function gets invoked for each video track:
video: (videoTrack, n) => {
if (n > 1) {
// Keep only the first video track
return { discard: true };
}
return {
// Shrink width to 640 only if the track is wider
width: Math.min(videoTrack.displayWidth, 640),
};
},
// Async functions work too:
audio: async (audioTrack, n) => {
if (audioTrack.languageCode !== 'rus') {
// Keep only Russian audio tracks
return { discard: true };
}
return {
codec: 'aac',
};
},
});
```
For documentation about the properties of video and audio tracks, refer to [Reading track metadata](./reading-media-files#reading-track-metadata).
## Trimming
Use the `trim` property in the conversion options to extract only a section of the input file into the output file:
+90
View File
@@ -0,0 +1,90 @@
# @mediabunny/mp3-encoder
Browsers typically have no support for MP3 encoding in their WebCodecs implementations. Given the ubiquity of the format, this extension package provides an MP3 encoder for use with Mediabunny. It is implemented using Mediabunny's [custom coder API](../supported-formats-and-codecs#custom-coders) and uses a highly-performant WASM build of the [LAME MP3 Encoder](https://lame.sourceforge.io/) under the hood.
<a class="!no-underline inline-flex items-center gap-1.5" :no-icon="true" href="https://github.com/Vanilagy/mediabunny/blob/main/packages/mp3-encoder/README.md">
GitHub page
<span class="vpi-arrow-right" />
</a>
## Installation
This library peer-depends on Mediabunny. Install both using npm:
```bash
npm install mediabunny @mediabunny/mp3-encoder
```
Alternatively, directly include them using a script tag:
```html
<script src="mediabunny.js"></script>
<script src="mediabunny-mp3-encoder.js"></script>
```
This will expose the global objects `Mediabunny` and `MediabunnyMp3Encoder`. Use `mediabunny-mp3-encoder.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases).
## Usage
```ts
import { registerMp3Encoder } from '@mediabunny/mp3-encoder';
registerMp3Encoder();
```
That's it - Mediabunny now uses the registered MP3 encoder automatically.
If you want to be more correct, check for native browser support first:
```ts
import { canEncodeAudio } from 'mediabunny';
import { registerMp3Encoder } from '@mediabunny/mp3-encoder';
if (!(await canEncodeAudio('mp3'))) {
registerMp3Encoder();
}
```
## Example
Here, we convert an input file to an MP3:
```ts
import {
Input,
ALL_FORMATS,
BlobSource,
Output,
BufferTarget,
Mp3OutputFormat,
canEncodeAudio,
Conversion,
} from 'mediabunny';
import { registerMp3Encoder } from '@mediabunny/mp3-encoder';
if (!(await canEncodeAudio('mp3'))) {
// Only register the custom encoder if there's no native support
registerMp3Encoder();
}
const input = new Input({
source: new BlobSource(file), // From a file picker, for example
formats: ALL_FORMATS,
});
const output = new Output({
format: new Mp3OutputFormat(),
target: new BufferTarget(),
});
const conversion = await Conversion.init({
input,
output,
});
await conversion.execute();
output.target.buffer; // => ArrayBuffer containing the MP3 file
```
## Implementation details
This library implements an MP3 encoder by registering a custom encoder class with Mediabunny. This class, when initialized, spawns a worker which then immediately loads a WASM build of the LAME MP3 encoder. Then, raw data is sent to the worker and encoded data is received from it. These encoded chunks are then concatenated in the main thread and properly split into separate MP3 frames.
Great care was put into ensuring maximum compatibility of this package; it works with bundlers, directly in the browser, as well as in Node, Deno, and Bun. All code (including worker & WASM) are bundled into a single file, eliminating the need for CDNs or WASM path arguments. This packages therefore serves as a reference implementation of WASM-based encoder extensions for Mediabunny.
The WASM build itself is a performance-optimized, SIMD-enabled build of LAME 3.100, with all unneeded features disabled. Because maximum performance was the priority, the build is slighter bigger, but ~130 kB gzipped is still very reasonable in my opinion. In my tests, it encodes 5 seconds of audio in ~90 milliseconds (55x real-time speed).
+1 -1
View File
@@ -34,6 +34,6 @@ You can also just include the library using a script tag in your HTML:
<script src="mediabunny.cjs"></script>
```
This will add a `Mediabunny` object to the global scope.
This will add a `Mediabunny` object to the global scope. You can provide types for this global using `mediabunny.d.ts`.
You can download a built distribution file from the [releases page](https://github.com/Vanilagy/mediabunny/releases). Use the `*.cjs` builds for normal script tag inclusion, or the `*.mjs` builds for script tags with `type="module"` or direct imports via ESM. Including the `mediabunny.d.ts` declaration file in your TypeScript project will declare a global `Mediabunny` namespace.
+19 -3
View File
@@ -42,7 +42,7 @@ while (notDone) {
### Video encoding config
All video sources that handle encoding internally require you to specify a `VideoEncodingConfiguration`, specifying the codec configuration to use:
All video sources that handle encoding internally require you to specify a `VideoEncodingConfig`, specifying the codec configuration to use:
```ts
type VideoEncodingConfig = {
codec: VideoCodec;
@@ -62,7 +62,7 @@ type VideoEncodingConfig = {
```
- `codec`: The [video codec](./supported-formats-and-codecs#video-codecs) used for encoding.
- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
- `latencyMode`: The latency mode as specified by the WebCodecs API. Media stream-driven video sources will automatically use the `realtime` setting.
- `latencyMode`: The latency mode as specified by the WebCodecs API. Browsers default to `quality`. Media stream-driven video sources will automatically use the `realtime` setting.
- `keyFrameInterval`: The maximum interval in seconds between two adjacent key frames. Defaults to 5 seconds. More frequent key frames improve seeking behavior but increase file size. When using multiple video tracks, this value should be set to the same value for all tracks.
- `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
@@ -70,7 +70,7 @@ type VideoEncodingConfig = {
### Audio encoding config
All audio sources that handle encoding internally require you to specify an `AudioEncodingConfiguration`, specifying the codec configuration to use:
All audio sources that handle encoding internally require you to specify an `AudioEncodingConfig`, specifying the codec configuration to use:
```ts
type AudioEncodingConfig = {
codec: AudioCodec;
@@ -123,6 +123,7 @@ const sampleSource = new VideoSampleSource({
});
await sampleSource.add(videoSample);
videoSample.close(); // If it's not needed anymore
// You may optionally force samples to be encoded as key frames:
await sampleSource.add(videoSample, { keyFrame: true });
@@ -163,6 +164,9 @@ const videoTrackSource = new MediaStreamVideoTrackSource(videoTrack, {
codec: 'vp9',
bitrate: 1e7,
});
// Make sure to allow any internal errors to properly bubble up
videoTrackSource.errorPromise.catch((error) => ...);
```
This source requires no additional method calls; data will automatically be captured and piped to the output file as soon as `start()` is called on the `Output`. Make sure to `stop()` on `videoTrack` after finalizing the `Output` if you don't need the user's media anymore.
@@ -171,6 +175,10 @@ This source requires no additional method calls; data will automatically be capt
If this source is the only MediaStreamTrack source in the `Output`, then the first video sample added by it starts at timestamp 0. If there are multiple, then the earliest media sample across all tracks starts at timestamp 0, and all tracks will be perfectly synchronized with each other.
:::
::: warning
`MediaStreamVideoTrackSource`'s internals are detached from the typical code flow but can still throw, so make sure to utilize `errorPromise` to deal with any errors and to stop the `Output`.
:::
### `EncodedVideoPacketSource`
The most barebones of all video sources, this source can be used to directly pipe [encoded packets](./packets-and-samples#encodedpacket) of video data to the output. This source requires that you take care of the encoding process yourself, which enables you to use the WebCodecs API manually or to plug in your own encoding stack. Alternatively, you may retrieve the encoded packets directly by reading them from another media file, allowing you to skip decoding and reencoding video data.
@@ -278,6 +286,7 @@ const sampleSource = new AudioSampleSource({
});
await sampleSource.add(audioSample);
audioSample.close(); // If it's not needed anymore
```
### `AudioBufferSource`
@@ -312,6 +321,9 @@ const audioTrackSource = new MediaStreamAudioTrackSource(audioTrack, {
codec: 'opus',
bitrate: 128e3,
});
// Make sure to allow any internal errors to properly bubble up
audioTrackSource.errorPromise.catch((error) => ...);
```
This source requires no additional method calls; data will automatically be captured and piped to the output file as soon as `start()` is called on the `Output`. Make sure to `stop()` on `audioTrack` after finalizing the `Output` if you don't need the user's media anymore.
@@ -320,6 +332,10 @@ This source requires no additional method calls; data will automatically be capt
If this source is the only MediaStreamTrack source in the `Output`, then the first audio sample added by it starts at timestamp 0. If there are multiple, then the earliest media sample across all tracks starts at timestamp 0, and all tracks will be perfectly synchronized with each other.
:::
::: warning
`MediaStreamAudioTrackSource`'s internals are detached from the typical code flow but can still throw, so make sure to utilize `errorPromise` to deal with any errors and to stop the `Output`.
:::
### `EncodedAudioPacketSource`
The most barebones of all audio sources, this source can be used to directly pipe [encoded packets](./packets-and-samples#encodedpacket) of audio data to the output. This source requires that you take care of the encoding process yourself, which enables you to use the WebCodecs API manually or to plug in your own encoding stack. Alternatively, you may retrieve the encoded packets directly by reading them from another media file, allowing you to skip decoding and reencoding audio data.
+11 -1
View File
@@ -206,11 +206,21 @@ const output = new Output({
The following options are available:
```ts
type Mp3OutputFormatOptions = {
xingHeader?: boolean;
onXingFrame?: (data: Uint8Array, position: number) => unknown;
};
```
- `xingHeader`\
Controls whether the Xing header, which contains additional metadata as well as an index, is written to the start of the MP3 file. Defaults to `true`.
::: info
When set to `false`, this option ensures [append-only writing](#append-only-writing).
:::
- `onXingFrame`\
Will be called once the Xing metadata frame is finalized, which happens at the end of the writing process.
Will be called once the Xing metadata frame is finalized, which happens at the end of the writing process. This callback only fires if `xingHeader` isn't set to `false`.
::: info
Most browsers don't support encoding MP3. Use the official [`@mediabunny/mp3-encoder`](./extensions/mp3-encoder) package to polyfill an encoder.
:::
## WAVE
+6 -1
View File
@@ -401,7 +401,7 @@ An audio sample represents a section of audio data. It can be created directly f
### Creating audio samples
Audio samples can be constructed either from an `AudioData` instance or an initialization object:
Audio samples can be constructed either from an `AudioData` instance, an initialization object, or an `AudioBuffer`:
```ts
import { AudioSample } from 'mediabunny';
@@ -417,6 +417,11 @@ const sample = new AudioSample({
sampleRate: 44100, // in Hz
timestamp: 0, // in seconds
});
// From AudioBuffer:
const timestamp = 0; // in seconds
const samples = AudioSample.fromAudioBuffer(audioBuffer, timestamp);
// => Returns multiple AudioSamples if the AudioBuffer is very long
```
The following audio sample formats are supported:
+1
View File
@@ -312,6 +312,7 @@ const output = new Output({
const uploadComplete = fetch('https://example.com/upload', {
method: 'POST',
body: readable,
duplex: 'half',
headers: {
'Content-Type': output.format.mimeType,
},
+3
View File
@@ -90,12 +90,15 @@ const sponsors = {
gold: [
{ image: '/sponsors/gling.svg', name: 'Gling AI', url: 'https://www.gling.ai/' },
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
],
individual: [
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/58149663', name: 'H7GhosT', url: 'https://github.com/H7GhosT' },
{ image: 'https://avatars.githubusercontent.com/u/91711202', name: 'ihasq', url: 'https://github.com/ihasq' },
{ image: 'https://avatars.githubusercontent.com/u/61233224', name: 'Allwhy', url: 'https://github.com/Allwhy' },
],
};
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+3 -4
View File
@@ -34,10 +34,9 @@ export default tseslint.config(
{
ignores: [
'dist',
'build',
'api-sketch.ts',
'build.mjs',
'append-namespace.mjs',
'dist-docs',
'packages/mp3-encoder/dist',
'packages/mp3-encoder/build',
'eslint.config.mjs',
'docs/.vitepress/cache',
]
@@ -91,13 +91,17 @@ const compressFile = async (file: File) => {
// Display the final media file
videoElement.style.display = '';
videoElement.src = URL.createObjectURL(new Blob([output.target.buffer!]));
videoElement.src = URL.createObjectURL(new Blob([output.target.buffer!], { type: output.format.mimeType }));
void videoElement.play();
compressionFacts.style.display = '';
compressionFacts.textContent
= `${(output.target.buffer!.byteLength / file.size * 100).toPrecision(3)}% of original size`;
} catch (error) {
console.error(error);
await currentConversion?.cancel();
errorElement.textContent = String(error);
clearInterval(currentIntervalId);
+1
View File
@@ -22,6 +22,7 @@
<hr class="w-full max-w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<p id="warning-element" class="text-amber-500"></p>
<div class="flex gap-4" id="main-container" style="display: none;">
<div class="flex flex-col items-center">
+42 -14
View File
@@ -1,4 +1,5 @@
import {
canEncodeAudio,
CanvasSource,
MediaStreamAudioTrackSource,
Mp4OutputFormat,
@@ -13,6 +14,7 @@ const mainContainer = document.querySelector('#main-container') as HTMLDivElemen
const videoElement = document.querySelector('video') as HTMLVideoElement;
const downloadButton = document.querySelector('#download-button') as HTMLAnchorElement;
const errorElement = document.querySelector('#error-element') as HTMLParagraphElement;
const warningElement = document.querySelector('#warning-element') as HTMLParagraphElement;
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!;
@@ -39,19 +41,29 @@ const startRecording = async () => {
videoElement.src = '';
downloadButton.style.display = 'none';
errorElement.textContent = '';
warningElement.textContent = '';
// Paint a white background to the canvas
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
// Get user microphone
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioIsEncodable = await canEncodeAudio('opus', {
bitrate: QUALITY_MEDIUM,
});
let audioTrack: MediaStreamAudioTrack | null = null;
if (audioIsEncodable) {
// Get user microphone
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioTrack = mediaStream.getAudioTracks()[0] ?? null;
} else {
warningElement.textContent
= 'Audio is not yet encodable by your browser, so the audio track has been omitted.';
}
horizontalRule.style.display = '';
mainContainer.style.display = '';
const audioTrack = mediaStream.getAudioTracks()[0];
// Create a new output file
output = new Output({
// We're using fragmented MP4 here; streamable WebM would also work
@@ -99,6 +111,8 @@ const startRecording = async () => {
codec: 'opus',
bitrate: QUALITY_MEDIUM,
});
audioSource.errorPromise.catch(cancelRecording); // Make sure errors are bubbled up
output.addAudioTrack(audioSource);
}
@@ -108,9 +122,9 @@ const startRecording = async () => {
readyForMoreFrames = true;
lastFrameNumber = -1;
// Start the video frame capture loop
void addVideoFrame();
videoCaptureInterval = window.setInterval(() => void addVideoFrame(), 1000 / frameRate);
// Start the video frame capture loop, making sure errors are caught
void addVideoFrame().catch(cancelRecording);
videoCaptureInterval = window.setInterval(() => void addVideoFrame().catch(cancelRecording), 1000 / frameRate);
const mimeType = await output.getMimeType();
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
@@ -121,21 +135,35 @@ const startRecording = async () => {
toggleRecordingButton.textContent = 'Stop recording';
toggleRecordingButton.disabled = false;
} catch (error) {
errorElement.textContent = String(error);
mainContainer.style.display = 'none';
toggleRecordingButton.textContent = 'Start recording';
toggleRecordingButton.disabled = false;
recording = false;
await cancelRecording(error);
}
};
const cancelRecording = async (error: unknown) => {
if (!recording) {
return; // Already canceled
}
console.error(error);
errorElement.textContent = String(error);
clearInterval(videoCaptureInterval);
mainContainer.style.display = 'none';
toggleRecordingButton.textContent = 'Start recording';
toggleRecordingButton.disabled = false;
recording = false;
await output?.cancel();
mediaStream?.getTracks().forEach(track => track.stop());
};
const stopRecording = async () => {
toggleRecordingButton.textContent = 'Stopping...';
toggleRecordingButton.disabled = true;
clearInterval(videoCaptureInterval);
mediaStream.getTracks().forEach(track => track.stop());
mediaStream?.getTracks().forEach(track => track.stop());
await output.finalize();
+16 -3
View File
@@ -132,6 +132,9 @@ const initMediaPlayer = async (file: File) => {
warningElement.textContent = problemMessage;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
// We must create the audio context with the matching sample rate for correct acoustic results
// (especially for low-sample rate files)
audioContext = new AudioContext({ sampleRate: audioTrack?.sampleRate });
@@ -166,7 +169,11 @@ const initMediaPlayer = async (file: File) => {
fileLoaded = true;
await startVideoIterator();
await play();
if (audioContext.state === 'running') {
// Start playback automatically if the audio context permits
await play();
}
playerContainer.style.display = '';
@@ -175,8 +182,10 @@ const initMediaPlayer = async (file: File) => {
controlsElement.style.opacity = '1';
playerContainer.style.cursor = '';
}
} catch (e) {
errorElement.textContent = String(e);
} catch (error) {
console.error(error);
errorElement.textContent = String(error);
playerContainer.style.display = 'none';
}
};
@@ -328,6 +337,10 @@ const getPlaybackTime = () => {
};
const play = async () => {
if (audioContext!.state === 'suspended') {
await audioContext!.resume();
}
if (getPlaybackTime() === totalDuration) {
// If we're at the end, let's snap back to the start
playbackTimeAtStart = 0;
@@ -129,6 +129,8 @@ const renderObject = (object: Record<string, unknown>) => {
listItem.removeChild(loadingSpan);
listItem.appendChild(renderValue(resolvedValue));
}).catch((error) => {
console.error(error);
// Show the promise error
listItem.removeChild(loadingSpan);
const errorSpan = document.createElement('span');
@@ -7,6 +7,7 @@ import {
QUALITY_HIGH,
getFirstEncodableAudioCodec,
getFirstEncodableVideoCodec,
OutputFormat,
} from 'mediabunny';
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
@@ -57,6 +58,8 @@ let currentScaleIndex = 0;
let collisionCount = 0;
let collisionsPerScale = 0;
let output: Output<OutputFormat, BufferTarget>;
/** === MAIN VIDEO FILE GENERATION LOGIC === */
const generateVideo = async () => {
@@ -82,7 +85,7 @@ const generateVideo = async () => {
initScene(duration);
// Create a new output file
const output = new Output({
output = new Output({
target: new BufferTarget(), // Stored in memory
format: new Mp4OutputFormat(),
});
@@ -177,13 +180,17 @@ const generateVideo = async () => {
videoInfo.style.display = '';
// Display and play the resulting media file
const videoBlob = new Blob([output.target.buffer!], { type: 'video/mp4' });
const videoBlob = new Blob([output.target.buffer!], { type: output.format.mimeType });
resultVideo.src = URL.createObjectURL(videoBlob);
void resultVideo.play();
const fileSizeMiB = (videoBlob.size / (1024 * 1024)).toPrecision(3);
videoInfo.textContent = `File size: ${fileSizeMiB} MiB`;
} catch (error) {
console.error(error);
await output?.cancel();
clearInterval(progressInterval);
errorElement.textContent = String(error);
progressBarContainer.style.display = 'none';
@@ -96,8 +96,10 @@ const generateThumbnails = async (file: File) => {
i++;
}
} catch (e) {
errorElement.textContent = String(e);
} catch (error) {
console.error(error);
errorElement.textContent = String(error);
thumbnailContainer.innerHTML = '';
}
};
+225 -2
View File
@@ -1,13 +1,16 @@
{
"name": "mediabunny",
"version": "1.3.2",
"version": "1.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.3.2",
"version": "1.7.3",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
],
"dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/dom-webcodecs": "0.1.13"
@@ -22,7 +25,9 @@
"@types/markdown-it-footnote": "^3.0.4",
"@types/node": "^22.13.10",
"esbuild": "^0.25.1",
"esbuild-plugin-external-global": "^1.0.1",
"eslint": "^9.22.0",
"find-cache-dir": "^5.0.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-mathjax3": "^4.3.2",
"mermaid": "^11.6.0",
@@ -1276,6 +1281,10 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mediabunny/mp3-encoder": {
"resolved": "packages/mp3-encoder",
"link": true
},
"node_modules/@mermaid-js/mermaid-mindmap": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-mindmap/-/mermaid-mindmap-9.3.0.tgz",
@@ -2448,6 +2457,13 @@
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
"license": "MIT"
},
"node_modules/@types/emscripten": {
"version": "1.40.1",
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.40.1.tgz",
"integrity": "sha512-sr53lnYkQNhjHNN0oJDdUm5564biioI5DuOpycufDVK7D3y+GR3oUswe2rlwY1nPNyusHbrJ9WoTyIHl4/Bpwg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
@@ -3472,6 +3488,13 @@
"node": ">= 6"
}
},
"node_modules/common-path-prefix": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
"integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==",
"dev": true,
"license": "ISC"
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -4307,6 +4330,13 @@
"@esbuild/win32-x64": "0.25.1"
}
},
"node_modules/esbuild-plugin-external-global": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/esbuild-plugin-external-global/-/esbuild-plugin-external-global-1.0.1.tgz",
"integrity": "sha512-NDzYHRoShpvLqNcrgV8ZQh61sMIFAry5KLTQV83BPG5iTXCCu7h72SCfJ97bW0GqtuqDD/1aqLbKinI/rNgUsg==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -4678,6 +4708,23 @@
"node": ">=8"
}
},
"node_modules/find-cache-dir": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-5.0.0.tgz",
"integrity": "sha512-OuWNfjfP05JcpAP3JPgAKUhWefjMRfI5iAoSsvE24ANYWJaepAtlSgWECSVEuRgSXpyNEc9DJwG/TZpgcOqyig==",
"dev": true,
"license": "MIT",
"dependencies": {
"common-path-prefix": "^3.0.0",
"pkg-dir": "^7.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -5155,6 +5202,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -5617,6 +5673,21 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -5828,6 +5899,24 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mediabunny": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.7.2.tgz",
"integrity": "sha512-M84Ice7IR1OpWmmDoUh691Hjpf+w2UTt+VWUH0WkiF5MdfFZ63OAF2TmOhbAEVZyvQ088xlvmaT01t00Qgy/QA==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
"packages/*"
],
"dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/dom-webcodecs": "0.1.13"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/Vanilagy"
}
},
"node_modules/mensch": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz",
@@ -6736,6 +6825,110 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pkg-dir": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz",
"integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"find-up": "^6.3.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkg-dir/node_modules/find-up": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz",
"integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^7.1.0",
"path-exists": "^5.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkg-dir/node_modules/locate-path": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz",
"integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^6.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkg-dir/node_modules/p-limit": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
"integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkg-dir/node_modules/p-locate": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz",
"integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkg-dir/node_modules/path-exists": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz",
"integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/pkg-dir/node_modules/yocto-queue": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz",
"integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkg-types": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.1.0.tgz",
@@ -6869,6 +7062,21 @@
],
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz",
@@ -8806,6 +9014,21 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.7.3",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/Vanilagy"
},
"peerDependencies": {
"mediabunny": "^1.0.0"
}
}
}
}
+18 -10
View File
@@ -1,15 +1,18 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.3.2",
"version": "1.7.3",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
"packages/*"
],
"main": "./dist/bundles/mediabunny.cjs",
"module": "./dist/modules/index.js",
"types": "./dist/modules/index.d.ts",
"module": "./dist/modules/src/index.js",
"types": "./dist/modules/src/index.d.ts",
"exports": {
"types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js",
"types": "./dist/modules/src/index.d.ts",
"import": "./dist/modules/src/index.js",
"require": "./dist/bundles/mediabunny.cjs"
},
"files": [
@@ -21,18 +24,21 @@
],
"sideEffects": false,
"scripts": {
"build": "rm -rf dist && tsx scripts/ensure-license-headers.ts && tsc -p src && npm run fix-build-import-paths && node build.mjs && api-extractor run && npm run check-docblocks && npm run append-namespace",
"watch": "node build.mjs --watch",
"build": "./build.sh",
"watch": "tsx scripts/bundle.ts --watch",
"lint": "eslint .",
"check": "tsc -p src --noEmit && tsc -p scripts --noEmit && tsc -p tsconfig.vite.json --noEmit && rm tsconfig.vite.tsbuildinfo",
"check": "tsc -p src --noEmit && tsc -p packages/mp3-encoder/src --noEmit && tsc -p scripts --noEmit && tsc -p tsconfig.vite.json --noEmit && rm tsconfig.vite.tsbuildinfo",
"check-docblocks": "tsx scripts/check-docblocks.ts dist/mediabunny.d.ts",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs && npm run examples:build",
"docs:preview": "vitepress preview docs",
"dev": "vite",
"examples:build": "vite build",
"fix-build-import-paths": "find dist -name \"*.js\" -type f -exec sed -i '' \"s/ from '\\([^']*\\)';/ from '\\1.js';/g\" {} \\;",
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts"
"fix-build-import-paths": "tsx scripts/add-import-extensions.ts",
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts",
"bump-patch": "npm version patch --no-git-tag-version --workspaces --include-workspace-root",
"bump-minor": "npm version minor --no-git-tag-version --workspaces --include-workspace-root",
"bump-major": "npm version major --no-git-tag-version --workspaces --include-workspace-root"
},
"license": "MPL-2.0",
"repository": {
@@ -61,7 +67,9 @@
"@types/markdown-it-footnote": "^3.0.4",
"@types/node": "^22.13.10",
"esbuild": "^0.25.1",
"esbuild-plugin-external-global": "^1.0.1",
"eslint": "^9.22.0",
"find-cache-dir": "^5.0.0",
"markdown-it-footnote": "^4.0.0",
"markdown-it-mathjax3": "^4.3.2",
"mermaid": "^11.6.0",
+1
View File
@@ -0,0 +1 @@
build/* linguist-generated
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+154
View File
@@ -0,0 +1,154 @@
# @mediabunny/mp3-encoder
[![](https://img.shields.io/npm/v/@mediabunny/mp3-encoder)](https://www.npmjs.com/package/@mediabunny/mp3-encoder)
[![](https://img.shields.io/bundlephobia/minzip/@mediabunny/mp3-encoder)](https://bundlephobia.com/package/@mediabunny/mp3-encoder)
[![](https://img.shields.io/npm/dm/@mediabunny/mp3-encoder)](https://www.npmjs.com/package/@mediabunny/mp3-encoder)
<div align="center">
<img src="./logo.svg" width="180" height="180">
</div>
Browsers typically have no support for MP3 encoding in their WebCodecs implementations. Given the ubiquity of the format, this extension package provides an MP3 encoder for use with [Mediabunny](https://github.com/Vanilagy/mediabunny). It is implemented using Mediabunny's [custom coder API](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) and uses a highly-performant WASM build of the [LAME MP3 Encoder](https://lame.sourceforge.io/) under the hood.
> This package, like the rest of Mediabunny, is enabled by its [sponsors](https://mediabunny.dev/#sponsors) and their donations. If you've derived value from this package, please consider [leaving a donation](https://github.com/sponsors/Vanilagy)! 💘
## Installation
This library peer-depends on Mediabunny. Install both using npm:
```bash
npm install mediabunny @mediabunny/mp3-encoder
```
Alternatively, directly include them using a script tag:
```html
<script src="mediabunny.js"></script>
<script src="mediabunny-mp3-encoder.js"></script>
```
This will expose the global objects `Mediabunny` and `MediabunnyMp3Encoder`. Use `mediabunny-mp3-encoder.d.ts` to provide types for these globals. You can download the built distribution files from the [releases page](https://github.com/Vanilagy/mediabunny/releases).
## Usage
```ts
import { registerMp3Encoder } from '@mediabunny/mp3-encoder';
registerMp3Encoder();
```
That's it - Mediabunny now uses the registered MP3 encoder automatically.
If you want to be more correct, check for native browser support first:
```ts
import { canEncodeAudio } from 'mediabunny';
import { registerMp3Encoder } from '@mediabunny/mp3-encoder';
if (!(await canEncodeAudio('mp3'))) {
registerMp3Encoder();
}
```
## Example
Here, we convert an input file to an MP3:
```ts
import {
Input,
ALL_FORMATS,
BlobSource,
Output,
BufferTarget,
Mp3OutputFormat,
canEncodeAudio,
Conversion,
} from 'mediabunny';
import { registerMp3Encoder } from '@mediabunny/mp3-encoder';
if (!(await canEncodeAudio('mp3'))) {
// Only register the custom encoder if there's no native support
registerMp3Encoder();
}
const input = new Input({
source: new BlobSource(file), // From a file picker, for example
formats: ALL_FORMATS,
});
const output = new Output({
format: new Mp3OutputFormat(),
target: new BufferTarget(),
});
const conversion = await Conversion.init({
input,
output,
});
await conversion.execute();
output.target.buffer; // => ArrayBuffer containing the MP3 file
```
For more ways of using Mediabunny, refer to its [guide](https://mediabunny.dev/guide/introduction).
## License
`@mediabunny/mp3-encoder` uses [the same MPL-2.0 license](https://sourceforge.net/projects/lame/files/lame/) as Mediabunny. The LAME MP3 Encoder is licensed under LGPL. On their [license page](https://lame.sourceforge.io/license.txt), they ask that you give them a shoutout with a link.
## Implementation details
This library implements an MP3 encoder by registering a custom encoder class with Mediabunny. This class, when initialized, spawns a worker which then immediately loads a WASM build of the LAME MP3 encoder. Then, raw data is sent to the worker and encoded data is received from it. These encoded chunks are then concatenated in the main thread and properly split into separate MP3 frames.
Great care was put into ensuring maximum compatibility of this package; it works with bundlers, directly in the browser, as well as in Node, Deno, and Bun. All code (including worker & WASM) are bundled into a single file, eliminating the need for CDNs or WASM path arguments. This packages therefore serves as a reference implementation of WASM-based encoder extensions for Mediabunny.
The WASM build itself is a performance-optimized, SIMD-enabled build of LAME 3.100, with all unneeded features disabled. Because maximum performance was the priority, the build is slighter bigger, but ~130 kB gzipped is still very reasonable in my opinion. In my tests, it encodes 5 seconds of audio in ~90 milliseconds (55x real-time speed).
## Building and development
Building this library is done using the build commands in the [Mediabunny root](https://github.com/Vanilagy/mediabunny).
For simplicity, all built WASM artifacts are included in the repo, since these rarely change. However, here are the instructions for building them from scratch:
### Prerequisites
[Install Emscripten](https://emscripten.org/docs/getting_started/downloads.html). The recommended way is using the emsdk, which involves cloning a repo and running a few commands.
### Compiling LAME:
[Download the LAME source code](https://sourceforge.net/projects/lame/files/lame/) from SourceForge. Then, in the folder:
```bash
emconfigure ./configure \
CFLAGS="-DNDEBUG -DNO_STDIO -O3 -msimd128" \
--disable-dependency-tracking \
--disable-shared \
--disable-gtktest \
--disable-analyzer-hooks \
--disable-decoder \
--disable-frontend
emmake make clean
emmake make
```
This generates `libmp3lame.a`, located in `libmp3lame/.libs`.
### Compiling the LAME bridge:
Now with LAME built, we must build the C code at `src/lame-bridge.c` containing a lean bridge interface between JavaScript and LAME's API. Clone Mediabunny, and then, in `packages/mp3-encoder` and with `build/libmp3lame.a` in place, run:
```bash
emcc src/lame-bridge.c build/libmp3lame.a \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-s SINGLE_FILE=1 \
-s ENVIRONMENT=web,worker \
-s EXPORTED_RUNTIME_METHODS=cwrap,HEAPU8 \
-s EXPORTED_FUNCTIONS=_malloc,_free \
-msimd128 \
-O3 \
-o build/lame.js
```
This generates `build/lame.js`, which contains both the JavaScript "glue code" as well as the compiled WASM encoded with Base64.
### Building the package
Then, the complete JavaScript package can be built alongside the rest of Mediabunny by running `npm run build` in Mediabunny's root.
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "dist/modules/src/index.d.ts",
"bundledPackages": [],
"compiler": {},
"apiReport": {
"enabled": false
},
"docModel": {
"enabled": false
},
"dtsRollup": {
"enabled": true,
"untrimmedFilePath": "dist/mediabunny-mp3-encoder.d.ts"
},
"tsdocMetadata": {
"enabled": false
},
"messages": {
"compilerMessageReporting": {
"default": {
"logLevel": "warning"
}
},
"extractorMessageReporting": {
"default": {
"logLevel": "warning"
}
},
"tsdocMessageReporting": {
"default": {
"logLevel": "warning"
}
}
},
"newlineKind": "lf"
}
File diff suppressed because one or more lines are too long
Binary file not shown.
+1342
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

+52
View File
@@ -0,0 +1,52 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.7.3",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"types": "./dist/modules/src/index.d.ts",
"exports": {
"types": "./dist/modules/src/index.d.ts",
"import": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"require": "./dist/bundles/mediabunny-mp3-encoder.mjs"
},
"files": [
"README.md",
"package.json",
"LICENSE",
"dist",
"src"
],
"sideEffects": false,
"license": "MPL-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/Vanilagy/mediabunny.git",
"directory": "packages/mp3-encoder"
},
"bugs": {
"url": "https://github.com/Vanilagy/mediabunny/issues"
},
"homepage": "https://mediabunny.dev/guide/extensions/mp3-encoder",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/Vanilagy"
},
"peerDependencies": {
"mediabunny": "^1.0.0"
},
"devDependencies": {
"@types/emscripten": "^1.40.1"
},
"keywords": [
"mp3",
"encoding",
"codec",
"mediabunny",
"lame",
"browser",
"wasm",
"polyfill"
]
}
+212
View File
@@ -0,0 +1,212 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import createModule from '../build/lame';
import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
type ExtendedEmscriptenModule = EmscriptenModule & {
cwrap: typeof cwrap;
};
let module: ExtendedEmscriptenModule;
let lamePtr: number;
let storedNumberOfChannels: number;
let storedSampleRate: number;
let storedBitrate: number;
let needsReset = false;
let initLame: (
numberOfChannels: number,
sampleRate: number,
bitrate: number
) => number;
let encodeSamples: (
lame: number,
leftPtr: number,
rightPtr: number,
sampleCount: number,
outputPtr: number,
outputSize: number
) => number;
let flushLame: (lame: number, outputPtr: number, outputSize: number) => number;
let closeLame: (lame: number) => void;
let inputSlice: Slice | null = null;
let outputSlice: Slice | null = null;
const init = async (numberOfChannels: number, sampleRate: number, bitrate: number) => {
storedNumberOfChannels = numberOfChannels;
storedSampleRate = sampleRate;
storedBitrate = bitrate;
module = (await createModule()) as ExtendedEmscriptenModule;
// Set up the functions
initLame = module.cwrap('init_lame', 'number', ['number', 'number', 'number']);
encodeSamples = module.cwrap(
'encode_samples',
'number',
['number', 'number', 'number', 'number', 'number', 'number'],
);
flushLame = module.cwrap('flush_lame', 'number', ['number', 'number', 'number']);
closeLame = module.cwrap('close_lame', null, ['number']);
lamePtr = initLame(numberOfChannels, sampleRate, bitrate);
};
const reset = () => {
closeLame(lamePtr);
lamePtr = initLame(storedNumberOfChannels, storedSampleRate, storedBitrate);
};
const encode = (audioData: ArrayBuffer, numberOfFrames: number) => {
if (needsReset) {
reset();
needsReset = false;
}
const audioBytes = new Uint8Array(audioData);
const sizePerChannel = audioBytes.length / storedNumberOfChannels;
inputSlice = maybeGrowSlice(inputSlice, audioBytes.length);
module.HEAPU8.set(audioBytes, inputSlice.ptr);
const requiredOutputSize = Math.ceil(1.25 * numberOfFrames + 7200);
outputSlice = maybeGrowSlice(outputSlice, requiredOutputSize);
const bytesWritten = encodeSamples(
lamePtr,
inputSlice.ptr,
inputSlice.ptr + (storedNumberOfChannels - 1) * sizePerChannel,
numberOfFrames,
outputSlice.ptr,
requiredOutputSize,
);
const result = module.HEAPU8.slice(outputSlice.ptr, outputSlice.ptr + bytesWritten);
return result.buffer;
};
const flush = () => {
if (needsReset) {
reset();
needsReset = false;
}
const requiredOutputSize = 7200;
outputSlice = maybeGrowSlice(outputSlice, requiredOutputSize);
const bytesWritten = flushLame(lamePtr, outputSlice.ptr, requiredOutputSize);
const result = module.HEAPU8.slice(outputSlice.ptr, outputSlice.ptr + bytesWritten);
needsReset = true; // After a flush, the encoder must be prepared to start a new encoding process
return result.buffer;
};
/** A "fat pointer" type thing. */
type Slice = {
ptr: number;
size: number;
};
/** Either returns the existing slice, or allocates a new one if there's no existing slice or it was too small. */
const maybeGrowSlice = (slice: Slice | null, requiredSize: number) => {
if (!slice || slice.size < requiredSize) {
if (slice) {
module._free(slice.ptr);
}
return {
ptr: module._malloc(requiredSize),
size: requiredSize,
};
}
return slice;
};
const onMessage = (data: { id: number; command: WorkerCommand }) => {
const { id, command } = data;
const handleCommand = async (): Promise<void> => {
try {
let result: WorkerResponseData;
const transferables: Transferable[] = [];
switch (command.type) {
case 'init': {
await init(
command.data.numberOfChannels,
command.data.sampleRate,
command.data.bitrate,
);
result = { success: true };
}; break;
case 'encode': {
const encodedData = encode(
command.data.audioData,
command.data.numberOfFrames,
);
result = { encodedData };
transferables.push(encodedData);
}; break;
case 'flush': {
const flushedData = flush();
result = { flushedData };
transferables.push(flushedData);
}; break;
}
const response: WorkerResponse = {
id,
success: true,
data: result,
};
sendMessage(response, transferables);
} catch (error) {
const response: WorkerResponse = {
id,
success: false,
error,
};
sendMessage(response);
}
};
void handleCommand();
};
const sendMessage = (data: unknown, transferables?: Transferable[]) => {
if (parentPort) {
parentPort.postMessage(data, transferables ?? []);
} else {
self.postMessage(data, { transfer: transferables ?? [] });
}
};
let parentPort: {
postMessage: (data: unknown, transferables?: Transferable[]) => void;
on: (event: string, listener: (data: never) => void) => void;
} | null = null;
if (typeof self === 'undefined') {
// We're in Node.js (or a runtime that mimics it)
const workerModule = 'worker_threads';
// eslint-disable-next-line @stylistic/max-len
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
parentPort = require(workerModule).parentPort;
}
if (parentPort) {
parentPort.on('message', onMessage);
} else {
self.addEventListener('message', event => onMessage(event.data as { id: number; command: WorkerCommand }));
}
+213
View File
@@ -0,0 +1,213 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { CustomAudioEncoder, AudioCodec, AudioSample, EncodedPacket, registerEncoder } from 'mediabunny';
import { FRAME_HEADER_SIZE, readFrameHeader } from '../../../shared/mp3-misc';
import type { WorkerCommand, WorkerResponse, WorkerResponseData } from './shared';
// @ts-expect-error An esbuild plugin handles this, TypeScript doesn't need to understand
import createWorker from './encode.worker';
class Mp3Encoder extends CustomAudioEncoder {
private worker: Worker | null = null;
private nextMessageId = 0;
private pendingMessages = new Map<number, {
resolve: (value: WorkerResponseData) => void;
reject: (reason?: unknown) => void;
}>();
private buffer = new Uint8Array(2 ** 16);
private currentBufferOffset = 0;
private currentTimestamp = 0;
private chunkMetadata: EncodedAudioChunkMetadata = {};
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
return codec === 'mp3'
&& (config.numberOfChannels === 1 || config.numberOfChannels === 2)
&& (config.sampleRate === 32000 || config.sampleRate === 44100 || config.sampleRate === 48000);
}
async init() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
this.worker = (await createWorker()) as Worker; // The actual encoding takes place in this worker
const onMessage = (data: WorkerResponse) => {
const pending = this.pendingMessages.get(data.id);
assert(pending !== undefined);
this.pendingMessages.delete(data.id);
if (data.success) {
pending.resolve(data.data);
} else {
pending.reject(data.error);
}
};
if (this.worker.addEventListener) {
this.worker.addEventListener('message', event => onMessage(event.data as WorkerResponse));
} else {
const nodeWorker = this.worker as unknown as {
on: (event: string, listener: (data: never) => void) => void;
};
nodeWorker.on('message', onMessage);
}
assert(this.config.bitrate);
await this.sendCommand({
type: 'init',
data: {
numberOfChannels: this.config.numberOfChannels,
sampleRate: this.config.sampleRate,
bitrate: this.config.bitrate,
},
});
this.chunkMetadata = {
decoderConfig: {
codec: 'mp3',
numberOfChannels: this.config.numberOfChannels,
sampleRate: this.config.sampleRate,
},
};
}
async encode(audioSample: AudioSample) {
const sizePerChannel = audioSample.allocationSize({
format: 's16-planar',
planeIndex: 0,
});
const requiredBytes = audioSample.numberOfChannels * sizePerChannel;
const audioData = new ArrayBuffer(requiredBytes);
const audioBytes = new Uint8Array(audioData);
for (let i = 0; i < audioSample.numberOfChannels; i++) {
audioSample.copyTo(audioBytes.subarray(i * sizePerChannel), {
format: 's16-planar', // LAME wants it in this format
planeIndex: i,
});
}
const result = await this.sendCommand({
type: 'encode',
data: {
audioData,
numberOfFrames: audioSample.numberOfFrames,
},
}, [audioData]);
assert('encodedData' in result);
this.digestOutput(new Uint8Array(result.encodedData));
}
async flush() {
const result = await this.sendCommand({ type: 'flush' });
assert('flushedData' in result);
this.digestOutput(new Uint8Array(result.flushedData));
}
close() {
this.worker?.terminate();
}
/**
* LAME returns data in chunks, but a chunk doesn't need to contain a full MP3 frame. Therefore, we must accumulate
* these chunks and extract the MP3 frames only when they're complete.
*/
private digestOutput(bytes: Uint8Array) {
const requiredBufferSize = this.currentBufferOffset + bytes.length;
if (requiredBufferSize > this.buffer.length) {
// Grow the buffer to the required size
const newSize = 1 << Math.ceil(Math.log2(requiredBufferSize));
const newBuffer = new Uint8Array(newSize);
newBuffer.set(this.buffer);
this.buffer = newBuffer;
}
this.buffer.set(bytes, this.currentBufferOffset);
this.currentBufferOffset = requiredBufferSize;
let pos = 0;
while (pos <= this.currentBufferOffset - FRAME_HEADER_SIZE) {
const word = new DataView(this.buffer.buffer).getUint32(pos, false);
const header = readFrameHeader(word, { pos, fileSize: null });
if (!header) {
break;
}
const fits = header.totalSize <= this.currentBufferOffset - pos;
if (!fits) {
// The frame isn't complete yet
break;
}
const data = this.buffer.slice(pos, pos + header.totalSize);
const duration = header.audioSamplesInFrame / header.sampleRate;
this.onPacket(new EncodedPacket(data, 'key', this.currentTimestamp, duration), this.chunkMetadata);
if (this.currentTimestamp === 0) {
this.chunkMetadata = {}; // Mimic WebCodecs-like behavior
}
this.currentTimestamp += duration;
pos += header.totalSize;
}
if (pos > 0) {
// Shift the data
this.buffer.set(this.buffer.subarray(pos, this.currentBufferOffset), 0);
this.currentBufferOffset -= pos;
}
}
private sendCommand(
command: WorkerCommand,
transferables?: Transferable[],
) {
return new Promise<WorkerResponseData>((resolve, reject) => {
const id = this.nextMessageId++;
this.pendingMessages.set(id, { resolve, reject });
assert(this.worker);
if (transferables) {
this.worker.postMessage({ id, command }, transferables);
} else {
this.worker.postMessage({ id, command });
}
});
}
}
/**
* Registers the LAME MP3 encoder, which Mediabunny will then use automatically when applicable. Make sure to call this
* function before starting any encoding task.
*
* Preferably, wrap the call in a condition to avoid overriding any native MP3 encoder:
*
* ```ts
* import { canEncodeAudio } from 'mediabunny';
* import { registerMp3Encoder } from '@mediabunny/mp3-encoder';
*
* if (!(await canEncodeAudio('mp3'))) {
* registerMp3Encoder();
* }
* ```
*
* @public
*/
export const registerMp3Encoder = () => {
registerEncoder(Mp3Encoder);
};
function assert(x: unknown): asserts x {
if (!x) {
throw new Error('Assertion failed.');
}
}
+40
View File
@@ -0,0 +1,40 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
#include <emscripten.h>
#include "../lib/lame.h"
EMSCRIPTEN_KEEPALIVE
lame_global_flags *init_lame(int number_of_channels, int sample_rate, int bitrate) {
lame_global_flags *gfp = lame_init();
lame_set_num_channels(gfp, number_of_channels);
lame_set_in_samplerate(gfp, sample_rate);
lame_set_out_samplerate(gfp, sample_rate);
lame_set_brate(gfp, bitrate / 1000); // MP3 wants "kilobitrate"
lame_set_bWriteVbrTag(gfp, 0);
int ret_code = lame_init_params(gfp);
return gfp;
}
EMSCRIPTEN_KEEPALIVE
int encode_samples(lame_global_flags *gfp, short int left_buf[], short int right_buf[], int sample_count, unsigned char *dest_buf, int dest_buf_size) {
return lame_encode_buffer(gfp, left_buf, right_buf, sample_count, dest_buf, dest_buf_size);
}
EMSCRIPTEN_KEEPALIVE
int flush_lame(lame_global_flags *gfp, unsigned char *dest_buf, int dest_buf_size) {
return lame_encode_flush(gfp, dest_buf, dest_buf_size);
}
EMSCRIPTEN_KEEPALIVE
void close_lame(lame_global_flags *gfp) {
lame_close(gfp);
}
+42
View File
@@ -0,0 +1,42 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
export type WorkerCommand = {
type: 'init';
data: {
numberOfChannels: number;
sampleRate: number;
bitrate: number;
};
} | {
type: 'encode';
data: {
audioData: ArrayBuffer;
numberOfFrames: number;
};
} | {
type: 'flush';
};
export type WorkerResponseData = {
success: boolean;
} | {
encodedData: ArrayBuffer;
} | {
flushedData: ArrayBuffer;
};
export type WorkerResponse = {
id: number;
} & ({
success: true;
data: WorkerResponseData;
} | {
success: false;
error: unknown;
});
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "../dist/modules",
"declaration": true,
"declarationMap": true,
"stripInternal": true,
"noEmit": false,
"moduleResolution": "nodenext",
"module": "nodenext",
"allowJs": true,
"paths": {
"mediabunny": ["../../../src/index.ts"],
},
},
"include": [
"**/*",
"../../../shared/**/*"
],
"references": [
{ "path": "../../../src" }
]
}
+37
View File
@@ -0,0 +1,37 @@
import * as fs from 'fs';
import * as path from 'path';
// .js extensions are technically required in compliant ECMAScript, and Webpack needs them, so we add them here.
const walkDir = (dir: string) => {
const files: string[] = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files.push(...walkDir(fullPath));
} else if (item.endsWith('.js')) {
files.push(fullPath);
}
}
return files;
};
const fixFile = (filePath: string) => {
const content = fs.readFileSync(filePath, 'utf8');
const fixed = content.replace(
/(\s+from\s+['"])([^'"]*)(['"])/g,
'$1$2.js$3',
);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
}
};
const jsFiles = walkDir('dist');
jsFiles.forEach(fixFile);
+125
View File
@@ -0,0 +1,125 @@
import * as esbuild from 'esbuild';
import process from 'node:process';
import PluginExternalGlobal from 'esbuild-plugin-external-global';
import { inlineWorkerPlugin } from './esbuild/inlined-workers.js';
/** Creates UMD and ESM variants, each unminified and minified. */
const createVariants = async (
entryPoint: string,
globalName: string,
outfileBase: string,
umdExtension: string,
specificUmdConfig: esbuild.BuildOptions = {},
specificEsmConfig: esbuild.BuildOptions = {},
) => {
const baseConfig: esbuild.BuildOptions = {
entryPoints: [entryPoint],
bundle: true,
logLevel: 'info',
banner: {
js: `/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/`,
},
legalComments: 'none',
};
const umdConfig: esbuild.BuildOptions = {
...baseConfig,
format: 'iife',
globalName,
footer: {
js:
`if (typeof module === "object" && typeof module.exports === "object") Object.assign(module.exports, ${globalName})`,
},
};
const esmConfig: esbuild.BuildOptions = {
...baseConfig,
format: 'esm',
};
const umdVariant = await esbuild.context({
...umdConfig,
...specificUmdConfig,
outfile: `${outfileBase}.${umdExtension}`,
});
const esmVariant = await esbuild.context({
...esmConfig,
...specificEsmConfig,
outfile: `${outfileBase}.mjs`,
});
const umdMinifiedVariant = await esbuild.context({
...umdConfig,
...specificUmdConfig,
outfile: `${outfileBase}.min.${umdExtension}`,
minify: true,
});
const esmMinifiedVariant = await esbuild.context({
...esmConfig,
...specificEsmConfig,
outfile: `${outfileBase}.min.mjs`,
minify: true,
});
return [umdVariant, esmVariant, umdMinifiedVariant, esmMinifiedVariant];
};
const mediabunnyVariants = await createVariants(
'src/index.ts',
'Mediabunny',
'dist/bundles/mediabunny',
'cjs',
);
const mp3EncoderVariants = await createVariants(
'packages/mp3-encoder/src/index.ts',
'MediabunnyMp3Encoder',
'packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
mediabunny: 'Mediabunny',
}),
inlineWorkerPlugin({
define: {
'import.meta.url': '""',
},
legalComments: 'none',
}),
],
},
{
external: ['mediabunny'],
plugins: [
inlineWorkerPlugin({
define: {
'import.meta.url': '""',
},
legalComments: 'none',
}),
],
},
);
const contexts = [
...mediabunnyVariants,
...mp3EncoderVariants,
];
if (process.argv[2] === '--watch') {
await Promise.all(contexts.map(ctx => ctx.watch()));
} else {
for (const ctx of contexts) {
await ctx.rebuild();
await ctx.dispose();
}
}
+3 -1
View File
@@ -1,5 +1,5 @@
import ts from 'typescript';
import * as fs from 'node:fs';
import * as fs from 'fs';
const checkDocblocks = (filePath: string) => {
const program = ts.createProgram([filePath], {});
@@ -17,6 +17,8 @@ const checkDocblocks = (filePath: string) => {
ts.isInterfaceDeclaration(node)
|| ts.isClassDeclaration(node)
|| ts.isMethodDeclaration(node)
|| ts.isGetAccessorDeclaration(node)
|| ts.isSetAccessorDeclaration(node)
|| ts.isPropertyDeclaration(node)
|| ts.isFunctionDeclaration(node)
|| ts.isTypeAliasDeclaration(node)
+4 -1
View File
@@ -23,17 +23,20 @@ const checkFile = (filePath: string) => {
const checkDirectory = (dirPath: string) => {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
checkDirectory(fullPath);
} else if (entry.name.endsWith('.ts')) {
} else if (entry.name.endsWith('.ts') || entry.name.endsWith('.c')) {
checkFile(fullPath);
}
}
};
checkDirectory(path.join(__dirname, '..', 'src'));
checkDirectory(path.join(__dirname, '..', 'packages', 'mp3-encoder', 'src'));
checkDirectory(path.join(__dirname, '..', 'shared'));
if (missingFiles.length > 0) {
console.error('Files missing license header:');
+103
View File
@@ -0,0 +1,103 @@
// Adapted from https://github.com/mitschabaude/esbuild-plugin-inline-worker
import * as esbuild from 'esbuild';
import findCacheDir from 'find-cache-dir';
import fs from 'node:fs';
import path from 'node:path';
export function inlineWorkerPlugin(extraConfig: esbuild.BuildOptions): esbuild.Plugin {
return {
name: 'esbuild-plugin-inline-worker',
setup(build) {
build.onLoad(
{ filter: /\.worker\.(js|jsx|ts|tsx)$/ },
async ({ path: workerPath }) => {
const workerCode = await buildWorker(workerPath, extraConfig);
return {
contents: `import inlineWorker from '__inline-worker'
export default function Worker() {
return inlineWorker(${JSON.stringify(workerCode)});
}
`,
loader: 'js',
};
},
);
const inlineWorkerFunctionCode = `
export default async function inlineWorker(scriptText) {
if (typeof Worker !== 'undefined' && typeof Bun === 'undefined') {
// Browser, Deno
const blob = new Blob([scriptText], { type: "text/javascript" });
const url = URL.createObjectURL(blob);
const worker = new Worker(url, { type: typeof Deno !== 'undefined' ? 'module' : undefined }); // module for Deno
URL.revokeObjectURL(url);
return worker;
} else {
// Node, Bun (Bun's Worker is flaky, worker_threads works much better)
let Worker;
try {
Worker = (await import('worker_threads')).Worker;
} catch {
const workerModule = 'worker_threads';
Worker = require(workerModule).Worker;
}
const worker = new Worker(scriptText, { eval: true });
return worker;
}
}
`;
build.onResolve({ filter: /^__inline-worker$/ }, ({ path }) => {
return { path, namespace: 'inline-worker' };
});
build.onLoad({ filter: /.*/, namespace: 'inline-worker' }, () => {
return { contents: inlineWorkerFunctionCode, loader: 'js' };
});
},
};
}
const cacheDir = findCacheDir({
name: 'esbuild-plugin-inline-worker',
create: true,
});
if (cacheDir === undefined) {
throw new Error('Cache directory not found.');
}
let i = 0;
async function buildWorker(workerPath: string, extraConfig: esbuild.BuildOptions) {
const scriptNameParts = path.basename(workerPath).split('.');
scriptNameParts.pop();
scriptNameParts.push(String(i)); // To make sure it doesn't clash with other builds
scriptNameParts.push('js');
const scriptName = scriptNameParts.join('.');
const bundlePath = path.resolve(cacheDir!, scriptName);
i = (i + 1) % 32;
if (extraConfig) {
delete extraConfig.entryPoints;
delete extraConfig.outfile;
delete extraConfig.outdir;
}
await esbuild.build({
entryPoints: [workerPath],
bundle: true,
minify: true,
outfile: bundlePath,
target: 'es2017',
format: 'esm',
...extraConfig,
});
return fs.promises.readFile(bundlePath, { encoding: 'utf-8' });
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
},
"include": [
"**/*"
]
}
+374 -178
View File
@@ -35,10 +35,131 @@ import {
VideoSampleSource,
AudioSampleSource,
} from './media-source';
import { assert, clamp, normalizeRotation, promiseWithResolvers, Rotation } from './misc';
import { assert, clamp, MaybePromise, normalizeRotation, promiseWithResolvers, Rotation } from './misc';
import { Output, TrackType } from './output';
import { AudioSample, VideoSample } from './sample';
/**
* Video-specific options.
* @public
*/
export type ConversionVideoOptions = {
/** If true, all video tracks will be discarded and will not be present in the output. */
discard?: boolean;
/**
* The desired width of the output video in pixels, defaulting to the video's natural display width. If height
* is not set, it will be deduced automatically based on aspect ratio.
*/
width?: number;
/**
* The desired height of the output video in pixels, defaulting to the video's natural display height. If width
* is not set, it will be deduced automatically based on aspect ratio.
*/
height?: number;
/**
* The fitting algorithm in case both width and height are set.
*
* - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
* - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
* letterboxing.
* - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio.
*/
fit?: 'fill' | 'contain' | 'cover';
/**
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
*/
rotate?: Rotation;
/**
* The desired frame rate of the output video, in hertz. If not specified, the original input frame rate will
* be used (which may be variable).
*/
frameRate?: number;
/** The desired output video codec. */
codec?: VideoCodec;
/** The desired bitrate of the output video. */
bitrate?: VideoEncodingConfig['bitrate'];
/** When true, video will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean;
};
/**
* Audio-specific options.
* @public
*/
export type ConversionAudioOptions = {
/** If true, all audio tracks will be discarded and will not be present in the output. */
discard?: boolean;
/** The desired channel count of the output audio. */
numberOfChannels?: number;
/** The desired sample rate of the output audio, in hertz. */
sampleRate?: number;
/** The desired output audio codec. */
codec?: AudioCodec;
/** The desired bitrate of the output audio. */
bitrate?: AudioEncodingConfig['bitrate'];
/** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean;
};
const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) => {
if (videoOptions !== undefined && (!videoOptions || typeof videoOptions !== 'object')) {
throw new TypeError('options.video, when provided, must be an object.');
}
if (videoOptions?.discard !== undefined && typeof videoOptions.discard !== 'boolean') {
throw new TypeError('options.video.discard, when provided, must be a boolean.');
}
if (videoOptions?.forceTranscode !== undefined && typeof videoOptions.forceTranscode !== 'boolean') {
throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.');
}
if (videoOptions?.codec !== undefined && !VIDEO_CODECS.includes(videoOptions.codec)) {
throw new TypeError(
`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`,
);
}
if (
videoOptions?.bitrate !== undefined
&& !(videoOptions.bitrate instanceof Quality)
&& (!Number.isInteger(videoOptions.bitrate) || videoOptions.bitrate <= 0)
) {
throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.');
}
if (
videoOptions?.width !== undefined
&& (!Number.isInteger(videoOptions.width) || videoOptions.width <= 0)
) {
throw new TypeError('options.video.width, when provided, must be a positive integer.');
}
if (
videoOptions?.height !== undefined
&& (!Number.isInteger(videoOptions.height) || videoOptions.height <= 0)
) {
throw new TypeError('options.video.height, when provided, must be a positive integer.');
}
if (videoOptions?.fit !== undefined && !['fill', 'contain', 'cover'].includes(videoOptions.fit)) {
throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".');
}
if (
videoOptions?.width !== undefined
&& videoOptions.height !== undefined
&& videoOptions.fit === undefined
) {
throw new TypeError(
'When both options.video.width and options.video.height are provided, options.video.fit must also be'
+ ' provided.',
);
}
if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
}
if (
videoOptions?.frameRate !== undefined
&& (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)
) {
throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.');
}
};
/**
* The options for media file conversion.
* @public
@@ -49,57 +170,23 @@ export type ConversionOptions = {
/** The output file. */
output: Output;
/** Video-specific options. */
video?: {
/** If true, all video tracks will be discarded and will not be present in the output. */
discard?: boolean;
/**
* The desired width of the output video in pixels, defaulting to the video's natural display width. If height
* is not set, it will be deduced automatically based on aspect ratio.
*/
width?: number;
/**
* The desired height of the output video in pixels, defaulting to the video's natural display height. If width
* is not set, it will be deduced automatically based on aspect ratio.
*/
height?: number;
/**
* The fitting algorithm in case both width and height are set.
*
* - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
* - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
* letterboxing.
* - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio.
*/
fit?: 'fill' | 'contain' | 'cover';
/**
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
*/
rotate?: Rotation;
/** The desired output video codec. */
codec?: VideoCodec;
/** The desired bitrate of the output video. */
bitrate?: VideoEncodingConfig['bitrate'];
/** When true, video will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean;
};
/**
* Video-specific options. When passing an object, the same options are applied to all video tracks. When passing a
* function, it will be invoked for each video track and is expected to return or resolve to the options
* for that specific track. The function is passed an instance of `InputVideoTrack` as well as a number `n`, which
* is the 1-based index of the track in the list of all video tracks.
*/
video?: ConversionVideoOptions
| ((track: InputVideoTrack, n: number) => MaybePromise<ConversionVideoOptions | undefined>);
/** Audio-specific options. */
audio?: {
/** If true, all audio tracks will be discarded and will not be present in the output. */
discard?: boolean;
/** The desired channel count of the output audio. */
numberOfChannels?: number;
/** The desired sample rate of the output audio, in hertz. */
sampleRate?: number;
/** The desired output audio codec. */
codec?: AudioCodec;
/** The desired bitrate of the output audio. */
bitrate?: AudioEncodingConfig['bitrate'];
/** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */
forceTranscode?: boolean;
};
/**
* Audio-specific options. When passing an object, the same options are applied to all audio tracks. When passing a
* function, it will be invoked for each audio track and is expected to return or resolve to the options
* for that specific track. The function is passed an instance of `InputAudioTrack` as well as a number `n`, which
* is the 1-based index of the track in the list of all audio tracks.
*/
audio?: ConversionAudioOptions
| ((track: InputAudioTrack, n: number) => MaybePromise<ConversionAudioOptions | undefined>);
/** Options to trim the input file. */
trim?: {
@@ -110,6 +197,42 @@ export type ConversionOptions = {
};
};
const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
if (audioOptions !== undefined && (!audioOptions || typeof audioOptions !== 'object')) {
throw new TypeError('options.audio, when provided, must be an object.');
}
if (audioOptions?.discard !== undefined && typeof audioOptions.discard !== 'boolean') {
throw new TypeError('options.audio.discard, when provided, must be a boolean.');
}
if (audioOptions?.forceTranscode !== undefined && typeof audioOptions.forceTranscode !== 'boolean') {
throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.');
}
if (audioOptions?.codec !== undefined && !AUDIO_CODECS.includes(audioOptions.codec)) {
throw new TypeError(
`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`,
);
}
if (
audioOptions?.bitrate !== undefined
&& !(audioOptions.bitrate instanceof Quality)
&& (!Number.isInteger(audioOptions.bitrate) || audioOptions.bitrate <= 0)
) {
throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.');
}
if (
audioOptions?.numberOfChannels !== undefined
&& (!Number.isInteger(audioOptions.numberOfChannels) || audioOptions.numberOfChannels <= 0)
) {
throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.');
}
if (
audioOptions?.sampleRate !== undefined
&& (!Number.isInteger(audioOptions.sampleRate) || audioOptions.sampleRate <= 0)
) {
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
}
};
const FALLBACK_NUMBER_OF_CHANNELS = 2;
const FALLBACK_SAMPLE_RATE = 48000;
@@ -212,88 +335,19 @@ export class Conversion {
if (options.output._tracks.length > 0 || options.output.state !== 'pending') {
throw new TypeError('options.output must be fresh: no tracks added and not started.');
}
if (options.video !== undefined && (!options.video || typeof options.video !== 'object')) {
throw new TypeError('options.video, when provided, must be an object.');
if (typeof options.video !== 'function') {
validateVideoOptions(options.video);
} else {
// We'll validate the return value later
}
if (options.video?.discard !== undefined && typeof options.video.discard !== 'boolean') {
throw new TypeError('options.video.discard, when provided, must be a boolean.');
}
if (options.video?.forceTranscode !== undefined && typeof options.video.forceTranscode !== 'boolean') {
throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.');
}
if (options.video?.codec !== undefined && !VIDEO_CODECS.includes(options.video.codec)) {
throw new TypeError(
`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`,
);
}
if (
options.video?.bitrate !== undefined
&& !(options.video.bitrate instanceof Quality)
&& (!Number.isInteger(options.video.bitrate) || options.video.bitrate <= 0)
) {
throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.');
}
if (
options.video?.width !== undefined
&& (!Number.isInteger(options.video.width) || options.video.width <= 0)
) {
throw new TypeError('options.video.width, when provided, must be a positive integer.');
}
if (
options.video?.height !== undefined
&& (!Number.isInteger(options.video.height) || options.video.height <= 0)
) {
throw new TypeError('options.video.height, when provided, must be a positive integer.');
}
if (options.video?.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.video.fit)) {
throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".');
}
if (
options.video?.width !== undefined
&& options.video.height !== undefined
&& options.video.fit === undefined
) {
throw new TypeError(
'When both options.video.width and options.video.height are provided, options.video.fit must also be'
+ ' provided.',
);
}
if (options.video?.rotate !== undefined && ![0, 90, 180, 270].includes(options.video.rotate)) {
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
}
if (options.audio !== undefined && (!options.audio || typeof options.audio !== 'object')) {
throw new TypeError('options.video, when provided, must be an object.');
}
if (options.audio?.discard !== undefined && typeof options.audio.discard !== 'boolean') {
throw new TypeError('options.audio.discard, when provided, must be a boolean.');
}
if (options.audio?.forceTranscode !== undefined && typeof options.audio.forceTranscode !== 'boolean') {
throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.');
}
if (options.audio?.codec !== undefined && !AUDIO_CODECS.includes(options.audio.codec)) {
throw new TypeError(
`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`,
);
}
if (
options.audio?.bitrate !== undefined
&& !(options.audio.bitrate instanceof Quality)
&& (!Number.isInteger(options.audio.bitrate) || options.audio.bitrate <= 0)
) {
throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.');
}
if (
options.audio?.numberOfChannels !== undefined
&& (!Number.isInteger(options.audio.numberOfChannels) || options.audio.numberOfChannels <= 0)
) {
throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.');
}
if (
options.audio?.sampleRate !== undefined
&& (!Number.isInteger(options.audio.sampleRate) || options.audio.sampleRate <= 0)
) {
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
if (typeof options.audio !== 'function') {
validateAudioOptions(options.audio);
} else {
// We'll validate the return value later
}
if (options.trim !== undefined && (!options.trim || typeof options.trim !== 'object')) {
throw new TypeError('options.trim, when provided, must be an object.');
}
@@ -327,16 +381,36 @@ export class Conversion {
const inputTracks = await this.input.getTracks();
const outputTrackCounts = this.output.format.getSupportedTrackCounts();
let nVideo = 1;
let nAudio = 1;
for (const track of inputTracks) {
if (track.isVideoTrack() && this._options.video?.discard) {
this.discardedTracks.push({
track,
reason: 'discarded_by_user',
});
continue;
let trackOptions: ConversionVideoOptions | ConversionAudioOptions | undefined = undefined;
if (track.isVideoTrack()) {
if (this._options.video) {
if (typeof this._options.video === 'function') {
trackOptions = await this._options.video(track, nVideo);
validateVideoOptions(trackOptions);
nVideo++;
} else {
trackOptions = this._options.video;
}
}
} else if (track.isAudioTrack()) {
if (this._options.audio) {
if (typeof this._options.audio === 'function') {
trackOptions = await this._options.audio(track, nAudio);
validateAudioOptions(trackOptions);
nAudio++;
} else {
trackOptions = this._options.audio;
}
}
} else {
assert(false);
}
if (track.isAudioTrack() && this._options.audio?.discard) {
if (trackOptions?.discard) {
this.discardedTracks.push({
track,
reason: 'discarded_by_user',
@@ -361,9 +435,9 @@ export class Conversion {
}
if (track.isVideoTrack()) {
await this._processVideoTrack(track);
await this._processVideoTrack(track, (trackOptions ?? {}) as ConversionVideoOptions);
} else if (track.isAudioTrack()) {
await this._processAudioTrack(track);
await this._processAudioTrack(track, (trackOptions ?? {}) as ConversionAudioOptions);
}
}
@@ -399,7 +473,7 @@ export class Conversion {
} catch (error) {
if (!this._canceled) {
// Make sure to cancel to stop other encoding processes and clean up resources
await this.cancel();
void this.cancel();
}
throw error;
@@ -432,7 +506,7 @@ export class Conversion {
}
/** @internal */
async _processVideoTrack(track: InputVideoTrack) {
async _processVideoTrack(track: InputVideoTrack, trackOptions: ConversionVideoOptions) {
const sourceCodec = track.codec;
if (!sourceCodec) {
this.discardedTracks.push({
@@ -444,7 +518,7 @@ export class Conversion {
let videoSource: VideoSource;
const totalRotation = normalizeRotation(track.rotation + (this._options.video?.rotate ?? 0));
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
const [originalWidth, originalHeight] = totalRotation % 180 === 0
@@ -458,19 +532,22 @@ export class Conversion {
// A lot of video encoders require that the dimensions be multiples of 2
const ceilToMultipleOfTwo = (value: number) => Math.ceil(value / 2) * 2;
if (this._options.video?.width !== undefined && this._options.video.height === undefined) {
width = ceilToMultipleOfTwo(this._options.video.width);
if (trackOptions.width !== undefined && trackOptions.height === undefined) {
width = ceilToMultipleOfTwo(trackOptions.width);
height = ceilToMultipleOfTwo(Math.round(width / aspectRatio));
} else if (this._options.video?.width === undefined && this._options.video?.height !== undefined) {
height = ceilToMultipleOfTwo(this._options.video.height);
} else if (trackOptions.width === undefined && trackOptions.height !== undefined) {
height = ceilToMultipleOfTwo(trackOptions.height);
width = ceilToMultipleOfTwo(Math.round(height * aspectRatio));
} else if (this._options.video?.width !== undefined && this._options.video.height !== undefined) {
width = ceilToMultipleOfTwo(this._options.video.width);
height = ceilToMultipleOfTwo(this._options.video.height);
} else if (trackOptions.width !== undefined && trackOptions.height !== undefined) {
width = ceilToMultipleOfTwo(trackOptions.width);
height = ceilToMultipleOfTwo(trackOptions.height);
}
const firstTimestamp = await track.getFirstTimestamp();
const needsTranscode = !!this._options.video?.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0;
const needsTranscode = !!trackOptions.forceTranscode
|| this._startTimestamp > 0
|| firstTimestamp < 0
|| !!trackOptions.frameRate;
const needsRerender = width !== originalWidth
|| height !== originalHeight
|| (totalRotation !== 0 && !outputSupportsRotation);
@@ -478,10 +555,10 @@ export class Conversion {
let videoCodecs = this.output.format.getSupportedVideoCodecs();
if (
!needsTranscode
&& !this._options.video?.bitrate
&& !trackOptions.bitrate
&& !needsRerender
&& videoCodecs.includes(sourceCodec)
&& (!this._options.video?.codec || this._options.video?.codec === sourceCodec)
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
) {
// Fast path, we can simply copy over the encoded packets
@@ -526,11 +603,11 @@ export class Conversion {
return;
}
if (this._options.video?.codec) {
videoCodecs = videoCodecs.filter(codec => codec === this._options.video?.codec);
if (trackOptions.codec) {
videoCodecs = videoCodecs.filter(codec => codec === trackOptions.codec);
}
const bitrate = this._options.video?.bitrate ?? QUALITY_HIGH;
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, { width, height, bitrate });
if (!encodableCodec) {
@@ -547,21 +624,42 @@ export class Conversion {
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
};
if (needsRerender) {
const source = new VideoSampleSource(encodingConfig);
videoSource = source;
const source = new VideoSampleSource(encodingConfig);
videoSource = source;
if (needsRerender) {
this._trackPromises.push((async () => {
await this._started;
const sink = new CanvasSink(track, {
width,
height,
fit: this._options.video?.fit ?? 'fill',
fit: trackOptions.fit ?? 'fill',
rotation: totalRotation, // Bake the rotation into the output
poolSize: 1,
});
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
const frameRate = trackOptions.frameRate;
let lastCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
let lastCanvasTimestamp: number | null = null;
let lastCanvasEndTimestamp: number | null = null;
/** Repeats the last sample to pad out the time until the specified timestamp. */
const padFrames = async (until: number) => {
assert(lastCanvas);
assert(frameRate !== undefined);
const frameDifference = Math.round((until - lastCanvasTimestamp!) * frameRate);
for (let i = 1; i < frameDifference; i++) {
const sample = new VideoSample(lastCanvas, {
timestamp: lastCanvasTimestamp! + i / frameRate,
duration: 1 / frameRate,
});
await source.add(sample);
}
};
for await (const { canvas, timestamp, duration } of iterator) {
if (this._synchronizer.shouldWait(track.id, timestamp)) {
@@ -572,37 +670,134 @@ export class Conversion {
return;
}
let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0);
lastCanvasEndTimestamp = timestamp + duration;
if (frameRate !== undefined) {
// Logic for skipping/repeating frames when a frame rate is set
const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
if (lastCanvas !== null) {
if (alignedTimestamp <= lastCanvasTimestamp!) {
lastCanvas = canvas;
lastCanvasTimestamp = alignedTimestamp;
// Skip this sample, since we already added one for this frame
continue;
} else {
// Check if we may need to repeat the previous frame
await padFrames(alignedTimestamp);
}
}
adjustedSampleTimestamp = alignedTimestamp;
}
const sample = new VideoSample(canvas, {
timestamp: Math.max(timestamp - this._startTimestamp, 0),
duration,
timestamp: adjustedSampleTimestamp,
duration: frameRate !== undefined ? 1 / frameRate : duration,
});
await source.add(sample);
sample.close();
if (frameRate !== undefined) {
lastCanvas = canvas;
lastCanvasTimestamp = adjustedSampleTimestamp;
} else {
sample.close();
}
}
if (lastCanvas) {
assert(lastCanvasEndTimestamp !== null);
assert(frameRate !== undefined);
// If necessary, pad until the end timestamp of the last sample
await padFrames(Math.floor(lastCanvasEndTimestamp * frameRate) / frameRate);
}
source.close();
this._synchronizer.closeTrack(track.id);
})());
} else {
const source = new VideoSampleSource(encodingConfig);
videoSource = source;
this._trackPromises.push((async () => {
await this._started;
const sink = new VideoSampleSink(track);
const frameRate = trackOptions.frameRate;
let lastSample: VideoSample | null = null;
let lastSampleTimestamp: number | null = null;
let lastSampleEndTimestamp: number | null = null;
/** Repeats the last sample to pad out the time until the specified timestamp. */
const padFrames = async (until: number) => {
assert(lastSample);
assert(frameRate !== undefined);
const frameDifference = Math.round((until - lastSampleTimestamp!) * frameRate);
for (let i = 1; i < frameDifference; i++) {
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
lastSample.setDuration(1 / frameRate);
await source.add(lastSample);
}
lastSample.close();
};
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
await this._synchronizer.wait(sample.timestamp);
}
sample.setTimestamp(Math.max(sample.timestamp - this._startTimestamp, 0));
if (this._canceled) {
lastSample?.close();
return;
}
let adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
lastSampleEndTimestamp = sample.timestamp + sample.duration;
if (frameRate !== undefined) {
// Logic for skipping/repeating frames when a frame rate is set
const alignedTimestamp = Math.floor(adjustedSampleTimestamp * frameRate) / frameRate;
if (lastSample !== null) {
if (alignedTimestamp <= lastSampleTimestamp!) {
lastSample.close();
lastSample = sample;
lastSampleTimestamp = alignedTimestamp;
// Skip this sample, since we already added one for this frame
continue;
} else {
// Check if we may need to repeat the previous frame
await padFrames(alignedTimestamp);
}
}
adjustedSampleTimestamp = alignedTimestamp;
sample.setDuration(1 / frameRate);
}
sample.setTimestamp(adjustedSampleTimestamp);
await source.add(sample);
sample.close();
if (frameRate !== undefined) {
lastSample = sample;
lastSampleTimestamp = adjustedSampleTimestamp;
} else {
sample.close();
}
}
if (lastSample) {
assert(lastSampleEndTimestamp !== null);
assert(frameRate !== undefined);
// If necessary, pad until the end timestamp of the last sample
await padFrames(Math.floor(lastSampleEndTimestamp * frameRate) / frameRate);
}
source.close();
@@ -612,6 +807,7 @@ export class Conversion {
}
this.output.addVideoTrack(videoSource, {
frameRate: trackOptions.frameRate,
languageCode: track.languageCode,
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
});
@@ -622,7 +818,7 @@ export class Conversion {
}
/** @internal */
async _processAudioTrack(track: InputAudioTrack) {
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions) {
const sourceCodec = track.codec;
if (!sourceCodec) {
this.discardedTracks.push({
@@ -639,8 +835,8 @@ export class Conversion {
const firstTimestamp = await track.getFirstTimestamp();
let numberOfChannels = this._options.audio?.numberOfChannels ?? originalNumberOfChannels;
let sampleRate = this._options.audio?.sampleRate ?? originalSampleRate;
let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels;
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
let needsResample = numberOfChannels !== originalNumberOfChannels
|| sampleRate !== originalSampleRate
|| this._startTimestamp > 0
@@ -648,11 +844,11 @@ export class Conversion {
let audioCodecs = this.output.format.getSupportedAudioCodecs();
if (
!this._options.audio?.forceTranscode
&& !this._options.audio?.bitrate
!trackOptions.forceTranscode
&& !trackOptions.bitrate
&& !needsResample
&& audioCodecs.includes(sourceCodec)
&& (!this._options.audio?.codec || this._options.audio.codec === sourceCodec)
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
) {
// Fast path, we can simply copy over the encoded packets
@@ -699,11 +895,11 @@ export class Conversion {
let codecOfChoice: AudioCodec | null = null;
if (this._options.audio?.codec) {
audioCodecs = audioCodecs.filter(codec => codec === this._options.audio!.codec);
if (trackOptions.codec) {
audioCodecs = audioCodecs.filter(codec => codec === trackOptions.codec);
}
const bitrate = this._options.audio?.bitrate ?? QUALITY_HIGH;
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
numberOfChannels,
+32 -4
View File
@@ -138,9 +138,23 @@ export const customAudioEncoders: typeof CustomAudioEncoder[] = [];
*/
export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof CustomAudioDecoder) => {
if (decoder.prototype instanceof CustomVideoDecoder) {
customVideoDecoders.push(decoder as typeof CustomVideoDecoder);
const casted = decoder as typeof CustomVideoDecoder;
if (customVideoDecoders.includes(casted)) {
console.warn('Video decoder already registered.');
return;
}
customVideoDecoders.push(casted);
} else if (decoder.prototype instanceof CustomAudioDecoder) {
customAudioDecoders.push(decoder as typeof CustomAudioDecoder);
const casted = decoder as typeof CustomAudioDecoder;
if (customAudioDecoders.includes(casted)) {
console.warn('Audio decoder already registered.');
return;
}
customAudioDecoders.push(casted);
} else {
throw new TypeError('Decoder must be a CustomVideoDecoder or CustomAudioDecoder.');
}
@@ -153,9 +167,23 @@ export const registerDecoder = (decoder: typeof CustomVideoDecoder | typeof Cust
*/
export const registerEncoder = (encoder: typeof CustomVideoEncoder | typeof CustomAudioEncoder) => {
if (encoder.prototype instanceof CustomVideoEncoder) {
customVideoEncoders.push(encoder as typeof CustomVideoEncoder);
const casted = encoder as typeof CustomVideoEncoder;
if (customVideoEncoders.includes(casted)) {
console.warn('Video encoder already registered.');
return;
}
customVideoEncoders.push(casted);
} else if (encoder.prototype instanceof CustomAudioEncoder) {
customAudioEncoders.push(encoder as typeof CustomAudioEncoder);
const casted = encoder as typeof CustomAudioEncoder;
if (customAudioEncoders.includes(casted)) {
console.warn('Audio encoder already registered.');
return;
}
customAudioEncoders.push(casted);
} else {
throw new TypeError('Encoder must be a CustomVideoEncoder or CustomAudioEncoder.');
}
+2 -2
View File
@@ -84,7 +84,7 @@ export {
getFirstEncodableSubtitleCodec,
} from './codec';
export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target';
export { Rotation, AnyIterable, SetRequired } from './misc';
export { Rotation, AnyIterable, SetRequired, MaybePromise } from './misc';
export {
Source,
BufferSource,
@@ -135,7 +135,7 @@ export {
AudioBufferSink,
WrappedAudioBuffer,
} from './media-sink';
export { ConversionOptions, Conversion } from './conversion';
export { Conversion, ConversionOptions, ConversionVideoOptions, ConversionAudioOptions } from './conversion';
export {
CustomVideoDecoder,
CustomAudioDecoder,
+2 -2
View File
@@ -13,7 +13,7 @@ import { IsobmffReader } from './isobmff/isobmff-reader';
import { EBMLId, EBMLReader } from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer';
import { FRAME_HEADER_SIZE } from './mp3/mp3-misc';
import { FRAME_HEADER_SIZE } from '../shared/mp3-misc';
import { Mp3Reader } from './mp3/mp3-reader';
import { OggDemuxer } from './ogg/ogg-demuxer';
import { OggReader } from './ogg/ogg-reader';
@@ -154,7 +154,7 @@ export class MatroskaInputFormat extends InputFormat {
}
}; break;
case EBMLId.DocType: {
const docType = ebmlReader.readString(size);
const docType = ebmlReader.readAsciiString(size);
if (docType !== desiredDocType) {
return false;
}
+207 -204
View File
@@ -55,6 +55,7 @@ import {
roundToMultiple,
normalizeRotation,
Bitstream,
insertSorted,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { Reader } from '../reader';
@@ -1458,6 +1459,12 @@ export class IsobmffDemuxer extends Demuxer {
const sampleIndex = this.metadataReader.readU32() - 1; // Convert to 0-indexed
track.sampleTable.keySampleIndices.push(sampleIndex);
}
if (track.sampleTable.keySampleIndices[0] !== 0) {
// Some files don't mark the first sample a key sample, which is basically almost always incorrect.
// Here, we correct for that mistake:
track.sampleTable.keySampleIndices.unshift(0);
}
}; break;
case 'stsc': {
@@ -1624,12 +1631,7 @@ export class IsobmffDemuxer extends Demuxer {
this.readContiguousBoxes(boxInfo.contentSize);
const insertionIndex = binarySearchLessOrEqual(
this.fragments,
this.currentFragment.moofOffset,
x => x.moofOffset,
);
this.fragments.splice(insertionIndex + 1, 0, this.currentFragment);
insertSorted(this.fragments, this.currentFragment, x => x.moofOffset);
// Compute the byte range of the sample data in this fragment, so we can load the whole fragment at once
for (const [, trackData] of this.currentFragment.trackData) {
@@ -1661,21 +1663,15 @@ export class IsobmffDemuxer extends Demuxer {
if (trackData) {
// We know there is sample data for this track in this fragment, so let's add it to the
// track's fragments:
const insertionIndex = binarySearchLessOrEqual(
this.currentTrack.fragments,
this.currentFragment.moofOffset,
x => x.moofOffset,
);
this.currentTrack.fragments.splice(insertionIndex + 1, 0, this.currentFragment);
insertSorted(this.currentTrack.fragments, this.currentFragment, x => x.moofOffset);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) {
const insertionIndex = binarySearchLessOrEqual(
insertSorted(
this.currentTrack.fragmentsWithKeyFrame,
this.currentFragment.moofOffset,
this.currentFragment,
x => x.moofOffset,
);
this.currentTrack.fragmentsWithKeyFrame.splice(insertionIndex + 1, 0, this.currentFragment);
}
const { currentFragmentState } = this.currentTrack;
@@ -1941,44 +1937,46 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
}
async getFirstPacket(options: PacketRetrievalOptions) {
if (this.internalTrack.demuxer.isFragmented) {
return this.performFragmentedLookup(
() => {
const startFragment = this.internalTrack.demuxer.fragments[0] ?? null;
if (startFragment?.isKnownToBeFirstFragment) {
// Walk from the very first fragment in the file until we find one with our track in it
let currentFragment: Fragment | null = startFragment;
while (currentFragment) {
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) {
return {
fragmentIndex: binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
),
sampleIndex: 0,
correctSampleFound: true,
};
}
currentFragment = currentFragment.nextFragment;
}
}
return {
fragmentIndex: -1,
sampleIndex: -1,
correctSampleFound: false,
};
},
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
Infinity,
options,
);
const regularPacket = await this.fetchPacketForSampleIndex(0, options);
if (regularPacket || !this.internalTrack.demuxer.isFragmented) {
// If there's a non-fragmented packet, always prefer that
return regularPacket;
}
return this.fetchPacketForSampleIndex(0, options);
return this.performFragmentedLookup(
() => {
const startFragment = this.internalTrack.demuxer.fragments[0] ?? null;
if (startFragment?.isKnownToBeFirstFragment) {
// Walk from the very first fragment in the file until we find one with our track in it
let currentFragment: Fragment | null = startFragment;
while (currentFragment) {
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) {
return {
fragmentIndex: binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
),
sampleIndex: 0,
correctSampleFound: true,
};
}
currentFragment = currentFragment.nextFragment;
}
}
return {
fragmentIndex: -1,
sampleIndex: -1,
correctSampleFound: false,
};
},
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
Infinity,
options,
);
}
private mapTimestampIntoTimescale(timestamp: number) {
@@ -1991,185 +1989,186 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
if (this.internalTrack.demuxer.isFragmented) {
return this.performFragmentedLookup(
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
timestampInTimescale,
timestampInTimescale,
options,
);
} else {
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
return this.fetchPacketForSampleIndex(sampleIndex, options);
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
// Prefer the non-fragmented packet
return regularPacket;
}
return this.performFragmentedLookup(
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
timestampInTimescale,
timestampInTimescale,
options,
);
}
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
if (this.internalTrack.demuxer.isFragmented) {
const locationInFragment = this.packetToFragmentLocation.get(packet);
if (locationInFragment === undefined) {
throw new Error('Packet was not created from this track.');
}
const regularSampleIndex = this.packetToSampleIndex.get(packet);
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
const fragmentSample = trackData.samples[locationInFragment.sampleIndex]!;
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
locationInFragment.fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return this.performFragmentedLookup(
() => {
if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
// We can simply take the next sample in the fragment
return {
fragmentIndex,
sampleIndex: locationInFragment.sampleIndex + 1,
correctSampleFound: true,
};
} else {
// Walk the list of fragments until we find the next fragment for this track
let currentFragment = locationInFragment.fragment;
while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) {
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return {
fragmentIndex,
sampleIndex: 0,
correctSampleFound: true,
};
}
}
return {
fragmentIndex,
sampleIndex: -1,
correctSampleFound: false,
};
}
},
fragmentSample.presentationTimestamp,
Infinity,
options,
);
if (regularSampleIndex !== undefined) {
// Prefer the non-fragmented packet
return this.fetchPacketForSampleIndex(regularSampleIndex + 1, options);
}
const sampleIndex = this.packetToSampleIndex.get(packet);
if (sampleIndex === undefined) {
const locationInFragment = this.packetToFragmentLocation.get(packet);
if (locationInFragment === undefined) {
throw new Error('Packet was not created from this track.');
}
return this.fetchPacketForSampleIndex(sampleIndex + 1, options);
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
locationInFragment.fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return this.performFragmentedLookup(
() => {
if (locationInFragment.sampleIndex + 1 < trackData.samples.length) {
// We can simply take the next sample in the fragment
return {
fragmentIndex,
sampleIndex: locationInFragment.sampleIndex + 1,
correctSampleFound: true,
};
} else {
// Walk the list of fragments until we find the next fragment for this track
let currentFragment = locationInFragment.fragment;
while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) {
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return {
fragmentIndex,
sampleIndex: 0,
correctSampleFound: true,
};
}
}
return {
fragmentIndex,
sampleIndex: -1,
correctSampleFound: false,
};
}
},
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
Infinity,
options,
);
}
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
if (this.internalTrack.demuxer.isFragmented) {
return this.performFragmentedLookup(
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
timestampInTimescale,
timestampInTimescale,
options,
);
}
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
const keyFrameSampleIndex = sampleIndex === -1
? -1
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
return this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
const regularPacket = await this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
// Prefer the non-fragmented packet
return regularPacket;
}
return this.performFragmentedLookup(
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
timestampInTimescale,
timestampInTimescale,
options,
);
}
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
if (this.internalTrack.demuxer.isFragmented) {
const locationInFragment = this.packetToFragmentLocation.get(packet);
if (locationInFragment === undefined) {
throw new Error('Packet was not created from this track.');
}
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
const fragmentSample = trackData.samples[locationInFragment.sampleIndex]!;
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
locationInFragment.fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return this.performFragmentedLookup(
() => {
const nextKeyFrameIndex = trackData.samples.findIndex(
(x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex,
);
if (nextKeyFrameIndex !== -1) {
// We can simply take the next key frame in the fragment
return {
fragmentIndex,
sampleIndex: nextKeyFrameIndex,
correctSampleFound: true,
};
} else {
// Walk the list of fragments until we find the next fragment for this track with a key frame
let currentFragment = locationInFragment.fragment;
while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
assert(keyFrameIndex !== -1); // There must be one
return {
fragmentIndex,
sampleIndex: keyFrameIndex,
correctSampleFound: true,
};
}
}
return {
fragmentIndex,
sampleIndex: -1,
correctSampleFound: false,
};
}
},
fragmentSample.presentationTimestamp,
Infinity,
options,
);
const regularSampleIndex = this.packetToSampleIndex.get(packet);
if (regularSampleIndex !== undefined) {
// Prefer the non-fragmented packet
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, regularSampleIndex);
return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);
}
const sampleIndex = this.packetToSampleIndex.get(packet);
if (sampleIndex === undefined) {
const locationInFragment = this.packetToFragmentLocation.get(packet);
if (locationInFragment === undefined) {
throw new Error('Packet was not created from this track.');
}
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, sampleIndex);
return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);
const trackData = locationInFragment.fragment.trackData.get(this.internalTrack.id)!;
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
locationInFragment.fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
return this.performFragmentedLookup(
() => {
const nextKeyFrameIndex = trackData.samples.findIndex(
(x, i) => x.isKeyFrame && i > locationInFragment.sampleIndex,
);
if (nextKeyFrameIndex !== -1) {
// We can simply take the next key frame in the fragment
return {
fragmentIndex,
sampleIndex: nextKeyFrameIndex,
correctSampleFound: true,
};
} else {
// Walk the list of fragments until we find the next fragment for this track with a key frame
let currentFragment = locationInFragment.fragment;
while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
assert(keyFrameIndex !== -1); // There must be one
return {
fragmentIndex,
sampleIndex: keyFrameIndex,
correctSampleFound: true,
};
}
}
return {
fragmentIndex,
sampleIndex: -1,
correctSampleFound: false,
};
}
},
-Infinity, // Use -Infinity as a search timestamp to avoid using the lookup entries
Infinity,
options,
);
}
private async fetchPacketForSampleIndex(sampleIndex: number, options: PacketRetrievalOptions) {
@@ -2701,3 +2700,7 @@ const extractRotationFromMatrix = (matrix: TransformationMatrix) => {
// Invert the rotation because matrices are post-multiplied in ISOBMFF
return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
};
const sampleTableIsEmpty = (sampleTable: SampleTable) => {
return sampleTable.sampleSizes.length === 0;
};
+13 -6
View File
@@ -32,6 +32,7 @@ import {
transformAnnexBToLengthPrefixed,
} from '../codec-data';
import { buildIsobmffMimeType } from './isobmff-misc';
import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE } from './isobmff-reader';
export const GLOBAL_TIMESCALE = 1000;
const TIMESTAMP_OFFSET = 2_082_844_800; // Seconds between Jan 1 1904 and Jan 1 1970
@@ -989,10 +990,7 @@ export class IsobmffMuxer extends Muxer {
const moofOffset = this.writer.getPos();
const mdatStartPos = moofOffset + this.boxWriter.measureBox(moofBox);
// Header with large size. We always reserve 16 bytes for it even if we don't end up using the large size.
const mdatHeaderSize = 16;
let currentPos = mdatStartPos + mdatHeaderSize;
let currentPos = mdatStartPos + MIN_BOX_HEADER_SIZE;
let fragmentStartTimestamp = Infinity;
for (const trackData of tracksInFragment) {
trackData.currentChunk!.offset = currentPos;
@@ -1006,6 +1004,15 @@ export class IsobmffMuxer extends Muxer {
}
const mdatSize = currentPos - mdatStartPos;
const needsLargeMdatSize = mdatSize >= 2 ** 32;
if (needsLargeMdatSize) {
// Shift all offsets by 8. Previously, all chunks were shifted assuming the large box size, but due to what
// I suspect is a bug in WebKit, it failed in Safari (when livestreaming with MSE, not for static playback).
for (const trackData of tracksInFragment) {
trackData.currentChunk!.offset! += MAX_BOX_HEADER_SIZE - MIN_BOX_HEADER_SIZE;
}
}
if (this.format._options.onMoof) {
this.writer.startTrackingWrites();
@@ -1025,11 +1032,11 @@ export class IsobmffMuxer extends Muxer {
this.writer.startTrackingWrites();
}
const mdatBox = mdat(mdatSize >= 2 ** 32);
const mdatBox = mdat(needsLargeMdatSize);
mdatBox.size = mdatSize;
this.boxWriter.writeBox(mdatBox);
this.writer.seek(mdatStartPos + mdatHeaderSize);
this.writer.seek(mdatStartPos + (needsLargeMdatSize ? MAX_BOX_HEADER_SIZE : MIN_BOX_HEADER_SIZE));
// Write sample data
for (const trackData of tracksInFragment) {
+42 -5
View File
@@ -312,8 +312,7 @@ export class EBMLWriter {
this.writer.write(this.helper.subarray(0, pos));
}
// Assumes the string is ASCII
writeString(str: string) {
writeAsciiString(str: string) {
this.writer.write(new Uint8Array(str.split('').map(x => x.charCodeAt(0))));
}
@@ -359,7 +358,7 @@ export class EBMLWriter {
this.writeUnsignedInt(data.data, size);
} else if (typeof data.data === 'string') {
this.writeVarInt(data.data.length);
this.writeString(data.data);
this.writeAsciiString(data.data);
} else if (data.data instanceof Uint8Array) {
this.writeVarInt(data.data.byteLength, data.size);
this.writer.write(data.data);
@@ -495,11 +494,17 @@ export class EBMLReader {
return value;
}
readString(length: number) {
readAsciiString(length: number) {
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
this.pos += length;
return String.fromCharCode(...new Uint8Array(view.buffer, offset, length));
// Actual string length might be shorter due to null terminators
let strLength = 0;
while (strLength < length && view.getUint8(offset + strLength) !== 0) {
strLength += 1;
}
return String.fromCharCode(...new Uint8Array(view.buffer, offset, strLength));
}
readElementId() {
@@ -588,6 +593,38 @@ export const CODEC_STRING_MAP: Partial<Record<MediaCodec, string>> = {
'webvtt': 'S_TEXT/WEBVTT',
};
export const readVarInt = (data: Uint8Array, offset: number) => {
if (offset >= data.length) {
throw new Error('Offset out of bounds.');
}
// Read the first byte to determine the width of the variable-length integer
const firstByte = data[offset]!;
// Find the position of VINT_MARKER, which determines the width
let width = 1;
let mask = 1 << 7;
while ((firstByte & mask) === 0 && width < 8) {
width++;
mask >>= 1;
}
if (offset + width > data.length) {
throw new Error('VarInt extends beyond data bounds.');
}
// First byte's value needs the marker bit cleared
let value = firstByte & (mask - 1);
// Read remaining bytes
for (let i = 1; i < width; i++) {
value *= 1 << 8;
value += data[offset + i]!;
}
return { value, width };
};
export function assertDefinedSize(size: number | null): asserts size is number {
if (size === null) {
throw new Error('Undefined element size is used in a place where it is not supported.');
+203 -32
View File
@@ -38,6 +38,7 @@ import {
binarySearchLessOrEqual,
COLOR_PRIMARIES_MAP_INVERSE,
findLastIndex,
insertSorted,
isIso639Dash2LanguageCode,
last,
MATRIX_COEFFICIENTS_MAP_INVERSE,
@@ -57,6 +58,7 @@ import {
LEVEL_0_AND_1_EBML_IDS,
MAX_HEADER_SIZE,
MIN_HEADER_SIZE,
readVarInt,
} from './ebml';
import { buildMatroskaMimeType } from './matroska-misc';
@@ -107,12 +109,20 @@ type ClusterTrackData = {
}[];
};
enum BlockLacing {
None,
Xiph,
FixedSize,
Ebml,
}
type ClusterBlock = {
timestamp: number;
duration: number;
isKeyFrame: boolean;
referencedTimestamps: number[];
data: Uint8Array;
lacing: BlockLacing;
};
type CuePoint = {
@@ -133,6 +143,7 @@ type InternalTrack = {
inputTrack: InputTrack | null;
codecId: string | null;
codecPrivate: Uint8Array | null;
defaultDuration: number | null;
languageCode: string;
info:
| null
@@ -220,13 +231,16 @@ export class MatroskaDemuxer extends Demuxer {
const fileSize = await this.input.source.getSize();
// Loop over all top-level elements in the file
while (this.metadataReader.pos <= fileSize - MIN_HEADER_SIZE) {
await this.metadataReader.reader.loadRange(
this.metadataReader.pos,
this.metadataReader.pos + MAX_HEADER_SIZE,
);
const { id, size } = this.metadataReader.readElementHeader();
const header = this.metadataReader.readElementHeader();
const id = header.id;
let size = header.size;
const startPos = this.metadataReader.pos;
if (id === EBMLId.EBML) {
@@ -242,6 +256,26 @@ export class MatroskaDemuxer extends Demuxer {
// and only segment
break;
}
} else if (id === EBMLId.Cluster) {
// Clusters are not a top-level element in Matroska, but some files contain a Segment whose size
// doesn't contain any of the clusters that follow it. In the case, we apply the following logic: if
// we find a top-level cluster, attribute it to the previous segment.
if (size === null) {
// Just in case this is one of those weird sizeless clusters, let's do our best and still try to
// determine its size.
const nextElementPos = await this.clusterReader.searchForNextElementId(
LEVEL_0_AND_1_EBML_IDS,
fileSize,
);
size = (nextElementPos ?? fileSize) - startPos;
}
const lastSegment = last(this.segments);
if (lastSegment) {
// Extend the previous segment's size
lastSegment.elementEndPos = startPos + size;
}
}
assertDefinedSize(size);
@@ -368,6 +402,13 @@ export class MatroskaDemuxer extends Demuxer {
this.readContiguousElements(this.metadataReader, size);
}
if (this.currentSegment.timestampScale === -1) {
// TimestampScale element is missing. Technically an invalid file, but let's default to the typical value,
// which is 1e6.
this.currentSegment.timestampScale = 1e6;
this.currentSegment.timestampFactor = 1e9 / 1e6;
}
// Put default tracks first
this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
@@ -464,16 +505,20 @@ export class MatroskaDemuxer extends Demuxer {
this.readContiguousElements(this.clusterReader, size);
for (const [trackId, trackData] of cluster.trackData) {
let blockReferencesExist = false;
const track = segment.tracks.find(x => x.id === trackId) ?? null;
// This must hold, as track datas only get created if a block for that track is encountered
assert(trackData.blocks.length > 0);
let blockReferencesExist = false;
let hasLacedBlocks = false;
for (let i = 0; i < trackData.blocks.length; i++) {
const block = trackData.blocks[i]!;
block.timestamp += cluster.timestamp;
blockReferencesExist ||= block.referencedTimestamps.length > 0;
hasLacedBlocks ||= block.lacing !== BlockLacing.None;
}
if (blockReferencesExist) {
@@ -496,43 +541,46 @@ export class MatroskaDemuxer extends Demuxer {
// Update block durations based on presentation order
const nextEntry = trackData.presentationTimestamps[i + 1]!;
currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;
} else if (currentBlock.duration === 0) {
if (track?.defaultDuration != null) {
if (currentBlock.lacing === BlockLacing.None) {
currentBlock.duration = track.defaultDuration;
} else {
// Handled by the lace resolution code
}
}
}
}
if (hasLacedBlocks) {
// Perform lace resolution. Here, we expand each laced block into multiple blocks where each contains
// one frame of the lace. We do this after determining block timestamps so we can properly distribute
// the block's duration across the laced frames.
this.expandLacedBlocks(trackData.blocks, track);
// Recompute since blocks have changed
trackData.presentationTimestamps = trackData.blocks
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
.sort((a, b) => a.timestamp - b.timestamp);
}
const firstBlock = trackData.blocks[trackData.presentationTimestamps[0]!.blockIndex]!;
const lastBlock = trackData.blocks[last(trackData.presentationTimestamps)!.blockIndex]!;
trackData.startTimestamp = firstBlock.timestamp;
trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;
const track = segment.tracks.find(x => x.id === trackId);
if (track) {
const insertionIndex = binarySearchLessOrEqual(
track.clusters,
cluster.elementStartPos,
x => x.elementStartPos,
);
track.clusters.splice(insertionIndex + 1, 0, cluster);
insertSorted(track.clusters, cluster, x => x.elementStartPos);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) {
const insertionIndex = binarySearchLessOrEqual(
track.clustersWithKeyFrame,
cluster.elementStartPos,
x => x.elementStartPos,
);
track.clustersWithKeyFrame.splice(insertionIndex + 1, 0, cluster);
insertSorted(track.clustersWithKeyFrame, cluster, x => x.elementStartPos);
}
}
}
const insertionIndex = binarySearchLessOrEqual(
segment.clusters,
elementStartPos,
x => x.elementStartPos,
);
segment.clusters.splice(insertionIndex + 1, 0, cluster);
insertSorted(segment.clusters, cluster, x => x.elementStartPos);
this.currentCluster = null;
return cluster;
@@ -554,6 +602,120 @@ export class MatroskaDemuxer extends Demuxer {
return trackData;
}
expandLacedBlocks(blocks: ClusterBlock[], track: InternalTrack | null) {
// https://www.matroska.org/technical/notes.html#block-lacing
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const originalBlock = blocks[blockIndex]!;
if (originalBlock.lacing === BlockLacing.None) {
continue;
}
const data = originalBlock.data;
let pos = 0;
const frameSizes: number[] = [];
const frameCount = data[pos]! + 1;
pos++;
switch (originalBlock.lacing) {
case BlockLacing.Xiph: {
let totalUsedSize = 0;
// Xiph lacing, just like in Ogg
for (let i = 0; i < frameCount - 1; i++) {
let frameSize = 0;
while (pos < data.length) {
const value = data[pos]!;
frameSize += value;
pos++;
if (value < 255) {
frameSizes.push(frameSize);
totalUsedSize += frameSize;
break;
}
}
}
// Compute the last frame's size from whatever's left
frameSizes.push(data.length - (pos + totalUsedSize));
}; break;
case BlockLacing.FixedSize: {
// Fixed size lacing: all frames have same size
const totalDataSize = data.length - 1; // Minus the frame count byte
const frameSize = Math.floor(totalDataSize / frameCount);
for (let i = 0; i < frameCount; i++) {
frameSizes.push(frameSize);
}
}; break;
case BlockLacing.Ebml: {
// EBML lacing: first size absolute, subsequent ones are coded as signed differences from the last
const firstResult = readVarInt(data, pos);
let currentSize = firstResult.value;
frameSizes.push(currentSize);
pos += firstResult.width;
let totalUsedSize = currentSize;
for (let i = 1; i < frameCount - 1; i++) {
const diffResult = readVarInt(data, pos);
const unsignedDiff = diffResult.value;
const bias = (1 << (diffResult.width * 7 - 1)) - 1; // Typo-corrected version of 2^((7*n)-1)^-1
const diff = unsignedDiff - bias;
currentSize += diff;
frameSizes.push(currentSize);
pos += diffResult.width;
totalUsedSize += currentSize;
}
// Compute the last frame's size from whatever's left
frameSizes.push(data.length - (pos + totalUsedSize));
}; break;
default: assert(false);
}
assert(frameSizes.length === frameCount);
blocks.splice(blockIndex, 1); // Remove the original block
let dataOffset = pos;
// Now, let's insert each frame as its own block
for (let i = 0; i < frameCount; i++) {
const frameSize = frameSizes[i]!;
const frameData = data.subarray(dataOffset, dataOffset + frameSize);
const blockDuration = originalBlock.duration || (frameCount * (track?.defaultDuration ?? 0));
// Distribute timestamps evenly across the block duration
const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount);
const frameDuration = blockDuration / frameCount;
blocks.splice(blockIndex + i, 0, {
timestamp: frameTimestamp,
duration: frameDuration,
isKeyFrame: originalBlock.isKeyFrame,
referencedTimestamps: originalBlock.referencedTimestamps,
data: frameData,
lacing: BlockLacing.None,
});
dataOffset += frameSize;
}
blockIndex += frameCount; // Skip the blocks we just added
blockIndex--;
}
}
readContiguousElements(reader: EBMLReader, totalSize: number) {
const startIndex = reader.pos;
@@ -569,7 +731,7 @@ export class MatroskaDemuxer extends Demuxer {
switch (id) {
case EBMLId.DocType: {
this.isWebM = reader.readString(size) === 'webm';
this.isWebM = reader.readAsciiString(size) === 'webm';
}; break;
case EBMLId.Seek: {
@@ -625,6 +787,7 @@ export class MatroskaDemuxer extends Demuxer {
inputTrack: null,
codecId: null,
codecPrivate: null,
defaultDuration: null,
languageCode: UNDETERMINED_LANGUAGE,
info: null,
};
@@ -777,7 +940,7 @@ export class MatroskaDemuxer extends Demuxer {
case EBMLId.CodecID: {
if (!this.currentTrack) break;
this.currentTrack.codecId = reader.readString(size);
this.currentTrack.codecId = reader.readAsciiString(size);
}; break;
case EBMLId.CodecPrivate: {
@@ -786,10 +949,17 @@ export class MatroskaDemuxer extends Demuxer {
this.currentTrack.codecPrivate = reader.readBytes(size);
}; break;
case EBMLId.DefaultDuration: {
if (!this.currentTrack) break;
this.currentTrack.defaultDuration
= this.currentTrack.segment.timestampFactor * reader.readUnsignedInt(size) / 1e9;
}; break;
case EBMLId.Language: {
if (!this.currentTrack) break;
this.currentTrack.languageCode = reader.readString(size);
this.currentTrack.languageCode = reader.readAsciiString(size);
if (!isIso639Dash2LanguageCode(this.currentTrack.languageCode)) {
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
@@ -947,14 +1117,16 @@ export class MatroskaDemuxer extends Demuxer {
const flags = reader.readU8();
const isKeyFrame = !!(flags & 0x80);
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
trackData.blocks.push({
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
duration: 0,
duration: 0, // Will set later
isKeyFrame,
referencedTimestamps: [],
data: reader.readBytes(size - (reader.pos - dataStartPos)),
lacing,
});
}; break;
@@ -978,16 +1150,17 @@ export class MatroskaDemuxer extends Demuxer {
const trackNumber = reader.readVarInt();
const relativeTimestamp = reader.readS16();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const flags = reader.readU8();
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
this.currentBlock = {
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
duration: 0,
duration: 0, // Will set later
isKeyFrame: true,
referencedTimestamps: [],
data: reader.readBytes(size - (reader.pos - dataStartPos)),
lacing,
};
trackData.blocks.push(this.currentBlock);
}; break;
@@ -1110,7 +1283,6 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
}
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
const block = trackData.blocks[locationInCluster.blockIndex]!;
const clusterIndex = binarySearchExact(
this.internalTrack.clusters,
@@ -1158,7 +1330,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
};
}
},
block.timestamp,
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
Infinity,
options,
);
@@ -1182,7 +1354,6 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
}
const trackData = locationInCluster.cluster.trackData.get(this.internalTrack.id)!;
const block = trackData.blocks[locationInCluster.blockIndex]!;
const clusterIndex = binarySearchExact(
this.internalTrack.clusters,
@@ -1237,7 +1408,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
};
}
},
block.timestamp,
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
Infinity,
options,
);
+57 -29
View File
@@ -13,10 +13,11 @@ import {
AnyIterable,
assert,
assertNever,
binarySearchLessOrEqual,
CallSerializer,
getInt24,
getUint24,
insertSorted,
isSafari,
last,
mapAsyncGenerator,
promiseWithResolvers,
@@ -750,7 +751,8 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
customDecoderCallSerializer = new CallSerializer();
customDecoderQueueSize = 0;
sampleQueue: VideoSample[] = [];
inputTimestamps: number[] = []; // Timestamps input into the decoder, sorted.
sampleQueue: VideoSample[] = []; // Safari-specific thing, check usage.
constructor(
onSample: (sample: VideoSample) => unknown,
@@ -762,28 +764,6 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
) {
super(onSample, onError);
const sampleHandler = (sample: VideoSample) => {
// For correct B-frame handling, we don't just hand over the frames directly but instead add them to a
// queue, because we want to ensure frames are emitted in presentation order. We flush the queue each time
// we receive a frame with a timestamp larger than the highest we've seen so far, as we can sure that is
// not a B-frame. Typically, WebCodecs automatically guarantees that frames are emitted in presentation
// order, but some browsers (Safari) don't always follow this rule.
if (this.sampleQueue.length > 0 && (sample.timestamp >= last(this.sampleQueue)!.timestamp)) {
for (const sample of this.sampleQueue) {
this.finalizeAndEmitSample(sample);
}
this.sampleQueue.length = 0;
}
const insertionIndex = binarySearchLessOrEqual(
this.sampleQueue,
sample.timestamp,
x => x.timestamp,
);
this.sampleQueue.splice(insertionIndex + 1, 0, sample);
};
const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));
if (MatchingCustomDecoder) {
// @ts-expect-error "Can't create instance of abstract class 🤓"
@@ -798,11 +778,45 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
throw new TypeError('The argument passed to onSample must be a VideoSample.');
}
sampleHandler(sample);
this.finalizeAndEmitSample(sample);
};
void this.customDecoderCallSerializer.call(() => this.customDecoder!.init());
} else {
// Specific handler for the WebCodecs VideoDecoder to iron out browser differences
const sampleHandler = (sample: VideoSample) => {
if (isSafari()) {
// For correct B-frame handling, we don't just hand over the frames directly but instead add them to
// a queue, because we want to ensure frames are emitted in presentation order. We flush the queue
// each time we receive a frame with a timestamp larger than the highest we've seen so far, as we
// can sure that is not a B-frame. Typically, WebCodecs automatically guarantees that frames are
// emitted in presentation order, but Safari doesn't always follow this rule.
if (this.sampleQueue.length > 0 && (sample.timestamp >= last(this.sampleQueue)!.timestamp)) {
for (const sample of this.sampleQueue) {
this.finalizeAndEmitSample(sample);
}
this.sampleQueue.length = 0;
}
insertSorted(this.sampleQueue, sample, x => x.timestamp);
} else {
// Assign it the next earliest timestamp from the input. We do this because browsers, by spec, are
// required to emit decoded frames in presentation order *while* retaining the timestamp of their
// originating EncodedVideoChunk. For files with B-frames but no out-of-order timestamps (like a
// missing ctts box, for example), this causes a mismatch. We therefore fix the timestamps and
// ensure they are sorted by doing this.
const timestamp = this.inputTimestamps.shift();
// There's no way we'd have more decoded frames than encoded packets we passed in. Actually, the
// correspondence should be 1:1.
assert(timestamp !== undefined);
sample.setTimestamp(timestamp);
this.finalizeAndEmitSample(sample);
}
};
this.decoder = new VideoDecoder({
output: frame => sampleHandler(new VideoSample(frame)),
error: onError,
@@ -837,6 +851,11 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
.then(() => this.customDecoderQueueSize--);
} else {
assert(this.decoder);
if (!isSafari()) {
insertSorted(this.inputTimestamps, packet.timestamp, x => x);
}
this.decoder.decode(packet.toEncodedVideoChunk());
}
}
@@ -849,10 +868,13 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
await this.decoder.flush();
}
for (const sample of this.sampleQueue) {
this.finalizeAndEmitSample(sample);
if (isSafari()) {
for (const sample of this.sampleQueue) {
this.finalizeAndEmitSample(sample);
}
this.sampleQueue.length = 0;
}
this.sampleQueue.length = 0;
}
close() {
@@ -1226,9 +1248,15 @@ class AudioDecoderWrapper extends DecoderWrapper<AudioSample> {
super(onSample, onError);
const sampleHandler = (sample: AudioSample) => {
const sampleRate = decoderConfig.sampleRate;
if (sample.numberOfFrames === 0) {
// We skip zero-data (empty) AudioSamples. These are sometimes emitted, for example, by Firefox when it
// decodes Vorbis (at the start).
sample.close();
return;
}
// Round the timestamp to the sample rate
const sampleRate = decoderConfig.sampleRate;
sample.setTimestamp(Math.round(sample.timestamp * sampleRate) / sampleRate);
onSample(sample);
+587 -261
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -292,6 +292,12 @@ export const binarySearchLessOrEqual = <T>(arr: T[], key: number, valueGetter: (
return ans;
};
/** Assumes the array is already sorted. */
export const insertSorted = <T>(arr: T[], item: T, valueGetter: (x: T) => number) => {
const insertionIndex = binarySearchLessOrEqual(arr, valueGetter(item), valueGetter);
arr.splice(insertionIndex + 1, 0, item); // This even behaves correctly for the -1 case
};
export const promiseWithResolvers = <T = void>() => {
let resolve: (value: T) => void;
let reject: (reason: unknown) => void;
@@ -586,3 +592,27 @@ export class CallSerializer {
return this.currentPromise = this.currentPromise.then(fn);
}
}
let isSafariCache: boolean | null = null;
export const isSafari = () => {
if (isSafariCache !== null) {
return isSafariCache;
}
const result = !!(
typeof navigator !== 'undefined'
&& navigator.vendor?.match(/apple/i)
&& !navigator.userAgent?.match(/crios/i)
&& !navigator.userAgent?.match(/fxios/i)
&& !navigator.userAgent?.match(/Opera|OPT\//)
);
isSafariCache = result;
return result;
};
/**
* T or a promise that resolves to T.
* @public
*/
export type MaybePromise<T> = T | Promise<T>;
+132 -63
View File
@@ -11,9 +11,9 @@ import { Demuxer } from '../demuxer';
import { Input } from '../input';
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
import { PacketRetrievalOptions } from '../media-sink';
import { assert, binarySearchExact, binarySearchLessOrEqual, last, UNDETERMINED_LANGUAGE } from '../misc';
import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { FrameHeader, getXingOffset, INFO, XING } from './mp3-misc';
import { FrameHeader, getXingOffset, INFO, XING } from '../../shared/mp3-misc';
import { Mp3Reader } from './mp3-reader';
type Sample = {
@@ -28,10 +28,15 @@ export class Mp3Demuxer extends Demuxer {
metadataPromise: Promise<void> | null = null;
firstFrameHeader: FrameHeader | null = null;
allSamples: Sample[] = [];
loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos
tracks: InputAudioTrack[] = [];
loadingMutex = new AsyncMutex();
lastLoadedPos = 0;
fileSize = 0;
nextTimestampInSamples = 0;
constructor(input: Input) {
super(input);
@@ -40,53 +45,12 @@ export class Mp3Demuxer extends Demuxer {
async readMetadata() {
return this.metadataPromise ??= (async () => {
const fileSize = await this.input.source.getSize();
this.reader.fileSize = fileSize;
this.fileSize = await this.input.source.getSize();
this.reader.fileSize = this.fileSize;
// Just load the entire file. Primitive, but the only way to actually ensure 100% correct timestamps.
// Random access in MP3 can be flaky and unreliable.
await this.reader.reader.loadRange(0, fileSize);
const id3Tag = this.reader.readId3();
if (id3Tag) {
this.reader.pos += id3Tag.size;
}
let nextTimestampInSamples = 0;
// Let's read all samples
while (true) {
const header = this.reader.readNextFrameHeader();
if (!header) {
break;
}
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
this.reader.pos = header.startPos + xingOffset;
const word = this.reader.readU32();
const isXing = word === XING || word === INFO;
this.reader.pos = header.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
if (isXing) {
// There's no actual audio data in this frame, so let's skip it
continue;
}
if (!this.firstFrameHeader) {
this.firstFrameHeader = header;
}
const sampleDuration = header.audioSamplesInFrame / header.sampleRate;
const sample: Sample = {
timestamp: nextTimestampInSamples / header.sampleRate,
duration: sampleDuration,
dataStart: header.startPos,
dataSize: header.totalSize,
};
this.allSamples.push(sample);
nextTimestampInSamples += header.audioSamplesInFrame;
// Keep loading until we find the first frame header
while (!this.firstFrameHeader && this.lastLoadedPos < this.fileSize) {
await this.loadNextChunk();
}
if (!this.firstFrameHeader) {
@@ -97,6 +61,80 @@ export class Mp3Demuxer extends Demuxer {
})();
}
/** Loads the next 0.5 MiB of frames. */
async loadNextChunk() {
const release = await this.loadingMutex.acquire();
try {
assert(this.lastLoadedPos < this.fileSize);
const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB
const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize);
await this.reader.reader.loadRange(this.lastLoadedPos, endPos);
this.lastLoadedPos = endPos;
assert(this.lastLoadedPos <= this.fileSize);
if (this.reader.pos === 0) {
// First time, let's see if there's an ID3 tag
const id3Tag = this.reader.readId3();
if (id3Tag) {
this.reader.pos += id3Tag.size;
}
}
this.parseFramesFromLoadedData();
} finally {
release();
}
}
private parseFramesFromLoadedData() {
while (true) {
const startPos = this.reader.pos;
const header = this.reader.readNextFrameHeader();
if (!header) {
break;
}
// Check if the entire frame fits in the loaded data
if (header.startPos + header.totalSize > this.lastLoadedPos) {
// Frame doesn't fit, reset positions and stop
this.reader.pos = startPos;
this.lastLoadedPos = startPos; // Snap this back too so that the next read is frame-aligned
break;
}
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
this.reader.pos = header.startPos + xingOffset;
const word = this.reader.readU32();
const isXing = word === XING || word === INFO;
this.reader.pos = header.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
if (isXing) {
// There's no actual audio data in this frame, so let's skip it
continue;
}
if (!this.firstFrameHeader) {
this.firstFrameHeader = header;
}
const sampleDuration = header.audioSamplesInFrame / header.sampleRate;
const sample: Sample = {
timestamp: this.nextTimestampInSamples / header.sampleRate,
duration: sampleDuration,
dataStart: header.startPos,
dataSize: header.totalSize,
};
this.loadedSamples.push(sample);
this.nextTimestampInSamples += header.audioSamplesInFrame;
}
}
async getMimeType() {
return 'audio/mpeg';
}
@@ -109,10 +147,10 @@ export class Mp3Demuxer extends Demuxer {
async computeDuration() {
await this.readMetadata();
const lastSample = last(this.allSamples);
assert(lastSample);
const track = this.tracks[0];
assert(track);
return lastSample.timestamp + lastSample.duration;
return track.computeDuration();
}
}
@@ -132,8 +170,9 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame;
}
computeDuration() {
return this.demuxer.computeDuration();
async computeDuration() {
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
getLanguageCode() {
@@ -169,7 +208,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return null;
}
const rawSample = this.demuxer.allSamples[sampleIndex];
const rawSample = this.demuxer.loadedSamples[sampleIndex];
if (!rawSample) {
return null;
}
@@ -193,12 +232,17 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
}
async getFirstPacket(options: PacketRetrievalOptions) {
// Ensure we have at least one frame loaded
while (this.demuxer.loadedSamples.length === 0 && this.demuxer.lastLoadedPos < this.demuxer.fileSize) {
await this.demuxer.loadNextChunk();
}
return this.getPacketAtIndex(0, options);
}
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
const sampleIndex = binarySearchExact(
this.demuxer.allSamples,
this.demuxer.loadedSamples,
packet.timestamp,
x => x.timestamp,
);
@@ -206,16 +250,41 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
throw new Error('Packet was not created from this track.');
}
return this.getPacketAtIndex(sampleIndex + 1, options);
const nextIndex = sampleIndex + 1;
// Ensure the next sample exists
while (nextIndex >= this.demuxer.loadedSamples.length && this.demuxer.lastLoadedPos < this.demuxer.fileSize) {
await this.demuxer.loadNextChunk();
}
return this.getPacketAtIndex(nextIndex, options);
}
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
const index = binarySearchLessOrEqual(
this.demuxer.allSamples,
timestamp,
x => x.timestamp,
);
return this.getPacketAtIndex(index, options);
while (true) {
const index = binarySearchLessOrEqual(
this.demuxer.loadedSamples,
timestamp,
x => x.timestamp,
);
if (index === -1 && this.demuxer.loadedSamples.length > 0) {
// We're before the first sample
return null;
}
if (this.demuxer.lastLoadedPos === this.demuxer.fileSize) {
// All data is loaded, return what we found
return this.getPacketAtIndex(index, options);
}
if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
// The next packet also exists, we're done
return this.getPacketAtIndex(index, options);
}
// Otherwise, keep loading data
await this.demuxer.loadNextChunk();
}
}
getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
+8 -3
View File
@@ -12,7 +12,7 @@ import { Output, OutputAudioTrack } from '../output';
import { Mp3OutputFormat } from '../output-format';
import { EncodedPacket } from '../packet';
import { Writer } from '../writer';
import { getXingOffset, INFO, readFrameHeader, XING } from './mp3-misc';
import { getXingOffset, INFO, readFrameHeader, XING } from '../../shared/mp3-misc';
import { Mp3Writer, XingFrameData } from './mp3-writer';
export class Mp3Muxer extends Muxer {
@@ -50,7 +50,9 @@ export class Mp3Muxer extends Muxer {
const release = await this.mutex.acquire();
try {
if (!this.xingFrameData) {
const writeXingHeader = this.format._options.xingHeader !== false;
if (!this.xingFrameData && writeXingHeader) {
const view = toDataView(packet.data);
if (view.byteLength < 4) {
throw new Error('Invalid MP3 header in sample.');
@@ -97,11 +99,14 @@ export class Mp3Muxer extends Muxer {
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
this.framePositions.push(this.writer.getPos());
this.writer.write(packet.data);
this.frameCount++;
await this.writer.flush();
if (writeXingHeader) {
this.framePositions.push(this.writer.getPos());
}
} finally {
release();
}
+1 -1
View File
@@ -8,7 +8,7 @@
import { assert } from '../misc';
import { Reader } from '../reader';
import { FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from './mp3-misc';
import { FRAME_HEADER_SIZE, FrameHeader, readFrameHeader } from '../../shared/mp3-misc';
export class Mp3Reader {
pos = 0;
+1 -1
View File
@@ -14,7 +14,7 @@ import {
MPEG_V2_BITRATES,
SAMPLING_RATES,
XING,
} from './mp3-misc';
} from '../../shared/mp3-misc';
export type XingFrameData = {
mpegVersionId: number;
+9
View File
@@ -475,6 +475,12 @@ export class WebMOutputFormat extends MkvOutputFormat {
* @public
*/
export type Mp3OutputFormatOptions = {
/**
* Controls whether the Xing header, which contains additional metadata as well as an index, is written to the start
* of the MP3 file. When disabled, the writing process becomes append-only. Defaults to true.
*/
xingHeader?: boolean;
/**
* Will be called once the Xing metadata frame is finalized.
*
@@ -496,6 +502,9 @@ export class Mp3OutputFormat extends OutputFormat {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.xingHeader !== undefined && typeof options.xingHeader !== 'boolean') {
throw new TypeError('options.xingHeader, when provided, must be a boolean.');
}
if (options.onXingFrame !== undefined && typeof options.onXingFrame !== 'function') {
throw new TypeError('options.onXingFrame, when provided, must be a function.');
}
+4 -5
View File
@@ -344,9 +344,8 @@ export class Output<
await this._muxer.start();
for (const track of this._tracks) {
track.source._start();
}
const promises = this._tracks.map(track => track.source._start());
await Promise.all(promises);
release();
})();
@@ -381,7 +380,7 @@ export class Output<
const release = await this._mutex.acquire();
const promises = this._tracks.map(x => x.source._flushOrWaitForClose());
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(true)); // Force close
await Promise.all(promises);
await this._writer.close();
@@ -411,7 +410,7 @@ export class Output<
const release = await this._mutex.acquire();
const promises = this._tracks.map(x => x.source._flushOrWaitForClose());
const promises = this._tracks.map(x => x.source._flushOrWaitForOngoingClose(false));
await Promise.all(promises);
await this._muxer.finalize();
+55
View File
@@ -236,6 +236,7 @@ export class VideoSample {
return new VideoSample(this._data.clone(), {
timestamp: this.timestamp,
duration: this.duration,
rotation: this.rotation,
});
} else if (this._data instanceof Uint8Array) {
return new VideoSample(this._data.slice(), {
@@ -245,6 +246,7 @@ export class VideoSample {
timestamp: this.timestamp,
duration: this.duration,
colorSpace: this.colorSpace,
rotation: this.rotation,
});
} else {
return new VideoSample(this._data, {
@@ -254,6 +256,7 @@ export class VideoSample {
timestamp: this.timestamp,
duration: this.duration,
colorSpace: this.colorSpace,
rotation: this.rotation,
});
}
}
@@ -1063,6 +1066,58 @@ export class AudioSample {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
(this.timestamp as number) = newTimestamp;
}
/**
* Creates AudioSamples from an AudioBuffer, starting at the given timestamp in seconds. Typically creates exactly
* one sample, but may create multiple if the AudioBuffer is exceedingly large.
*/
static fromAudioBuffer(audioBuffer: AudioBuffer, timestamp: number) {
if (!(audioBuffer instanceof AudioBuffer)) {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const MAX_FLOAT_COUNT = 64 * 1024 * 1024;
const numberOfChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const totalFrames = audioBuffer.length;
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
let currentRelativeFrame = 0;
let remainingFrames = totalFrames;
const result: AudioSample[] = [];
// Create AudioData in a chunked fashion so we don't create huge Float32Arrays
while (remainingFrames > 0) {
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
for (let channel = 0; channel < numberOfChannels; channel++) {
audioBuffer.copyFromChannel(
chunkData.subarray(channel * framesToCopy, channel * framesToCopy + framesToCopy),
channel,
currentRelativeFrame,
);
}
const audioSample = new AudioSample({
format: 'f32-planar',
sampleRate,
numberOfFrames: framesToCopy,
numberOfChannels,
timestamp: timestamp + currentRelativeFrame / sampleRate,
data: chunkData,
});
result.push(audioSample);
currentRelativeFrame += framesToCopy;
remainingFrames -= framesToCopy;
}
return result;
}
}
const getBytesPerSample = (format: AudioSampleFormat): number => {
+22 -2
View File
@@ -164,11 +164,13 @@ export type UrlSourceOptions = {
*/
export class UrlSource extends Source {
/** @internal */
private _url: string | URL;
private _url: URL;
/** @internal */
private _options: UrlSourceOptions;
/** @internal */
private _fullData: ArrayBuffer | null = null;
/** @internal */
private _nextUrlVersion: number | null = null;
constructor(
url: string | URL,
@@ -189,7 +191,7 @@ export class UrlSource extends Source {
super();
this._url = url;
this._url = url instanceof URL ? url : new URL(url);
this._options = options;
}
@@ -203,6 +205,11 @@ export class UrlSource extends Source {
headers['Range'] = `bytes=${range.start}-${range.end - 1}`;
}
if (this._nextUrlVersion !== null) {
this._url.searchParams.set('mediabunny_version', this._nextUrlVersion.toString());
this._nextUrlVersion++;
}
const response = await retriedFetch(
this._url,
mergeObjectsDeeply(this._options.requestInit ?? {}, {
@@ -218,6 +225,19 @@ export class UrlSource extends Source {
const buffer = await response.arrayBuffer();
if (
response.status === 206
&& range
&& buffer.byteLength !== range.end - range.start
&& this._nextUrlVersion === null
) {
// We did a range request but it resolved with the wrong range; in Chromium, this can be due to a caching
// bug (https://issues.chromium.org/issues/436025873). Let's circumvent the cache for the rest of the
// session by appending a version to the URL.
this._nextUrlVersion = 1;
return this._makeRequest(range);
}
if (response.status === 200) {
// The server didn't return 206 Partial Content, so it's not a range response
this._fullData = buffer;
+5 -2
View File
@@ -2,12 +2,15 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../dist/modules",
"rootDir": "..",
"declaration": true,
"declarationMap": true,
"stripInternal": true,
"noEmit": false
"noEmit": false,
"composite": true
},
"include": [
"**/*"
"**/*",
"../shared/**/*"
]
}
+6 -3
View File
@@ -6,12 +6,15 @@
"composite": true,
"noEmit": false,
"paths": {
"mediabunny": ["./dist/mediabunny.d.ts"],
"mediabunny": ["./src/index.ts"],
},
"types": ["vite/client"]
},
"include": [
"./vite.config.ts",
"./examples/**/*.ts",
]
"./examples/**/*.ts"
],
"references": [
{ "path": "./src" }
]
}