Compare commits

...
26 Commits
Author SHA1 Message Date
Vanilagy 03b4843c1d Fix incorrectly fixed .js imports 2025-07-26 00:50:47 +02:00
Vanilagy 9bfa855f25 Downgrade @types/web-codecs for better type support, specify requirements in README and docs 2025-07-26 00:34:24 +02:00
Vanilagy d768f3c63b Add section in docs for Jonny 2025-07-25 13:22:18 +02:00
Vanilagy c4d13ed698 Improve UrlSource fetching logic 2025-07-25 12:07:54 +02:00
Vanilagy 5e933d9362 Add packet type derivation logic, new verifyKeyPackets options, new determinePacketType method 2025-07-24 16:09:01 +02:00
Vanilagy 904ac2e366 Improve error messages in examples 2025-07-19 17:28:39 +02:00
Vanilagy 4b12468008 Add support for reading and writing RF64 WAVE files 2025-07-18 16:48:23 +02:00
Vanilagy 2277810853 Add sponsor 2025-07-18 14:24:21 +02:00
Vanilagy 19b1c015b6 Add full URLs for OpenGraph image, add more graph tags 2025-07-18 09:25:24 +02:00
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
49 changed files with 1162 additions and 378 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ indent_size = 2
indent_style = space
indent_size = 4
[*.yaml]
[*.yml]
indent_style = space
indent_size = 2
@@ -2,6 +2,7 @@ name: Lint
on:
push:
pull_request:
jobs:
lint:
+6 -3
View File
@@ -11,9 +11,7 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -41,6 +39,11 @@ jobs:
- name: 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
run: npm publish --provenance
env:
-1
View File
@@ -1,5 +1,4 @@
/node_modules
/build
/dist
/dist-docs
.DS_Store
+6 -4
View File
@@ -5,7 +5,7 @@
[![](https://img.shields.io/npm/dm/mediabunny)](https://www.npmjs.com/package/mediabunny)
<div align="center">
<img src="./docs/public/mediabunny-logo.svg" height="180">
<img src="./docs/public/mediabunny-logo.svg" width="180" height="180">
</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.
@@ -16,11 +16,11 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<div align="center">
<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>
&nbsp;&nbsp;&nbsp;&nbsp;
<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>
</div>
@@ -49,6 +49,8 @@ Core features include:
npm install mediabunny
```
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
```js
@@ -152,7 +154,7 @@ For development, clone this repository and install it using a modern version of
```bash
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$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": [],
"compiler": {},
"apiReport": {
+4 -4
View File
@@ -37,20 +37,20 @@ const esmConfig = {
const ctxUmd = await esbuild.context({
...umdConfig,
outfile: 'dist/mediabunny.js',
outfile: 'dist/bundles/mediabunny.cjs',
});
const ctxEsm = await esbuild.context({
...esmConfig,
outfile: 'dist/mediabunny.mjs',
outfile: 'dist/bundles/mediabunny.mjs',
});
const ctxUmdMinified = await esbuild.context({
...umdConfig,
outfile: 'dist/mediabunny.min.js',
outfile: 'dist/bundles/mediabunny.min.cjs',
minify: true,
});
const ctxEsmMinified = await esbuild.context({
...esmConfig,
outfile: 'dist/mediabunny.min.mjs',
outfile: 'dist/bundles/mediabunny.min.mjs',
minify: true,
});
+7 -5
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<script src="../dist/mediabunny.js"></script>
<script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module">
const fileInput = document.createElement('input');
@@ -21,7 +21,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.Mp4OutputFormat();
const outputFormat = new Mediabunny.Mp4OutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -38,8 +38,8 @@
target
}),
audio: {
codec: 'opus',
bitrate: 128000,
//codec: 'opus',
//bitrate: 128000,
//numberOfChannels: 1,
//sampleRate: 4000
//discard: true
@@ -70,6 +70,8 @@
},
*/
video: {
forceTranscode: true,
codec: 'av1',
//discard: true,
//width: 1280,
//discard: true,
@@ -87,7 +89,7 @@
},
trim: {
start: 0,
end: 10
end: 20
},
});
console.log(conversion);
+70 -6
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<script src="../dist/mediabunny.js"></script>
<script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module">
const fileInput = document.createElement('input');
@@ -15,14 +15,78 @@
formats: Mediabunny.ALL_FORMATS,
source
});
const videoTrack = await input.getPrimaryVideoTrack();
const packetSink = new Mediabunny.EncodedPacketSink(videoTrack);
const sampleSink = new Mediabunny.VideoSampleSink(videoTrack);
for await (const packet of packetSink.packets(undefined, undefined, {verifyType: true})) {
const guess = packet.type;
const real = await videoTrack.determinePacketType(packet);
if (guess !== real) {
console.log(guess, real, packet);
}
}
console.log("don")
/*
console.time()
for await (const packet of packetSink.packets(undefined, undefined, { verifyType: true })) {
//console.log(packet)
}
console.timeEnd()
*/
//console.log(await packetSink.getPacket(6.666666666666667, { verifyType: true }))
/*
for await (const packet of packetSink.packets()) {
const guess = packet.type;
const real = await videoTrack.determinePacketType(packet);
if (guess !== real) {
console.log(guess, real, packet);
}
}
console.log("done")
*/
/*
const timestamp = 6.666666666666667;
const thePacket = await packetSink.getPacket(timestamp);
console.log(videoTrack.codec, thePacket, await videoTrack.determinePacketType(thePacket));
sampleSink.getSample(thePacket.timestamp);
*/
/*
let packet = await packetSink.getFirstPacket();
while (packet) {
console.log(packet)
const sample = await sampleSink.getSample(packet.timestamp);
packet = await packetSink.getNextKeyPacket(packet);
}
*/
/*
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 sink = new Mediabunny.VideoSampleSink(videoTrack);
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
if (packet.timestamp >= 2) break;
console.log(packet);
}
const sample = await sink.getSample(3);
console.log(sample);
sample.draw(ctx, 1500, 500, 50, 50, 0, 0);
*/
/*
let timestamps = [];
+1 -1
View File
@@ -1,6 +1,6 @@
<button>Go</button>
<script src="../dist/mediabunny.js"></script>
<script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module">
function download(blob, filename) {
+1 -1
View File
@@ -1,6 +1,6 @@
<!DOCTYPE html>
<script src="../dist/mediabunny.js"></script>
<script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module">
function download(blob, filename) {
+1 -1
View File
@@ -15,7 +15,7 @@
</div>
</div>
<script src="../dist/mediabunny.js"></script>
<script src="../dist/bundles/mediabunny.cjs"></script>
<script type="module">
const fileInput = document.querySelector('input[type="file"]');
+11 -3
View File
@@ -4,11 +4,13 @@ import tailwindcss from '@tailwindcss/vite';
import llmstxt from 'vitepress-plugin-llms';
import { HeadConfig } from 'vitepress';
const DESCRIPTION = 'A JavaScript library for reading, writing, and converting media files. Directly in the browser,'
+ ' and faster than anybunny else.';
// https://vitepress.dev/reference/site-config
export default withMermaid({
title: 'Mediabunny',
description: 'A JavaScript library for reading, writing, and converting media files. Directly in the browser, and'
+ ' faster than anybunny else.',
description: DESCRIPTION,
cleanUrls: true,
head: [
['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }],
@@ -16,9 +18,13 @@ export default withMermaid({
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:site_name', content: 'Mediabunny' }],
['meta', { property: 'og:url', content: 'https://mediabunny.dev/' }],
['meta', { property: 'og:image', content: '/mediabunny-og-image.png' }],
['meta', { property: 'og:image', content: 'https://mediabunny.dev/mediabunny-og-image.png' }],
['meta', { property: 'og:locale', content: 'en-US' }],
['meta', { property: 'og:description', content: DESCRIPTION }],
['meta', { name: 'twitter:image', content: 'https://mediabunny.dev/mediabunny-og-image.png' }],
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
['meta', { name: 'twitter:site', content: '@vanilagy' }],
['meta', { name: 'twitter:description', content: DESCRIPTION }],
],
themeConfig: {
logo: '/mediabunny-logo.svg',
@@ -75,6 +81,7 @@ export default withMermaid({
{ icon: 'github', link: 'https://github.com/Vanilagy/mediabunny' },
{ icon: 'discord', link: 'https://discord.gg/hmpkyYuS4U' },
{ icon: 'x', link: 'https://x.com/vanilagy' },
{ icon: 'bluesky', link: 'https://bsky.app/profile/vanilagy.bsky.social' },
],
search: {
@@ -113,6 +120,7 @@ export default withMermaid({
((pageData.frontmatter['head'] ??= []) as HeadConfig[]).push(
['meta', { property: 'og:title', content: title }],
['meta', { property: 'twitter:title', content: title }],
);
},
});
+8 -2
View File
@@ -17,6 +17,10 @@ bun add mediabunny
```
:::
::: info
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.
:::
Then, simply import it like this:
```ts
import { ... } from 'mediabunny'; // ESM
@@ -27,7 +31,9 @@ ESM is preferred because it gives you tree shaking.
You can also just include the library using a script tag in your 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.
+26
View File
@@ -144,6 +144,32 @@ for await (const packet of sink.packets(start, end)) {
The `packets` method is more performant than manual iteration as it will intelligently preload future packets before they are needed.
#### Verifying key packets
By default, packet types are determined using the metadata provided by the containing file. Some files can erroneously label some delta packets as key packets, leading to potential decoder errors. To be guaranteed that a key packet is actually a key packet, you can enable the `verifyKeyPackets` option:
```ts
// If the packet returned by this method has type: 'key', it's guaranteed
// to be a key packet.
await sink.getPacket(5, { verifyKeyPackets: true });
// Returned packets are guaranteed to be key packets
await sink.getKeyPacket(10, { verifyKeyPackets: true });
await sink.getNextKeyPacket(packet, { verifyKeyPackets: true });
// Also works for the iterator:
for await (const packet of sink.packets(
undefined,
undefined,
{ verifyKeyPackets: true },
)) {
// ...
}
```
::: info
`verifyKeyPackets` only works when `metadataOnly` is not also enabled.
:::
#### Metadata-only packet retrieval
Sometimes, you're only interested in a packet's metadata (timestamp, duration, type, ...) and not in its encoded media data. All methods on `EncodedPacketSink` accept a final `options` parameter which you can use to retrieve [metadata-only packets](./packets-and-samples#metadata-only-packets):
+3
View File
@@ -227,8 +227,11 @@ const output = new Output({
The following options are available:
```ts
type WavOutputFormatOptions = {
large?: boolean;
onHeader?: (data: Uint8Array, position: number) => unknown;
};
```
- `large`\
When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible for regular WAVE files.
- `onHeader`\
Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the start of the data chunk (with a placeholder size of 0).
+26 -4
View File
@@ -133,6 +133,16 @@ encodedPacket.type; // => PacketType ('key' | 'delta')
For example, in a video track, it is common to have a key frame about every few seconds. When seeking, if the user seeks to a position shortly after a key frame, the decoded data can be shown quickly; if they seek far away from a key frame, the decoder must first crunch through many delta frames before it can show anything.
#### Determining a packet's actual type
The `type` field is derived from metadata in the containing file, which can sometimes (in rare cases) be incorrect. To determine a packet's actual type with certainty, you can do this:
```ts
// `packet` must come from the InputTrack `track`
const type = await track.determinePacketType(packet); // => PacketType | null
```
This determines the packet's type by looking into its bitstream. `null` is returned when the type couldn't be determined.
---
You can query the packet's timing information:
@@ -309,17 +319,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
draw(
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
dx: number,
dy: number,
dWidth?: number,
dHeight?: number,
dWidth?: number, // defaults to displayWidth
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;
```
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:
```ts
+12
View File
@@ -286,6 +286,18 @@ See [Media sinks](./media-sinks) for a full list of sinks.
### Examples
Loop over all raw encoded packets of a track:
```ts
import { EncodedPacketSink } from 'mediabunny';
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
console.log(packet.timestamp);
}
```
Here we iterate over all samples (frames) of a video track:
```ts
import { VideoSampleSink } from 'mediabunny';
+2 -1
View File
@@ -95,6 +95,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ 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' },
],
};
</script>
@@ -237,7 +238,7 @@ await conversion.execute();
</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="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">
+2 -1
View File
@@ -38,6 +38,7 @@ const startRecording = async () => {
mainContainer.style.display = 'none';
videoElement.src = '';
downloadButton.style.display = 'none';
errorElement.textContent = '';
// Paint a white background to the canvas
context.fillStyle = 'white';
@@ -85,7 +86,7 @@ const startRecording = async () => {
// Add the video track, with the canvas as the source
videoSource = new CanvasSource(canvas, {
codec: 'vp9',
codec: 'avc',
bitrate: QUALITY_MEDIUM,
keyFrameInterval: 0.5,
latencyMode: 'realtime', // Allow the encoder to skip frames to keep up with real-time constraints
+2
View File
@@ -29,6 +29,8 @@
<hr class="w-full max-w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<p id="warning-element" class="text-amber-500 mb-1"></p>
<div id="player" class="relative bg-black rounded-xl shrink min-h-14 min-w-0 w-full max-w-5xl overflow-hidden select-none" style="display: none;">
<canvas class="size-full object-contain" width="1280" height="720"></canvas>
+32 -8
View File
@@ -30,6 +30,7 @@ const volumeIconWrapper = document.querySelector('#volume-icon-wrapper') as HTML
const volumeButton = document.querySelector('#volume-button') as HTMLButtonElement;
const fullscreenButton = document.querySelector('#fullscreen-button') as HTMLButtonElement;
const errorElement = document.querySelector('#error-element') as HTMLDivElement;
const warningElement = document.querySelector('#warning-element') as HTMLDivElement;
const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!;
@@ -81,6 +82,8 @@ const initMediaPlayer = async (file: File) => {
fileNameElement.textContent = file.name;
horizontalRule.style.display = '';
playerContainer.style.display = 'none';
errorElement.textContent = '';
warningElement.textContent = '';
// Create an Input from the file
const input = new Input({
@@ -95,17 +98,38 @@ const initMediaPlayer = async (file: File) => {
let videoTrack = await input.getPrimaryVideoTrack();
let audioTrack = await input.getPrimaryAudioTrack();
if (!(await videoTrack?.canDecode())) {
// We can't decode the video track, so treat it like there is no video track
videoTrack = null;
let problemMessage = '';
if (videoTrack) {
if (videoTrack.codec === null) {
problemMessage += 'Unsupported video codec. ';
videoTrack = null;
} else if (!(await videoTrack.canDecode())) {
problemMessage += 'Unable to decode the video track. ';
videoTrack = null;
}
}
if (!(await audioTrack?.canDecode())) {
// We can't decode the audio track, so treat it like there is no audio track
audioTrack = null;
if (audioTrack) {
if (audioTrack.codec === null) {
problemMessage += 'Unsupported audio codec. ';
audioTrack = null;
} else if (!(await audioTrack.canDecode())) {
problemMessage += 'Unable to decode the audio track. ';
audioTrack = null;
}
}
if (!videoTrack && !audioTrack) {
throw new Error('Media file has no playable video or audio track.');
if (!problemMessage) {
problemMessage = 'No audio or video track found.';
}
throw new Error(problemMessage);
}
if (problemMessage) {
warningElement.textContent = problemMessage;
}
// We must create the audio context with the matching sample rate for correct acoustic results
@@ -253,7 +277,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
// 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!) {
const node = audioContext!.createBufferSource();
node.buffer = buffer;
@@ -15,7 +15,7 @@ const THUMBNAIL_SIZE = 200;
const generateThumbnails = async (file: File) => {
fileNameElement.textContent = file.name;
horizontalRule.style.display = '';
errorElement.innerHTML = '';
errorElement.textContent = '';
thumbnailContainer.innerHTML = '';
try {
@@ -30,6 +30,14 @@ const generateThumbnails = async (file: File) => {
throw new Error('File has no video track.');
}
if (videoTrack.codec === null) {
throw new Error('Unsupported video codec.');
}
if (!(await videoTrack.canDecode())) {
throw new Error('Unable to decode the video track.');
}
// Compute width and height of the thumbnails such that the larger dimension is equal to THUMBNAIL_SIZE
const width = videoTrack.displayWidth > videoTrack.displayHeight
? THUMBNAIL_SIZE
+28 -42
View File
@@ -1,16 +1,16 @@
{
"name": "mediabunny",
"version": "1.0.2",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.0.2",
"version": "1.3.3",
"license": "MPL-2.0",
"dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/dom-webcodecs": "^0.1.15"
"@types/dom-webcodecs": "0.1.13"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
@@ -319,9 +319,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -329,9 +329,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"dev": true,
"license": "MIT",
"engines": {
@@ -339,13 +339,13 @@
}
},
"node_modules/@babel/parser": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.27.0"
"@babel/types": "^7.28.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -355,14 +355,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz",
"integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1238,18 +1238,14 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.8",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
"integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
"version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -1262,16 +1258,6 @@
"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": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
@@ -1280,9 +1266,9 @@
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
"version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2457,9 +2443,9 @@
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
"license": "MIT"
},
"node_modules/@types/estree": {
+15 -11
View File
@@ -1,25 +1,27 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.0.2",
"version": "1.3.3",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"main": "./dist/mediabunny.js",
"module": "./dist/mediabunny.mjs",
"types": "./dist/mediabunny.d.ts",
"main": "./dist/bundles/mediabunny.cjs",
"module": "./dist/modules/index.js",
"types": "./dist/modules/index.d.ts",
"exports": {
"types": "./dist/mediabunny.d.ts",
"import": "./dist/mediabunny.mjs",
"require": "./dist/mediabunny.js"
"types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js",
"require": "./dist/bundles/mediabunny.cjs"
},
"files": [
"README.md",
"package.json",
"LICENSE",
"dist"
"dist",
"src"
],
"sideEffects": false,
"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",
"lint": "eslint .",
"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:preview": "vitepress preview docs",
"dev": "vite",
"examples:build": "vite build"
"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"
},
"license": "MPL-2.0",
"repository": {
@@ -45,7 +49,7 @@
},
"dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/dom-webcodecs": "^0.1.15"
"@types/dom-webcodecs": "0.1.13"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
+37
View File
@@ -0,0 +1,37 @@
import * as fs from 'fs';
import * as path from 'path';
// .js extensions are technically required in compliant ECMAScript, and Webpack needs them, so we add them here.
const walkDir = (dir: string) => {
const files: string[] = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files.push(...walkDir(fullPath));
} else if (item.endsWith('.js')) {
files.push(fullPath);
}
}
return files;
};
const fixFile = (filePath: string) => {
const content = fs.readFileSync(filePath, 'utf8');
const fixed = content.replace(
/(\s+from\s+['"])([^'"]*)(['"])/g,
'$1$2.js$3',
);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
}
};
const jsFiles = walkDir('dist');
jsFiles.forEach(fixFile);
-3
View File
@@ -1,3 +0,0 @@
import { appendFileSync } from 'fs';
appendFileSync('dist/mediabunny.d.ts', '\nexport as namespace Mediabunny;');
+1 -1
View File
@@ -1,5 +1,5 @@
import ts from 'typescript';
import * as fs from 'node:fs';
import * as fs from 'fs';
const checkDocblocks = (filePath: string) => {
const program = ts.createProgram([filePath], {});
+342 -180
View File
@@ -7,7 +7,18 @@
*/
import { VP9_LEVEL_TABLE } from './codec';
import { assert, Bitstream, last, readExpGolomb, readSignedExpGolomb, toDataView } from './misc';
import { InputVideoTrack } from './input-track';
import {
assert,
assertNever,
Bitstream,
last,
readExpGolomb,
readSignedExpGolomb,
toDataView,
toUint8Array,
} from './misc';
import { EncodedPacket, PacketType } from './packet';
// References for AVC/HEVC code:
// ISO 14496-15
@@ -72,6 +83,39 @@ const findNalUnitsInAnnexB = (packetData: Uint8Array) => {
return nalUnits;
};
/** Finds all NAL units in an AVC packet in length-prefixed format. */
const findNalUnitsInLengthPrefixed = (packetData: Uint8Array, lengthSize: 1 | 2 | 3 | 4) => {
const nalUnits: Uint8Array[] = [];
let offset = 0;
const dataView = new DataView(packetData.buffer, packetData.byteOffset, packetData.byteLength);
while (offset + lengthSize <= packetData.length) {
let nalUnitLength: number;
if (lengthSize === 1) {
nalUnitLength = dataView.getUint8(offset);
} else if (lengthSize === 2) {
nalUnitLength = dataView.getUint16(offset, false);
} else if (lengthSize === 3) {
nalUnitLength = (dataView.getUint16(offset, false) << 8) + dataView.getUint8(offset + 2);
} else if (lengthSize === 4) {
nalUnitLength = dataView.getUint32(offset, false);
} else {
assertNever(lengthSize);
assert(false);
}
offset += lengthSize;
const nalUnit = packetData.subarray(offset, offset + nalUnitLength);
nalUnits.push(nalUnit);
offset += nalUnitLength;
}
return nalUnits;
};
const removeEmulationPreventionBytes = (data: Uint8Array) => {
const result: number[] = [];
const len = data.length;
@@ -879,30 +923,6 @@ export const extractVp9CodecInfoFromPacket = (
// https://storage.googleapis.com/downloads.webmproject.org/docs/vp9/vp9-bitstream-specification-v0.7-20170222-draft.pdf
// http://downloads.webmproject.org/docs/vp9/vp9-bitstream_superframe-and-uncompressed-header_v1.0.pdf
// Handle superframe
const lastByte = packet[packet.length - 1];
if (lastByte && (lastByte & 0xe0) === 0xc0) { // Is superframe
const bytesPerFrameSize = ((lastByte & 0x18) >> 3) + 1;
const numFrames = (lastByte & 0x07) + 1;
const indexSize = 2 + numFrames * bytesPerFrameSize;
// Verify matching marker bytes
if (packet[packet.length - indexSize] !== lastByte) {
return null;
}
// Get first frame size
let frameSize = 0;
const offset = packet.length - indexSize + 1;
for (let i = 0; i < bytesPerFrameSize; i++) {
if (!packet[offset + i]) return null;
frameSize |= packet[offset + i]! << (8 * i);
}
packet = packet.subarray(0, frameSize);
}
const bitstream = new Bitstream(packet);
// Frame marker (0b10)
@@ -1048,13 +1068,8 @@ export type Av1CodecInfo = {
chromaSamplePosition: number;
};
/**
* When AV1 codec information is not provided by the container, we can still try to extract the information by digging
* into the AV1 bitstream.
*/
export const extractAv1CodecInfoFromPacket = (
packet: Uint8Array,
): Av1CodecInfo | null => {
/** Iterates over all OBUs in an AV1 packet bistream. */
export function* iterateAv1PacketObus(packet: Uint8Array) {
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
const bitstream = new Bitstream(packet);
@@ -1064,7 +1079,6 @@ export const extractAv1CodecInfoFromPacket = (
for (let i = 0; i < 8; i++) {
const byte = bitstream.readAlignedByte();
if (byte === undefined) return 0;
value |= ((byte & 0x7f) << (i * 7));
@@ -1088,11 +1102,11 @@ export const extractAv1CodecInfoFromPacket = (
while (bitstream.getBitsLeft() >= 8) {
// Parse OBU header
const obuHeader = bitstream.readBits(8);
const obuType = (obuHeader >> 3) & 0xf;
const obuExtension = (obuHeader >> 2) & 0x1;
const obuHasSizeField = (obuHeader >> 1) & 0x1;
bitstream.skipBits(1);
const obuType = bitstream.readBits(4);
const obuExtension = bitstream.readBits(1);
const obuHasSizeField = bitstream.readBits(1);
bitstream.skipBits(1);
// Skip extension header if present
if (obuExtension) {
@@ -1103,159 +1117,180 @@ export const extractAv1CodecInfoFromPacket = (
let obuSize: number;
if (obuHasSizeField) {
const obuSizeValue = readLeb128();
if (obuSizeValue === null) return null; // It was invalid
if (obuSizeValue === null) return; // It was invalid
obuSize = obuSizeValue;
} else {
// Calculate remaining bits and convert to bytes, rounding down
obuSize = Math.floor(bitstream.getBitsLeft() / 8);
}
// We're only interested in Sequence Header OBU (type 1)
if (obuType === 1) {
// Read sequence header fields
const seqProfile = bitstream.readBits(3);
assert(bitstream.pos % 8 === 0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const stillPicture = bitstream.readBits(1);
const reducedStillPictureHeader = bitstream.readBits(1);
let seqLevel = 0;
let seqTier = 0;
let bufferDelayLengthMinus1 = 0;
if (reducedStillPictureHeader) {
seqLevel = bitstream.readBits(5);
} else {
// Parse timing_info_present_flag
const timingInfoPresentFlag = bitstream.readBits(1);
if (timingInfoPresentFlag) {
// Skip timing info (num_units_in_display_tick, time_scale, equal_picture_interval)
bitstream.skipBits(32); // num_units_in_display_tick
bitstream.skipBits(32); // time_scale
const equalPictureInterval = bitstream.readBits(1);
if (equalPictureInterval) {
// Skip num_ticks_per_picture_minus_1 (uvlc)
// Since this is variable length, we'd need to implement uvlc reading
// For now, we'll return null as this is rare
return null;
}
}
// Parse decoder_model_info_present_flag
const decoderModelInfoPresentFlag = bitstream.readBits(1);
if (decoderModelInfoPresentFlag) {
// Store buffer_delay_length_minus_1 instead of just skipping
bufferDelayLengthMinus1 = bitstream.readBits(5);
bitstream.skipBits(32); // num_units_in_decoding_tick
bitstream.skipBits(5); // buffer_removal_time_length_minus_1
bitstream.skipBits(5); // frame_presentation_time_length_minus_1
}
// Parse operating_points_cnt_minus_1
const operatingPointsCntMinus1 = bitstream.readBits(5);
// For each operating point
for (let i = 0; i <= operatingPointsCntMinus1; i++) {
// operating_point_idc[i]
bitstream.skipBits(12);
// seq_level_idx[i]
const seqLevelIdx = bitstream.readBits(5);
if (i === 0) {
seqLevel = seqLevelIdx;
}
if (seqLevelIdx > 7) {
// seq_tier[i]
const seqTierTemp = bitstream.readBits(1);
if (i === 0) {
seqTier = seqTierTemp;
}
}
if (decoderModelInfoPresentFlag) {
// decoder_model_present_for_this_op[i]
const decoderModelPresentForThisOp = bitstream.readBits(1);
if (decoderModelPresentForThisOp) {
const n = bufferDelayLengthMinus1 + 1;
bitstream.skipBits(n); // decoder_buffer_delay[op]
bitstream.skipBits(n); // encoder_buffer_delay[op]
bitstream.skipBits(1); // low_delay_mode_flag[op]
}
}
// initial_display_delay_present_flag
const initialDisplayDelayPresentFlag = bitstream.readBits(1);
if (initialDisplayDelayPresentFlag) {
// initial_display_delay_minus_1[i]
bitstream.skipBits(4);
}
}
}
const highBitdepth = bitstream.readBits(1);
let bitDepth = 8;
if (seqProfile === 2 && highBitdepth) {
const twelveBit = bitstream.readBits(1);
bitDepth = twelveBit ? 12 : 10;
} else if (seqProfile <= 2) {
bitDepth = highBitdepth ? 10 : 8;
}
let monochrome = 0;
if (seqProfile !== 1) {
monochrome = bitstream.readBits(1);
}
let chromaSubsamplingX = 1;
let chromaSubsamplingY = 1;
let chromaSamplePosition = 0;
if (!monochrome) {
if (seqProfile === 0) {
chromaSubsamplingX = 1;
chromaSubsamplingY = 1;
} else if (seqProfile === 1) {
chromaSubsamplingX = 0;
chromaSubsamplingY = 0;
} else {
if (bitDepth === 12) {
chromaSubsamplingX = bitstream.readBits(1);
if (chromaSubsamplingX) {
chromaSubsamplingY = bitstream.readBits(1);
}
}
}
if (chromaSubsamplingX && chromaSubsamplingY) {
chromaSamplePosition = bitstream.readBits(2);
}
}
return {
profile: seqProfile,
level: seqLevel,
tier: seqTier,
bitDepth,
monochrome,
chromaSubsamplingX,
chromaSubsamplingY,
chromaSamplePosition,
};
}
yield {
type: obuType,
data: packet.subarray(bitstream.pos / 8, bitstream.pos / 8 + obuSize),
};
// Move to next OBU
// The OBU size is in bytes, so skip that many bytes.
bitstream.skipBits(obuSize * 8);
}
};
/**
* When AV1 codec information is not provided by the container, we can still try to extract the information by digging
* into the AV1 bitstream.
*/
export const extractAv1CodecInfoFromPacket = (
packet: Uint8Array,
): Av1CodecInfo | null => {
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
for (const { type, data } of iterateAv1PacketObus(packet)) {
if (type !== 1) {
continue; // 1 == OBU_SEQUENCE_HEADER
}
const bitstream = new Bitstream(data);
// Read sequence header fields
const seqProfile = bitstream.readBits(3);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const stillPicture = bitstream.readBits(1);
const reducedStillPictureHeader = bitstream.readBits(1);
let seqLevel = 0;
let seqTier = 0;
let bufferDelayLengthMinus1 = 0;
if (reducedStillPictureHeader) {
seqLevel = bitstream.readBits(5);
} else {
// Parse timing_info_present_flag
const timingInfoPresentFlag = bitstream.readBits(1);
if (timingInfoPresentFlag) {
// Skip timing info (num_units_in_display_tick, time_scale, equal_picture_interval)
bitstream.skipBits(32); // num_units_in_display_tick
bitstream.skipBits(32); // time_scale
const equalPictureInterval = bitstream.readBits(1);
if (equalPictureInterval) {
// Skip num_ticks_per_picture_minus_1 (uvlc)
// Since this is variable length, we'd need to implement uvlc reading
// For now, we'll return null as this is rare
return null;
}
}
// Parse decoder_model_info_present_flag
const decoderModelInfoPresentFlag = bitstream.readBits(1);
if (decoderModelInfoPresentFlag) {
// Store buffer_delay_length_minus_1 instead of just skipping
bufferDelayLengthMinus1 = bitstream.readBits(5);
bitstream.skipBits(32); // num_units_in_decoding_tick
bitstream.skipBits(5); // buffer_removal_time_length_minus_1
bitstream.skipBits(5); // frame_presentation_time_length_minus_1
}
// Parse operating_points_cnt_minus_1
const operatingPointsCntMinus1 = bitstream.readBits(5);
// For each operating point
for (let i = 0; i <= operatingPointsCntMinus1; i++) {
// operating_point_idc[i]
bitstream.skipBits(12);
// seq_level_idx[i]
const seqLevelIdx = bitstream.readBits(5);
if (i === 0) {
seqLevel = seqLevelIdx;
}
if (seqLevelIdx > 7) {
// seq_tier[i]
const seqTierTemp = bitstream.readBits(1);
if (i === 0) {
seqTier = seqTierTemp;
}
}
if (decoderModelInfoPresentFlag) {
// decoder_model_present_for_this_op[i]
const decoderModelPresentForThisOp = bitstream.readBits(1);
if (decoderModelPresentForThisOp) {
const n = bufferDelayLengthMinus1 + 1;
bitstream.skipBits(n); // decoder_buffer_delay[op]
bitstream.skipBits(n); // encoder_buffer_delay[op]
bitstream.skipBits(1); // low_delay_mode_flag[op]
}
}
// initial_display_delay_present_flag
const initialDisplayDelayPresentFlag = bitstream.readBits(1);
if (initialDisplayDelayPresentFlag) {
// initial_display_delay_minus_1[i]
bitstream.skipBits(4);
}
}
}
const highBitdepth = bitstream.readBits(1);
let bitDepth = 8;
if (seqProfile === 2 && highBitdepth) {
const twelveBit = bitstream.readBits(1);
bitDepth = twelveBit ? 12 : 10;
} else if (seqProfile <= 2) {
bitDepth = highBitdepth ? 10 : 8;
}
let monochrome = 0;
if (seqProfile !== 1) {
monochrome = bitstream.readBits(1);
}
let chromaSubsamplingX = 1;
let chromaSubsamplingY = 1;
let chromaSamplePosition = 0;
if (!monochrome) {
if (seqProfile === 0) {
chromaSubsamplingX = 1;
chromaSubsamplingY = 1;
} else if (seqProfile === 1) {
chromaSubsamplingX = 0;
chromaSubsamplingY = 0;
} else {
if (bitDepth === 12) {
chromaSubsamplingX = bitstream.readBits(1);
if (chromaSubsamplingX) {
chromaSubsamplingY = bitstream.readBits(1);
}
}
}
if (chromaSubsamplingX && chromaSubsamplingY) {
chromaSamplePosition = bitstream.readBits(2);
}
}
return {
profile: seqProfile,
level: seqLevel,
tier: seqTier,
bitDepth,
monochrome,
chromaSubsamplingX,
chromaSubsamplingY,
chromaSamplePosition,
};
}
return null;
};
@@ -1392,3 +1427,130 @@ export const parseModesFromVorbisSetupPacket = (setupHeader: Uint8Array) => {
return { modeBlockflags };
};
/** Determines a packet's type (key or delta) by digging into the packet bitstream. */
export const determineVideoPacketType = async (
videoTrack: InputVideoTrack,
packet: EncodedPacket,
): Promise<PacketType | null> => {
assert(videoTrack.codec);
switch (videoTrack.codec) {
case 'avc': {
const decoderConfig = await videoTrack.getDecoderConfig();
assert(decoderConfig);
let nalUnits: Uint8Array[];
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[4]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
nalUnits = findNalUnitsInLengthPrefixed(packet.data, lengthSize);
} else {
// Stream is in Annex B format
nalUnits = findNalUnitsInAnnexB(packet.data);
}
const isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === 5);
return isKeyframe ? 'key' : 'delta';
};
case 'hevc': {
const decoderConfig = await videoTrack.getDecoderConfig();
assert(decoderConfig);
let nalUnits: Uint8Array[];
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[21]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
nalUnits = findNalUnitsInLengthPrefixed(packet.data, lengthSize);
} else {
// Stream is in Annex B format
nalUnits = findNalUnitsInAnnexB(packet.data);
}
const isKeyframe = nalUnits.some((x) => {
const type = extractNalUnitTypeForHevc(x);
return 16 <= type && type <= 23;
});
return isKeyframe ? 'key' : 'delta';
};
case 'vp8': {
// VP8, once again, by far the easiest to deal with.
const frameType = packet.data[0]! & 0b1;
return frameType === 0 ? 'key' : 'delta';
};
case 'vp9': {
const bitstream = new Bitstream(packet.data);
if (bitstream.readBits(2) !== 2) {
return null;
};
const profileLowBit = bitstream.readBits(1);
const profileHighBit = bitstream.readBits(1);
const profile = (profileHighBit << 1) + profileLowBit;
// Skip reserved bit for profile 3
if (profile === 3) {
bitstream.skipBits(1);
}
const showExistingFrame = bitstream.readBits(1);
if (showExistingFrame) {
return null;
}
const frameType = bitstream.readBits(1);
return frameType === 0 ? 'key' : 'delta';
};
case 'av1': {
let reducedStillPictureHeader = false;
for (const { type, data } of iterateAv1PacketObus(packet.data)) {
if (type === 1) { // OBU_SEQUENCE_HEADER
const bitstream = new Bitstream(data);
bitstream.skipBits(4);
reducedStillPictureHeader = !!bitstream.readBits(1);
} else if (
type === 3 // OBU_FRAME_HEADER
|| type === 6 // OBU_FRAME
|| type === 7 // OBU_REDUNDANT_FRAME_HEADER
) {
if (reducedStillPictureHeader) {
return 'key';
}
const bitstream = new Bitstream(data);
const showExistingFrame = bitstream.readBits(1);
if (showExistingFrame) {
return null;
}
const frameType = bitstream.readBits(2);
return frameType === 0 ? 'key' : 'delta';
}
}
return null;
};
default: {
assertNever(videoTrack.codec);
assert(false);
};
}
};
+1 -1
View File
@@ -498,7 +498,7 @@ export class Conversion {
? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
: undefined;
for await (const packet of sink.packets(undefined, endPacket)) {
for await (const packet of sink.packets(undefined, endPacket, { verifyKeyPackets: true })) {
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
await this._synchronizer.wait(packet.timestamp);
}
+3
View File
@@ -6,6 +6,9 @@
* 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 {
Output,
OutputOptions,
+6 -4
View File
@@ -13,6 +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 { Mp3Reader } from './mp3/mp3-reader';
import { OggDemuxer } from './ogg/ogg-demuxer';
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
// another header nearby:
// another header right after it:
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) {
return false;
}
@@ -257,7 +259,7 @@ export class Mp3InputFormat extends InputFormat {
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;
}
@@ -289,7 +291,7 @@ export class WaveInputFormat extends InputFormat {
const riffReader = new RiffReader(input._mainReader);
const riffType = riffReader.readAscii(4);
if (riffType !== 'RIFF' && riffType !== 'RIFX') {
if (riffType !== 'RIFF' && riffType !== 'RIFX' && riffType !== 'RF64') {
return false;
}
+42 -3
View File
@@ -7,11 +7,12 @@
*/
import { AudioCodec, MediaCodec, VideoCodec } from './codec';
import { determineVideoPacketType } from './codec-data';
import { customAudioDecoders, customVideoDecoders } from './custom-coder';
import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink';
import { assert, Rotation } from './misc';
import { TrackType } from './output';
import { EncodedPacket } from './packet';
import { EncodedPacket, PacketType } from './packet';
/**
* Contains aggregate statistics about the encoded packets of a track.
@@ -62,6 +63,11 @@ export abstract class InputTrack {
abstract getCodecParameterString(): Promise<string | null>;
/** Checks if this track's packets can be decoded by the browser. */
abstract canDecode(): Promise<boolean>;
/**
* For a given packet of this track, this method determines the actual type of this packet (key/delta) by looking
* into its bitstream. Returns null if the type couldn't be determined.
*/
abstract determinePacketType(packet: EncodedPacket): Promise<PacketType | null>;
/** Returns true iff this track is a video track. */
isVideoTrack(): this is InputVideoTrack {
@@ -222,7 +228,10 @@ export class InputVideoTrack extends InputTrack {
|| (colorSpace.matrix as string) === 'bt2020-ncl';
}
/** Returns the decoder configuration for decoding the track's packets using a VideoDecoder. */
/**
* Returns the decoder configuration for decoding the track's packets using a VideoDecoder. Returns null if the
* track's codec is unknown.
*/
getDecoderConfig() {
return this._backing.getDecoderConfig();
}
@@ -257,6 +266,21 @@ export class InputVideoTrack extends InputTrack {
return false;
}
}
async determinePacketType(packet: EncodedPacket): Promise<PacketType | null> {
if (!(packet instanceof EncodedPacket)) {
throw new TypeError('packet must be an EncodedPacket.');
}
if (packet.isMetadataOnly) {
throw new TypeError('packet must not be metadata-only to determine its type.');
}
if (this.codec === null) {
return null;
}
return determineVideoPacketType(this, packet);
}
}
export interface InputAudioTrackBacking extends InputTrackBacking {
@@ -299,7 +323,10 @@ export class InputAudioTrack extends InputTrack {
return this._backing.getSampleRate();
}
/** Returns the decoder configuration for decoding the track's packets using an AudioDecoder. */
/**
* Returns the decoder configuration for decoding the track's packets using an AudioDecoder. Returns null if the
* track's codec is unknown.
*/
getDecoderConfig() {
return this._backing.getDecoderConfig();
}
@@ -338,4 +365,16 @@ export class InputAudioTrack extends InputTrack {
return false;
}
}
async determinePacketType(packet: EncodedPacket): Promise<PacketType | null> {
if (!(packet instanceof EncodedPacket)) {
throw new TypeError('packet must be an EncodedPacket.');
}
if (this.codec === null) {
return null;
}
return 'key'; // No audio codec with delta packets
}
}
+87 -30
View File
@@ -54,6 +54,7 @@ import {
isIso639Dash2LanguageCode,
roundToMultiple,
normalizeRotation,
Bitstream,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { Reader } from '../reader';
@@ -74,6 +75,7 @@ type InternalTrack = {
fragmentLookupTable: FragmentLookupTableEntry[] | null;
currentFragmentState: FragmentTrackState | null;
fragments: Fragment[];
fragmentsWithKeyFrame: Fragment[];
/** The segment durations of all edit list entries leading up to the main one (from which the offset is taken.) */
editListPreviousSegmentDurations: number;
/** The media time offset of the main edit list entry (with media time !== -1) */
@@ -167,6 +169,7 @@ type FragmentTrackState = {
type FragmentTrackData = {
startTimestamp: number;
endTimestamp: number;
firstKeyFrameTimestamp: number | null;
samples: FragmentTrackSample[];
presentationTimestamps: {
presentationTimestamp: number;
@@ -607,6 +610,7 @@ export class IsobmffDemuxer extends Demuxer {
fragmentLookupTable: null,
currentFragmentState: null,
fragments: [],
fragmentsWithKeyFrame: [],
editListPreviousSegmentDurations: 0,
editListOffset: 0,
} satisfies InternalTrack as InternalTrack;
@@ -701,7 +705,10 @@ export class IsobmffDemuxer extends Demuxer {
}
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) {
@@ -710,7 +717,8 @@ export class IsobmffDemuxer extends Demuxer {
}
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;
@@ -942,7 +950,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (sampleSize === 16) {
track.info.codec = 'pcm-s16be';
} 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') {
if (sampleSize === 8) {
@@ -950,7 +959,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (sampleSize === 16) {
track.info.codec = 'pcm-s16';
} 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 ') {
track.info.codec = 'pcm-u8';
@@ -1193,7 +1203,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 32) {
track.info.codec = 'pcm-s32';
} else {
throw new Error(`Invalid ipcm sample size ${pcmSampleSize}.`);
console.warn(`Invalid ipcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
}
} else {
if (pcmSampleSize === 16) {
@@ -1203,7 +1214,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 32) {
track.info.codec = 'pcm-s32be';
} 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') {
@@ -1215,7 +1227,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 64) {
track.info.codec = 'pcm-f64';
} else {
throw new Error(`Invalid fpcm sample size ${pcmSampleSize}.`);
console.warn(`Invalid fpcm sample size ${pcmSampleSize}.`);
track.info.codec = null;
}
} else {
if (pcmSampleSize === 32) {
@@ -1223,7 +1236,8 @@ export class IsobmffDemuxer extends Demuxer {
} else if (pcmSampleSize === 64) {
track.info.codec = 'pcm-f64be';
} 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;
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': {
const track = this.currentTrack;
@@ -1635,6 +1668,16 @@ export class IsobmffDemuxer extends Demuxer {
);
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;
assert(currentFragmentState);
@@ -1733,7 +1776,8 @@ export class IsobmffDemuxer extends Demuxer {
assert(track.currentFragmentState);
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();
@@ -1770,6 +1814,7 @@ export class IsobmffDemuxer extends Demuxer {
const trackData: FragmentTrackData = {
startTimestamp: 0,
endTimestamp: 0,
firstKeyFrameTimestamp: null,
samples: [],
presentationTimestamps: [],
startTimestampIsFinal: false,
@@ -1831,13 +1876,19 @@ export class IsobmffDemuxer extends Demuxer {
.map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i }))
.sort((a, b) => a.presentationTimestamp - b.presentationTimestamp);
// Update sample durations based on presentation order
for (let i = 0; i < trackData.presentationTimestamps.length - 1; i++) {
const current = trackData.presentationTimestamps[i]!;
const next = trackData.presentationTimestamps[i + 1]!;
for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
const currentEntry = trackData.presentationTimestamps[i]!;
const currentSample = trackData.samples[currentEntry.sampleIndex]!;
const duration = next.presentationTimestamp - current.presentationTimestamp;
trackData.samples[current.sampleIndex]!.duration = duration;
if (trackData.firstKeyFrameTimestamp === null && currentSample.isKeyFrame) {
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]!;
@@ -2074,13 +2125,13 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
correctSampleFound: true,
};
} 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;
while (currentFragment.nextFragment) {
currentFragment = currentFragment.nextFragment;
const trackData = currentFragment.trackData.get(this.internalTrack.id);
if (trackData) {
if (trackData && trackData.firstKeyFrameTimestamp !== null) {
const fragmentIndex = binarySearchExact(
this.internalTrack.fragments,
currentFragment.moofOffset,
@@ -2089,9 +2140,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
assert(fragmentIndex !== -1);
const keyFrameIndex = trackData.samples.findIndex(x => x.isKeyFrame);
if (keyFrameIndex === -1) {
throw new Error('Not supported: Fragment does not contain key sample.');
}
assert(keyFrameIndex !== -1); // There must be one
return {
fragmentIndex,
@@ -2231,26 +2280,34 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
}
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.internalTrack.fragments,
this.internalTrack.fragmentsWithKeyFrame,
timestampInTimescale,
x => x.trackData.get(this.internalTrack.id)!.startTimestamp,
);
let fragmentIndex = -1;
let sampleIndex = -1;
let correctSampleFound = false;
if (fragmentIndex !== -1) {
const fragment = this.internalTrack.fragments[fragmentIndex]!;
if (indexInKeyFrameFragments !== -1) {
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 index = findLastIndex(trackData.presentationTimestamps, (x) => {
const sample = trackData.samples[x.sampleIndex]!;
return sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale;
});
if (index === -1) {
throw new Error('Not supported: Fragment does not begin with a key sample.');
}
assert(index !== -1); // It's a key frame fragment, so there must be a key frame
const entry = trackData.presentationTimestamps[index]!;
sampleIndex = entry.sampleIndex;
+10 -15
View File
@@ -220,7 +220,7 @@ export class MatroskaDemuxer extends Demuxer {
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(
this.metadataReader.pos,
this.metadataReader.pos + MAX_HEADER_SIZE,
@@ -484,24 +484,18 @@ export class MatroskaDemuxer extends Demuxer {
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
.sort((a, b) => a.timestamp - b.timestamp);
let hasKeyFrame = false;
for (let i = 0; i < trackData.presentationTimestamps.length; i++) {
const entry = trackData.presentationTimestamps[i]!;
const block = trackData.blocks[entry.blockIndex]!;
const currentEntry = trackData.presentationTimestamps[i]!;
const currentBlock = trackData.blocks[currentEntry.blockIndex]!;
if (block.isKeyFrame) {
hasKeyFrame = true;
if (trackData.firstKeyFrameTimestamp === null && block.isKeyFrame) {
trackData.firstKeyFrameTimestamp = block.timestamp;
}
if (trackData.firstKeyFrameTimestamp === null && currentBlock.isKeyFrame) {
trackData.firstKeyFrameTimestamp = currentBlock.timestamp;
}
if (i < trackData.presentationTimestamps.length - 1) {
// Update block durations based on presentation order
const nextEntry = trackData.presentationTimestamps[i + 1]!;
const nextBlock = trackData.blocks[nextEntry.blockIndex]!;
block.duration = nextBlock.timestamp - block.timestamp;
currentBlock.duration = nextEntry.timestamp - currentBlock.timestamp;
}
}
@@ -520,6 +514,7 @@ export class MatroskaDemuxer extends Demuxer {
);
track.clusters.splice(insertionIndex + 1, 0, cluster);
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
if (hasKeyFrame) {
const insertionIndex = binarySearchLessOrEqual(
track.clustersWithKeyFrame,
@@ -562,7 +557,7 @@ export class MatroskaDemuxer extends Demuxer {
readContiguousElements(reader: EBMLReader, totalSize: number) {
const startIndex = reader.pos;
while (reader.pos - startIndex < totalSize) {
while (reader.pos - startIndex <= totalSize - MIN_HEADER_SIZE) {
this.traverseElement(reader);
}
}
@@ -1210,7 +1205,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
correctBlockFound: true,
};
} 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;
while (currentCluster.nextCluster) {
currentCluster = currentCluster.nextCluster;
@@ -1473,7 +1468,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
}
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
break;
} else {
+1 -2
View File
@@ -694,8 +694,7 @@ export class MatroskaMuxer extends Muxer {
const bitstream = new Bitstream(chunk.data);
// Check if it's a "superframe"
if (bitstream.readBits(2) !== 0b10) return;
bitstream.skipBits(2);
const profileLowBit = bitstream.readBits(1);
const profileHighBit = bitstream.readBits(1);
+89 -10
View File
@@ -39,6 +39,14 @@ export type PacketRetrievalOptions = {
* be loaded.
*/
metadataOnly?: boolean;
/**
* When set to true, key packets will be verified upon retrieval by looking into the packet's bitstream.
* If not enabled, the packet types will be determined solely by what's stored in the containing file and may be
* incorrect, potentially leading to decoder errors. Since determining a packet's actual type requires looking into
* its data, this option cannot be enabled together with `metadataOnly`.
*/
verifyKeyPackets?: boolean;
};
const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
@@ -48,6 +56,12 @@ const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
if (options.metadataOnly !== undefined && typeof options.metadataOnly !== 'boolean') {
throw new TypeError('options.metadataOnly, when defined, must be a boolean.');
}
if (options.verifyKeyPackets !== undefined && typeof options.verifyKeyPackets !== 'boolean') {
throw new TypeError('options.verifyKeyPackets, when defined, must be a boolean.');
}
if (options.verifyKeyPackets && options.metadataOnly) {
throw new TypeError('options.verifyKeyPackets and options.metadataOnly cannot be enabled together.');
}
};
const validateTimestamp = (timestamp: number) => {
@@ -56,6 +70,30 @@ const validateTimestamp = (timestamp: number) => {
}
};
const maybeFixPacketType = (
track: InputTrack,
promise: Promise<EncodedPacket | null>,
options: PacketRetrievalOptions,
) => {
if (options.verifyKeyPackets) {
return promise.then(async (packet) => {
if (!packet || packet.type === 'delta') {
return packet;
}
const determinedType = await track.determinePacketType(packet);
if (determinedType) {
// @ts-expect-error Technically readonly
packet.type = determinedType;
}
return packet;
});
} else {
return promise;
}
};
/**
* Sink for retrieving encoded packets from an input track.
* @public
@@ -78,7 +116,8 @@ export class EncodedPacketSink {
*/
getFirstPacket(options: PacketRetrievalOptions = {}) {
validatePacketRetrievalOptions(options);
return this._track._backing.getFirstPacket(options);
return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options);
}
/**
@@ -92,7 +131,8 @@ export class EncodedPacketSink {
getPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
validateTimestamp(timestamp);
validatePacketRetrievalOptions(options);
return this._track._backing.getPacket(timestamp, options);
return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options);
}
/**
@@ -104,7 +144,8 @@ export class EncodedPacketSink {
throw new TypeError('packet must be an EncodedPacket.');
}
validatePacketRetrievalOptions(options);
return this._track._backing.getNextPacket(packet, options);
return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options);
}
/**
@@ -114,24 +155,60 @@ export class EncodedPacketSink {
* last key packet using `getKeyPacket(Infinity)`. The method returns null if the timestamp is before the first
* key packet in the track.
*
* To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
*
* @param timestamp - The timestamp used for retrieval, in seconds.
*/
getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}): Promise<EncodedPacket | null> {
validateTimestamp(timestamp);
validatePacketRetrievalOptions(options);
return this._track._backing.getKeyPacket(timestamp, options);
if (!options.verifyKeyPackets) {
return this._track._backing.getKeyPacket(timestamp, options);
}
const packet = await this._track._backing.getKeyPacket(timestamp, options);
if (!packet || packet.type === 'delta') {
return packet;
}
const determinedType = await this._track.determinePacketType(packet);
if (determinedType === 'delta') {
// Try returning the previous key packet (in hopes that it's actually a key packet)
return this.getKeyPacket(packet.timestamp - 1 / this._track.timeResolution, options);
}
return packet;
}
/**
* Retrieves the key packet following the given packet (in decode order), or null if the given packet is the last
* key packet.
*
* To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
*/
getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) {
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}): Promise<EncodedPacket | null> {
if (!(packet instanceof EncodedPacket)) {
throw new TypeError('packet must be an EncodedPacket.');
}
validatePacketRetrievalOptions(options);
return this._track._backing.getNextKeyPacket(packet, options);
if (!options.verifyKeyPackets) {
return this._track._backing.getNextKeyPacket(packet, options);
}
const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
if (!nextPacket || nextPacket.type === 'delta') {
return nextPacket;
}
const determinedType = await this._track.determinePacketType(nextPacket);
if (determinedType === 'delta') {
// Try returning the next key packet (in hopes that it's actually a key packet)
return this.getNextKeyPacket(nextPacket, options);
}
return nextPacket;
}
/**
@@ -346,7 +423,8 @@ export abstract class BaseMediaSampleSink<
});
const packetSink = this._createPacketSink();
const keyPacket = await packetSink.getKeyPacket(startTimestamp) ?? await packetSink.getFirstPacket();
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true })
?? await packetSink.getFirstPacket();
if (!keyPacket) {
return;
}
@@ -364,7 +442,7 @@ export abstract class BaseMediaSampleSink<
? null
: packet.type === 'key' && packet.timestamp === endTimestamp
? packet
: await packetSink.getNextKeyPacket(packet);
: await packetSink.getNextKeyPacket(packet, { verifyKeyPackets: true });
if (keyPacket) {
endPacket = keyPacket;
@@ -567,7 +645,7 @@ export abstract class BaseMediaSampleSink<
}
const targetPacket = await packetSink.getPacket(timestamp);
const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp);
const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp, { verifyKeyPackets: true });
if (!keyPacket) {
if (maxSequenceNumber !== -1) {
@@ -737,6 +815,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
// Round the timestamps to the time resolution
sample.setTimestamp(Math.round(sample.timestamp * this.timeResolution) / this.timeResolution);
sample.setDuration(Math.round(sample.duration * this.timeResolution) / this.timeResolution);
sample.setRotation(this.rotation);
this.onSample(sample);
}
+9 -5
View File
@@ -48,7 +48,7 @@ export class Bitstream {
this.pos = 8 * byteOffset;
}
readBit() {
private readBit() {
const byteIndex = Math.floor(this.pos / 8);
const byte = this.bytes[byteIndex] ?? 0;
const bitIndex = 0b111 - (this.pos & 0b111);
@@ -59,6 +59,10 @@ export class Bitstream {
}
readBits(n: number) {
if (n === 1) {
return this.readBit();
}
let result = 0;
for (let i = 0; i < n; i++) {
@@ -100,7 +104,7 @@ export class Bitstream {
/** Reads an exponential-Golomb universal code from a Bitstream. */
export const readExpGolomb = (bitstream: Bitstream) => {
let leadingZeroBits = 0;
while (bitstream.readBit() === 0 && leadingZeroBits < 32) {
while (bitstream.readBits(1) === 0 && leadingZeroBits < 32) {
leadingZeroBits++;
}
@@ -511,15 +515,15 @@ export const retriedFetch = async (
try {
return await fetch(url, requestInit);
} catch (error) {
console.error('Retrying failed fetch. Error:', error);
attempts++;
const retryDelayInSeconds = getRetryDelay(attempts);
if (retryDelayInSeconds === null) {
throw error;
}
console.error('Retrying failed fetch. Error:', error);
if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) {
throw new TypeError('Retry delay must be a non-negative finite number.');
}
+4
View File
@@ -85,6 +85,10 @@ export const readFrameHeader = (word: number, reader: { pos: number; fileSize: n
reader.pos += 1;
if (firstByte !== 0xff) {
return null;
}
if ((secondByte & 0xe0) !== 0xe0) {
return null;
}
+1 -1
View File
@@ -65,7 +65,7 @@ export class Mp3Reader {
assert(this.fileSize);
until ??= this.fileSize;
while (this.pos < until - FRAME_HEADER_SIZE) {
while (this.pos <= until - FRAME_HEADER_SIZE) {
const word = this.readU32();
this.pos -= 4;
+9
View File
@@ -546,6 +546,12 @@ export class Mp3OutputFormat extends OutputFormat {
* @public
*/
export type WavOutputFormatOptions = {
/**
* When enabled, an RF64 file be written, allowing for file sizes to exceed 4 GiB, which is otherwise not possible
* for regular WAVE files.
*/
large?: boolean;
/**
* Will be called once the file header is written. The header consists of the RIFF header, the format chunk, and the
* start of the data chunk (with a placeholder size of 0).
@@ -565,6 +571,9 @@ export class WavOutputFormat extends OutputFormat {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.large !== undefined && typeof options.large !== 'boolean') {
throw new TypeError('options.large, when provided, must be a boolean.');
}
if (options.onHeader !== undefined && typeof options.onHeader !== 'function') {
throw new TypeError('options.onHeader, when provided, must be a function.');
}
+111 -2
View File
@@ -366,9 +366,79 @@ export class VideoSample {
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
dx: number,
dy: number,
dWidth: number = this.displayWidth,
dHeight: number = this.displayHeight,
dWidth?: number,
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 (!(
(typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)
|| (
@@ -378,6 +448,18 @@ export class VideoSample {
)) {
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)) {
throw new TypeError('dx must be a number.');
}
@@ -395,6 +477,29 @@ export class VideoSample {
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();
context.save();
@@ -412,6 +517,10 @@ export class VideoSample {
context.drawImage(
source,
sx,
sy,
sWidth,
sHeight,
-dWidth / 2,
-dHeight / 2,
dWidth,
+27 -2
View File
@@ -218,7 +218,8 @@ export class UrlSource extends Source {
const buffer = await response.arrayBuffer();
if (!range) {
if (response.status === 200) {
// The server didn't return 206 Partial Content, so it's not a range response
this._fullData = buffer;
}
@@ -252,6 +253,26 @@ export class UrlSource extends Source {
return this._fullData.byteLength;
}
// First, try a HEAD request to get the size
try {
const headResponse = await retriedFetch(
this._url,
mergeObjectsDeeply(this._options.requestInit ?? {}, {
method: 'HEAD',
}),
this._options.getRetryDelay ?? (() => null),
);
if (headResponse.ok) {
const contentLength = headResponse.headers.get('Content-Length');
if (contentLength) {
return parseInt(contentLength);
}
}
} catch {
// We tried
}
// Try a range request to get the Content-Range header
const rangeResponse = await retriedFetch(
this._url,
@@ -267,9 +288,13 @@ export class UrlSource extends Source {
if (contentRange) {
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
if (match && match[1]) {
return parseInt(match[1], 10);
return parseInt(match[1]);
}
}
} else if (rangeResponse.status === 200) {
// The server just returned the whole thing
this._fullData = await rangeResponse.arrayBuffer();
return this._fullData.byteLength;
}
// If the range request didn't provide the size, make a full GET request
+1 -2
View File
@@ -1,9 +1,8 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../build",
"outDir": "../dist/modules",
"declaration": true,
"sourceMap": true,
"declarationMap": true,
"stripInternal": true,
"noEmit": false
+15
View File
@@ -35,6 +35,21 @@ export class RiffReader {
return view.getUint32(offset, this.littleEndian);
}
readU64() {
let low: number;
let high: number;
if (this.littleEndian) {
low = this.readU32();
high = this.readU32();
} else {
high = this.readU32();
low = this.readU32();
}
return high * 0x100000000 + low;
}
readAscii(length: number) {
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
this.pos += length;
+9 -3
View File
@@ -14,14 +14,20 @@ export class RiffWriter {
constructor(private writer: Writer) {}
writeU16(value: number) {
this.helperView.setUint16(0, value, true);
this.writer.write(this.helper.subarray(0, 2));
}
writeU32(value: number) {
this.helperView.setUint32(0, value, true);
this.writer.write(this.helper.subarray(0, 4));
}
writeU16(value: number) {
this.helperView.setUint16(0, value, true);
this.writer.write(this.helper.subarray(0, 2));
writeU64(value: number) {
this.helperView.setUint32(0, value, true);
this.helperView.setUint32(4, Math.floor(value / 2 ** 32), true);
this.writer.write(this.helper);
}
writeAscii(text: string) {
+24 -3
View File
@@ -53,9 +53,13 @@ export class WaveDemuxer extends Demuxer {
const actualFileSize = await this.metadataReader.reader.source.getSize();
const riffType = this.metadataReader.readAscii(4);
this.metadataReader.littleEndian = riffType === 'RIFF';
this.metadataReader.littleEndian = riffType !== 'RIFX';
const totalFileSize = Math.min(this.metadataReader.readU32() + 8, actualFileSize);
const isRf64 = riffType === 'RF64';
const outerChunkSize = this.metadataReader.readU32();
let totalFileSize = isRf64 ? actualFileSize : Math.min(outerChunkSize + 8, actualFileSize);
const format = this.metadataReader.readAscii(4);
if (format !== 'WAVE') {
@@ -63,6 +67,9 @@ export class WaveDemuxer extends Demuxer {
}
this.metadataReader.pos = 12;
let chunksRead = 0;
let dataChunkSize: number | null = null;
while (this.metadataReader.pos < totalFileSize) {
await this.metadataReader.reader.loadRange(this.metadataReader.pos, this.metadataReader.pos + 8);
@@ -70,14 +77,28 @@ export class WaveDemuxer extends Demuxer {
const chunkSize = this.metadataReader.readU32();
const startPos = this.metadataReader.pos;
if (isRf64 && chunksRead === 0 && chunkId !== 'ds64') {
throw new Error('Invalid RF64 file: First chunk must be "ds64".');
}
if (chunkId === 'fmt ') {
await this.parseFmtChunk(chunkSize);
} else if (chunkId === 'data') {
dataChunkSize ??= chunkSize;
this.dataStart = this.metadataReader.pos;
this.dataSize = Math.min(chunkSize, totalFileSize - this.dataStart);
this.dataSize = Math.min(dataChunkSize, totalFileSize - this.dataStart);
} else if (chunkId === 'ds64') {
// File and data chunk sizes are defined in here instead
const riffChunkSize = this.metadataReader.readU64();
dataChunkSize = this.metadataReader.readU64();
totalFileSize = Math.min(riffChunkSize + 8, actualFileSize);
}
this.metadataReader.pos = startPos + chunkSize + (chunkSize & 1); // Handle padding
chunksRead++;
}
if (!this.audioInfo) {
+57 -9
View File
@@ -18,10 +18,13 @@ import { assert } from '../misc';
export class WaveMuxer extends Muxer {
private format: WavOutputFormat;
private isRf64: boolean;
private writer: Writer;
private riffWriter: RiffWriter;
private headerWritten = false;
private dataSize = 0;
private sampleRate: number | null = null;
private sampleCount = 0;
constructor(output: Output, format: WavOutputFormat) {
super(output);
@@ -29,6 +32,7 @@ export class WaveMuxer extends Muxer {
this.format = format;
this.writer = output._writer;
this.riffWriter = new RiffWriter(output._writer);
this.isRf64 = !!format._options.large;
}
async start() {
@@ -58,13 +62,22 @@ export class WaveMuxer extends Muxer {
assert(meta.decoderConfig);
this.writeHeader(track, meta.decoderConfig);
this.sampleRate = meta.decoderConfig.sampleRate;
this.headerWritten = true;
}
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
if (!this.isRf64 && this.writer.getPos() + packet.data.byteLength >= 2 ** 32) {
throw new Error(
'Adding more audio data would exceed the maximum RIFF size of 4 GiB. To write larger files, use'
+ ' RF64 by setting `large: true` in the WavOutputFormatOptions.',
);
}
this.writer.write(packet.data);
this.dataSize += packet.data.byteLength;
this.sampleCount += Math.round(packet.duration * this.sampleRate!);
await this.writer.flush();
} finally {
@@ -101,10 +114,26 @@ export class WaveMuxer extends Muxer {
const blockSize = pcmInfo.sampleSize * channels;
// RIFF header
this.riffWriter.writeAscii('RIFF');
this.riffWriter.writeU32(0); // File size placeholder
this.riffWriter.writeAscii(this.isRf64 ? 'RF64' : 'RIFF');
if (this.isRf64) {
this.riffWriter.writeU32(0xffffffff); // Not used in RF64
} else {
this.riffWriter.writeU32(0); // File size placeholder
}
this.riffWriter.writeAscii('WAVE');
if (this.isRf64) {
this.riffWriter.writeAscii('ds64');
this.riffWriter.writeU32(28); // Chunk size
this.riffWriter.writeU64(0); // RIFF size placeholder
this.riffWriter.writeU64(0); // Data size placeholder
this.riffWriter.writeU64(0); // Sample count placeholder
this.riffWriter.writeU32(0); // Table length
// Empty table
}
// fmt chunk
this.riffWriter.writeAscii('fmt ');
this.riffWriter.writeU32(16); // Chunk size
@@ -117,7 +146,12 @@ export class WaveMuxer extends Muxer {
// data chunk
this.riffWriter.writeAscii('data');
this.riffWriter.writeU32(0); // Data size placeholder
if (this.isRf64) {
this.riffWriter.writeU32(0xffffffff); // Not used in RF64
} else {
this.riffWriter.writeU32(0); // Data size placeholder
}
if (this.format._options.onHeader) {
const { data, start } = this.writer.stopTrackingWrites();
@@ -130,13 +164,27 @@ export class WaveMuxer extends Muxer {
const endPos = this.writer.getPos();
// Write file size
this.writer.seek(4);
this.riffWriter.writeU32(this.dataSize + 36); // File size - 8
if (this.isRf64) {
// Write riff size
this.writer.seek(20);
this.riffWriter.writeU64(endPos - 8);
// Write data chunk size
this.writer.seek(40);
this.riffWriter.writeU32(this.dataSize);
// Write data size
this.writer.seek(28);
this.riffWriter.writeU64(this.dataSize);
// Write sample count
this.writer.seek(36);
this.riffWriter.writeU64(this.sampleCount);
} else {
// Write file size
this.writer.seek(4);
this.riffWriter.writeU32(endPos - 8);
// Write data chunk size
this.writer.seek(40);
this.riffWriter.writeU32(this.dataSize);
}
this.writer.seek(endPos);
+1 -1
View File
@@ -20,7 +20,7 @@ const rollupInput = Object.fromEntries(
export default defineConfig({
resolve: {
alias: {
mediabunny: path.resolve(__dirname, './dist/mediabunny.mjs'),
mediabunny: path.resolve(__dirname, './dist/bundles/mediabunny.mjs'),
},
},
plugins: [