Compare commits

..
18 Commits
Author SHA1 Message Date
Vanilagy b1b493ab10 Fix out of bounds reads for Matroska demuxer 2025-07-15 16:03:00 +02:00
Vanilagy cf0bbe896a Add longer variant to VideoSample.draw 2025-07-15 11:49:33 +02:00
Vanilagy c5d299c4ec QuickTime 😩 2025-07-14 09:53:26 +02:00
Vanilagy b06f510382 Bump patch version 2025-07-11 10:58:58 +02:00
Vanilagy 14ad80df5d Improve ISOBMFF demuxer to support fragments without a key frame 2025-07-11 10:57:24 +02:00
Vanilagy 514a533445 Reduce MP3 detection false positives 2025-07-11 10:07:45 +02:00
Vanilagy 9072a7a9f7 Be even more graceful with invalid ISOBMFF files 2025-07-10 17:47:56 +02:00
Vanilagy fba463f46b Be more graceful with unsupported edit lists 2025-07-10 17:33:10 +02:00
Vanilagy 3621c59034 Center bunny, add bisky 2025-07-10 11:39:02 +02:00
Vanilagy a16911874c Clarify built distribution files in docs 2025-07-09 12:14:29 +02:00
Vanilagy f59a79dcf7 Bump permissions for Release workflow 2025-07-09 12:05:33 +02:00
Vanilagy 19b7036d82 Bump version to 1.0.4 2025-07-09 11:59:32 +02:00
Vanilagy 47bae059db Add build artifact upload step to Release workflow 2025-07-09 11:58:15 +02:00
Vanilagy 84c9d37cb7 Revise build step and dist structure for better Webpack, CJS and pnpm support 2025-07-09 11:53:19 +02:00
David P.andGitHub 3e96184a6f Merge pull request #11 from studnitz/patch-1
Fix typo
2025-07-09 10:29:23 +02:00
Alexander von StudnitzandGitHub ee59837857 Fix typo 2025-07-09 09:47:26 +02:00
Vanilagy 08cf34ee0d Adjust README image sizes 2025-07-08 15:42:32 +02:00
Vanilagy 1b382d3e57 Improve unsupported encoder configuration error messages 2025-07-07 11:21:42 +02:00
32 changed files with 329 additions and 144 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ indent_size = 2
indent_style = space indent_style = space
indent_size = 4 indent_size = 4
[*.yaml] [*.yml]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
@@ -2,6 +2,7 @@ name: Lint
on: on:
push: push:
pull_request:
jobs: jobs:
lint: lint:
+6 -3
View File
@@ -11,9 +11,7 @@ jobs:
release: release:
name: Release name: Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions: write-all
contents: read
id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -41,6 +39,11 @@ jobs:
- name: Run build - name: Run build
run: npm run build run: npm run build
- 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
- name: Create Publish to npm - name: Create Publish to npm
run: npm publish --provenance run: npm publish --provenance
env: env:
-1
View File
@@ -1,5 +1,4 @@
/node_modules /node_modules
/build
/dist /dist
/dist-docs /dist-docs
.DS_Store .DS_Store
+4 -4
View File
@@ -5,7 +5,7 @@
[![](https://img.shields.io/npm/dm/mediabunny)](https://www.npmjs.com/package/mediabunny) [![](https://img.shields.io/npm/dm/mediabunny)](https://www.npmjs.com/package/mediabunny)
<div align="center"> <div align="center">
<img src="./docs/public/mediabunny-logo.svg" height="180"> <img src="./docs/public/mediabunny-logo.svg" width="180" height="180">
</div> </div>
Mediabunny is a JavaScript library for reading, writing, and converting media files (like MP4, WebM, MP3), directly in the browser. It aims to be a complete toolkit for high-performance media operations on the web. It's written from scratch in pure TypeScript, has zero dependencies, is very performant, and is extremely tree-shakable, meaning you only include what you use. You can think of it a bit like [FFmpeg](https://ffmpeg.org/), but built from the ground up for the web. Mediabunny is a JavaScript library for reading, writing, and converting media files (like MP4, WebM, MP3), directly in the browser. It aims to be a complete toolkit for high-performance media operations on the web. It's written from scratch in pure TypeScript, has zero dependencies, is very performant, and is extremely tree-shakable, meaning you only include what you use. You can think of it a bit like [FFmpeg](https://ffmpeg.org/), but built from the ground up for the web.
@@ -16,11 +16,11 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<div align="center"> <div align="center">
<a href="https://www.gling.ai/" target="_blank"> <a href="https://www.gling.ai/" target="_blank">
<img src="./docs/public/sponsors/gling.svg" height="60" alt="Gling AI"> <img src="./docs/public/sponsors/gling.svg" width="60" height="60" alt="Gling AI">
</a> </a>
&nbsp;&nbsp;&nbsp;&nbsp; &nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://diffusion.studio/" target="_blank"> <a href="https://diffusion.studio/" target="_blank">
<img src="./docs/public/sponsors/diffusionstudio.png" height="60" alt="Diffusion Studio"> <img src="./docs/public/sponsors/diffusionstudio.png" width="60" height="60" alt="Diffusion Studio">
</a> </a>
</div> </div>
@@ -152,7 +152,7 @@ For development, clone this repository and install it using a modern version of
```bash ```bash
npm install # Install dependencies npm install # Install dependencies
npm run watch # Development build with watch mode npm run watch # Build bundles on watch mode
npm run build # Production build with type definitions npm run build # Production build with type definitions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"mainEntryPointFilePath": "build/index.d.ts", "mainEntryPointFilePath": "dist/modules/index.d.ts",
"bundledPackages": [], "bundledPackages": [],
"compiler": {}, "compiler": {},
"apiReport": { "apiReport": {
+4 -4
View File
@@ -37,20 +37,20 @@ const esmConfig = {
const ctxUmd = await esbuild.context({ const ctxUmd = await esbuild.context({
...umdConfig, ...umdConfig,
outfile: 'dist/mediabunny.js', outfile: 'dist/bundles/mediabunny.cjs',
}); });
const ctxEsm = await esbuild.context({ const ctxEsm = await esbuild.context({
...esmConfig, ...esmConfig,
outfile: 'dist/mediabunny.mjs', outfile: 'dist/bundles/mediabunny.mjs',
}); });
const ctxUmdMinified = await esbuild.context({ const ctxUmdMinified = await esbuild.context({
...umdConfig, ...umdConfig,
outfile: 'dist/mediabunny.min.js', outfile: 'dist/bundles/mediabunny.min.cjs',
minify: true, minify: true,
}); });
const ctxEsmMinified = await esbuild.context({ const ctxEsmMinified = await esbuild.context({
...esmConfig, ...esmConfig,
outfile: 'dist/mediabunny.min.mjs', outfile: 'dist/bundles/mediabunny.min.mjs',
minify: true, minify: true,
}); });
+4 -3
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<script src="../dist/mediabunny.js"></script> <script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module"> <script type="module">
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
@@ -21,7 +21,7 @@
chunked: true, chunked: true,
chunkSize: 2**20 chunkSize: 2**20
}); });
const outputFormat = new Mediabunny.MovOutputFormat(); const outputFormat = new Mediabunny.Mp4OutputFormat();
const button = document.createElement('button'); const button = document.createElement('button');
button.textContent = 'Cancel'; button.textContent = 'Cancel';
@@ -38,7 +38,8 @@
target target
}), }),
audio: { audio: {
codec: 'pcm-f64' codec: 'opus',
bitrate: 128000,
//numberOfChannels: 1, //numberOfChannels: 1,
//sampleRate: 4000 //sampleRate: 4000
//discard: true //discard: true
+13 -6
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<script src="../dist/mediabunny.js"></script> <script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module"> <script type="module">
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
@@ -15,14 +15,21 @@
formats: Mediabunny.ALL_FORMATS, formats: Mediabunny.ALL_FORMATS,
source source
}); });
const canvas = document.createElement('canvas');
canvas.width = 1920;
canvas.height = 1080;
canvas.style.background = 'ghostwhite';
const ctx = canvas.getContext('2d');
document.body.append(canvas);
const videoTrack = await input.getPrimaryVideoTrack(); const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.VideoSampleSink(videoTrack);
const sink = new Mediabunny.EncodedPacketSink(videoTrack); const sample = await sink.getSample(3);
for await (const packet of sink.packets()) { console.log(sample);
if (packet.timestamp >= 2) break;
console.log(packet); sample.draw(ctx, 1500, 500, 50, 50, 0, 0);
}
/* /*
let timestamps = []; let timestamps = [];
+1 -1
View File
@@ -1,6 +1,6 @@
<button>Go</button> <button>Go</button>
<script src="../dist/mediabunny.js"></script> <script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module"> <script type="module">
function download(blob, filename) { function download(blob, filename) {
+1 -1
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<script src="../dist/mediabunny.js"></script> <script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module"> <script type="module">
function download(blob, filename) { function download(blob, filename) {
+1 -1
View File
@@ -15,7 +15,7 @@
</div> </div>
</div> </div>
<script src="../dist/mediabunny.js"></script> <script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module"> <script type="module">
const fileInput = document.querySelector('input[type="file"]'); const fileInput = document.querySelector('input[type="file"]');
+1
View File
@@ -75,6 +75,7 @@ export default withMermaid({
{ icon: 'github', link: 'https://github.com/Vanilagy/mediabunny' }, { icon: 'github', link: 'https://github.com/Vanilagy/mediabunny' },
{ icon: 'discord', link: 'https://discord.gg/hmpkyYuS4U' }, { icon: 'discord', link: 'https://discord.gg/hmpkyYuS4U' },
{ icon: 'x', link: 'https://x.com/vanilagy' }, { icon: 'x', link: 'https://x.com/vanilagy' },
{ icon: 'bluesky', link: 'https://bsky.app/profile/vanilagy.bsky.social' },
], ],
search: { search: {
+4 -2
View File
@@ -27,7 +27,9 @@ ESM is preferred because it gives you tree shaking.
You can also just include the library using a script tag in your HTML: You can also just include the library using a script tag in your HTML:
```html ```html
<script src="path/to/mediabunny.js"></script> <script src="mediabunny.cjs"></script>
``` ```
You can download the built distribution file from the [releases page](https://github.com/Vanilagy/mediabunny/releases). This will add a `Mediabunny` object to the global scope.
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.
+16 -4
View File
@@ -309,17 +309,29 @@ The `VideoFrame` returned by this method **must** be closed separately from the
--- ---
It's also common to draw video samples to a `<canvas>` element or an `OffscreenCanvas`. For this, you can use the following method: It's also common to draw video samples to a `<canvas>` element or an `OffscreenCanvas`. For this, you can use the following methods:
```ts ```ts
draw( draw(
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
dx: number, dx: number,
dy: number, dy: number,
dWidth?: number, dWidth?: number, // defaults to displayWidth
dHeight?: number, dHeight?: number, // defaults to displayHeight
): void;
draw(
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
sx: number,
sy: number,
sWidth: number,
sHeight: number,
dx: number,
dy: number,
dWidth?: number, // defaults to sWidth
dHeight?: number, // defaults to sHeight
): void; ): void;
``` ```
This method is similar to [drawImage](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage) and paints the video frame at the given position with the given dimensions. If the dimensions aren't specified, they will default to the display dimensions. This method will automatically draw the frame with the correct rotation based on its `rotation` property. These methods behave like [drawImage](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage) and paint the video frame at the given position with the given dimensions. This method will automatically draw the frame with the correct rotation based on its `rotation` property.
If you want to draw the raw underlying image to a canvas directly (without respecting the rotation metadata), then you can use the following method: If you want to draw the raw underlying image to a canvas directly (without respecting the rotation metadata), then you can use the following method:
```ts ```ts
+1 -1
View File
@@ -237,7 +237,7 @@ await conversion.execute();
</div> </div>
</div> </div>
<div class="flex flex-col-reverse lg:flex-row gap-4 lg:gap-20 lg:items-center"> <div class="flex flex-col-reverse lg:flex-row gap-4 lg:gap-20 items-center">
<div class="relative flex-1 min-w-0"> <div class="relative flex-1 min-w-0">
<div class="absolute size-70 rounded-full bg-[#ff45ac]/0 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 blur-[200px]" /> <div class="absolute size-70 rounded-full bg-[#ff45ac]/0 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 blur-[200px]" />
<img class="relative" src="./assets/inspiring-io.svg"> <img class="relative" src="./assets/inspiring-io.svg">
+1 -1
View File
@@ -85,7 +85,7 @@ const startRecording = async () => {
// Add the video track, with the canvas as the source // Add the video track, with the canvas as the source
videoSource = new CanvasSource(canvas, { videoSource = new CanvasSource(canvas, {
codec: 'vp9', codec: 'avc',
bitrate: QUALITY_MEDIUM, bitrate: QUALITY_MEDIUM,
keyFrameInterval: 0.5, keyFrameInterval: 0.5,
latencyMode: 'realtime', // Allow the encoder to skip frames to keep up with real-time constraints latencyMode: 'realtime', // Allow the encoder to skip frames to keep up with real-time constraints
+1 -1
View File
@@ -253,7 +253,7 @@ const runAudioIterator = async () => {
} }
// To play back audio, we loop over all audio chunks (typically very short) of the file and play them at the correct // To play back audio, we loop over all audio chunks (typically very short) of the file and play them at the correct
// timestamp. The result is a continuous, uninteruppted audio signal. // timestamp. The result is a continuous, uninterrupted audio signal.
for await (const { buffer, timestamp } of audioBufferIterator!) { for await (const { buffer, timestamp } of audioBufferIterator!) {
const node = audioContext!.createBufferSource(); const node = audioContext!.createBufferSource();
node.buffer = buffer; node.buffer = buffer;
+24 -38
View File
@@ -1,12 +1,12 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"version": "1.0.1", "version": "1.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mediabunny", "name": "mediabunny",
"version": "1.0.1", "version": "1.1.1",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-mediacapture-transform": "^0.1.11",
@@ -319,9 +319,9 @@
} }
}, },
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.25.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -329,9 +329,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -339,13 +339,13 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.27.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.27.0" "@babel/types": "^7.28.0"
}, },
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
@@ -355,14 +355,14 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.27.0", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.25.9", "@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.25.9" "@babel/helper-validator-identifier": "^7.27.1"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@@ -1238,18 +1238,14 @@
} }
}, },
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8", "version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
@@ -1262,16 +1258,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@jridgewell/set-array": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -1280,9 +1266,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25", "version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+14 -10
View File
@@ -1,25 +1,27 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.0.1", "version": "1.1.1",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module", "type": "module",
"main": "./dist/mediabunny.js", "main": "./dist/bundles/mediabunny.cjs",
"module": "./dist/mediabunny.mjs", "module": "./dist/modules/index.js",
"types": "./dist/mediabunny.d.ts", "types": "./dist/modules/index.d.ts",
"exports": { "exports": {
"types": "./dist/mediabunny.d.ts", "types": "./dist/modules/index.d.ts",
"import": "./dist/mediabunny.mjs", "import": "./dist/modules/index.js",
"require": "./dist/mediabunny.js" "require": "./dist/bundles/mediabunny.cjs"
}, },
"files": [ "files": [
"README.md", "README.md",
"package.json", "package.json",
"LICENSE", "LICENSE",
"dist" "dist",
"src"
], ],
"sideEffects": false,
"scripts": { "scripts": {
"build": "tsx scripts/ensure-license-headers.ts && node build.mjs && tsc -p src && api-extractor run && npm run check-docblocks && tsx scripts/append-namespace.ts", "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", "watch": "node build.mjs --watch",
"lint": "eslint .", "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 scripts --noEmit && tsc -p tsconfig.vite.json --noEmit && rm tsconfig.vite.tsbuildinfo",
@@ -28,7 +30,9 @@
"docs:build": "vitepress build docs && npm run examples:build", "docs:build": "vitepress build docs && npm run examples:build",
"docs:preview": "vitepress preview docs", "docs:preview": "vitepress preview docs",
"dev": "vite", "dev": "vite",
"examples:build": "vite build" "examples:build": "vite build",
"fix-build-import-paths": "find dist -name \"*.js\" -type f -exec sed -i -r \"s/ from '([^']+)';/ from '\\1.js';/g\" {} \\;",
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts"
}, },
"license": "MPL-2.0", "license": "MPL-2.0",
"repository": { "repository": {
-3
View File
@@ -1,3 +0,0 @@
import { appendFileSync } from 'fs';
appendFileSync('dist/mediabunny.d.ts', '\nexport as namespace Mediabunny;');
+3
View File
@@ -6,6 +6,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
/// <reference types="dom-mediacapture-transform" preserve="true" />
/// <reference types="dom-webcodecs" preserve="true" />
export { export {
Output, Output,
OutputOptions, OutputOptions,
+5 -3
View File
@@ -13,6 +13,7 @@ import { IsobmffReader } from './isobmff/isobmff-reader';
import { EBMLId, EBMLReader } from './matroska/ebml'; import { EBMLId, EBMLReader } from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer'; import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer'; import { Mp3Demuxer } from './mp3/mp3-demuxer';
import { FRAME_HEADER_SIZE } from './mp3/mp3-misc';
import { Mp3Reader } from './mp3/mp3-reader'; import { Mp3Reader } from './mp3/mp3-reader';
import { OggDemuxer } from './ogg/ogg-demuxer'; import { OggDemuxer } from './ogg/ogg-demuxer';
import { OggReader } from './ogg/ogg-reader'; import { OggReader } from './ogg/ogg-reader';
@@ -245,9 +246,10 @@ export class Mp3InputFormat extends InputFormat {
} }
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find // Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
// another header nearby: // another header right after it:
mp3Reader.pos = firstHeader.startPos + firstHeader.totalSize; mp3Reader.pos = firstHeader.startPos + firstHeader.totalSize;
const secondHeader = mp3Reader.readNextFrameHeader(Math.min(framesStartPos + 4096, sourceSize)); await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + FRAME_HEADER_SIZE);
const secondHeader = mp3Reader.readNextFrameHeader(mp3Reader.pos + FRAME_HEADER_SIZE);
if (!secondHeader) { if (!secondHeader) {
return false; return false;
} }
@@ -257,7 +259,7 @@ export class Mp3InputFormat extends InputFormat {
return false; return false;
} }
// We have found two matching MP3 frames, a strong indicator that this is an MP3 file // We have found two matching consecutive MP3 frames, a strong indicator that this is an MP3 file
return true; return true;
} }
+87 -30
View File
@@ -54,6 +54,7 @@ import {
isIso639Dash2LanguageCode, isIso639Dash2LanguageCode,
roundToMultiple, roundToMultiple,
normalizeRotation, normalizeRotation,
Bitstream,
} from '../misc'; } from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet'; import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { Reader } from '../reader'; import { Reader } from '../reader';
@@ -74,6 +75,7 @@ type InternalTrack = {
fragmentLookupTable: FragmentLookupTableEntry[] | null; fragmentLookupTable: FragmentLookupTableEntry[] | null;
currentFragmentState: FragmentTrackState | null; currentFragmentState: FragmentTrackState | null;
fragments: Fragment[]; fragments: Fragment[];
fragmentsWithKeyFrame: Fragment[];
/** The segment durations of all edit list entries leading up to the main one (from which the offset is taken.) */ /** The segment durations of all edit list entries leading up to the main one (from which the offset is taken.) */
editListPreviousSegmentDurations: number; editListPreviousSegmentDurations: number;
/** The media time offset of the main edit list entry (with media time !== -1) */ /** The media time offset of the main edit list entry (with media time !== -1) */
@@ -167,6 +169,7 @@ type FragmentTrackState = {
type FragmentTrackData = { type FragmentTrackData = {
startTimestamp: number; startTimestamp: number;
endTimestamp: number; endTimestamp: number;
firstKeyFrameTimestamp: number | null;
samples: FragmentTrackSample[]; samples: FragmentTrackSample[];
presentationTimestamps: { presentationTimestamps: {
presentationTimestamp: number; presentationTimestamp: number;
@@ -607,6 +610,7 @@ export class IsobmffDemuxer extends Demuxer {
fragmentLookupTable: null, fragmentLookupTable: null,
currentFragmentState: null, currentFragmentState: null,
fragments: [], fragments: [],
fragmentsWithKeyFrame: [],
editListPreviousSegmentDurations: 0, editListPreviousSegmentDurations: 0,
editListOffset: 0, editListOffset: 0,
} satisfies InternalTrack as InternalTrack; } satisfies InternalTrack as InternalTrack;
@@ -701,7 +705,10 @@ export class IsobmffDemuxer extends Demuxer {
} }
if (relevantEntryFound) { if (relevantEntryFound) {
throw new Error('Unsupported edit list: multiple edits are not supported.'); console.warn(
'Unsupported edit list: multiple edits are not currently supported. Only using first edit.',
);
break;
} }
if (mediaTime === -1) { if (mediaTime === -1) {
@@ -710,7 +717,8 @@ export class IsobmffDemuxer extends Demuxer {
} }
if (mediaRate !== 1) { if (mediaRate !== 1) {
throw new Error('Unsupported edit list: media rate must be 1.'); console.warn('Unsupported edit list entry: media rate must be 1.');
break;
} }
track.editListPreviousSegmentDurations = previousSegmentDurations; track.editListPreviousSegmentDurations = previousSegmentDurations;
@@ -942,7 +950,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (sampleSize === 16) { } else if (sampleSize === 16) {
track.info.codec = 'pcm-s16be'; track.info.codec = 'pcm-s16be';
} else { } else {
throw new Error(`Unsupported sample size ${sampleSize} for codec 'twos'.`); console.warn(`Unsupported sample size ${sampleSize} for codec 'twos'.`);
track.info.codec = null;
} }
} else if (lowercaseBoxName === 'sowt') { } else if (lowercaseBoxName === 'sowt') {
if (sampleSize === 8) { if (sampleSize === 8) {
@@ -950,7 +959,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (sampleSize === 16) { } else if (sampleSize === 16) {
track.info.codec = 'pcm-s16'; track.info.codec = 'pcm-s16';
} else { } else {
throw new Error(`Unsupported sample size ${sampleSize} for codec 'sowt'.`); console.warn(`Unsupported sample size ${sampleSize} for codec 'sowt'.`);
track.info.codec = null;
} }
} else if (lowercaseBoxName === 'raw ') { } else if (lowercaseBoxName === 'raw ') {
track.info.codec = 'pcm-u8'; track.info.codec = 'pcm-u8';
@@ -1193,7 +1203,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 32) { } else if (pcmSampleSize === 32) {
track.info.codec = 'pcm-s32'; track.info.codec = 'pcm-s32';
} else { } else {
throw new Error(`Invalid ipcm sample size ${pcmSampleSize}.`); console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
} }
} else { } else {
if (pcmSampleSize === 16) { if (pcmSampleSize === 16) {
@@ -1203,7 +1214,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 32) { } else if (pcmSampleSize === 32) {
track.info.codec = 'pcm-s32be'; track.info.codec = 'pcm-s32be';
} else { } else {
throw new Error(`Invalid ipcm sample size ${pcmSampleSize}.`); console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
} }
} }
} else if (track.info.codec === 'pcm-f32be') { } else if (track.info.codec === 'pcm-f32be') {
@@ -1215,7 +1227,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 64) { } else if (pcmSampleSize === 64) {
track.info.codec = 'pcm-f64'; track.info.codec = 'pcm-f64';
} else { } else {
throw new Error(`Invalid fpcm sample size ${pcmSampleSize}.`); console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
} }
} else { } else {
if (pcmSampleSize === 32) { if (pcmSampleSize === 32) {
@@ -1223,7 +1236,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 64) { } else if (pcmSampleSize === 64) {
track.info.codec = 'pcm-f64be'; track.info.codec = 'pcm-f64be';
} else { } else {
throw new Error(`Invalid fpcm sample size ${pcmSampleSize}.`); console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
} }
} }
} }
@@ -1405,8 +1419,27 @@ export class IsobmffDemuxer extends Demuxer {
}; break; }; break;
case 'stz2': { case 'stz2': {
throw new Error('Unsupported.'); const track = this.currentTrack;
}; assert(track);
if (!track.sampleTable) {
break;
}
this.metadataReader.pos += 4; // Version + flags
this.metadataReader.pos += 3; // Reserved
const fieldSize = this.metadataReader.readU8(); // in bits
const sampleCount = this.metadataReader.readU32();
const bytes = this.metadataReader.readBytes(Math.ceil(sampleCount * fieldSize / 8));
const bitstream = new Bitstream(bytes);
for (let i = 0; i < sampleCount; i++) {
const sampleSize = bitstream.readBits(fieldSize);
track.sampleTable.sampleSizes.push(sampleSize);
}
}; break;
case 'stss': { case 'stss': {
const track = this.currentTrack; const track = this.currentTrack;
@@ -1635,6 +1668,16 @@ export class IsobmffDemuxer extends Demuxer {
); );
this.currentTrack.fragments.splice(insertionIndex + 1, 0, this.currentFragment); this.currentTrack.fragments.splice(insertionIndex + 1, 0, this.currentFragment);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) {
const insertionIndex = binarySearchLessOrEqual(
this.currentTrack.fragmentsWithKeyFrame,
this.currentFragment.moofOffset,
x => x.moofOffset,
);
this.currentTrack.fragmentsWithKeyFrame.splice(insertionIndex + 1, 0, this.currentFragment);
}
const { currentFragmentState } = this.currentTrack; const { currentFragmentState } = this.currentTrack;
assert(currentFragmentState); assert(currentFragmentState);
@@ -1733,7 +1776,8 @@ export class IsobmffDemuxer extends Demuxer {
assert(track.currentFragmentState); assert(track.currentFragmentState);
if (this.currentFragment.trackData.has(track.id)) { if (this.currentFragment.trackData.has(track.id)) {
throw new Error('Can\'t have two trun boxes for the same track in one fragment.'); console.warn('Can\'t have two trun boxes for the same track in one fragment. Ignoring...');
break;
} }
const version = this.metadataReader.readU8(); const version = this.metadataReader.readU8();
@@ -1770,6 +1814,7 @@ export class IsobmffDemuxer extends Demuxer {
const trackData: FragmentTrackData = { const trackData: FragmentTrackData = {
startTimestamp: 0, startTimestamp: 0,
endTimestamp: 0, endTimestamp: 0,
firstKeyFrameTimestamp: null,
samples: [], samples: [],
presentationTimestamps: [], presentationTimestamps: [],
startTimestampIsFinal: false, startTimestampIsFinal: false,
@@ -1831,13 +1876,19 @@ export class IsobmffDemuxer extends Demuxer {
.map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i })) .map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i }))
.sort((a, b) => a.presentationTimestamp - b.presentationTimestamp); .sort((a, b) => a.presentationTimestamp - b.presentationTimestamp);
// Update sample durations based on presentation order for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
for (let i = 0; i < trackData.presentationTimestamps.length - 1; i++) { const currentEntry = trackData.presentationTimestamps[i]!;
const current = trackData.presentationTimestamps[i]!; const currentSample = trackData.samples[currentEntry.sampleIndex]!;
const next = trackData.presentationTimestamps[i + 1]!;
const duration = next.presentationTimestamp - current.presentationTimestamp; if (trackData.firstKeyFrameTimestamp === null && currentSample.isKeyFrame) {
trackData.samples[current.sampleIndex]!.duration = duration; trackData.firstKeyFrameTimestamp = currentSample.presentationTimestamp;
}
if (i < trackData.presentationTimestamps.length - 1) {
// Update sample durations based on presentation order
const nextEntry = trackData.presentationTimestamps[i + 1]!;
currentSample.duration = nextEntry.presentationTimestamp - currentEntry.presentationTimestamp;
}
} }
const firstSample = trackData.samples[trackData.presentationTimestamps[0]!.sampleIndex]!; const firstSample = trackData.samples[trackData.presentationTimestamps[0]!.sampleIndex]!;
@@ -2074,13 +2125,13 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
correctSampleFound: true, correctSampleFound: true,
}; };
} else { } else {
// Walk the list of fragments until we find the next fragment for this track // Walk the list of fragments until we find the next fragment for this track with a key frame
let currentFragment = locationInFragment.fragment; let currentFragment = locationInFragment.fragment;
while (currentFragment.nextFragment) { while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment; currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id); const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) { if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const fragmentIndex = binarySearchExact( const fragmentIndex = binarySearchExact(
this.internalTrack.fragments, this.internalTrack.fragments,
currentFragment.moofOffset, currentFragment.moofOffset,
@@ -2089,9 +2140,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
assert(fragmentIndex !== -1); assert(fragmentIndex !== -1);
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame); const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
if (keyFrameIndex === -1) { assert(keyFrameIndex !== -1); // There must be one
throw new Error('Not supported: Fragment does not contain key sample.');
}
return { return {
fragmentIndex, fragmentIndex,
@@ -2231,26 +2280,34 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
} }
private findKeySampleInFragmentsForTimestamp(timestampInTimescale: number) { private findKeySampleInFragmentsForTimestamp(timestampInTimescale: number) {
const fragmentIndex = binarySearchLessOrEqual( const indexInKeyFrameFragments = binarySearchLessOrEqual(
// This array is technically not sorted by start timestamp, but for any reasonable file, it basically is. // This array is technically not sorted by start timestamp, but for any reasonable file, it basically is.
this.internalTrack.fragments, this.internalTrack.fragmentsWithKeyFrame,
timestampInTimescale, timestampInTimescale,
x => x.trackData.get(this.internalTrack.id)!.startTimestamp, x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
); );
let fragmentIndex = -1;
let sampleIndex = -1; let sampleIndex = -1;
let correctSampleFound = false; let correctSampleFound = false;
if (fragmentIndex !== -1) { if (indexInKeyFrameFragments !== -1) {
const fragment = this.internalTrack.fragments[fragmentIndex]!; const fragment = this.internalTrack.fragmentsWithKeyFrame[indexInKeyFrameFragments]!;
// Now, let's find the actual index of the fragment in the list of ALL fragments, not just key frame ones
fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
fragment.moofOffset,
x => x.moofOffset,
);
assert(fragmentIndex !== -1);
const trackData = fragment.trackData.get(this.internalTrack.id)!; const trackData = fragment.trackData.get(this.internalTrack.id)!;
const index = findLastIndex(trackData.presentationTimestamps, (x) => { const index = findLastIndex(trackData.presentationTimestamps, (x) => {
const sample = trackData.samples[x.sampleIndex]!; const sample = trackData.samples[x.sampleIndex]!;
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale; return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
}); });
assert(index !== -1); // It's a key frame fragment, so there must be a key frame
if (index === -1) {
throw new Error('Not supported: Fragment does not begin with a key sample.');
}
const entry = trackData.presentationTimestamps[index]!; const entry = trackData.presentationTimestamps[index]!;
sampleIndex = entry.sampleIndex; sampleIndex = entry.sampleIndex;
+10 -15
View File
@@ -220,7 +220,7 @@ export class MatroskaDemuxer extends Demuxer {
const fileSize = await this.input.source.getSize(); const fileSize = await this.input.source.getSize();
while (this.metadataReader.pos < fileSize - MIN_HEADER_SIZE) { while (this.metadataReader.pos <= fileSize - MIN_HEADER_SIZE) {
await this.metadataReader.reader.loadRange( await this.metadataReader.reader.loadRange(
this.metadataReader.pos, this.metadataReader.pos,
this.metadataReader.pos + MAX_HEADER_SIZE, this.metadataReader.pos + MAX_HEADER_SIZE,
@@ -484,24 +484,18 @@ export class MatroskaDemuxer extends Demuxer {
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i })) .map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
let hasKeyFrame = false;
for (let i = 0; i < trackData.presentationTimestamps.length; i++) { for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
const entry = trackData.presentationTimestamps[i]!; const currentEntry = trackData.presentationTimestamps[i]!;
const block = trackData.blocks[entry.blockIndex]!; const currentBlock = trackData.blocks[currentEntry.blockIndex]!;
if (block.isKeyFrame) { if (trackData.firstKeyFrameTimestamp === null && currentBlock.isKeyFrame) {
hasKeyFrame = true; trackData.firstKeyFrameTimestamp = currentBlock.timestamp;
if (trackData.firstKeyFrameTimestamp === null && block.isKeyFrame) {
trackData.firstKeyFrameTimestamp = block.timestamp;
}
} }
if (i < trackData.presentationTimestamps.length - 1) { if (i < trackData.presentationTimestamps.length - 1) {
// Update block durations based on presentation order // Update block durations based on presentation order
const nextEntry = trackData.presentationTimestamps[i + 1]!; const nextEntry = trackData.presentationTimestamps[i + 1]!;
const nextBlock = trackData.blocks[nextEntry.blockIndex]!; currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;
block.duration = nextBlock.timestamp - block.timestamp;
} }
} }
@@ -520,6 +514,7 @@ export class MatroskaDemuxer extends Demuxer {
); );
track.clusters.splice(insertionIndex + 1, 0, cluster); track.clusters.splice(insertionIndex + 1, 0, cluster);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) { if (hasKeyFrame) {
const insertionIndex = binarySearchLessOrEqual( const insertionIndex = binarySearchLessOrEqual(
track.clustersWithKeyFrame, track.clustersWithKeyFrame,
@@ -562,7 +557,7 @@ export class MatroskaDemuxer extends Demuxer {
readContiguousElements(reader: EBMLReader, totalSize: number) { readContiguousElements(reader: EBMLReader, totalSize: number) {
const startIndex = reader.pos; const startIndex = reader.pos;
while (reader.pos - startIndex < totalSize) { while (reader.pos - startIndex <= totalSize - MIN_HEADER_SIZE) {
this.traverseElement(reader); this.traverseElement(reader);
} }
} }
@@ -1210,7 +1205,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
correctBlockFound: true, correctBlockFound: true,
}; };
} else { } else {
// Walk the list of clusters until we find the next cluster for this track // Walk the list of clusters until we find the next cluster for this track with a key frame
let currentCluster = locationInCluster.cluster; let currentCluster = locationInCluster.cluster;
while (currentCluster.nextCluster) { while (currentCluster.nextCluster) {
currentCluster = currentCluster.nextCluster; currentCluster = currentCluster.nextCluster;
@@ -1473,7 +1468,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
} }
const endPos = dataStartPos + size; const endPos = dataStartPos + size;
if (endPos >= segment.elementEndPos - MIN_HEADER_SIZE) { if (endPos > segment.elementEndPos - MIN_HEADER_SIZE) {
// No more elements fit in this segment // No more elements fit in this segment
break; break;
} else { } else {
+1
View File
@@ -737,6 +737,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
// Round the timestamps to the time resolution // Round the timestamps to the time resolution
sample.setTimestamp(Math.round(sample.timestamp * this.timeResolution) / this.timeResolution); sample.setTimestamp(Math.round(sample.timestamp * this.timeResolution) / this.timeResolution);
sample.setDuration(Math.round(sample.duration * this.timeResolution) / this.timeResolution); sample.setDuration(Math.round(sample.duration * this.timeResolution) / this.timeResolution);
sample.setRotation(this.rotation);
this.onSample(sample); this.onSample(sample);
} }
+6 -4
View File
@@ -416,8 +416,9 @@ class VideoEncoderWrapper {
const support = await VideoEncoder.isConfigSupported(encoderConfig); const support = await VideoEncoder.isConfigSupported(encoderConfig);
if (!support.supported) { if (!support.supported) {
throw new Error( throw new Error(
'This specific encoder configuration is not supported by this browser. Consider using another' `This specific encoder configuration (${encoderConfig.codec}, ${encoderConfig.bitrate} bps,`
+ ' codec or changing your video parameters.', + ` ${encoderConfig.width}x${encoderConfig.height}) is not supported by this browser. Consider`
+ ` using another codec or changing your video parameters.`,
); );
} }
@@ -988,8 +989,9 @@ class AudioEncoderWrapper {
const support = await AudioEncoder.isConfigSupported(encoderConfig); const support = await AudioEncoder.isConfigSupported(encoderConfig);
if (!support.supported) { if (!support.supported) {
throw new Error( throw new Error(
'This specific encoder configuration not supported by this browser. Consider using another' `This specific encoder configuration (${encoderConfig.codec}, ${encoderConfig.bitrate} bps,`
+ ' codec or changing your audio parameters.', + ` ${encoderConfig.numberOfChannels} channels, ${encoderConfig.sampleRate} Hz) is not`
+ ` supported by this browser. Consider using another codec or changing your audio parameters.`,
); );
} }
+4
View File
@@ -85,6 +85,10 @@ export const readFrameHeader = (word: number, reader: { pos: number; fileSize: n
reader.pos += 1; reader.pos += 1;
if (firstByte !== 0xff) {
return null;
}
if ((secondByte & 0xe0) !== 0xe0) { if ((secondByte & 0xe0) !== 0xe0) {
return null; return null;
} }
+1 -1
View File
@@ -65,7 +65,7 @@ export class Mp3Reader {
assert(this.fileSize); assert(this.fileSize);
until ??= this.fileSize; until ??= this.fileSize;
while (this.pos < until - FRAME_HEADER_SIZE) { while (this.pos <= until - FRAME_HEADER_SIZE) {
const word = this.readU32(); const word = this.readU32();
this.pos -= 4; this.pos -= 4;
+111 -2
View File
@@ -366,9 +366,79 @@ export class VideoSample {
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
dx: number, dx: number,
dy: number, dy: number,
dWidth: number = this.displayWidth, dWidth?: number,
dHeight: number = this.displayHeight, dHeight?: number,
): void;
/**
* Draws the video sample to a 2D canvas context. Rotation metadata will be taken into account.
*
* @param sx - The x-coordinate of the top left corner of the sub-rectangle of the source image to draw into the
* destination context.
* @param sy - The y-coordinate of the top left corner of the sub-rectangle of the source image to draw into the
* destination context.
* @param sWidth - The width of the sub-rectangle of the source image to draw into the destination context.
* @param sHeight - The height of the sub-rectangle of the source image to draw into the destination context.
* @param dx - The x-coordinate in the destination canvas at which to place the top-left corner of the source image.
* @param dy - The y-coordinate in the destination canvas at which to place the top-left corner of the source image.
* @param dWidth - The width in pixels with which to draw the image in the destination canvas.
* @param dHeight - The height in pixels with which to draw the image in the destination canvas.
*/
draw(
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
sx: number,
sy: number,
sWidth: number,
sHeight: number,
dx: number,
dy: number,
dWidth?: number,
dHeight?: number,
): void;
draw(
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
arg1: number,
arg2: number,
arg3?: number,
arg4?: number,
arg5?: number,
arg6?: number,
arg7?: number,
arg8?: number,
) { ) {
let sx = 0;
let sy = 0;
let sWidth = this.displayWidth;
let sHeight = this.displayHeight;
let dx = 0;
let dy = 0;
let dWidth = this.displayWidth;
let dHeight = this.displayHeight;
if (arg5 !== undefined) {
sx = arg1!;
sy = arg2!;
sWidth = arg3!;
sHeight = arg4!;
dx = arg5;
dy = arg6!;
if (arg7 !== undefined) {
dWidth = arg7;
dHeight = arg8!;
} else {
dWidth = sWidth;
dHeight = sHeight;
}
} else {
dx = arg1;
dy = arg2;
if (arg3 !== undefined) {
dWidth = arg3;
dHeight = arg4!;
}
}
if (!( if (!(
(typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D) (typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)
|| ( || (
@@ -378,6 +448,18 @@ export class VideoSample {
)) { )) {
throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.'); throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.');
} }
if (!Number.isFinite(sx)) {
throw new TypeError('sx must be a number.');
}
if (!Number.isFinite(sy)) {
throw new TypeError('sy must be a number.');
}
if (!Number.isFinite(sWidth) || sWidth < 0) {
throw new TypeError('sWidth must be a non-negative number.');
}
if (!Number.isFinite(sHeight) || sHeight < 0) {
throw new TypeError('sHeight must be a non-negative number.');
}
if (!Number.isFinite(dx)) { if (!Number.isFinite(dx)) {
throw new TypeError('dx must be a number.'); throw new TypeError('dx must be a number.');
} }
@@ -395,6 +477,29 @@ export class VideoSample {
throw new Error('VideoSample is closed.'); throw new Error('VideoSample is closed.');
} }
// The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is
// stored. Therefore, we must map these back onto the original, pre-rotation image.
if (this.rotation === 90) {
[sx, sy, sWidth, sHeight] = [
sy,
this.codedHeight - sx - sWidth,
sHeight,
sWidth,
];
} else if (this.rotation === 180) {
[sx, sy] = [
this.codedWidth - sx - sWidth,
this.codedHeight - sy - sHeight,
];
} else if (this.rotation === 270) {
[sx, sy, sWidth, sHeight] = [
this.codedWidth - sy - sHeight,
sx,
sHeight,
sWidth,
];
}
const source = this.toCanvasImageSource(); const source = this.toCanvasImageSource();
context.save(); context.save();
@@ -412,6 +517,10 @@ export class VideoSample {
context.drawImage( context.drawImage(
source, source,
sx,
sy,
sWidth,
sHeight,
-dWidth / 2, -dWidth / 2,
-dHeight / 2, -dHeight / 2,
dWidth, dWidth,
+1 -2
View File
@@ -1,9 +1,8 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "../build", "outDir": "../dist/modules",
"declaration": true, "declaration": true,
"sourceMap": true,
"declarationMap": true, "declarationMap": true,
"stripInternal": true, "stripInternal": true,
"noEmit": false "noEmit": false
+1 -1
View File
@@ -20,7 +20,7 @@ const rollupInput = Object.fromEntries(
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
mediabunny: path.resolve(__dirname, './dist/mediabunny.mjs'), mediabunny: path.resolve(__dirname, './dist/bundles/mediabunny.mjs'),
}, },
}, },
plugins: [ plugins: [