Compare commits

..
19 Commits
Author SHA1 Message Date
Vanilagy fff69619ec Lint 2026-05-09 00:42:37 +02:00
Vanilagy fc2f98825b Bump patch 2026-05-09 00:41:08 +02:00
Vanilagy 3cde68e0ae Fix AudioResampler emitting timestamps before startTimestamp (fixes #366) 2026-05-09 00:40:25 +02:00
Vanilagy 9e3db7cf91 Bump patch 2026-05-07 11:12:14 +02:00
Vanilagy fff09411e6 Fixed negative timestamps occurring with live media (fixes #364) 2026-05-07 11:11:37 +02:00
Vanilagy daf42181c9 Add sponsor 2026-05-06 20:12:14 +02:00
Vanilagy e790f5ff97 Bump minor 2026-05-05 09:21:33 +02:00
Vanilagy 47ad5c6806 Add BlobSourceOptions.useStreamReader (#179) 2026-05-04 14:20:21 +02:00
Vanilagy 8955ba70c2 Sorry Jonny <3 2026-05-04 10:53:09 +02:00
Vanilagy 1680bd02ad Holistic technical website cleanup 2026-05-03 19:47:39 +02:00
Vanilagy 4810c13c29 Bump patch 2026-05-02 14:27:47 +02:00
Vanilagy 9ed21bf9c6 Fix behavior with files with invalid pasp box (with num & den === 0) (fixes #362) 2026-05-02 14:27:25 +02:00
Vanilagy 62dfc5dd1c Bump minor 2026-04-30 10:34:36 +02:00
Vanilagy 782d3e134b Use edit lists to model non-zero start timestamps for regular MP4 (fixes #336); fix MP4 PCM logic breaking down with approximate packet timestamps 2026-04-30 10:32:42 +02:00
Vanilagy 9224fb886c Add link rel="canonical" 2026-04-29 17:00:59 +02:00
Vanilagy a61631a299 Clarify in readme 2026-04-29 16:30:10 +02:00
Vanilagy f3dec587fd Make FLAC encoder bit depth depend on input sample format, add sampleFormat audio transformation option, fix incorrect bitrate validation for FLAC (closes #357) 2026-04-29 16:27:20 +02:00
Vanilagy 2d49122277 Sanitize initial HEVC packet to avoid Chromium's validation logic from tripping up (#314) 2026-04-29 11:37:56 +02:00
Vanilagy 06a89ed085 Make code 12px 2026-04-28 17:57:44 +02:00
97 changed files with 1523 additions and 341 deletions
+19 -8
View File
@@ -3,10 +3,12 @@
<script src="../dist/bundles/mediabunny.cjs"></script>
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
<script src="../packages/ac3/dist/bundles/mediabunny-ac3.js"></script>
<script src="../packages/flac-encoder/dist/bundles/mediabunny-flac-encoder.js"></script>
<script type="module">
//MediabunnyMp3Encoder.registerMp3Encoder();
MediabunnyAc3.registerAc3Decoder();
MediabunnyFlacEncoder.registerFlacEncoder();
const fileInput = document.createElement('input');
fileInput.type = 'file';
@@ -23,7 +25,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.WavOutputFormat();
const outputFormat = new Mediabunny.Mp4OutputFormat();
const p = document.createElement('p');
p.textContent = 'Capturing...';
@@ -57,7 +59,7 @@
const tracks = [];
let start = 0;
if (true) {
if (false) {
input = new Mediabunny.Input({
source: new Mediabunny.UrlSource('http://localhost:8000/index.m3u8'),
formats: Mediabunny.ALL_FORMATS,
@@ -85,15 +87,20 @@
});
}
const primaryTrack = await input.getPrimaryAudioTrack();
const startTime = await primaryTrack.getFirstTimestamp();
console.log(startTime)
//const primaryTrack = await input.getPrimaryAudioTrack();
//const startTime = await primaryTrack.getFirstTimestamp();
//console.log(startTime)
let ctx = null;
let conversion = await Mediabunny.Conversion.init({
input,
output,
audio: (track) => ({ discard: track.number !== primaryTrack.number }),
audio: {
codec: 'aac',
forceTranscode: true,
//forceTranscode: true,
//sampleFormat: 's16',
},
/*
video: {
discard: true,
@@ -139,11 +146,15 @@
}
},
trim: {
start: startTime,
end: startTime + 2,
start: 300.14984567374756 - 100,
end: 310.1548298151939 - 100,
//end: 10,
//start: startTime,
//end: startTime + 2,
},
});
//console.log(conversion);
console.log(conversion.discardedTracks);
let progress = 0;
conversion.onProgress = newProgress => progress = newProgress;
+34 -6
View File
@@ -11,7 +11,6 @@
document.body.append(fileInput);
fileInput.addEventListener('change', async () => {
/*
const file = fileInput.files[0];
const input = new Mediabunny.Input({
formats: Mediabunny.ALL_FORMATS,
@@ -19,15 +18,43 @@
});
const track = await input.getPrimaryAudioTrack();
const sink3 = new Mediabunny.EncodedPacketSink(track);
console.log((await sink3.getFirstPacket()).data.join(', '));
return;
console.log(await track.getDurationFromMetadata(), await track.computeDuration());
const sink = new Mediabunny.EncodedPacketSink(track);
for await (const packet of sink.packets()) {
console.log(packet);
}
const output = new Mediabunny.Output({
format: new Mediabunny.Mp4OutputFormat(),
target: new Mediabunny.BufferTarget(),
});
const conversion = await Mediabunny.Conversion.init({ input, output });
await conversion.execute();
//console.log(await input.getDurationFromMetadata(), await input.computeDuration());
return;
// Download it now
const blob = new Blob([output.target.buffer]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name.replace(/\.\w+$/, '.mp4');
a.click();
URL.revokeObjectURL(url);
/*
const track = await input.getPrimaryAudioTrack();
const sink = new Mediabunny.EncodedPacketSink(track);
for await (const packet of sink.packets()) {
console.log(packet);
}
console.log("Done")
*/
/*
const input = new Mediabunny.Input({
source: new Mediabunny.UrlSource('https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8'),
formats: Mediabunny.ALL_FORMATS,
@@ -36,6 +63,7 @@
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
console.log(await sink.getFirstPacket());
*/
/*
return
+112 -11
View File
@@ -1,3 +1,4 @@
/* eslint-disable @stylistic/max-len */
import { withMermaid } from 'vitepress-plugin-mermaid';
import footnote from 'markdown-it-footnote';
import tailwindcss from '@tailwindcss/vite';
@@ -7,9 +8,12 @@ import { HeadConfig } from 'vitepress';
// @ts-ignore This file gets generated once docs:generate is run
import apiRoutes from '../api/index.json';
import m3u8Grammar from './m3u8-grammar.json' with { type: 'json' };
import fs from 'node:fs/promises';
import path from 'node:path';
const DESCRIPTION = 'A JavaScript library for reading, writing, and converting media files. Directly in the browser,'
+ ' and faster than anybunny else.';
const ORIGIN = 'https://mediabunny.dev';
// https://vitepress.dev/reference/site-config
export default withMermaid({
@@ -17,22 +21,31 @@ export default withMermaid({
description: DESCRIPTION,
cleanUrls: true,
sitemap: {
hostname: 'https://mediabunny.dev',
hostname: ORIGIN,
transformItems: async (items) => {
const entries = await fs.readdir('./examples');
for (const entry of entries) {
const isDirectory = await fs.stat(path.join('./examples', entry)).then(stat => stat.isDirectory());
if (isDirectory) {
items.push({
url: `/examples/${entry}/`, // With trailing slash
});
}
}
return items;
},
},
lastUpdated: true,
// lastUpdated: true,
head: [
['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }],
['link', { rel: 'icon', type: 'image/svg+xml', href: '/mediabunny-logo.svg' }],
['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: 'https://mediabunny.dev/mediabunny-og-image.png' }],
['meta', { property: 'og:image', content: `${ORIGIN}/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:image', content: `${ORIGIN}/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',
@@ -40,12 +53,12 @@ export default withMermaid({
// https://vitepress.dev/reference/default-theme-config
nav: [
{ text: 'Guide', link: '/guide/introduction', activeMatch: '/guide' },
{ text: 'API', link: '/api', activeMatch: '/api' },
{ text: 'API', link: '/api/', activeMatch: '/api/' }, // Trailing slash because it's index.html from there
{ text: 'LLMs', link: '/llms', activeMatch: '/llms' },
{ text: 'Examples', link: '/examples', activeMatch: '/examples' },
{ text: 'Blog', link: '/blog', activeMatch: '/blog' },
{ text: 'Sponsors', link: '/#sponsors', activeMatch: '/#sponsors' },
{ text: 'License', link: 'https://github.com/Vanilagy/mediabunny#license' },
{ text: 'License', link: 'https://github.com/Vanilagy/mediabunny#license', rel: 'noopener' },
{
text: 'More',
items: [
@@ -186,6 +199,8 @@ export default withMermaid({
llmstxt({
ignoreFiles: [
'api/*',
'examples.md',
'llms.md',
],
}),
],
@@ -197,9 +212,95 @@ export default withMermaid({
title += ' | Mediabunny';
}
const canonicalUrl = `${ORIGIN}/${pageData.relativePath}`
.replace(/index\.md$/, '')
.replace(/\.md$/, '');
const isBlogPost = canonicalUrl.includes('/blog/');
const breadcrumbs: object[] = [];
if (canonicalUrl.includes('/guide/')) {
breadcrumbs.push({
'@type': 'ListItem',
'position': 1,
'name': 'Guide',
'item': `${ORIGIN}/guide`,
}, {
'@type': 'ListItem',
'position': 2,
'name': pageData.title,
});
}
if (canonicalUrl.includes('/api/')) {
breadcrumbs.push({
'@type': 'ListItem',
'position': 1,
'name': 'API docs',
'item': `${ORIGIN}/api/`,
}, {
'@type': 'ListItem',
'position': 2,
'name': pageData.title,
});
}
if (canonicalUrl.includes('/codec-registry/')) {
breadcrumbs.push({
'@type': 'ListItem',
'position': 1,
'name': 'Codec registry',
'item': `${ORIGIN}/codec-registry/overview`,
}, {
'@type': 'ListItem',
'position': 2,
'name': pageData.title,
});
}
((pageData.frontmatter['head'] ??= []) as HeadConfig[]).push(
['meta', { property: 'og:type', content: isBlogPost ? 'article' : 'website' }],
['meta', { property: 'og:title', content: title }],
['meta', { property: 'twitter:title', content: title }],
['meta', { property: 'og:description', content: pageData.description || DESCRIPTION }],
['meta', { property: 'og:url', content: canonicalUrl }],
['meta', { name: 'twitter:title', content: title }],
['meta', { name: 'twitter:description', content: pageData.description || DESCRIPTION }],
['link', { rel: 'canonical', href: canonicalUrl }],
);
if (isBlogPost) {
((pageData.frontmatter['head'] ??= []) as HeadConfig[]).push(
['meta', { property: 'article:published_time', content: String(pageData.frontmatter['publishedOnIso']) }],
['meta', { property: 'article:author', content: String(pageData.frontmatter['author']) }],
);
breadcrumbs.push({
'@type': 'ListItem',
'position': 1,
'name': 'Blog posts',
'item': `${ORIGIN}/blog`,
}, {
'@type': 'ListItem',
'position': 2,
'name': pageData.title,
});
}
if (breadcrumbs.length > 0) {
((pageData.frontmatter['head'] ??= []) as HeadConfig[]).push(
['script', { type: 'application/ld+json' }, JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
'itemListElement': breadcrumbs,
})],
);
}
},
buildEnd: async () => {
const files = await fs.readdir('./docs/api');
for (const file of files) {
await fs.copyFile('./docs/api/' + file, './dist-docs/api/' + file);
}
},
});
+1
View File
@@ -1,6 +1,7 @@
{
"heading": "Mediabunny API reference",
"intro": "Here you can find detailed documentation for all classes, functions, constants and types exposed by Mediabunny's public API.",
"description": "Detailed documentation for all classes, functions, constants and types exposed by Mediabunny's public API.",
"Samples": "Raw, unencoded chunks of media data, such as video frames or sections of audio.",
"Packets": "Chunks of encoded media data.",
+2
View File
@@ -1,4 +1,6 @@
---
title: Blog posts
description: Announcements, news, and devblogs about Mediabunny.
outline: false
---
+5 -3
View File
@@ -1,6 +1,8 @@
---
title: Mediabunny now supports HLS!
description: Mediabunny v1.42.0 adds read/write support for HTTP Live Streaming (HLS), pushing the frontier of fully client-side media processing.
publishedOn: April 24, 2026
publishedOnIso: "2026-04-24"
author: Vanilagy
authorImage: /vani.png
authorLink: https://github.com/Vanilagy
@@ -77,7 +79,7 @@ The API surface added by the HLS update is vast and I obviously can't cover it i
By using the Conversion API, you can just do this:
<div class="text-xs">
<div class="text-[13.7142857143px]">
```ts
import { ... } from 'mediabunny';
@@ -107,7 +109,7 @@ That's it. This will stream-download the entire HLS playlist, transcode it if ne
This is basically the inverse of the previous example. Just like we're able to read HLS and turn it into an MP4, we're able to read any input file and turn it into a full HLS playlist including master playlist, media playlists and segments:
<div class="text-xs">
<div class="text-[13.7142857143px]">
```ts
import { ... } from 'mediabunny';
@@ -163,7 +165,7 @@ No transcode server is needed here, it's all handled by the client, and the serv
You could build an OBS-like broadcasting system where a user records their screen, facecam or microphone, encodes multiple variants locally, and then broadcasts finished HLS segments directly to the server, meaning no transcoding is needed.
<div class="text-xs overflow-auto">
<div class="text-[13.7142857143px] overflow-auto">
```ts
// Get the screen and mic
+4
View File
@@ -1,3 +1,7 @@
---
description: Advanced Audio Coding (AAC) audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: Dolby Digital (AC-3) audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: A-law companded PCM audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: AOMedia Video 1 (AV1) video codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: Advanced Video Coding (H.264) video codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: Dolby Digital Plus (E-AC-3) audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: Free Lossless Audio Codec (FLAC) audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: High Efficiency Video Coding (H.265) video codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: MP3 (MPEG-1/2 Audio Layer III) audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: Opus audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: The Mediabunny Codec Registry formalizes the precise definitions of all video and audio codecs supported by Mediabunny, like expected codec string and packet data format.
---
# Mediabunny Codec Registry
The Mediabunny Codec Registry formalizes the precise definitions of all video and audio codecs supported by Mediabunny. More specifically, for any given codec, it describes the format that `EncodedPacket`, `VideoDecoderConfig` and `AudioDecoderConfig` must adhere to. All packets coming out of or going into Mediabunny are expected to adhere to this registry.
+4
View File
@@ -1,3 +1,7 @@
---
description: PCM audio codec definitions, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: μ-law companded PCM audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: Vorbis audio codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: VP8 video codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+4
View File
@@ -1,3 +1,7 @@
---
description: VP9 video codec definition, defining legal codec strings, decoder configs, and packet data formats.
---
<script setup>
import { VPBadge } from 'vitepress/theme'
</script>
+1
View File
@@ -1,4 +1,5 @@
---
description: "Demos showcasing the various features of Mediabunny: metadata extraction, thumbnail generation, compression, media playback, live streaming, and more."
layout: home
title: Examples
+5
View File
@@ -1,3 +1,7 @@
---
description: Efficiently convert any media file to any format, directly in the browser. Optionally apply trimming, video resizing, rotation, custom overlays, and more.
---
# Converting media files
The [reading](./reading-media-files) and [writing](./writing-media-files) primitives in Mediabunny provide everything you need to convert media files. However, since this is such a common operation and the details can be tricky, Mediabunny ships with a built-in file conversion abstraction.
@@ -242,6 +246,7 @@ type ConversionAudioOptions = {
bitrate?: number | Quality;
numberOfChannels?: number;
sampleRate?: number;
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
forceTranscode?: boolean;
process?: (sample: AudioSample) => MaybePromise<
AudioSample | AudioSample[] | null
+4
View File
@@ -1,3 +1,7 @@
---
description: The @mediabunny/aac-encoder extension provides a fast AAC encoder polyfill for use in the browser and on the server.
---
# @mediabunny/aac-encoder
Some browsers lack support for AAC encoding in their WebCodecs implementations. This extension package provides a reliable AAC-LC encoder for use with Mediabunny. It is implemented using Mediabunny's [custom coder API](../supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [FFmpeg](https://ffmpeg.org/)'s AAC encoder under the hood.
+4
View File
@@ -1,3 +1,7 @@
---
description: The @mediabunny/ac3 extension provides fast AC-3 and E-AC-3 decoders and encoders for both browser and server environments.
---
# @mediabunny/ac3
Browsers have no support for AC-3 (Dolby Digital) or E-AC-3 (Dolby Digital Plus) in their WebCodecs implementations. This extension package provides both a decoder and encoder for use with Mediabunny, allowing you to decode and encode these codecs directly in the browser. It is implemented using Mediabunny's [custom coder API](../supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [FFmpeg](https://ffmpeg.org/)'s AC-3 and E-AC-3 coders under the hood.
+4
View File
@@ -1,3 +1,7 @@
---
description: The @mediabunny/flac-encoder extension provides a highly-performant FLAC encoder polyfill for use in the browser and on the server.
---
# @mediabunny/flac-encoder
No browser currently supports FLAC encoding in their WebCodecs implementations. This extension package provides a reliable FLAC encoder for use with Mediabunny. It is implemented using Mediabunny's [custom coder API](../supported-formats-and-codecs#custom-coders) and uses a fast, size-optimized WASM build of [libFLAC](https://github.com/xiph/flac) under the hood.
+4
View File
@@ -1,3 +1,7 @@
---
description: The @mediabunny/mp3-encoder extension provides a highly-performant MP3 encoder polyfill for use in the browser and on the server.
---
# @mediabunny/mp3-encoder
Browsers typically have no support for MP3 encoding in their WebCodecs implementations. Given the ubiquity of the format, this extension package provides an MP3 encoder for use with Mediabunny. It is implemented using Mediabunny's [custom coder API](../supported-formats-and-codecs#custom-coders) and uses a highly-performant WASM build of the [LAME MP3 Encoder](https://lame.sourceforge.io/) under the hood.
+4
View File
@@ -1,3 +1,7 @@
---
description: Mediabunny supports a wide range of media input formats, such as MP4, WebM, MP3, HLS, and many more.
---
# Input formats
Mediabunny supports a wide variety of commonly used container formats for reading input files. These *input formats* are used in two ways:
+4
View File
@@ -1,3 +1,7 @@
---
description: Install Mediabunny using npm (or your favorite package manager), or include it directly as a <script> tag.
---
# Installation
Install Mediabunny using your favorite package manager:
+4
View File
@@ -1,3 +1,7 @@
---
description: Mediabunny is a zero-dependency, tree-shakable TypeScript library for reading, writing and converting media files in the browser. Like FFmpeg, but for the web.
---
# Introduction
Mediabunny is a JavaScript library for reading, writing, and converting media files (like MP4 or WebM), 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, 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 for the web's needs.
+4
View File
@@ -1,3 +1,7 @@
---
description: Media sinks enable different ways to extract video and audio data from files, from built-in decoding to manual per-packet control.
---
# Media sinks
## Introduction
+4
View File
@@ -1,3 +1,7 @@
---
description: Media sources enable multiple ways to add media data to new media files, from built-in encoding to manual per-packet control.
---
# Media sources
## Introduction
+4
View File
@@ -1,3 +1,7 @@
---
description: Mediabunny can write a wide range of media output formats, including MP4, WebM, MP3, HLS, and many more.
---
# Output formats
## Introduction
+4
View File
@@ -1,3 +1,7 @@
---
description: Learn about how packets carry encoded media data, samples carry raw decoded media data, and the operations you can perform on them.
---
# Packets & samples
## Introduction
+4
View File
@@ -1,3 +1,7 @@
---
description: A collection of short code snippets that showcase the most common operations that Mediabunny can do.
---
# Quick start
This page is a collection of short code snippets that showcase the most common operations you may use this library for.
+4
View File
@@ -1,3 +1,7 @@
---
description: Use Mediabunny to read HLS (.m3u8) playlists, both VOD and live. Extract tracks, metadata, and media data efficiently and with precise control.
---
# Reading HLS
Mediabunny has full support for reading HLS (.m3u8) playlists, both VOD and live. This page will go into HLS-specific behavior and advice. For general information about how to read any media file (including HLS) in Mediabunny, refer to [Reading media files](./reading-media-files).
+4
View File
@@ -1,3 +1,7 @@
---
description: Efficiently extract metadata, video frames, and audio samples from any media file, such as MP4 or WebM.
---
# Reading media files
Mediabunny allows you to read media files with great control and efficiency. You can use it to extract metadata (such as duration or resolution), as well as to read actual media data from video and audio tracks with frame-accurate timing. Many commonly used [input file formats](./input-formats) are supported. Using [input sources](#input-sources), data can be read from multiple sources, such as directly from memory, from the user's disk, or even over the network.
@@ -1,3 +1,7 @@
---
description: Mediabunny supports a wide range of media container formats (.mp4, .webm, .mp3, .wav, .m3u8, ...) and video/audio codecs (H.264, HEVC, VP9, AV1, AAC, Opus, FLAC, ...).
---
# Supported formats & codecs
## Container formats
+1
View File
@@ -1,4 +1,5 @@
---
description: Mediabunny can create new HLS (.m3u8) playlists directly in the browser, both VOD and live. Define tracks, add media data, and upload directly to a server.
outline: [2, 4]
---
+4
View File
@@ -1,3 +1,7 @@
---
description: Use Mediabunny to create new video and audio files of any size directly in the browser, and with full control over frame timing, tracks, and more.
---
# Writing media files
Mediabunny enables you to create media files with very fine levels of control. You can add an arbitrary number of video, audio and subtitle tracks to a media file, and precisely control the timing of media data. This library supports [many output file formats](./output-formats). Using [output targets](#output-targets), you can decide if you want to build up the entire file in memory or stream it out in chunks as it's being created - allowing you to create very large files.
+13 -11
View File
@@ -1,7 +1,8 @@
---
layout: home
title: Mediabunny
title: Mediabunny — A complete JavaScript media toolkit for the browser
titleTemplate: false
hero:
name: Mediabunny
@@ -133,6 +134,7 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/255616819', name: 'cronischarles-del', url: 'https://github.com/cronischarles-del' },
{ image: 'https://avatars.githubusercontent.com/u/37973863', name: 'Gomi', url: 'https://github.com/gxy5202' },
{ image: 'https://avatars.githubusercontent.com/u/36898190', name: 'jepcd', url: 'https://github.com/jepcd' },
{ image: 'https://avatars.githubusercontent.com/u/695166', name: 'Takenori Nakagawa', url: 'https://github.com/ww24' },
{ image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' },
{ 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' },
@@ -155,7 +157,7 @@ const sponsors = {
<p class="!my-0 flex-1 text-sm !leading-6">{{ quote.quote }}</p>
<div class="shrink-0 flex gap-2 items-center mt-1.5">
<a :href="quote.url" target="_blank">
<img :src="quote.image" class="size-8 rounded-full shrink-0" alt="Author image">
<img :src="quote.image" class="size-8 rounded-full shrink-0" :alt="quote.author">
</a>
<p class="!my-0 flex-1 text-xs font-medium">{{ quote.author }}</p>
</div>
@@ -173,7 +175,7 @@ npm install mediabunny
<div class="space-y-16 mt-18">
<div class="flex flex-col lg:flex-row lg:gap-20 lg:items-center">
<div class="flex-1 min-w-0">
<h1 class="inline-block" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Read any media file, efficiently</h1>
<h2 class="inline-block !text-[32px] !border-0 !m-0 !p-0 !leading-[40px]" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Read any media file, efficiently</h2>
<p class="text-lg">Mediabunny allows you efficiently read data from any video or audio file, no matter the size: duration, resolution, rotation, tracks, codecs and other metadata, as well as raw or decoded media data from anywhere in the file. Load only what you need.</p>
<a class="!no-underline inline-flex items-center gap-1.5" :no-icon="true" href="/guide/reading-media-files">
Docs
@@ -249,7 +251,7 @@ const { buffer } = output.target; // Contains the final file
</div>
<div class="flex-1 min-w-0">
<h1 class="inline-block" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Create new media files, programmatically</h1>
<h2 class="inline-block !text-[32px] !border-0 !m-0 !p-0 !leading-[40px]" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Create new media files, programmatically</h2>
<p class="text-lg">Generate new media files (such as MP4 or WebM) directly on the client, and as fast as the hardware allows. Add multiple video, audio, or subtitle tracks, and have precise control down to the microsecond.</p>
<a class="!no-underline inline-flex items-center gap-1.5" :no-icon="true" href="/guide/writing-media-files">
Docs
@@ -260,7 +262,7 @@ const { buffer } = output.target; // Contains the final file
<div class="flex flex-col lg:flex-row lg:gap-20 lg:items-center">
<div class="flex-1 min-w-0">
<h1 class="inline-block" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Hopping-fast file conversion</h1>
<h2 class="inline-block !text-[32px] !border-0 !m-0 !p-0 !leading-[40px]" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Hopping-fast file conversion</h2>
<p class="text-lg">Use the Conversion API to transform any media file into any other media file: perform transmuxing, transcoding, resizing, rotation, audio resampling, trimming, and more.</p>
<a class="!no-underline inline-flex items-center gap-1.5" :no-icon="true" href="/guide/converting-media-files">
Docs
@@ -296,14 +298,14 @@ await conversion.execute();
<img class="relative" src="./assets/inspiring-io.svg">
</div>
<div class="flex-1 min-w-0">
<h1 class="inline-block" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Universal I/O</h1>
<h2 class="inline-block !text-[32px] !border-0 !m-0 !p-0 !leading-[40px]" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Universal I/O</h2>
<p class="text-lg">Read and write files from and to memory, disk, or the network. Create files for offline use, or live-stream them as they're being created. Inject media data from a canvas, webcam, screen, microphone, audio buffer, your own encoding stack, or whatever. It's all up to you.</p>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-4 lg:gap-20 lg:items-center">
<div class="flex-1 min-w-0">
<h1 class="inline-block" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Wide container & codec support</h1>
<h2 class="inline-block !text-[32px] !border-0 !m-0 !p-0 !leading-[40px]" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Wide container & codec support</h2>
<p class="text-lg">Mediabunny supports many commonly used container formats and a wide variety of video and audio codecs. And all of them bidirectionally, too! (that means for reading and writing)</p>
<a class="!no-underline inline-flex items-center gap-1.5" :no-icon="true" href="/guide/supported-formats-and-codecs">
See full list
@@ -329,14 +331,14 @@ await conversion.execute();
<p class="!my-0 text-center text-[10px] opacity-50 !leading-4">Reading BigBuckBunny1080pH264.mov (691 MiB) from disk<br>Ryzen 7600X, RTX 4070, NVMe SSD, measured 2025-06-22</p>
</div>
<div class="flex-1 min-w-0">
<h1 class="inline-block" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">High performance</h1>
<h2 class="inline-block !text-[32px] !border-0 !m-0 !p-0 !leading-[40px]" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">High performance</h2>
<p class="text-lg">By reading only what you need, writing progressively, utilizing hardware-accelerated encoding and decoding via the WebCodecs API, and using a pipelined design, Mediabunny is able to get the job done fast.</p>
</div>
</div>
<div class="flex flex-col lg:flex-row gap-4 lg:gap-20 lg:items-center">
<div class="flex-1 min-w-0">
<h1 class="inline-block" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Built from scratch, for the web</h1>
<h2 class="inline-block !text-[32px] !border-0 !m-0 !p-0 !leading-[40px]" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">Built from scratch, for the web</h2>
<p class="text-lg">Mediabunny is 100% implemented in TypeScript and has zero dependencies. Its API was designed to be highly tree-shakable, meaning you only include what you use.</p>
</div>
<div class="space-y-2 flex-1 min-w-0">
@@ -369,10 +371,10 @@ await conversion.execute();
<hr class="!my-16" />
<div class="flex flex-col items-center">
<h1 id="sponsors" class="flex items-center gap-4" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">
<h2 id="sponsors" class="flex items-center gap-4 !text-[32px] !border-0 !m-0 !p-0 !leading-[40px]" style="background: -webkit-linear-gradient(-30deg, #ff45ac, #ff78c2); -webkit-background-clip: text; color: transparent;">
Made possible by you
<img class="size-8" src="./assets/fluent-emoji--heart-suit.svg">
</h1>
</h2>
<p class="max-w-2xl">Mediabunny is an open-source project released under the <a href="https://choosealicense.com/licenses/mpl-2.0/" target="_blank">MPL-2.0</a> and is therefore free to use for any purpose, including closed-source commercial use. A permissive license is essential for a foundational library like this to truly thrive. That said, this project requires an immense amount of work and care. This is made possible by the generous financial backing of these awesome sponsors:</p>
<template v-if="sponsors.gold.length > 0">
<h3 class="!text-3xl">Gold sponsors</h3>
+4
View File
@@ -1,3 +1,7 @@
---
description: Teach Claude Code, Codex, Cursor, Copilot or any other AI coding agent how to make full use of Mediabunny.
---
# Mediabunny and LLMs
<div class="grid place-items-center my-14">
+1
View File
@@ -32,6 +32,7 @@ export default tseslint.config(
'@typescript-eslint/no-unsafe-enum-comparison': 'off',
'@typescript-eslint/no-unsafe-unary-minus': 'off',
'@typescript-eslint/no-deprecated': 'error',
'@typescript-eslint/consistent-type-exports': 'error',
},
},
{
@@ -153,7 +153,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://remotion.media/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
+17 -2
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File compression example | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and Mediabunny will convert it to a heavily-compressed MP4 file.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./file-compression.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/file-compression/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="File compression example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and Mediabunny will convert it to a heavily-compressed MP4 file.">
<meta property="og:url" content="https://mediabunny.dev/examples/file-compression/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="File compression example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and Mediabunny will convert it to a heavily-compressed MP4 file.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"File compression"}]}</script>
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
+1 -1
View File
@@ -248,7 +248,7 @@ loadSampleButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://remotion.media/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
+17 -2
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HLS transcoding example | Mediabunny</title>
<meta name="description" content="Select a directory, then a video. Mediabunny will create a complete HLS VOD package with five video renditions and one audio track.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./hls-transcoding.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/hls-transcoding/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="HLS transcoding example | Mediabunny">
<meta property="og:description" content="Select a directory, then a video. Mediabunny will create a complete HLS VOD package with five video renditions and one audio track.">
<meta property="og:url" content="https://mediabunny.dev/examples/hls-transcoding/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="HLS transcoding example | Mediabunny">
<meta name="twitter:description" content="Select a directory, then a video. Mediabunny will create a complete HLS VOD package with five video renditions and one audio track.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"HLS transcoding"}]}</script>
</head>
<body class="flex flex-col items-center bg-zinc-50 px-2 py-10 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200">
+17 -2
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live recording & streaming example | Mediabunny</title>
<meta name="description" content="The live canvas state and your microphone input will be written into a fragmented MP4 file and live-streamed to a &lt;video&gt; element.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./live-recording.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/live-recording/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Live recording & streaming example | Mediabunny">
<meta property="og:description" content="The live canvas state and your microphone input will be written into a fragmented MP4 file and live-streamed to a &lt;video&gt; element.">
<meta property="og:url" content="https://mediabunny.dev/examples/live-recording/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Live recording & streaming example | Mediabunny">
<meta name="twitter:description" content="The live canvas state and your microphone input will be written into a fragmented MP4 file and live-streamed to a &lt;video&gt; element.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Live recording & streaming"}]}</script>
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
+17 -2
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Media player example | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./media-player.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/media-player/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Media player example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<meta property="og:url" content="https://mediabunny.dev/examples/media-player/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Media player example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Media player"}]}</script>
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2 h-svh">
+1 -1
View File
@@ -749,7 +749,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://remotion.media/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
+17 -2
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Metadata extraction example | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and Mediabunny will start extracting various metadata about that file.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./metadata-extraction.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/metadata-extraction/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Metadata extraction example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and Mediabunny will start extracting various metadata about that file.">
<meta property="og:url" content="https://mediabunny.dev/examples/metadata-extraction/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Metadata extraction example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and Mediabunny will start extracting various metadata about that file.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Metadata extraction"}]}</script>
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
@@ -238,7 +238,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://remotion.media/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
+17 -2
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Procedural Generation example | Mediabunny</title>
<meta name="description" content="Using Mediabunny, this page will procedurally generate a video of musical bouncing balls as fast as possible.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./procedural-generation.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/procedural-generation/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Procedural Generation example | Mediabunny">
<meta property="og:description" content="Using Mediabunny, this page will procedurally generate a video of musical bouncing balls as fast as possible.">
<meta property="og:url" content="https://mediabunny.dev/examples/procedural-generation/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Procedural Generation example | Mediabunny">
<meta name="twitter:description" content="Using Mediabunny, this page will procedurally generate a video of musical bouncing balls as fast as possible.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Procedural generation"}]}</script>
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
+17 -2
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thumbnail generation example | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and Mediabunny will extract video thumbnails for it.">
<script type="module" src="./../base.ts"></script>
<script type="module" src="./thumbnail-generation.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/thumbnail-generation/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Thumbnail generation example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and Mediabunny will extract video thumbnails for it.">
<meta property="og:url" content="https://mediabunny.dev/examples/thumbnail-generation/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Thumbnail generation example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and Mediabunny will extract video thumbnails for it.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Thumbnail generation"}]}</script>
</head>
<body class="flex flex-col items-center py-10 bg-gray-50 text-gray-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
@@ -139,7 +139,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://remotion.media/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.42.0",
"version": "1.44.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.42.0",
"version": "1.44.2",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7751,9 +7751,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.41.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.41.0.tgz",
"integrity": "sha512-cJWBHvAyRNgTbsx8Z2oHptX/PdiywwsS+GIvv2k9eioXSOzzAemBsHwf7jM6fvW3b65azvZGP9RLuO1DI8JmdA==",
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.44.0.tgz",
"integrity": "sha512-rs54ixLAe5MSa6q35MVsVXyXZ+aYkEZYhAw57bseil+mmxFuqAS8YrELaWBcsqAJLCTb8Tse4AoYEhtkwCHYlQ==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12077,7 +12077,7 @@
},
"packages/aac-encoder": {
"name": "@mediabunny/aac-encoder",
"version": "1.42.0",
"version": "1.44.1",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12092,7 +12092,7 @@
},
"packages/ac3": {
"name": "@mediabunny/ac3",
"version": "1.42.0",
"version": "1.44.1",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12107,7 +12107,7 @@
},
"packages/flac-encoder": {
"name": "@mediabunny/flac-encoder",
"version": "1.42.0",
"version": "1.44.1",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12122,7 +12122,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.42.0",
"version": "1.44.1",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.42.0",
"version": "1.44.2",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/aac-encoder",
"author": "Vanilagy",
"version": "1.42.0",
"version": "1.44.2",
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/ac3",
"author": "Vanilagy",
"version": "1.42.0",
"version": "1.44.2",
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-ac3.mjs",
"module": "./dist/bundles/mediabunny-ac3.mjs",
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/flac-encoder",
"author": "Vanilagy",
"version": "1.42.0",
"version": "1.44.2",
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
+9 -16
View File
@@ -12,7 +12,6 @@
#include <stdlib.h>
#include <string.h>
#define BITS_PER_SAMPLE 16
#define COMPRESSION_LEVEL 5
typedef struct {
@@ -23,14 +22,10 @@ typedef struct {
typedef struct {
FLAC__StreamEncoder *encoder;
// Input buffer for interleaved int16 samples from JS
int16_t *input_buffer;
// Input buffer for interleaved int32 samples from JS
FLAC__int32 *input_buffer;
int input_buffer_size;
// Widened to int32 for libFLAC
FLAC__int32 *int32_buffer;
int int32_buffer_size;
// Contiguous output buffer for encoded frame data
uint8_t *output_buffer;
int output_size;
@@ -48,6 +43,7 @@ typedef struct {
bool header_done;
int channels;
int bits_per_sample;
} EncoderContext;
static void ensure_output_capacity(EncoderContext *ctx, int needed) {
@@ -120,13 +116,14 @@ static void reset_output(EncoderContext *ctx) {
}
EMSCRIPTEN_KEEPALIVE
int init_encoder(int channels, int sample_rate) {
int init_encoder(int channels, int sample_rate, int bits_per_sample) {
EncoderContext *ctx = calloc(1, sizeof(EncoderContext));
if (!ctx) {
return 0;
}
ctx->channels = channels;
ctx->bits_per_sample = bits_per_sample;
ctx->encoder = FLAC__stream_encoder_new();
if (!ctx->encoder) {
@@ -136,7 +133,7 @@ int init_encoder(int channels, int sample_rate) {
FLAC__stream_encoder_set_channels(ctx->encoder, channels);
FLAC__stream_encoder_set_sample_rate(ctx->encoder, sample_rate);
FLAC__stream_encoder_set_bits_per_sample(ctx->encoder, BITS_PER_SAMPLE);
FLAC__stream_encoder_set_bits_per_sample(ctx->encoder, bits_per_sample);
FLAC__stream_encoder_set_compression_level(ctx->encoder, COMPRESSION_LEVEL);
FLAC__stream_encoder_set_verify(ctx->encoder, false);
@@ -174,19 +171,15 @@ EMSCRIPTEN_KEEPALIVE
int send_samples(int ctx_ptr, int num_samples) {
EncoderContext *ctx = (EncoderContext *)ctx_ptr;
// Widen int16 to int32 for libFLAC
int total = num_samples * ctx->channels;
if (total > ctx->int32_buffer_size) {
ctx->int32_buffer = realloc(ctx->int32_buffer, total * sizeof(FLAC__int32));
ctx->int32_buffer_size = total;
}
int shift = 32 - ctx->bits_per_sample;
for (int i = 0; i < total; i++) {
ctx->int32_buffer[i] = ctx->input_buffer[i];
ctx->input_buffer[i] >>= shift;
}
reset_output(ctx);
FLAC__bool ok = FLAC__stream_encoder_process_interleaved(ctx->encoder, ctx->int32_buffer, num_samples);
FLAC__bool ok = FLAC__stream_encoder_process_interleaved(ctx->encoder, ctx->input_buffer, num_samples);
return ok ? 0 : -1;
}
+5 -4
View File
@@ -16,7 +16,7 @@ type ExtendedEmscriptenModule = EmscriptenModule & {
let module: ExtendedEmscriptenModule;
let modulePromise: Promise<ExtendedEmscriptenModule> | null = null;
let initEncoderFn: (channels: number, sampleRate: number) => number;
let initEncoderFn: (channels: number, sampleRate: number, bitsPerSample: number) => number;
let getEncodeInputPtr: (ctx: number, size: number) => number;
let sendSamplesFn: (ctx: number, numSamples: number) => number;
let getOutputData: (ctx: number) => number;
@@ -37,7 +37,7 @@ const ensureModule = async () => {
module = await modulePromise;
modulePromise = null;
initEncoderFn = module.cwrap('init_encoder', 'number', ['number', 'number']);
initEncoderFn = module.cwrap('init_encoder', 'number', ['number', 'number', 'number']);
getEncodeInputPtr = module.cwrap('get_encode_input_ptr', 'number', ['number', 'number']);
sendSamplesFn = module.cwrap('send_samples', 'number', ['number', 'number']);
getOutputData = module.cwrap('get_output_data', 'number', ['number']);
@@ -50,10 +50,10 @@ const ensureModule = async () => {
}
};
const initEncoder = async (numberOfChannels: number, sampleRate: number) => {
const initEncoder = async (numberOfChannels: number, sampleRate: number, bitsPerSample: 16 | 24) => {
await ensureModule();
const ctx = initEncoderFn(numberOfChannels, sampleRate);
const ctx = initEncoderFn(numberOfChannels, sampleRate, bitsPerSample);
if (ctx === 0) {
throw new Error('Failed to initialize FLAC encoder.');
}
@@ -121,6 +121,7 @@ const onMessage = (data: { id: number; command: WorkerCommand }) => {
const { ctx, header } = await initEncoder(
command.data.numberOfChannels,
command.data.sampleRate,
command.data.bitsPerSample,
);
result = { type: command.type, ctx, header };
transferables.push(header);
+51 -19
View File
@@ -29,7 +29,7 @@ class FlacEncoder extends CustomAudioEncoder {
reject: (reason?: unknown) => void;
}>();
private ctx = 0;
private ctx: number | null = null;
private chunkMetadata: EncodedAudioChunkMetadata = {};
private description: Uint8Array | null = null;
private nextTimestampInSamples: number | null = null;
@@ -65,19 +65,6 @@ class FlacEncoder extends CustomAudioEncoder {
};
nodeWorker.on('message', onMessage);
}
const result = await this.sendCommand({
type: 'init',
data: {
numberOfChannels: this.config.numberOfChannels,
sampleRate: this.config.sampleRate,
},
});
this.ctx = result.ctx;
this.description = new Uint8Array(result.header);
this.resetInternalState();
}
private resetInternalState() {
@@ -94,15 +81,50 @@ class FlacEncoder extends CustomAudioEncoder {
}
async encode(audioSample: AudioSample) {
if (this.ctx === null) {
// This is the first sample, let's do some init
let bitsPerSample: 16 | 24;
switch (audioSample.format) {
case 'u8':
case 'u8-planar':
case 's16':
case 's16-planar':
bitsPerSample = 16;
break;
case 's32':
case 's32-planar':
case 'f32':
case 'f32-planar':
bitsPerSample = 24;
break;
default:
assertNever(audioSample.format);
assert(false);
}
const result = await this.sendCommand({
type: 'init',
data: {
numberOfChannels: this.config.numberOfChannels,
sampleRate: this.config.sampleRate,
bitsPerSample,
},
});
this.ctx = result.ctx;
this.description = new Uint8Array(result.header);
this.resetInternalState();
}
if (this.nextTimestampInSamples === null) {
this.nextTimestampInSamples = Math.round(audioSample.timestamp * this.config.sampleRate);
}
const totalBytes = audioSample.allocationSize({ format: 's16', planeIndex: 0 });
const audioBytes = new Uint8Array(totalBytes);
audioSample.copyTo(audioBytes, { format: 's16', planeIndex: 0 });
const totalBytes = audioSample.allocationSize({ format: 's32', planeIndex: 0 });
const audioData = new ArrayBuffer(totalBytes);
audioSample.copyTo(audioData, { format: 's32', planeIndex: 0 });
const audioData = audioBytes.buffer;
const result = await this.sendCommand({
type: 'encode',
data: {
@@ -116,6 +138,10 @@ class FlacEncoder extends CustomAudioEncoder {
}
async flush() {
if (this.ctx === null) {
return;
}
const result = await this.sendCommand({ type: 'flush', data: { ctx: this.ctx } });
this.emitPackets(result.packets);
@@ -173,7 +199,8 @@ class FlacEncoder extends CustomAudioEncoder {
/**
* Registers the FLAC encoder, which Mediabunny will then use automatically when applicable. Make sure to call this
* function before starting any encoding task.
* function before starting any encoding task. The FLAC encoder will automatically determine the output bit depth
* (16 or 24) based on the sample format of incoming `AudioSample` instances.
*
* Preferably, wrap the call in a condition to avoid overriding any native FLAC encoder:
*
@@ -198,3 +225,8 @@ function assert(x: unknown): asserts x {
throw new Error('Assertion failed.');
}
}
export const assertNever = (x: never) => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unexpected value: ${x}`);
};
+1
View File
@@ -16,6 +16,7 @@ export type WorkerCommand = {
data: {
numberOfChannels: number;
sampleRate: number;
bitsPerSample: 16 | 24;
};
} | {
type: 'encode';
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.42.0",
"version": "1.44.2",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+31 -4
View File
@@ -49,11 +49,13 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
// Extract special fields
const headingText = apiConfig['heading'] || 'API Reference';
const introText = apiConfig['intro'];
const indexDescription = apiConfig['description'];
// Create a copy without the special fields for group processing
const groupConfig = { ...apiConfig };
delete groupConfig['heading'];
delete groupConfig['intro'];
delete groupConfig['description'];
// Clear and recreate output directory (skip if dry run)
if (!dry) {
@@ -712,7 +714,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
? `${variableName}(\n${params.join(',\n')},\n): ${returnType};`
: `${variableName}(): ${returnType};`;
let markdown = `<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Function" />\n\n# ${variableName}\n\n\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`;
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Function" />\n\n# ${variableName}\n\n\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`;
// Find referenced types in all parameters and return type
const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]);
@@ -726,7 +728,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
}
} else {
// Handle regular variables
let markdown = `<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Constant" />\n\n# ${variableName}\n\n${description ? `${description}\n\n` : ''}`;
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Constant" />\n\n# ${variableName}\n\n${description ? `${description}\n\n` : ''}`;
const variableValue = declaration.initializer ? declaration.initializer.getText() : 'undefined';
const variableDefinition = `const ${variableName} = ${variableValue};`;
markdown += `\`\`\`ts\n${variableDefinition}\n\`\`\``;
@@ -1512,7 +1514,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
deprecatedProperties.sort(compareMemberNames);
deprecatedMethods.sort(compareMemberNames);
let markdown = '';
let markdown = buildFrontmatter(description);
// Add VPBadge import and badge for all types
markdown += `<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n`;
@@ -1794,7 +1796,7 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
throw new Error(`Groups found in code but not in API config: ${missingGroups.join(', ')}`);
}
let indexMarkdown = `# ${headingText}\n\n`;
let indexMarkdown = `${buildFrontmatter(indexDescription ?? '')}# ${headingText}\n\n`;
// Add intro text if provided
if (introText) {
@@ -1913,6 +1915,31 @@ const getFullJSDocDescription = (node: ts.Node): string => {
return extractJsDocDescription(node, { tagHandling: 'stopAtFirst' });
};
// Convert a markdown description (possibly multi-paragraph, with **bold**, `code`,
// and [text](link) from processed @link tags) into a single-line plain-text string
// suitable for the `description` field in YAML frontmatter.
const descriptionToFrontmatter = (description: string): string => {
return description
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/__([^_]+)__/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\s+/g, ' ')
.trim();
};
const buildFrontmatter = (description: string): string => {
if (!description) {
return '';
}
const cleaned = descriptionToFrontmatter(description);
if (!cleaned) {
return '';
}
const yamlValue = `"${cleaned.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
return `---\ndescription: ${yamlValue}\n---\n\n`;
};
const main = () => {
const args = process.argv.slice(2);
+1 -1
View File
@@ -60,7 +60,7 @@ export class AdtsMuxer extends Muxer {
const release = await this.mutex.acquire();
try {
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
// First packet - determine input format from metadata
if (this.inputIsAdts === null) {
+112
View File
@@ -866,6 +866,21 @@ export type HevcSpsInfo = {
minSpatialSegmentationIdc: number;
};
export const concatHevcNalUnits = (nalUnits: Uint8Array[], decoderConfig: VideoDecoderConfig) => {
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;
return concatNalUnitsInLengthPrefixed(nalUnits, lengthSize);
} else {
// Stream is in Annex B format
return concatNalUnitsInAnnexB(nalUnits);
}
};
export const iterateHevcNalUnits = (packetData: Uint8Array, decoderConfig: VideoDecoderConfig) => {
if (decoderConfig.description) {
const bytes = toUint8Array(decoderConfig.description);
@@ -1602,6 +1617,103 @@ export const deserializeHevcDecoderConfigurationRecord = (data: Uint8Array): Hev
}
};
enum HevcNaluOrderState {
audAllowed,
beforeFirstVcl,
afterFirstVcl,
eoBitstreamAllowed,
noMoreDataAllowed,
}
// This function sanitzes the contents of an HEVC packet such that
// https://source.chromium.org/chromium/chromium/src/+/main:media/formats/mp4/hevc.cc's validation logic does not trip
// up on its contents. The validation is often too strict and rejects packets that Chromium could decode just fine.
// Chromium code retrieved on 2026-04-29.
// See https://issues.chromium.org/issues/507611247.
export const sanitizeHevcPacketForChromium = (
packetData: Uint8Array,
decoderConfig: VideoDecoderConfig,
): Uint8Array | null => {
const removedNalUnits = new Set<number>();
let orderState: HevcNaluOrderState = HevcNaluOrderState.audAllowed;
for (const loc of iterateHevcNalUnits(packetData, decoderConfig)) {
if (orderState === HevcNaluOrderState.noMoreDataAllowed) {
removedNalUnits.add(loc.offset);
continue;
}
const type = extractNalUnitTypeForHevc(packetData[loc.offset]!);
if (orderState === HevcNaluOrderState.eoBitstreamAllowed && type !== 37 /* EOB_NUT */) {
removedNalUnits.add(loc.offset);
continue;
}
let remove = false;
if (type === 35) { // AUD_NUT
if (orderState > HevcNaluOrderState.audAllowed) {
remove = true;
} else {
orderState = HevcNaluOrderState.beforeFirstVcl;
}
} else if (type <= 31) { // VCL (0-31)
if (orderState > HevcNaluOrderState.afterFirstVcl) {
remove = true;
} else {
orderState = HevcNaluOrderState.afterFirstVcl;
}
} else if (type === 36) { // EOS_NUT
if (orderState !== HevcNaluOrderState.afterFirstVcl) {
remove = true;
} else {
orderState = HevcNaluOrderState.eoBitstreamAllowed;
}
} else if (type === 37) { // EOB_NUT
if (orderState < HevcNaluOrderState.afterFirstVcl) {
remove = true;
} else {
orderState = HevcNaluOrderState.noMoreDataAllowed;
}
} else if (
type === 32 || type === 33 || type === 34 || type === 39
|| (type >= 41 && type <= 44) || (type >= 48 && type <= 55)
) { // VPS, SPS, PPS, PREFIX_SEI, RSV_NVCL41..44, UNSPEC48..55
if (orderState > HevcNaluOrderState.beforeFirstVcl) {
remove = true;
} else {
orderState = HevcNaluOrderState.beforeFirstVcl;
}
} else if (
type === 38 || type === 40
|| (type >= 45 && type <= 47) || (type >= 56 && type <= 63)
) { // FD, SUFFIX_SEI, RSV_NVCL45..47, UNSPEC56..63
if (orderState < HevcNaluOrderState.afterFirstVcl) {
remove = true;
}
}
if (remove) {
removedNalUnits.add(loc.offset);
}
}
// If nothing violated the rules, return null to signal that
if (removedNalUnits.size === 0) {
return null;
}
const filteredNalUnits: Uint8Array[] = [];
for (const loc of iterateHevcNalUnits(packetData, decoderConfig)) {
if (!removedNalUnits.has(loc.offset)) {
filteredNalUnits.push(packetData.subarray(loc.offset, loc.offset + loc.length));
}
}
return concatHevcNalUnits(filteredNalUnits, decoderConfig);
};
export type Vp9CodecInfo = {
profile: number;
level: number;
+31
View File
@@ -784,6 +784,37 @@ export const validateVideoChunkMetadata = (metadata: EncodedVideoChunkMetadata |
'Video chunk metadata decoder configuration must specify a valid codedHeight (positive integer).',
);
}
if (
metadata.decoderConfig.displayAspectWidth !== undefined
&& (
!Number.isInteger(metadata.decoderConfig.displayAspectWidth)
|| metadata.decoderConfig.displayAspectWidth <= 0
)
) {
throw new TypeError(
'Video chunk metadata decoder configuration displayAspectWidth, when defined, must be a positive integer.',
);
}
if (
metadata.decoderConfig.displayAspectHeight !== undefined
&& (
!Number.isInteger(metadata.decoderConfig.displayAspectHeight)
|| metadata.decoderConfig.displayAspectHeight <= 0
)
) {
throw new TypeError(
'Video chunk metadata decoder configuration displayAspectHeight, when defined, must be a positive integer.',
);
}
if (
(metadata.decoderConfig.displayAspectWidth !== undefined)
!== (metadata.decoderConfig.displayAspectHeight !== undefined)
) {
throw new TypeError(
'Video chunk metadata decoder configuration must specify both displayAspectWidth and displayAspectHeight,'
+ ' or neither.',
);
}
if (metadata.decoderConfig.description !== undefined) {
if (!isAllowSharedBufferSource(metadata.decoderConfig.description)) {
throw new TypeError(
+46 -11
View File
@@ -50,7 +50,15 @@ import {
} from './misc';
import { Output, OutputTrackGroup, TrackType } from './output';
import { Mp4OutputFormat } from './output-format';
import { AudioSample, clampCropRectangle, CropRectangle, validateCropRectangle, VideoSample } from './sample';
import {
AudioSample,
audioSampleToInterleavedFormat,
clampCropRectangle,
CropRectangle,
toInterleavedAudioFormat,
validateCropRectangle,
VideoSample,
} from './sample';
import { MetadataTags, validateMetadataTags } from './metadata';
import { NullTarget } from './target';
import { AudioResampler } from './resample';
@@ -261,6 +269,13 @@ export type ConversionAudioOptions = {
numberOfChannels?: number;
/** The desired sample rate of the output audio, in hertz. */
sampleRate?: number;
/**
* The desired sample format (and therefore bit depth) of the audio samples before they are passed to the encoder.
* Can be used to control bit depth with certain output codecs such as FLAC.
*
* Setting this field forces audio transcoding.
*/
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
/** The desired output audio codec. */
codec?: AudioCodec;
/** The desired bitrate of the output audio. */
@@ -442,6 +457,12 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions) => {
) {
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
}
if (
audioOptions?.sampleFormat !== undefined
&& !['u8', 's16', 's32', 'f32'].includes(audioOptions.sampleFormat)
) {
throw new TypeError('options.audio.sampleFormat, when provided, must be one of: u8, s16, s32, f32.');
}
if (audioOptions?.process !== undefined && typeof audioOptions.process !== 'function') {
throw new TypeError('options.audio.process, when provided, must be a function.');
}
@@ -1347,7 +1368,7 @@ export class Conversion {
timestamp: lastCanvasTimestamp! + i / frameRate,
duration: 1 / frameRate,
});
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
sample.close();
}
};
@@ -1384,7 +1405,7 @@ export class Conversion {
timestamp: adjustedSampleTimestamp,
duration: frameRate !== undefined ? 1 / frameRate : duration,
});
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
sample.close();
if (frameRate !== undefined) {
@@ -1425,7 +1446,7 @@ export class Conversion {
for (let i = 1; i < frameDifference; i++) {
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
lastSample.setDuration(1 / frameRate);
await this._registerVideoSample(track, trackOptions, outputTrackId, source, lastSample);
await this._registerVideoSample(trackOptions, outputTrackId, source, lastSample);
}
lastSample.close();
@@ -1464,7 +1485,7 @@ export class Conversion {
}
sample.setTimestamp(adjustedSampleTimestamp);
await this._registerVideoSample(track, trackOptions, outputTrackId, source, sample);
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
if (frameRate !== undefined) {
lastSample = sample;
@@ -1513,7 +1534,6 @@ export class Conversion {
/** @internal */
async _registerVideoSample(
track: InputVideoTrack,
trackOptions: ConversionVideoOptions,
outputTrackId: number,
source: VideoSampleSource,
@@ -1609,6 +1629,7 @@ export class Conversion {
&& audioCodecs.includes(sourceCodec)
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
&& !trackOptions.process
&& trackOptions.sampleFormat === undefined
) {
// Fast path, we can simply copy over the encoded packets
@@ -1745,7 +1766,7 @@ export class Conversion {
// Offset the timestamp as needed
sample.setTimestamp(sample.timestamp - this._startTimestamp);
await this._registerAudioSample(track, trackOptions, outputTrackId, source, sample);
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
sample.close();
}
@@ -1778,16 +1799,25 @@ export class Conversion {
/** @internal */
async _registerAudioSample(
track: InputAudioTrack,
trackOptions: ConversionAudioOptions,
outputTrackId: number,
source: AudioSampleSource,
sample: AudioSample,
inputSample: AudioSample,
) {
if (this._canceled) {
return;
}
let sample = inputSample;
if (
trackOptions.sampleFormat !== undefined
&& toInterleavedAudioFormat(sample.format) !== trackOptions.sampleFormat
) {
// Do a sample format conversion
sample = audioSampleToInterleavedFormat(sample, trackOptions.sampleFormat);
}
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
let finalSamples: AudioSample[];
@@ -1823,8 +1853,12 @@ export class Conversion {
}
}
} finally {
if (sample !== inputSample) {
sample.close();
}
for (const finalSample of finalSamples) {
if (finalSample !== sample) {
if (finalSample !== inputSample) {
finalSample.close();
}
}
@@ -1855,9 +1889,10 @@ export class Conversion {
startTime: this._startTimestamp,
endTime: this._endTimestamp,
onSample: async (sample) => {
assert(sample.timestamp >= this._startTimestamp);
sample.setTimestamp(sample.timestamp - this._startTimestamp);
await this._registerAudioSample(track, trackOptions, outputTrackId, source, sample);
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
sample.close();
},
});
+12 -1
View File
@@ -386,6 +386,11 @@ export type AudioTransformOptions = {
numberOfChannels?: number;
/** The desired output sample rate in hertz to resample to. */
sampleRate?: number;
/**
* The desired sample format (and therefore bit depth) of the audio samples before they are passed to the encoder.
* Can be used to control bit depth with certain output codecs such as FLAC.
*/
sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
/**
* Allows for custom user-defined processing of audio samples, e.g. for applying audio effects or timestamp
* modifications. Called for each audio sample after resampling and remixing.
@@ -406,7 +411,7 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
}
if (
config.bitrate === undefined
&& (!(PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
&& !((PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
) {
throw new TypeError('config.bitrate must be provided for compressed audio codecs.');
}
@@ -433,6 +438,12 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
) {
throw new TypeError('config.transform.sampleRate, when provided, must be a positive integer.');
}
if (
config.transform.sampleFormat !== undefined
&& !['u8', 's16', 's32', 'f32'].includes(config.transform.sampleFormat)
) {
throw new TypeError('config.transform.sampleFormat, when provided, must be one of: u8, s16, s32, f32.');
}
if (config.transform.process !== undefined && typeof config.transform.process !== 'function') {
throw new TypeError('config.transform.process, when provided, must be a function.');
}
+1 -1
View File
@@ -214,7 +214,7 @@ export class FlacMuxer extends Muxer {
const release = await this.mutex.acquire();
try {
this.validateAndNormalizeTimestamp(
this.validateTimestamp(
track,
packet.timestamp,
packet.type === 'key',
+8 -12
View File
@@ -614,17 +614,15 @@ export class HlsMuxer extends Muxer {
const release = await playlist.mutex.acquire();
try {
const timestamp = this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
const adjustedPacket = packet.clone({ timestamp });
trackData.packets.push(adjustedPacket);
this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
trackData.packets.push(packet);
if (playlist.currentSegmentStartTimestamp === null) {
playlist.currentSegmentStartTimestamp = adjustedPacket.timestamp;
playlist.currentSegmentStartTimestamp = packet.timestamp;
} else if (!playlist.currentSegmentStartTimestampIsFixed) {
playlist.currentSegmentStartTimestamp = Math.min(
playlist.currentSegmentStartTimestamp,
adjustedPacket.timestamp,
packet.timestamp,
);
}
@@ -645,17 +643,15 @@ export class HlsMuxer extends Muxer {
const release = await playlist.mutex.acquire();
try {
const timestamp = this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
const adjustedPacket = packet.clone({ timestamp });
trackData.packets.push(adjustedPacket);
this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
trackData.packets.push(packet);
if (playlist.currentSegmentStartTimestamp === null) {
playlist.currentSegmentStartTimestamp = adjustedPacket.timestamp;
playlist.currentSegmentStartTimestamp = packet.timestamp;
} else if (!playlist.currentSegmentStartTimestampIsFixed) {
playlist.currentSegmentStartTimestamp = Math.min(
playlist.currentSegmentStartTimestamp,
adjustedPacket.timestamp,
packet.timestamp,
);
}
+82 -82
View File
@@ -22,48 +22,48 @@ if ((globalThis as Record<symbol, unknown>)[MEDIABUNNY_LOADED_SYMBOL]) {
export {
Output,
OutputOptions,
type OutputOptions,
OutputTrack,
OutputVideoTrack,
OutputAudioTrack,
OutputSubtitleTrack,
OutputTrackGroup,
BaseTrackMetadata,
VideoTrackMetadata,
AudioTrackMetadata,
SubtitleTrackMetadata,
OutputEvents,
type BaseTrackMetadata,
type VideoTrackMetadata,
type AudioTrackMetadata,
type SubtitleTrackMetadata,
type OutputEvents,
} from './output';
export {
OutputFormat,
AdtsOutputFormat,
AdtsOutputFormatOptions,
type AdtsOutputFormatOptions,
CmafOutputFormat,
CmafOutputFormatOptions,
type CmafOutputFormatOptions,
FlacOutputFormat,
FlacOutputFormatOptions,
type FlacOutputFormatOptions,
HlsOutputFormat,
HlsOutputFormatOptions,
HlsOutputPlaylistInfo,
HlsOutputSegmentInfo,
type HlsOutputFormatOptions,
type HlsOutputPlaylistInfo,
type HlsOutputSegmentInfo,
IsobmffOutputFormat,
IsobmffOutputFormatOptions,
type IsobmffOutputFormatOptions,
MkvOutputFormat,
MkvOutputFormatOptions,
type MkvOutputFormatOptions,
MovOutputFormat,
Mp3OutputFormat,
Mp3OutputFormatOptions,
type Mp3OutputFormatOptions,
Mp4OutputFormat,
MpegTsOutputFormat,
MpegTsOutputFormatOptions,
type MpegTsOutputFormatOptions,
OggOutputFormat,
OggOutputFormatOptions,
type OggOutputFormatOptions,
WavOutputFormat,
WavOutputFormatOptions,
type WavOutputFormatOptions,
WebMOutputFormat,
WebMOutputFormatOptions,
InclusiveIntegerRange,
TrackCountLimits,
type WebMOutputFormatOptions,
type InclusiveIntegerRange,
type TrackCountLimits,
} from './output-format';
export {
MediaSource,
@@ -76,17 +76,17 @@ export {
EncodedAudioPacketSource,
EncodedVideoPacketSource,
MediaStreamAudioTrackSource,
MediaStreamAudioTrackSourceOptions,
type MediaStreamAudioTrackSourceOptions,
MediaStreamVideoTrackSource,
MediaStreamVideoTrackSourceOptions,
type MediaStreamVideoTrackSourceOptions,
TextSubtitleSource,
VideoSampleSource,
} from './media-source';
export {
MediaCodec,
VideoCodec,
AudioCodec,
SubtitleCodec,
type MediaCodec,
type VideoCodec,
type AudioCodec,
type SubtitleCodec,
VIDEO_CODECS,
AUDIO_CODECS,
PCM_AUDIO_CODECS,
@@ -102,12 +102,12 @@ export {
getDecodableAudioCodecs,
} from './decode';
export {
VideoEncodingConfig,
VideoEncodingAdditionalOptions,
VideoTransformOptions,
AudioEncodingConfig,
AudioEncodingAdditionalOptions,
AudioTransformOptions,
type VideoEncodingConfig,
type VideoEncodingAdditionalOptions,
type VideoTransformOptions,
type AudioEncodingConfig,
type AudioEncodingAdditionalOptions,
type AudioTransformOptions,
canEncode,
canEncodeVideo,
canEncodeAudio,
@@ -128,69 +128,69 @@ export {
} from './encode';
export {
Target,
TargetEvents,
TargetRequest,
type TargetEvents,
type TargetRequest,
AppendOnlyStreamTarget,
BufferTarget,
BufferTargetOptions,
type BufferTargetOptions,
FilePathTarget,
FilePathTargetOptions,
type FilePathTargetOptions,
NullTarget,
PathedTarget,
RangedTarget,
StreamTarget,
StreamTargetOptions,
StreamTargetChunk,
type StreamTargetOptions,
type StreamTargetChunk,
} from './target';
export {
AnyIterable,
type AnyIterable,
ConcurrentRunner,
EventEmitter,
EventListenerOptions,
FilePath,
MaybePromise,
type EventListenerOptions,
type FilePath,
type MaybePromise,
} from './misc';
export {
PsshBox,
type PsshBox,
} from './isobmff/isobmff-misc';
export {
Rational,
Rectangle,
Rotation,
SetOptional,
SetRequired,
type Rational,
type Rectangle,
type Rotation,
type SetOptional,
type SetRequired,
} from './misc';
export {
TrackType,
type TrackType,
ALL_TRACK_TYPES,
} from './output';
export {
Source,
SourceEvents,
type SourceEvents,
SourceRef,
SourceRequest,
type SourceRequest,
BlobSource,
BlobSourceOptions,
type BlobSourceOptions,
BufferSource,
CustomPathedSource,
FilePathSource,
FilePathSourceOptions,
type FilePathSourceOptions,
PathedSource,
StreamSource,
StreamSourceOptions,
type StreamSourceOptions,
RangedSource,
ReadableStreamSource,
ReadableStreamSourceOptions,
type ReadableStreamSourceOptions,
UrlSource,
UrlSourceOptions,
type UrlSourceOptions,
} from './source';
export {
InputFormat,
InputFormatOptions,
type InputFormatOptions,
AdtsInputFormat,
FlacInputFormat,
IsobmffInputFormat,
IsobmffInputFormatOptions,
type IsobmffInputFormatOptions,
HlsInputFormat,
MatroskaInputFormat,
Mp3InputFormat,
@@ -216,38 +216,38 @@ export {
} from './input-format';
export {
Input,
InputOptions,
InputEvents,
type InputOptions,
type InputEvents,
InputDisposedError,
UnsupportedInputFormatError,
} from './input';
export {
DurationMetadataRequestOptions,
type DurationMetadataRequestOptions,
} from './demuxer';
export {
InputTrack,
InputVideoTrack,
InputAudioTrack,
InputTrackQuery,
PacketStats,
type InputTrackQuery,
type PacketStats,
asc,
desc,
prefer,
} from './input-track';
export {
EncodedPacket,
EncodedPacketSideData,
PacketType,
type EncodedPacketSideData,
type PacketType,
} from './packet';
export {
AudioSample,
AudioSampleInit,
AudioSampleCopyToOptions,
type AudioSampleInit,
type AudioSampleCopyToOptions,
VideoSample,
VideoSampleInit,
VideoSamplePixelFormat,
type VideoSampleInit,
type VideoSamplePixelFormat,
VideoSampleColorSpace,
CropRectangle,
type CropRectangle,
VIDEO_SAMPLE_PIXEL_FORMATS,
} from './sample';
export {
@@ -255,20 +255,20 @@ export {
AudioSampleSink,
BaseMediaSampleSink,
CanvasSink,
CanvasSinkOptions,
type CanvasSinkOptions,
EncodedPacketSink,
PacketRetrievalOptions,
type PacketRetrievalOptions,
VideoSampleSink,
WrappedAudioBuffer,
WrappedCanvas,
type WrappedAudioBuffer,
type WrappedCanvas,
} from './media-sink';
export {
Conversion,
ConversionOptions,
ConversionVideoOptions,
ConversionAudioOptions,
type ConversionOptions,
type ConversionVideoOptions,
type ConversionAudioOptions,
ConversionCanceledError,
DiscardedTrack,
type DiscardedTrack,
} from './conversion';
export {
CustomVideoDecoder,
@@ -279,11 +279,11 @@ export {
registerEncoder,
} from './custom-coder';
export {
MetadataTags,
AttachedImage,
type MetadataTags,
type AttachedImage,
RichImageData,
AttachedFile,
TrackDisposition,
type TrackDisposition,
} from './metadata';
// 🐡🦔
+46 -8
View File
@@ -174,6 +174,12 @@ const u64 = (value: number) => {
return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]] as number[];
};
const i64 = (value: number) => {
view.setInt32(0, Math.floor(value / 2 ** 32), false);
view.setUint32(4, value, false);
return [bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]] as number[];
};
const fixed_8_8 = (value: number) => {
view.setInt16(0, 2 ** 8 * value, false);
return [bytes[0], bytes[1]] as number[];
@@ -384,11 +390,14 @@ export const mvhd = (
creationTime: number,
trackDatas: IsobmffTrackData[],
) => {
const duration = intoTimescale(Math.max(
const duration = Math.max(
0,
...trackDatas
.map(x => presentationSpan(x)),
), GLOBAL_TIMESCALE);
.map(trackData => (
intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE)
+ intoTimescale(trackData.startTimestampOffset ?? 0, GLOBAL_TIMESCALE)
)),
);
const nextTrackId = Math.max(0, ...trackDatas.map(x => x.track.id)) + 1;
// Conditionally use u64 if u32 isn't enough
@@ -417,7 +426,9 @@ const presentationSpan = (trackData: IsobmffTrackData) => {
let minTimestamp = Infinity;
let maxEndTimestamp = -Infinity;
for (const sample of trackData.samples) {
for (let i = 0; i < trackData.samples.length; i++) {
const sample = trackData.samples[i]!;
if (sample.timestamp < minTimestamp) {
minTimestamp = sample.timestamp;
}
@@ -440,9 +451,11 @@ const presentationSpan = (trackData: IsobmffTrackData) => {
*/
export const trak = (trackData: IsobmffTrackData, creationTime: number) => {
const trackMetadata = getTrackMetadata(trackData);
const needsEditList = trackData.startTimestampOffset !== null && trackData.startTimestampOffset > 0;
return box('trak', undefined, [
tkhd(trackData, creationTime),
needsEditList ? edts(trackData, trackData.startTimestampOffset!) : null,
mdia(trackData, creationTime),
trackMetadata.name !== undefined
? box('udta', undefined, [
@@ -459,10 +472,8 @@ export const tkhd = (
trackData: IsobmffTrackData,
creationTime: number,
) => {
const durationInGlobalTimescale = intoTimescale(
presentationSpan(trackData),
GLOBAL_TIMESCALE,
);
const durationInGlobalTimescale = intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE)
+ intoTimescale(trackData.startTimestampOffset ?? 0, GLOBAL_TIMESCALE);
const needsU64 = !isU32(creationTime) || !isU32(durationInGlobalTimescale);
const u32OrU64 = needsU64 ? u64 : u32;
@@ -497,6 +508,32 @@ export const tkhd = (
]);
};
/** Edit Box: Specifies edits to the track's media. */
export const edts = (trackData: IsobmffTrackData, offset: number) => {
const startOffset = intoTimescale(offset, GLOBAL_TIMESCALE);
const mediaDuration = intoTimescale(presentationSpan(trackData), GLOBAL_TIMESCALE);
const needs64Bits = !isU32(startOffset) || !isU32(mediaDuration);
const u32OrU64 = needs64Bits ? u64 : u32;
const i32OrI64 = needs64Bits ? i64 : i32;
return box('edts', undefined, [
fullBox('elst', needs64Bits ? 1 : 0, 0, [
u32(2), // Entry count
// #1
u32OrU64(startOffset), // Segment duration
i32OrI64(-1), // Media time
fixed_16_16(1), // Media rate
// #2
u32OrU64(mediaDuration), // Segment duration
i32OrI64(0), // Media time
fixed_16_16(1), // Media rate
]),
]);
};
/** Media Box: Describes and define a track's media type and sample data. */
export const mdia = (trackData: IsobmffTrackData, creationTime: number) => box('mdia', undefined, [
mdhd(trackData, creationTime),
@@ -509,6 +546,7 @@ export const mdhd = (
trackData: IsobmffTrackData,
creationTime: number,
) => {
// Since the duration represents the raw media duration, edit list offsets are not taken into account here
const localDuration = intoTimescale(
presentationSpan(trackData),
trackData.timescale,
+7 -4
View File
@@ -1475,10 +1475,13 @@ export class IsobmffDemuxer extends Demuxer {
const num = readU32Be(slice);
const den = readU32Be(slice);
if (num > den) {
track.info.squarePixelWidth = Math.round(track.info.width * num / den);
} else {
track.info.squarePixelHeight = Math.round(track.info.height * den / num);
// https://github.com/Vanilagy/mediabunny/issues/362
if (num > 0 && den > 0) {
if (num > den) {
track.info.squarePixelWidth = Math.round(track.info.width * num / den);
} else {
track.info.squarePixelHeight = Math.round(track.info.height * den / num);
}
}
}; break;
+76 -31
View File
@@ -52,7 +52,7 @@ import {
import { buildIsobmffMimeType } from './isobmff-misc';
import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE } from './isobmff-reader';
export const GLOBAL_TIMESCALE = 1000;
export const GLOBAL_TIMESCALE = 57600; // LCM of a bunch of common frame rates (24, 25, 30, 60, 144, ...)
const TIMESTAMP_OFFSET = 2_082_844_800; // Seconds between Jan 1 1904 and Jan 1 1970
export type Sample = {
@@ -85,6 +85,7 @@ export type IsobmffTrackData = {
compositionTimeOffsetTable: { sampleCount: number; sampleCompositionTimeOffset: number }[];
lastTimescaleUnits: number | null;
lastSample: Sample | null;
startTimestampOffset: number | null;
finalizedChunks: Chunk[];
currentChunk: Chunk | null;
@@ -120,6 +121,7 @@ export type IsobmffTrackData = {
* Some players expect this for PCM audio.
*/
requiresPcmTransformation: boolean;
expectedNextPcmPacketTimestamp: number | null;
/**
* The "ADTS stripping" involves removing the ADTS header from each AAC packet. SOBMFF stores raw AAC data, not
* ADTS-wrapped data.
@@ -395,7 +397,10 @@ export class IsobmffMuxer extends Muxer {
// The frame rate set by the user may not be an integer. Since timescale is an integer, we'll approximate the
// frame time (inverse of frame rate) with a rational number, then use that approximation's denominator
// as the timescale.
const timescale = computeRationalApproximation(1 / (track.metadata.frameRate ?? 57600), 1e6).denominator;
const timescale = computeRationalApproximation(
1 / (track.metadata.frameRate ?? GLOBAL_TIMESCALE),
1e6,
).denominator;
const displayAspectWidth = decoderConfig.displayAspectWidth;
const displayAspectHeight = decoderConfig.displayAspectHeight;
@@ -425,6 +430,7 @@ export class IsobmffMuxer extends Muxer {
compositionTimeOffsetTable: [],
lastTimescaleUnits: null,
lastSample: null,
startTimestampOffset: null,
finalizedChunks: [],
currentChunk: null,
compactlyCodedChunkTable: [],
@@ -494,6 +500,7 @@ export class IsobmffMuxer extends Muxer {
requiresPcmTransformation:
!this.isFragmented
&& (PCM_AUDIO_CODECS as readonly string[]).includes(track.source._codec),
expectedNextPcmPacketTimestamp: null,
requiresAdtsStripping,
firstPacket: packet,
},
@@ -505,6 +512,7 @@ export class IsobmffMuxer extends Muxer {
compositionTimeOffsetTable: [],
lastTimescaleUnits: null,
lastSample: null,
startTimestampOffset: null,
finalizedChunks: [],
currentChunk: null,
compactlyCodedChunkTable: [],
@@ -547,6 +555,7 @@ export class IsobmffMuxer extends Muxer {
compositionTimeOffsetTable: [],
lastTimescaleUnits: null,
lastSample: null,
startTimestampOffset: null,
finalizedChunks: [],
currentChunk: null,
compactlyCodedChunkTable: [],
@@ -591,7 +600,7 @@ export class IsobmffMuxer extends Muxer {
packetData = concatNalUnitsInLengthPrefixed(nalUnits, 4);
}
const timestamp = this.validateAndNormalizeTimestamp(
this.validateTimestamp(
trackData.track,
packet.timestamp,
packet.type === 'key',
@@ -599,7 +608,7 @@ export class IsobmffMuxer extends Muxer {
const internalSample = this.createSampleForTrack(
trackData,
packetData,
timestamp,
packet.timestamp,
packet.duration,
packet.type,
);
@@ -629,41 +638,61 @@ export class IsobmffMuxer extends Muxer {
packetData = packetData.subarray(headerLength);
}
const timestamp = this.validateAndNormalizeTimestamp(
this.validateTimestamp(
trackData.track,
packet.timestamp,
packet.type === 'key',
);
let timestamp = packet.timestamp;
let duration = packet.duration;
if (trackData.info.requiresPcmTransformation) {
// Packets may have only approximate timestamp/duration information, but for our PCM logic, we need it
// to be precise. So here, we refine the values.
const pcmInfo = parsePcmCodec(
trackData.info.decoderConfig.codec as PcmAudioCodec,
);
const frameSize = pcmInfo.sampleSize * trackData.info.numberOfChannels;
// Compute the precise duration
duration = packetData.byteLength / frameSize / trackData.info.sampleRate;
if (trackData.info.expectedNextPcmPacketTimestamp !== null) {
const diff = timestamp - trackData.info.expectedNextPcmPacketTimestamp;
if (diff < 0.01) {
timestamp = trackData.info.expectedNextPcmPacketTimestamp;
} else {
const paddedDuration = await this.padWithSilence(
trackData,
trackData.info.expectedNextPcmPacketTimestamp,
diff,
);
timestamp = trackData.info.expectedNextPcmPacketTimestamp + paddedDuration;
}
}
trackData.info.expectedNextPcmPacketTimestamp = timestamp + duration;
}
const internalSample = this.createSampleForTrack(
trackData,
packetData,
timestamp,
packet.duration,
duration,
packet.type,
);
if (trackData.info.requiresPcmTransformation) {
await this.maybePadWithSilence(trackData, timestamp);
}
await this.registerSample(trackData, internalSample);
} finally {
release();
}
}
private async maybePadWithSilence(trackData: IsobmffAudioTrackData, untilTimestamp: number) {
// The PCM transformation assumes that all samples are contiguous. This is not something that is enforced, so
// we need to pad the "holes" in between samples (and before the first sample) with additional
// "silence samples".
const lastSample = last(trackData.samples);
const lastEndTimestamp = lastSample
? lastSample.timestamp + lastSample.duration
: 0;
const delta = untilTimestamp - lastEndTimestamp;
const deltaInTimescale = intoTimescale(delta, trackData.timescale);
private async padWithSilence(trackData: IsobmffAudioTrackData, timestamp: number, duration: number) {
const deltaInTimescale = intoTimescale(duration, trackData.timescale);
duration = deltaInTimescale / trackData.timescale;
if (deltaInTimescale > 0) {
const { sampleSize, silentValue } = parsePcmCodec(
@@ -675,12 +704,14 @@ export class IsobmffMuxer extends Muxer {
const paddingSample = this.createSampleForTrack(
trackData,
new Uint8Array(data.buffer),
lastEndTimestamp,
delta,
timestamp,
duration,
'key',
);
await this.registerSample(trackData, paddingSample);
}
return duration;
}
async addSubtitleCue(track: OutputSubtitleTrack, cue: SubtitleCue, meta?: SubtitleMetadata) {
@@ -689,7 +720,7 @@ export class IsobmffMuxer extends Muxer {
try {
const trackData = this.getSubtitleTrackData(track, meta);
this.validateAndNormalizeTimestamp(trackData.track, cue.timestamp, true);
this.validateTimestamp(trackData.track, cue.timestamp, true);
if (track.source._codec === 'webvtt') {
trackData.cueQueue.push(cue);
@@ -821,6 +852,11 @@ export class IsobmffMuxer extends Muxer {
}
if (trackData.type === 'audio' && trackData.info.requiresPcmTransformation) {
if (!this.isFragmented) {
// The first timestamp is the lowest
trackData.startTimestampOffset ??= trackData.timestampProcessingQueue[0]!.timestamp;
}
let totalDuration = 0;
// Compute the total duration in the track timescale (which is equal to the amount of PCM audio samples)
@@ -848,6 +884,10 @@ export class IsobmffMuxer extends Muxer {
const sortedTimestamps = trackData.timestampProcessingQueue.map(x => x.timestamp).sort((a, b) => a - b);
if (!this.isFragmented) {
trackData.startTimestampOffset ??= sortedTimestamps[0]!;
}
for (let i = 0; i < trackData.timestampProcessingQueue.length; i++) {
const sample = trackData.timestampProcessingQueue[i]!;
@@ -857,12 +897,6 @@ export class IsobmffMuxer extends Muxer {
// model it.
sample.decodeTimestamp = sortedTimestamps[i]!;
if (!this.isFragmented && trackData.lastTimescaleUnits === null) {
// In non-fragmented files, the first decode timestamp is always zero. If the first presentation
// timestamp isn't zero, we'll simply use the composition time offset to achieve it.
sample.decodeTimestamp = 0;
}
const sampleCompositionTimeOffset
= intoTimescale(sample.timestamp - sample.decodeTimestamp, trackData.timescale);
const durationInTimescale = intoTimescale(sample.duration, trackData.timescale);
@@ -1377,6 +1411,17 @@ export class IsobmffMuxer extends Muxer {
} else {
for (const trackData of this.trackDatas) {
await this.finalizeCurrentChunk(trackData);
// Must hold because we will have processed at least one sample
assert(trackData.startTimestampOffset !== null);
// Shift all of the samples by the start offset. We'll then write out an edit list that will shift them
// back to their proper spot in the composition.
for (let i = 0; i < trackData.samples.length; i++) {
const sample = trackData.samples[i]!;
sample.timestamp -= trackData.startTimestampOffset;
sample.decodeTimestamp -= trackData.startTimestampOffset;
}
}
}
+10 -8
View File
@@ -1051,14 +1051,16 @@ export class MatroskaDemuxer extends Demuxer {
const num = this.currentTrack.info.displayWidth * this.currentTrack.info.height;
const den = this.currentTrack.info.displayHeight * this.currentTrack.info.width;
if (num > den) {
this.currentTrack.info.squarePixelWidth = Math.round(
this.currentTrack.info.width * num / den,
);
} else {
this.currentTrack.info.squarePixelHeight = Math.round(
this.currentTrack.info.height * den / num,
);
if (num > 0 && den > 0) {
if (num > den) {
this.currentTrack.info.squarePixelWidth = Math.round(
this.currentTrack.info.width * num / den,
);
} else {
this.currentTrack.info.squarePixelHeight = Math.round(
this.currentTrack.info.height * den / num,
);
}
}
}
+8 -6
View File
@@ -907,7 +907,9 @@ export class MatroskaMuxer extends Muxer {
const trackData = this.getVideoTrackData(track, packet, meta);
const isKeyFrame = packet.type === 'key';
let timestamp = this.validateAndNormalizeTimestamp(trackData.track, packet.timestamp, isKeyFrame);
this.validateTimestamp(trackData.track, packet.timestamp, isKeyFrame);
let timestamp = packet.timestamp;
let duration = packet.duration;
if (track.metadata.frameRate !== undefined) {
@@ -950,9 +952,9 @@ export class MatroskaMuxer extends Muxer {
}
const isKeyFrame = packet.type === 'key';
const timestamp = this.validateAndNormalizeTimestamp(trackData.track, packet.timestamp, isKeyFrame);
const audioChunk = this.createInternalChunk(packetData, timestamp, packet.duration, packet.type);
this.validateTimestamp(trackData.track, packet.timestamp, isKeyFrame);
const audioChunk = this.createInternalChunk(packetData, packet.timestamp, packet.duration, packet.type);
trackData.chunkQueue.push(audioChunk);
await this.interleaveChunks();
} finally {
@@ -966,10 +968,10 @@ export class MatroskaMuxer extends Muxer {
try {
const trackData = this.getSubtitleTrackData(track, meta);
const timestamp = this.validateAndNormalizeTimestamp(trackData.track, cue.timestamp, true);
this.validateTimestamp(trackData.track, cue.timestamp, true);
let bodyText = cue.text;
const timestampMs = Math.round(timestamp * 1000);
const timestampMs = Math.round(cue.timestamp * 1000);
// Replace in-body timestamps so that they're relative to the cue start time
inlineTimestampRegex.lastIndex = 0;
@@ -985,7 +987,7 @@ export class MatroskaMuxer extends Muxer {
const subtitleChunk = this.createInternalChunk(
body,
timestamp,
cue.timestamp,
cue.duration,
'key',
additions.trim() ? textEncoder.encode(additions) : null,
+20 -11
View File
@@ -17,6 +17,7 @@ import {
iterateAvcNalUnits,
iterateHevcNalUnits,
parseAvcSps,
sanitizeHevcPacketForChromium,
} from './codec-data';
import { CustomVideoDecoder, customVideoDecoders, CustomAudioDecoder, customAudioDecoders } from './custom-coder';
import { InputDisposedError } from './input';
@@ -980,20 +981,28 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
insertSorted(this.inputTimestamps, packet.timestamp, x => x);
}
// Workaround for https://issues.chromium.org/issues/470109459
if (isChromium() && this.currentPacketIndex === 0 && this.codec === 'avc') {
const filteredNalUnits: Uint8Array[] = [];
if (isChromium() && this.currentPacketIndex === 0) {
if (this.codec === 'avc') {
// Workaround for https://issues.chromium.org/issues/470109459
const filteredNalUnits: Uint8Array[] = [];
for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!);
// These trip up Chromium's key frame detection, so let's strip them
if (!(type >= 20 && type <= 31)) {
filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));
for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!);
// These trip up Chromium's key frame detection, so let's strip them
if (!(type >= 20 && type <= 31)) {
filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));
}
}
const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);
packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);
} else if (this.codec === 'hevc') {
// Workaround for https://issues.chromium.org/issues/507611247
const sanitizedData = sanitizeHevcPacketForChromium(packet.data, this.decoderConfig);
if (sanitizedData) {
packet = new EncodedPacket(sanitizedData, packet.type, packet.timestamp, packet.duration);
}
}
const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);
packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);
}
this.decoder.decode(packet.toEncodedVideoChunk());
+34 -18
View File
@@ -50,7 +50,13 @@ import {
customAudioEncoders,
} from './custom-coder';
import { EncodedPacket, EncodedPacketSideData } from './packet';
import { AudioSample, clampCropRectangle, VideoSample } from './sample';
import {
AudioSample,
audioSampleToInterleavedFormat,
clampCropRectangle,
toInterleavedAudioFormat,
VideoSample,
} from './sample';
import {
AudioEncodingConfig,
buildAudioEncoderConfig,
@@ -75,11 +81,6 @@ export abstract class MediaSource {
_closingPromise: Promise<void> | null = null;
/** @internal */
_closed = false;
/**
* @internal
* A time offset in seconds that is added to all timestamps generated by this source.
*/
_timestampOffset = 0;
/** @internal */
_ensureValidAdd() {
@@ -1470,7 +1471,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
let frameCount = 0;
let errored = false;
let lastSampleTimestamp: number | null = null;
let pauseOffset = 0;
let timestampOffset = 0;
const tick = () => {
assert(frameRate !== null);
@@ -1538,9 +1539,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
// pause. Doing it like this instead of simply keeping track of the paused time is better since
// it retains the frame rate of the underlying source.
const timeDelta = currentTimestamp - lastSampleTimestamp;
// We modify this field instead of _timestampOffset since we still might have data in flight
// in the encoder, with which we don't want to mess.
pauseOffset -= timeDelta;
timestampOffset -= timeDelta;
}
lastSampleTimestamp = currentTimestamp;
}
@@ -1568,7 +1567,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
}
}
this._timestampOffset = target - firstVideoFrameTimestamp;
timestampOffset = target - firstVideoFrameTimestamp;
}
lastSampleTimestamp = currentTimestamp;
@@ -1580,7 +1579,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
}
const sample = new VideoSample(videoFrame, {
timestamp: currentTimestamp + pauseOffset,
timestamp: currentTimestamp + timestampOffset,
});
void this._encoder.add(sample, true)
@@ -1888,6 +1887,21 @@ class AudioEncoderWrapper {
private async processAndEncode(audioSample: AudioSample, shouldClose: boolean) {
const config = this.encodingConfig;
if (
config.transform?.sampleFormat !== undefined
&& toInterleavedAudioFormat(audioSample.format) !== config.transform.sampleFormat
) {
// Do a sample format conversion
const newSample = audioSampleToInterleavedFormat(audioSample, config.transform.sampleFormat);
if (shouldClose) {
audioSample.close();
}
audioSample = newSample;
shouldClose = true;
}
if (config.transform?.process) {
let processed = config.transform.process(audioSample);
if (processed instanceof Promise) {
@@ -1910,6 +1924,10 @@ class AudioEncoderWrapper {
}
await this.encodeSample(sample, true);
}
if (shouldClose) {
audioSample.close();
}
} else {
await this.encodeSample(audioSample, shouldClose);
}
@@ -2550,7 +2568,7 @@ export class MediaStreamAudioTrackSource extends AudioSource {
let firstAudioDataTimestamp: number | null = null;
let errored = false;
let lastSampleTimestamp: number | null = null;
let pauseOffset = 0;
let timestampOffset = 0;
const onAudioSample = (audioSample: AudioSample) => {
if (errored) {
@@ -2568,9 +2586,7 @@ export class MediaStreamAudioTrackSource extends AudioSource {
// the pause. Doing it like this instead of simply keeping track of the paused time is better
// since it retains the sample rate of the underlying source.
const timeDelta = currentTimestamp - lastSampleTimestamp;
// We modify this field instead of _timestampOffset since we still might have data in flight
// in the encoder, with which we don't want to mess.
pauseOffset -= timeDelta;
timestampOffset -= timeDelta;
}
lastSampleTimestamp = currentTimestamp;
}
@@ -2598,7 +2614,7 @@ export class MediaStreamAudioTrackSource extends AudioSource {
}
}
this._timestampOffset = target - firstAudioDataTimestamp;
timestampOffset = target - firstAudioDataTimestamp;
}
lastSampleTimestamp = currentTimestamp;
@@ -2609,7 +2625,7 @@ export class MediaStreamAudioTrackSource extends AudioSource {
return;
}
audioSample.setTimestamp(currentTimestamp + pauseOffset);
audioSample.setTimestamp(currentTimestamp + timestampOffset);
void this._encoder.add(audioSample, true)
.catch((error) => {
+2
View File
@@ -938,6 +938,8 @@ export type Rational = {
};
export const simplifyRational = (rational: Rational): Rational => {
assert(Number.isInteger(rational.num));
assert(Number.isInteger(rational.den));
assert(rational.den !== 0);
let a = Math.abs(rational.num);
+1 -1
View File
@@ -110,7 +110,7 @@ export class Mp3Muxer extends Muxer {
this.frameCount++;
}
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
if (writeXingHeader) {
this.framePositions.push(this.writer.getPos());
+16 -12
View File
@@ -474,18 +474,22 @@ export class MpegTsDemuxer extends Demuxer {
elementaryStream.info.width = spsInfo.displayWidth;
elementaryStream.info.height = spsInfo.displayHeight;
if (spsInfo.pixelAspectRatio.num > spsInfo.pixelAspectRatio.den) {
elementaryStream.info.squarePixelWidth = Math.round(
elementaryStream.info.width
* spsInfo.pixelAspectRatio.num / spsInfo.pixelAspectRatio.den,
);
elementaryStream.info.squarePixelHeight = elementaryStream.info.height;
} else {
elementaryStream.info.squarePixelWidth = elementaryStream.info.width;
elementaryStream.info.squarePixelHeight = Math.round(
elementaryStream.info.height
* spsInfo.pixelAspectRatio.den / spsInfo.pixelAspectRatio.num,
);
const num = spsInfo.pixelAspectRatio.num;
const den = spsInfo.pixelAspectRatio.den;
if (num > 0 && den > 0) {
if (num > den) {
elementaryStream.info.squarePixelWidth = Math.round(
elementaryStream.info.width * num / den,
);
elementaryStream.info.squarePixelHeight = elementaryStream.info.height;
} else {
elementaryStream.info.squarePixelWidth = elementaryStream.info.width;
elementaryStream.info.squarePixelHeight = Math.round(
elementaryStream.info.height * den / num,
);
}
}
elementaryStream.info.colorSpace = {
+4 -4
View File
@@ -227,7 +227,7 @@ export class MpegTsMuxer extends Muxer {
try {
const trackData = this.getVideoTrackData(track, meta);
const timestamp = this.validateAndNormalizeTimestamp(
this.validateTimestamp(
trackData.track,
packet.timestamp,
packet.type === 'key',
@@ -241,7 +241,7 @@ export class MpegTsMuxer extends Muxer {
trackData.timestampProcessingQueue.push({
data: preparedData,
presentationTimestamp: timestamp,
presentationTimestamp: packet.timestamp,
decodeTimestamp: null,
isKeyframe: packet.type === 'key',
});
@@ -260,7 +260,7 @@ export class MpegTsMuxer extends Muxer {
try {
const trackData = this.getAudioTrackData(track, meta);
const timestamp = this.validateAndNormalizeTimestamp(
this.validateTimestamp(
trackData.track,
packet.timestamp,
packet.type === 'key',
@@ -274,7 +274,7 @@ export class MpegTsMuxer extends Muxer {
trackData.timestampProcessingQueue.push({
data: preparedData,
presentationTimestamp: timestamp,
presentationTimestamp: packet.timestamp,
decodeTimestamp: null,
isKeyframe: packet.type === 'key',
});
+1 -5
View File
@@ -42,9 +42,7 @@ export abstract class Muxer {
maxTimestampBeforeLastKeyPacket: number | null;
}>();
protected validateAndNormalizeTimestamp(track: OutputTrack, timestampInSeconds: number, isKeyPacket: boolean) {
timestampInSeconds += track.source._timestampOffset;
protected validateTimestamp(track: OutputTrack, timestampInSeconds: number, isKeyPacket: boolean) {
if (timestampInSeconds < 0) {
throw new Error(`Timestamps must be non-negative (got ${timestampInSeconds}s).`);
}
@@ -78,7 +76,5 @@ export abstract class Muxer {
timestampInfo.maxTimestamp = Math.max(timestampInfo.maxTimestamp, timestampInSeconds);
}
return timestampInSeconds;
}
}
+1 -1
View File
@@ -267,7 +267,7 @@ export class OggMuxer extends Muxer {
try {
const trackData = this.getTrackData(track, meta);
this.validateAndNormalizeTimestamp(trackData.track, packet.timestamp, packet.type === 'key');
this.validateTimestamp(trackData.track, packet.timestamp, packet.type === 'key');
const currentTimestampInSamples = trackData.currentTimestampInSamples;
+7 -4
View File
@@ -20,7 +20,6 @@ export class AudioResampler {
targetSampleRate: number;
sourceNumberOfChannels: number | null = null;
targetNumberOfChannels: number;
startTime: number;
endTime: number;
onSample: (sample: AudioSample) => Promise<void>;
@@ -33,6 +32,7 @@ export class AudioResampler {
maxWrittenFrame: number | null = null;
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
tempSourceBuffer!: Float32Array;
timestampOffset: number;
constructor(options: {
targetSampleRate: number;
@@ -43,7 +43,6 @@ export class AudioResampler {
}) {
this.targetSampleRate = options.targetSampleRate;
this.targetNumberOfChannels = options.targetNumberOfChannels;
this.startTime = options.startTime;
this.endTime = options.endTime;
this.onSample = options.onSample;
@@ -51,7 +50,11 @@ export class AudioResampler {
this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
this.bufferStartFrame = Math.floor(this.startTime * this.targetSampleRate);
this.bufferStartFrame = Math.floor(options.startTime * this.targetSampleRate);
// Set to ensure that if the buffer start frame lands on a fractional sample, that the first timestamp still
// comes out as exactly startTime
this.timestampOffset = options.startTime - this.bufferStartFrame / this.targetSampleRate;
}
/**
@@ -273,7 +276,7 @@ export class AudioResampler {
format: 'f32',
sampleRate: this.targetSampleRate,
numberOfChannels: this.targetNumberOfChannels,
timestamp: timestampSeconds,
timestamp: timestampSeconds + this.timestampOffset,
data: outputData,
});
+30
View File
@@ -1953,6 +1953,21 @@ const isAudioData = (x: unknown): x is AudioData => {
return typeof AudioData !== 'undefined' && x instanceof AudioData;
};
export const toInterleavedAudioFormat = (format: AudioSampleFormat): 'u8' | 's16' | 's32' | 'f32' => {
switch (format) {
case 'u8-planar':
return 'u8';
case 's16-planar':
return 's16';
case 's32-planar':
return 's32';
case 'f32-planar':
return 'f32';
default:
return format;
}
};
/**
* WebKit has a bug where calling AudioData.copyTo with a format different from the source format
* crashes the tab when there are more than 2 channels. This function works around that by always
@@ -2061,3 +2076,18 @@ const doAudioDataCopyToWebKitWorkaround = (
}
}
};
export const audioSampleToInterleavedFormat = (sample: AudioSample, format: 'u8' | 's16' | 's32' | 'f32') => {
const size = sample.allocationSize({ format, planeIndex: 0 });
const buffer = new ArrayBuffer(size);
sample.copyTo(buffer, { format, planeIndex: 0 });
return new AudioSample({
data: buffer,
format,
numberOfChannels: sample.numberOfChannels,
sampleRate: sample.sampleRate,
timestamp: sample.timestamp,
duration: sample.duration,
});
};
+14 -1
View File
@@ -443,6 +443,12 @@ export class BufferSource extends Source {
export type BlobSourceOptions = {
/** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 8 MiB. */
maxCacheSize?: number;
/**
* Defaults to `true`. When `true`, Mediabunny will acquire a `ReadableStream` reader internally to efficiently read
* data from the blob. Since this can lead to errors in some (very) rare cases due to browser bugs, you can set this
* field to `false` to try a slower but more stable reading method.
*/
useStreamReader?: boolean;
};
/**
@@ -456,6 +462,8 @@ export class BlobSource extends Source {
/** @internal */
_blob: Blob;
/** @internal */
_options: BlobSourceOptions;
/** @internal */
_orchestrator: ReadOrchestrator;
/**
@@ -475,10 +483,15 @@ export class BlobSource extends Source {
) {
throw new TypeError('options.maxCacheSize, when provided, must be a non-negative number.');
}
if (options.useStreamReader !== undefined && typeof options.useStreamReader !== 'boolean') {
throw new TypeError('options.useStreamReader, when provided, must be a boolean.');
}
super();
this._blob = blob;
this._options = options;
this._orchestrator = new ReadOrchestrator({
maxCacheSize: options.maxCacheSize ?? (8 * 2 ** 20 /* 8 MiB */),
maxWorkerCount: 4,
@@ -520,7 +533,7 @@ export class BlobSource extends Source {
// - ReadableStream stalls under backpressure (especially video)
// Affects Safari and all iOS browsers (Chrome, Firefox, etc.).
// Use arrayBuffer() fallback for WebKit browsers.
if ('stream' in this._blob && !isWebKit()) {
if ('stream' in this._blob && !isWebKit() && this._options.useStreamReader !== false) {
// Get a reader of the blob starting at the required offset, and then keep it around
const slice = this._blob.slice(worker.currentPos);
reader = slice.stream().getReader();
+1 -1
View File
@@ -79,7 +79,7 @@ export class WaveMuxer extends Muxer {
this.headerWritten = true;
}
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
this.validateTimestamp(track, packet.timestamp, packet.type === 'key');
if (!this.isRf64 && this.writer.getPos() + packet.data.byteLength >= 2 ** 32) {
throw new Error(
+34 -1
View File
@@ -1,6 +1,12 @@
import { ALL_FORMATS } from '../../src/input-format.js';
import { Input } from '../../src/input.js';
import { AdtsOutputFormat, HlsOutputFormat, Mp4OutputFormat, MpegTsOutputFormat } from '../../src/output-format.js';
import {
AdtsOutputFormat,
HlsOutputFormat,
Mp4OutputFormat,
MpegTsOutputFormat,
WavOutputFormat,
} from '../../src/output-format.js';
import { Output, OutputTrackGroup } from '../../src/output.js';
import { BufferSource, CustomPathedSource, UrlSource } from '../../src/source.js';
import { expect, test } from 'vitest';
@@ -369,3 +375,30 @@ test('HLS track assignability can be overridden', async () => {
expect(newMasterPlayist).not.toBe(masterPlayist);
expect(newMasterPlayist.match(/\.m3u8/g)?.length).toBe(1);
});
test('Fractional audio sample boundary', async () => {
using input = new Input({
source: new UrlSource('/trim-buck-bunny-ffmpeg.ts'),
formats: ALL_FORMATS,
});
const output = new Output({
format: new WavOutputFormat(),
target: new BufferTarget(),
});
const conversion = await Conversion.init({
input,
output,
video: {
discard: true,
},
audio: {
forceTranscode: true,
},
trim: {
start: 0.4 / 48000,
},
});
await conversion.execute();
});
+107
View File
@@ -0,0 +1,107 @@
import { expect, test } from 'vitest';
import { Input } from '../../src/input.js';
import { ALL_FORMATS } from '../../src/input-format.js';
import { AudioSampleSource } from '../../src/media-source.js';
import { AudioSampleSink } from '../../src/media-sink.js';
import { assert } from '../../src/misc.js';
import { Output } from '../../src/output.js';
import { FlacOutputFormat } from '../../src/output-format.js';
import { AudioSample } from '../../src/sample.js';
import { BufferSource } from '../../src/source.js';
import { BufferTarget } from '../../src/target.js';
import { registerFlacEncoder } from '@mediabunny/flac-encoder';
test('FLAC encoder, 24-bit', async () => {
registerFlacEncoder();
const sampleRate = 48000;
const channels = 2;
const durationSeconds = 2;
const data = createF32SineWave(sampleRate, channels, durationSeconds);
using sample = await encodeAndDecodeFirstSample(new AudioSample({
data,
format: 'f32',
numberOfChannels: channels,
sampleRate,
timestamp: 0,
}));
expect(sample.format).toBe('s32');
});
test('FLAC encoder, 16-bit', async () => {
registerFlacEncoder();
const sampleRate = 48000;
const channels = 2;
const durationSeconds = 2;
const data = createS16SineWave(sampleRate, channels, durationSeconds);
using sample = await encodeAndDecodeFirstSample(new AudioSample({
data,
format: 's16',
numberOfChannels: channels,
sampleRate,
timestamp: 0,
}));
expect(sample.format).toBe('s16');
});
const createF32SineWave = (sampleRate: number, channels: number, durationSeconds: number) => {
const totalFrames = sampleRate * durationSeconds;
const data = new Float32Array(totalFrames * channels);
for (let i = 0; i < totalFrames; i++) {
const value = Math.sin(2 * Math.PI * 440 * i / sampleRate);
for (let ch = 0; ch < channels; ch++) {
data[i * channels + ch] = value;
}
}
return data;
};
const createS16SineWave = (sampleRate: number, channels: number, durationSeconds: number) => {
const totalFrames = sampleRate * durationSeconds;
const data = new Int16Array(totalFrames * channels);
for (let i = 0; i < totalFrames; i++) {
const value = Math.round(Math.sin(2 * Math.PI * 440 * i / sampleRate) * 32767);
for (let ch = 0; ch < channels; ch++) {
data[i * channels + ch] = value;
}
}
return data;
};
const encodeAndDecodeFirstSample = async (audioSample: AudioSample) => {
const output = new Output({
format: new FlacOutputFormat(),
target: new BufferTarget(),
});
const audioSource = new AudioSampleSource({ codec: 'flac' });
output.addAudioTrack(audioSource);
await output.start();
await audioSource.add(audioSample);
audioSource.close();
await output.finalize();
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryAudioTrack();
assert(track);
const sink = new AudioSampleSink(track);
const sample = await sink.getSample(0);
assert(sample);
return sample;
};
+2 -2
View File
@@ -47,7 +47,7 @@ test('FLAC encoding', async () => {
target: new BufferTarget(),
});
const audioSource = new AudioSampleSource({ codec: 'flac', bitrate: 1 });
const audioSource = new AudioSampleSource({ codec: 'flac' });
output.addAudioTrack(audioSource);
await output.start();
@@ -97,7 +97,7 @@ test('FLAC with huge timestamps', async () => {
target: new BufferTarget(),
});
const audioSource = new AudioSampleSource({ codec: 'flac', bitrate: 1 });
const audioSource = new AudioSampleSource({ codec: 'flac' });
output.addAudioTrack(audioSource);
await output.start();
+248
View File
@@ -9,6 +9,8 @@ import { BufferTarget } from '../../src/target.js';
import { Mp4OutputFormat } from '../../src/output-format.js';
import { Conversion } from '../../src/conversion.js';
import { assert } from '../../src/misc.js';
import { EncodedAudioPacketSource, EncodedVideoPacketSource } from '../../src/media-source.js';
import { EncodedPacket } from '../../src/packet.js';
const __dirname = new URL('.', import.meta.url).pathname;
@@ -104,3 +106,249 @@ test('Fragmented fMP4 with video+audio preserves B-frame CTS', async () => {
expect(timestamps).toEqual(originalTimestamps);
});
test('Zero start timestamp, regular MP4', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const source = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source);
await output.start();
const meta = { decoderConfig: { codec: 'vp8', codedWidth: 1280, codedHeight: 720 } };
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1), meta);
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 0.1, 0.1));
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 0.2, 0.1));
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 0.3, 0.1));
await output.finalize();
// Hacky but works
const str = String.fromCharCode(...new Uint8Array(output.target.buffer!));
expect(str.includes('edts') || str.includes('elst')).toBe(false);
});
test('Non-zero start timestamp, regular MP4', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const source = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source);
await output.start();
const meta = { decoderConfig: { codec: 'vp8', codedWidth: 1280, codedHeight: 720 } };
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 1, 0.1), meta);
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 1.1, 0.1));
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 1.2, 0.1));
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 1.3, 0.1));
await output.finalize();
// Hacky but works
const str = String.fromCharCode(...new Uint8Array(output.target.buffer!));
expect(str.includes('edts') && str.includes('elst')).toBe(true);
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryVideoTrack();
assert(track);
const sink = new EncodedPacketSink(track);
const timestamps: number[] = [];
const durations: number[] = [];
for await (const packet of sink.packets()) {
timestamps.push(packet.timestamp);
durations.push(packet.duration);
}
expect(timestamps).toEqual([1, 1.1, 1.2, 1.3]);
expect(durations).toEqual([0.1, 0.1, 0.1, 0.1]);
});
test('Non-zero start timestamp, fragmented MP4', async () => {
const output = new Output({
format: new Mp4OutputFormat({ fastStart: 'fragmented' }),
target: new BufferTarget(),
});
const source = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source);
await output.start();
const meta = { decoderConfig: { codec: 'vp8', codedWidth: 1280, codedHeight: 720 } };
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 1, 0.1), meta);
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 1.1, 0.1));
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 1.2, 0.1));
await source.add(new EncodedPacket(new Uint8Array(1024), 'delta', 1.3, 0.1));
await output.finalize();
// Hacky but works
const str = String.fromCharCode(...new Uint8Array(output.target.buffer!));
expect(str.includes('edts') || str.includes('elst')).toBe(false);
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryVideoTrack();
assert(track);
const sink = new EncodedPacketSink(track);
const timestamps: number[] = [];
const durations: number[] = [];
for await (const packet of sink.packets()) {
timestamps.push(packet.timestamp);
durations.push(packet.duration);
}
expect(timestamps).toEqual([1, 1.1, 1.2, 1.3]);
expect(durations).toEqual([0.1, 0.1, 0.1, 0.1]);
});
test('PCM audio', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const source = new EncodedAudioPacketSource('pcm-s16');
output.addAudioTrack(source);
await output.start();
const meta = { decoderConfig: { codec: 'pcm-s16', numberOfChannels: 2, sampleRate: 48000 } };
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 1024 / 2 / 2 / 48000), meta);
await output.finalize();
const input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const audioTrack = await input.getPrimaryAudioTrack();
assert(audioTrack);
expect(await audioTrack.getFirstTimestamp()).toBe(0);
});
test('PCM audio with non-zero timestamp', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const source = new EncodedAudioPacketSource('pcm-s16');
output.addAudioTrack(source);
await output.start();
const meta = { decoderConfig: { codec: 'pcm-s16', numberOfChannels: 2, sampleRate: 48000 } };
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 1, 1024 / 2 / 2 / 48000), meta);
await output.finalize();
const input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const audioTrack = await input.getPrimaryAudioTrack();
assert(audioTrack);
expect(await audioTrack.getFirstTimestamp()).toBe(1);
});
test('PCM audio, silence padding', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const source = new EncodedAudioPacketSource('pcm-s16');
output.addAudioTrack(source);
await output.start();
const meta = { decoderConfig: { codec: 'pcm-s16', numberOfChannels: 2, sampleRate: 48000 } };
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 1024 / 2 / 2 / 48000), meta);
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 1, 1024 / 2 / 2 / 48000), meta);
await output.finalize();
const input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const audioTrack = await input.getPrimaryAudioTrack();
assert(audioTrack);
expect(await audioTrack.getCodec()).toBe('pcm-s16');
const numChannels = await audioTrack.getNumberOfChannels();
const expectedFrameCount = 48000 + 256;
const sink = new EncodedPacketSink(audioTrack);
let frameCount = 0;
for await (const packet of sink.packets()) {
frameCount += packet.byteLength / 2 / numChannels;
}
expect(frameCount).toBe(expectedFrameCount);
});
test('PCM audio, no silence padding with approximate timestamps', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const source = new EncodedAudioPacketSource('pcm-s16');
output.addAudioTrack(source);
await output.start();
const meta = { decoderConfig: { codec: 'pcm-s16', numberOfChannels: 2, sampleRate: 48000 } };
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 1024 / 2 / 2 / 48000), meta);
// 0.006 is 256/48000 "rounded up", but it's close enough for silence padding not to kick in
await source.add(new EncodedPacket(new Uint8Array(1024), 'key', 0.006, 1024 / 2 / 2 / 48000), meta);
await output.finalize();
const input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const audioTrack = await input.getPrimaryAudioTrack();
assert(audioTrack);
expect(await audioTrack.getCodec()).toBe('pcm-s16');
const numChannels = await audioTrack.getNumberOfChannels();
const expectedFrameCount = 256 + 256;
const sink = new EncodedPacketSink(audioTrack);
let frameCount = 0;
for await (const packet of sink.packets()) {
frameCount += packet.byteLength / 2 / numChannels;
}
expect(frameCount).toBe(expectedFrameCount);
});