mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-10 01:03:45 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fff69619ec | ||
|
|
fc2f98825b | ||
|
|
3cde68e0ae | ||
|
|
9e3db7cf91 | ||
|
|
fff09411e6 | ||
|
|
daf42181c9 | ||
|
|
e790f5ff97 | ||
|
|
47ad5c6806 | ||
|
|
8955ba70c2 | ||
|
|
1680bd02ad | ||
|
|
4810c13c29 | ||
|
|
9ed21bf9c6 | ||
|
|
62dfc5dd1c | ||
|
|
782d3e134b | ||
|
|
9224fb886c | ||
|
|
a61631a299 | ||
|
|
f3dec587fd | ||
|
|
2d49122277 | ||
|
|
06a89ed085 |
+19
-8
@@ -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
@@ -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
@@ -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,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.",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
---
|
||||
title: Blog posts
|
||||
description: Announcements, news, and devblogs about Mediabunny.
|
||||
outline: false
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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]
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 <video> 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 <video> 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 <video> 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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+9
-9
@@ -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
@@ -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,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,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",
|
||||
|
||||
Generated
BIN
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export type WorkerCommand = {
|
||||
data: {
|
||||
numberOfChannels: number;
|
||||
sampleRate: number;
|
||||
bitsPerSample: 16 | 24;
|
||||
};
|
||||
} | {
|
||||
type: 'encode';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
// 🐡🦔
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user