mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 22:03:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
224da29440 | ||
|
|
c53b9a6fa1 | ||
|
|
835143c2e2 | ||
|
|
f547f455af | ||
|
|
96fe4c399c | ||
|
|
784eb89ae2 | ||
|
|
ba0491de3a | ||
|
|
64f986bc1b | ||
|
|
0b6c6ccb6c | ||
|
|
e1c50ef16c | ||
|
|
c6227cea5a | ||
|
|
cbb2681d33 | ||
|
|
7a2a23bcb0 | ||
|
|
b63d86226f | ||
|
|
3371813c33 | ||
|
|
1fe115ac83 | ||
|
|
7e42e298c5 | ||
|
|
597b68b299 | ||
|
|
f5803598e8 | ||
|
|
b98d8e58ca | ||
|
|
dd60ca71b3 | ||
|
|
4b99fd08ca | ||
|
|
5b238c6d0f | ||
|
|
2f83e07a55 | ||
|
|
1aaded9241 | ||
|
|
5a6b849ff8 | ||
|
|
b60d4cafed | ||
|
|
95091cce9c | ||
|
|
72ed933bf4 | ||
|
|
ef3b53ff8a |
@@ -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
@@ -1,5 +1,8 @@
|
||||
/node_modules
|
||||
.vscode
|
||||
node_modules
|
||||
/dist
|
||||
/dist-docs
|
||||
.DS_Store
|
||||
/docs/.vitepress/cache
|
||||
/docs/.vitepress/cache
|
||||
|
||||
packages/mp3-encoder/dist
|
||||
@@ -49,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
@@ -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": {
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
@@ -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
|
||||
+8
-3
@@ -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';
|
||||
@@ -70,8 +73,10 @@
|
||||
},
|
||||
*/
|
||||
video: {
|
||||
forceTranscode: true,
|
||||
codec: 'av1',
|
||||
//frameRate: 27.123,
|
||||
//width: 320,
|
||||
//forceTranscode: true,
|
||||
//codec: 'av1',
|
||||
//discard: true,
|
||||
//width: 1280,
|
||||
//discard: true,
|
||||
|
||||
@@ -16,6 +16,30 @@
|
||||
source
|
||||
});
|
||||
|
||||
/*
|
||||
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 +53,7 @@
|
||||
}
|
||||
}
|
||||
console.log("don")
|
||||
*/
|
||||
|
||||
/*
|
||||
console.time()
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -11,6 +11,7 @@ It has the following features:
|
||||
- Trimming
|
||||
- Video resizing & fitting
|
||||
- Video rotation
|
||||
- Video frame rate adjustment
|
||||
- Audio resampling
|
||||
- Audio up/downmixing
|
||||
|
||||
@@ -101,6 +102,7 @@ type ConversionOptions = {
|
||||
height?: number;
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
rotate?: 0 | 90 | 180 | 270;
|
||||
frameRate?: number;
|
||||
codec?: VideoCodec;
|
||||
bitrate?: number | Quality;
|
||||
forceTranscode?: boolean;
|
||||
@@ -141,6 +143,10 @@ 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.
|
||||
|
||||
### 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).
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -212,6 +212,10 @@ type Mp3OutputFormatOptions = {
|
||||
- `onXingFrame`\
|
||||
Will be called once the Xing metadata frame is finalized, which happens at the end of the writing process.
|
||||
|
||||
::: info
|
||||
Most browsers don't support encoding MP3. Use the official [`@mediabunny/mp3-encoder`](./extensions/mp3-encoder) package to polyfill an encoder.
|
||||
:::
|
||||
|
||||
## WAVE
|
||||
|
||||
This output format creates WAVE (.wav) files.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -98,6 +98,7 @@ const sponsors = {
|
||||
{ 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>
|
||||
|
||||
+3
-4
@@ -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,7 +91,7 @@ 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 = '';
|
||||
|
||||
@@ -180,7 +180,7 @@ 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();
|
||||
|
||||
|
||||
Generated
+225
-2
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.1",
|
||||
"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.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.6.0.tgz",
|
||||
"integrity": "sha512-6qBEk4sxAiZQINUvww/ose/hwqw7MIZXYbnBMezd7Uoh6v+Fkr4JQAkNyimli/fEgOJ0DJ8tglTJ/hyLMCH69w==",
|
||||
"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.6.1",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/Vanilagy"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mediabunny": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-9
@@ -1,15 +1,18 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.4.1",
|
||||
"version": "1.6.1",
|
||||
"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,10 +24,10 @@
|
||||
],
|
||||
"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",
|
||||
@@ -32,7 +35,10 @@
|
||||
"dev": "vite",
|
||||
"examples:build": "vite build",
|
||||
"fix-build-import-paths": "tsx scripts/add-import-extensions.ts",
|
||||
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
build/* linguist-generated
|
||||
@@ -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.
|
||||
@@ -0,0 +1,154 @@
|
||||
# @mediabunny/mp3-encoder
|
||||
|
||||
[](https://www.npmjs.com/package/@mediabunny/mp3-encoder)
|
||||
[](https://bundlephobia.com/package/@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! 💘
|
||||
|
||||
## 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.
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+2
File diff suppressed because one or more lines are too long
Generated
BIN
Binary file not shown.
Executable
+1342
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 |
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.6.1",
|
||||
"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"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Vanilagy/mediabunny/issues"
|
||||
},
|
||||
"homepage": "https://mediabunny.dev/",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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:');
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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.');
|
||||
}
|
||||
|
||||
async function buildWorker(workerPath: string, extraConfig: esbuild.BuildOptions) {
|
||||
const scriptNameParts = path.basename(workerPath).split('.');
|
||||
scriptNameParts.pop();
|
||||
scriptNameParts.push('js');
|
||||
const scriptName = scriptNameParts.join('.');
|
||||
const bundlePath = path.resolve(cacheDir!, scriptName);
|
||||
|
||||
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' });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
},
|
||||
"include": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
+148
-15
@@ -77,6 +77,11 @@ export type ConversionOptions = {
|
||||
* 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. */
|
||||
@@ -261,8 +266,14 @@ export class Conversion {
|
||||
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.video?.frameRate !== undefined
|
||||
&& (!Number.isFinite(options.video.frameRate) || options.video.frameRate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.');
|
||||
}
|
||||
if (options.audio !== undefined && (!options.audio || typeof options.audio !== 'object')) {
|
||||
throw new TypeError('options.video, when provided, must be an object.');
|
||||
throw new TypeError('options.audio, 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.');
|
||||
@@ -399,7 +410,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;
|
||||
@@ -470,7 +481,10 @@ export class Conversion {
|
||||
}
|
||||
|
||||
const firstTimestamp = await track.getFirstTimestamp();
|
||||
const needsTranscode = !!this._options.video?.forceTranscode || this._startTimestamp > 0 || firstTimestamp < 0;
|
||||
const needsTranscode = !!this._options.video?.forceTranscode
|
||||
|| this._startTimestamp > 0
|
||||
|| firstTimestamp < 0
|
||||
|| !!this._options.video?.frameRate;
|
||||
const needsRerender = width !== originalWidth
|
||||
|| height !== originalHeight
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation);
|
||||
@@ -547,10 +561,10 @@ 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;
|
||||
|
||||
@@ -562,6 +576,27 @@ export class Conversion {
|
||||
poolSize: 1,
|
||||
});
|
||||
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
||||
const frameRate = this._options.video?.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 +607,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 = this._options.video?.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 +744,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
this.output.addVideoTrack(videoSource, {
|
||||
frameRate: this._options.video?.frameRate,
|
||||
languageCode: track.languageCode,
|
||||
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
|
||||
});
|
||||
|
||||
+32
-4
@@ -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.');
|
||||
}
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+196
-189
@@ -1941,44 +1941,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 +1993,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 +2704,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;
|
||||
};
|
||||
|
||||
+39
-1
@@ -499,7 +499,13 @@ export class EBMLReader {
|
||||
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 +594,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.');
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
LEVEL_0_AND_1_EBML_IDS,
|
||||
MAX_HEADER_SIZE,
|
||||
MIN_HEADER_SIZE,
|
||||
readVarInt,
|
||||
} from './ebml';
|
||||
import { buildMatroskaMimeType } from './matroska-misc';
|
||||
|
||||
@@ -107,12 +108,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 +142,7 @@ type InternalTrack = {
|
||||
inputTrack: InputTrack | null;
|
||||
codecId: string | null;
|
||||
codecPrivate: Uint8Array | null;
|
||||
defaultDuration: number | null;
|
||||
languageCode: string;
|
||||
info:
|
||||
| null
|
||||
@@ -220,13 +230,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 +255,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);
|
||||
@@ -471,16 +504,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) {
|
||||
@@ -503,16 +540,35 @@ 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,
|
||||
@@ -561,6 +617,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;
|
||||
|
||||
@@ -632,6 +802,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
inputTrack: null,
|
||||
codecId: null,
|
||||
codecPrivate: null,
|
||||
defaultDuration: null,
|
||||
languageCode: UNDETERMINED_LANGUAGE,
|
||||
info: null,
|
||||
};
|
||||
@@ -793,6 +964,13 @@ 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;
|
||||
|
||||
@@ -954,14 +1132,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;
|
||||
|
||||
@@ -985,16 +1165,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;
|
||||
@@ -1117,7 +1298,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,
|
||||
@@ -1165,7 +1345,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
};
|
||||
}
|
||||
},
|
||||
block.timestamp,
|
||||
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
|
||||
Infinity,
|
||||
options,
|
||||
);
|
||||
@@ -1189,7 +1369,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,
|
||||
@@ -1244,7 +1423,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
};
|
||||
}
|
||||
},
|
||||
block.timestamp,
|
||||
-Infinity, // Use -Infinity as a search timestamp to avoid using the cues
|
||||
Infinity,
|
||||
options,
|
||||
);
|
||||
|
||||
+36
-25
@@ -80,7 +80,8 @@ export abstract class MediaSource {
|
||||
/** @internal */
|
||||
async _start() {}
|
||||
/** @internal */
|
||||
async _flushAndClose() {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async _flushAndClose(forceClose: boolean) {}
|
||||
|
||||
/**
|
||||
* Closes this source. This prevents future samples from being added and signals to the output file that no further
|
||||
@@ -103,7 +104,7 @@ export abstract class MediaSource {
|
||||
}
|
||||
|
||||
this._closingPromise = (async () => {
|
||||
await this._flushAndClose();
|
||||
await this._flushAndClose(false);
|
||||
|
||||
this._closed = true;
|
||||
|
||||
@@ -116,12 +117,12 @@ export abstract class MediaSource {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _flushOrWaitForClose() {
|
||||
async _flushOrWaitForOngoingClose(forceClose: boolean) {
|
||||
if (this._closingPromise) {
|
||||
// Since closing also flushes, we don't want to do it twice
|
||||
return this._closingPromise;
|
||||
} else {
|
||||
return this._flushAndClose();
|
||||
return this._flushAndClose(forceClose);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,14 +452,20 @@ class VideoEncoderWrapper {
|
||||
})();
|
||||
}
|
||||
|
||||
async flushAndClose() {
|
||||
async flushAndClose(forceClose: boolean) {
|
||||
this.checkForEncoderError();
|
||||
|
||||
if (this.customEncoder) {
|
||||
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
|
||||
if (!forceClose) {
|
||||
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
|
||||
}
|
||||
|
||||
await this.customEncoderCallSerializer.call(() => this.customEncoder!.close());
|
||||
} else if (this.encoder) {
|
||||
await this.encoder.flush();
|
||||
if (!forceClose) {
|
||||
await this.encoder.flush();
|
||||
}
|
||||
|
||||
this.encoder.close();
|
||||
}
|
||||
|
||||
@@ -512,8 +519,8 @@ export class VideoSampleSource extends VideoSource {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override _flushAndClose() {
|
||||
return this._encoder.flushAndClose();
|
||||
override _flushAndClose(forceClose: boolean) {
|
||||
return this._encoder.flushAndClose(forceClose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,8 +571,8 @@ export class CanvasSource extends VideoSource {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override _flushAndClose() {
|
||||
return this._encoder.flushAndClose();
|
||||
override _flushAndClose(forceClose: boolean) {
|
||||
return this._encoder.flushAndClose(forceClose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,7 +721,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override async _flushAndClose() {
|
||||
override async _flushAndClose(forceClose: boolean) {
|
||||
if (this._abortController) {
|
||||
this._abortController.abort();
|
||||
this._abortController = null;
|
||||
@@ -746,7 +753,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
});
|
||||
}
|
||||
|
||||
await this._encoder.flushAndClose();
|
||||
await this._encoder.flushAndClose(forceClose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1226,14 +1233,20 @@ class AudioEncoderWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
async flushAndClose() {
|
||||
async flushAndClose(forceClose: boolean) {
|
||||
this.checkForEncoderError();
|
||||
|
||||
if (this.customEncoder) {
|
||||
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
|
||||
if (!forceClose) {
|
||||
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
|
||||
}
|
||||
|
||||
await this.customEncoderCallSerializer.call(() => this.customEncoder!.close());
|
||||
} else if (this.encoder) {
|
||||
await this.encoder.flush();
|
||||
if (!forceClose) {
|
||||
await this.encoder.flush();
|
||||
}
|
||||
|
||||
this.encoder.close();
|
||||
}
|
||||
|
||||
@@ -1289,8 +1302,8 @@ export class AudioSampleSource extends AudioSource {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override _flushAndClose() {
|
||||
return this._encoder.flushAndClose();
|
||||
override _flushAndClose(forceClose: boolean) {
|
||||
return this._encoder.flushAndClose(forceClose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1333,8 +1346,8 @@ export class AudioBufferSource extends AudioSource {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override _flushAndClose() {
|
||||
return this._encoder.flushAndClose();
|
||||
override _flushAndClose(forceClose: boolean) {
|
||||
return this._encoder.flushAndClose(forceClose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,7 +1493,7 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override async _flushAndClose() {
|
||||
override async _flushAndClose(forceClose: boolean) {
|
||||
if (this._abortController) {
|
||||
this._abortController.abort();
|
||||
this._abortController = null;
|
||||
@@ -1493,7 +1506,7 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
await this._audioContext.suspend();
|
||||
}
|
||||
|
||||
await this._encoder.flushAndClose();
|
||||
await this._encoder.flushAndClose(forceClose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1527,9 +1540,7 @@ type MediaStreamTrackProcessorControllerMessage = {
|
||||
const mediaStreamTrackProcessorWorkerCode = () => {
|
||||
const sendMessage = (message: MediaStreamTrackProcessorWorkerMessage, transfer?: Transferable[]) => {
|
||||
if (transfer) {
|
||||
// The error is bullshit, it's using the wrong postMessage
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
||||
self.postMessage(message, transfer as any);
|
||||
self.postMessage(message, { transfer });
|
||||
} else {
|
||||
self.postMessage(message);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import { assert, binarySearchExact, binarySearchLessOrEqual, last, 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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
MPEG_V2_BITRATES,
|
||||
SAMPLING_RATES,
|
||||
XING,
|
||||
} from './mp3-misc';
|
||||
} from '../../shared/mp3-misc';
|
||||
|
||||
export type XingFrameData = {
|
||||
mpegVersionId: number;
|
||||
|
||||
+2
-2
@@ -380,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();
|
||||
@@ -410,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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user