Compare commits

...
16 Commits
Author SHA1 Message Date
Vanilagy b99b86db0b Bump patch 2026-07-03 16:44:39 +02:00
Vanilagy b0e5f655aa Make AUD-based NALU stripping less aggressive (closes #426) 2026-07-03 16:37:06 +02:00
Vanilagy 0c2853d0aa Merge branch 'main' of https://github.com/Vanilagy/metamuxer 2026-07-03 16:24:09 +02:00
Vanilagy cc970e1f53 Fix invalid check for no available read worker stalling reads, bump patch 2026-07-03 16:24:08 +02:00
Vanilagy 5e68d9655c Make ProRes tracks return true for hasOnlyKeyPackets() 2026-07-02 22:58:51 +02:00
Vanilagy 7171cf4338 Add sync workspace deps script 2026-07-02 20:42:46 +02:00
Vanilagy baff6a67a3 Omg 2026-07-02 20:31:13 +02:00
Vanilagy d0401b42a5 Bump TurboRes to fix bug 2026-07-02 20:30:36 +02:00
Vanilagy 72427b8b39 Bump TurboRes, use desiredSize, bump patch 2026-07-02 20:18:25 +02:00
Viktor SzépeandGitHub 6812cf1643 Fix typos in source, tests, and docs (#430)
* Fix typos in source, tests, and docs

* Fix introduction guide typos
2026-07-02 09:33:43 +02:00
Zi How PohandGitHub c9a828c272 Fix duplicated entries in generated API docs (#429) 2026-07-02 07:31:09 +00:00
Vanilagy e851120bfe Add PhotoCircle bronze sponsor 2026-07-01 18:11:01 +02:00
Vanilagy 1a4fec9f6e Bump TurboRes & patch 2026-07-01 17:18:21 +02:00
Vanilagy 8c8ce8a4a5 Merge main into release AFTER the npm publish completes 2026-07-01 16:26:58 +02:00
Vanilagy 7ea8f6d751 Up timeout on test 2026-07-01 16:25:53 +02:00
Vanilagy 8fb22633fe Add frame container atom header stripping & readding for ProRes in Matroska, bump patch 2026-07-01 16:22:38 +02:00
32 changed files with 210 additions and 103 deletions
+9 -9
View File
@@ -25,15 +25,6 @@ jobs:
with:
script: "core.setFailed('Non-prerelease releases must target the main branch (got: ${{ github.event.release.target_commitish }}).')"
- name: Merge main into release branch
if: ${{ !github.event.release.prerelease }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout release
git merge origin/main --no-ff -m "Merge main into release for tag ${{ github.event.release.tag_name }}"
git push origin release
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@@ -107,3 +98,12 @@ jobs:
- name: Publish workspace packages to npm
run: npm publish --access public --workspaces ${{ github.event.release.prerelease && '--tag beta' || '' }}
- name: Merge main into release branch
if: ${{ !github.event.release.prerelease }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout release
git merge origin/main --no-ff -m "Merge main into release for tag ${{ github.event.release.tag_name }}"
git push origin release
+1 -1
View File
@@ -1,6 +1,6 @@
- Prefer functions declared using const, not using the function keyword
- Code style is tab indent with semicolons
- Mediabunny core code is contained in src/, extensions are in packages/*/, website is in docs/
- Tests: Prefer fewer, longer test files over many small ones. Test files should be named after the general catergory of thing that is being tested, not after any individual single test.
- Tests: Prefer fewer, longer test files over many small ones. Test files should be named after the general category of thing that is being tested, not after any individual single test.
- Avoid ifs without a {} block. So no if (cond) return;, always do if (cond) { return; }
- `type` instead of `interface` for object types
+4
View File
@@ -70,6 +70,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media (l
<a href="https://jellypod.ai/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/jellypod.png" width="40" height="40" alt="Jellypod">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.photocircleapp.com/" target="_blank" rel="sponsored">
<img src="./docs/public/sponsors/photocircle.png" width="40" height="40" alt="PhotoCircle">
</a>
</div>
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
+2 -2
View File
@@ -60,7 +60,7 @@ To get going with Mediabunny, here are some starting points:
## Server-side usage
Mediabunny's simple yet flexible API provides a modern alternative to traditional server-side media processing pipelines which may involve calling out to FFmpeg's CLI manually. Mediabunny was primary built for client-side environments, but when combined with the [`@mediabunny/server`](./extensions/server) extension, the full Mediabunny feature set is available in server-side environments such as Node, Bun, and Deno.
Mediabunny's simple yet flexible API provides a modern alternative to traditional server-side media processing pipelines which may involve calling out to FFmpeg's CLI manually. Mediabunny was primarily built for client-side environments, but when combined with the [`@mediabunny/server`](./extensions/server) extension, the full Mediabunny feature set is available in server-side environments such as Node, Bun, and Deno.
The extension enables:
- Video decoders and encoders for AVC (H.264), HEVC (H.265), VP8, VP9, and AV1. Supports both length-prefixed and Annex B AVC/HEVC as well as transparent video via VP9.
@@ -74,7 +74,7 @@ For more, see [the corresponding guide](./extensions/server).
## Motivation
Mediabunny is the evolution of my previous libraries, [mp4-muxer](https://github.com/Vanilagy/mp4-muxer) and [webm-muxer](https://github.com/Vanilagy/webm-muxer), which were both created due to the advent of the WebCodecs API. While they fulfilled their job just fine, I saw a few painpoints:
Mediabunny is the evolution of my previous libraries, [mp4-muxer](https://github.com/Vanilagy/mp4-muxer) and [webm-muxer](https://github.com/Vanilagy/webm-muxer), which were both created due to the advent of the WebCodecs API. While they fulfilled their job just fine, I saw a few pain points:
- Lots of duplicated code between the two libraries, otherwise very similar API.
- No help with the difficulties of navigating the WebCodecs API & related browser APIs.
- "mp4-demuxer when??"
+1 -1
View File
@@ -342,7 +342,7 @@ Each chunk written to the `WritableStream` represents a contiguous chunk of byte
::: warning
Note that some byte regions in the output file may be written to multiple times. It is therefore **incorrect** to construct the final file by simply concatenating all `Uint8Array`s together - you **must** write each chunk of data at the specified byte offset position _in the order_ in which the chunks arrived. If you don't do this, your output file will likely be invalid or corrupted.
Some [output formats](./output-formats) have *append-only* writing modes in which the byte offset of a written chunk will always be equal to the total number of bytes in all previously written chunks. In other words, when writing is append-only, simply concatening all `Uint8Array`s yields the correct result. Some APIs (like `appendBuffer` of Media Source Extensions) require this, so make sure to configure your output format accordingly for those cases.
Some [output formats](./output-formats) have *append-only* writing modes in which the byte offset of a written chunk will always be equal to the total number of bytes in all previously written chunks. In other words, when writing is append-only, simply concatenating all `Uint8Array`s yields the correct result. Some APIs (like `appendBuffer` of Media Source Extensions) require this, so make sure to configure your output format accordingly for those cases.
:::
#### Chunked mode
+1
View File
@@ -110,6 +110,7 @@ const sponsors = {
{ image: '/sponsors/rve.png', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
{ image: '/sponsors/mux.jpg', name: 'Mux', url: 'https://www.mux.com/' },
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
{ image: '/sponsors/photocircle.png', name: 'PhotoCircle', url: 'https://www.photocircleapp.com/' },
],
individual: [
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+13 -13
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.50.1",
"version": "1.50.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.50.1",
"version": "1.50.6",
"license": "MPL-2.0",
"workspaces": [
".",
@@ -10963,9 +10963,9 @@
"optional": true
},
"node_modules/turbores": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/turbores/-/turbores-1.1.2.tgz",
"integrity": "sha512-6k3XH+sG8uOCJEBamKhAXFwIAfLqz7LAQr2mmEx2UBl6zC+4XAGQtp1fGEugnVl2w8Ec5GOUvD+b0tyxUJvmHg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/turbores/-/turbores-1.2.1.tgz",
"integrity": "sha512-HcnLHiUggT2YXmnTpUdT3JHnkGWvdJlW9XBPLKZBBB3X5oOEuBaHF99cW+H+CSxnhM+rth2qDTCAteIKhKwiGQ==",
"license": "MPL-2.0",
"funding": {
"type": "individual",
@@ -12894,7 +12894,7 @@
},
"packages/aac-encoder": {
"name": "@mediabunny/aac-encoder",
"version": "1.50.1",
"version": "1.50.6",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12909,7 +12909,7 @@
},
"packages/ac3": {
"name": "@mediabunny/ac3",
"version": "1.50.1",
"version": "1.50.6",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12924,7 +12924,7 @@
},
"packages/flac-encoder": {
"name": "@mediabunny/flac-encoder",
"version": "1.50.1",
"version": "1.50.6",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12939,7 +12939,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.50.1",
"version": "1.50.6",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
@@ -12954,10 +12954,10 @@
},
"packages/prores": {
"name": "@mediabunny/prores",
"version": "1.50.1",
"version": "1.50.6",
"license": "MPL-2.0",
"dependencies": {
"turbores": "^1.1.2"
"turbores": "^1.2.1"
},
"funding": {
"type": "individual",
@@ -12969,10 +12969,10 @@
},
"packages/server": {
"name": "@mediabunny/server",
"version": "1.50.1",
"version": "1.50.6",
"license": "MPL-2.0",
"dependencies": {
"@mediabunny/prores": "^1.50.0",
"@mediabunny/prores": "^1.50.5",
"node-av": "^6.0.0"
},
"funding": {
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.50.1",
"version": "1.50.6",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
@@ -56,10 +56,10 @@
"examples:build": "vite build",
"fix-build-import-paths": "tsx scripts/add-import-extensions.ts",
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts",
"bump-patch": "npm version patch --no-git-tag-version --workspaces",
"bump-minor": "npm version minor --no-git-tag-version --workspaces",
"bump-major": "npm version major --no-git-tag-version --workspaces",
"set-version": "npm version --no-git-tag-version --workspaces"
"bump-patch": "npm version patch --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts",
"bump-minor": "npm version minor --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts",
"bump-major": "npm version major --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts",
"set-version": "npm version --no-git-tag-version --workspaces && tsx scripts/sync-workspace-deps.ts"
},
"license": "MPL-2.0",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/aac-encoder",
"author": "Vanilagy",
"version": "1.50.1",
"version": "1.50.6",
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-aac-encoder.mjs",
"module": "./dist/bundles/mediabunny-aac-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/ac3",
"author": "Vanilagy",
"version": "1.50.1",
"version": "1.50.6",
"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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/flac-encoder",
"author": "Vanilagy",
"version": "1.50.1",
"version": "1.50.6",
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.50.1",
"version": "1.50.6",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/prores",
"author": "Vanilagy",
"version": "1.50.1",
"version": "1.50.6",
"description": "Apple ProRes decoder extension for Mediabunny, based on TurboRes.",
"main": "./dist/bundles/mediabunny-prores.mjs",
"module": "./dist/bundles/mediabunny-prores.mjs",
@@ -34,7 +34,7 @@
"url": "https://github.com/sponsors/Vanilagy"
},
"dependencies": {
"turbores": "^1.1.2"
"turbores": "^1.2.1"
},
"peerDependencies": {
"mediabunny": "^1.49.0"
+5 -9
View File
@@ -83,16 +83,12 @@ class ProresDecoder extends CustomVideoDecoder {
async decode(packet: EncodedPacket) {
assert(this.decoder);
if (this.decoder.useSharedMemory) {
await this.runDecode(packet);
} else {
while (this.decoder.decodeQueueSize >= this.decoder.concurrency) {
await this.decoder.dequeued;
}
void this.runDecode(packet)
.catch(error => this.onError(error));
while (this.decoder.desiredSize <= 0) {
await this.decoder.dequeued;
}
void this.runDecode(packet)
.catch(error => this.onError(error));
}
private async runDecode(packet: EncodedPacket) {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/server",
"author": "Vanilagy",
"version": "1.50.1",
"version": "1.50.6",
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.",
"main": "./dist/bundles/mediabunny-server.cjs",
"module": "./dist/bundles/mediabunny-server.mjs",
@@ -35,7 +35,7 @@
},
"dependencies": {
"node-av": "^6.0.0",
"@mediabunny/prores": "^1.50.0"
"@mediabunny/prores": "^1.50.6"
},
"peerDependencies": {
"mediabunny": "^1.45.0"
+5 -5
View File
@@ -103,7 +103,7 @@ const mp3EncoderVariants = await createVariants(
'packages/mp3-encoder/src/index.ts',
'MediabunnyMp3Encoder',
'packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
@@ -134,7 +134,7 @@ const ac3Variants = await createVariants(
'packages/ac3/src/index.ts',
'MediabunnyAc3',
'packages/ac3/dist/bundles/mediabunny-ac3',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
@@ -165,7 +165,7 @@ const aacEncoderVariants = await createVariants(
'packages/aac-encoder/src/index.ts',
'MediabunnyAacEncoder',
'packages/aac-encoder/dist/bundles/mediabunny-aac-encoder',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
@@ -196,7 +196,7 @@ const flacEncoderVariants = await createVariants(
'packages/flac-encoder/src/index.ts',
'MediabunnyFlacEncoder',
'packages/flac-encoder/dist/bundles/mediabunny-flac-encoder',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
@@ -227,7 +227,7 @@ const proresVariants = await createVariants(
'packages/prores/src/index.ts',
'MediabunnyProres',
'packages/prores/dist/bundles/mediabunny-prores',
'js', // The bundles are purely for the browser, not for Node (due to the peer dependecy)
'js', // The bundles are purely for the browser, not for Node (due to the peer dependency)
{
plugins: [
PluginExternalGlobal.externalGlobalPlugin({
+13 -16
View File
@@ -127,25 +127,22 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false)
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
if (!declaration) return;
// If it's a reexport, follow it recursively
// If it's a reexport, resolve it to the underlying symbol (following chains of aliases,
// e.g. a re-export of a re-export) rather than recursing into the whole module it lives in
// -- `getExportsOfModule` above already gives us one entry per exported symbol, so a module
// recursion here would revisit (and duplicate) every other export of that module too.
if (exportSymbol.flags & ts.SymbolFlags.Alias) {
const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
let aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
while (aliasedSymbol.flags & ts.SymbolFlags.Alias) {
aliasedSymbol = typeChecker.getAliasedSymbol(aliasedSymbol);
}
const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
if (aliasedDeclaration) {
const sourceFile = aliasedDeclaration.getSourceFile();
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
// If the aliased declaration lives in a module we've already visited (e.g. a
// same-file `export type { Foo }` re-export of a local declaration), recursing
// won't reach it, so add the alias symbol directly. Otherwise follow the reexport.
if (moduleSymbol && !visited.has(moduleSymbol)) {
symbols.push(...getAllExportedSymbols(moduleSymbol, visited));
} else {
// Push the aliased symbol (not the alias) so downstream sees the real
// declaration and its JSDoc rather than the empty ExportSpecifier.
const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public');
if (hasPublicTag) {
symbols.push(aliasedSymbol);
}
// Push the aliased symbol (not the alias) so downstream sees the real
// declaration and its JSDoc rather than the empty ExportSpecifier.
const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public');
if (hasPublicTag) {
symbols.push(aliasedSymbol);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import fs from 'node:fs';
import path from 'node:path';
// After `npm version --workspaces` bumps each package's version, this rewrites dependency ranges that point at sibling
// workspaces so they follow along.
const root = path.join(import.meta.dirname, '..');
const workspaceDirs = ['.', ...fs.readdirSync(path.join(root, 'packages')).map(x => `packages/${x}`)]
.filter(dir => fs.existsSync(path.join(root, dir, 'package.json')));
type Manifest = {
name: string;
version: string;
dependencies?: Record<string, string>;
};
const manifests = workspaceDirs.map((dir) => {
const filePath = path.join(root, dir, 'package.json');
return { filePath, json: JSON.parse(fs.readFileSync(filePath, 'utf8')) as Manifest };
});
const versions = new Map(manifests.map(({ json }) => [json.name, json.version]));
for (const { filePath, json } of manifests) {
let changed = false;
for (const name of Object.keys(json.dependencies ?? {})) {
const version = versions.get(name);
if (version && json.dependencies![name] !== `^${version}`) {
json.dependencies![name] = `^${version}`;
changed = true;
}
}
if (changed) {
fs.writeFileSync(filePath, JSON.stringify(json, null, 2) + '\n');
console.log(`Synced workspace dependency ranges in ${path.relative(root, filePath)}`);
}
}
+1 -1
View File
@@ -1878,7 +1878,7 @@ export type Av1CodecInfo = {
chromaSamplePosition: number;
};
/** Iterates over all OBUs in an AV1 packet bistream. */
/** Iterates over all OBUs in an AV1 packet bitstream. */
export const iterateAv1PacketObus = function* (packet: Uint8Array) {
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
+1 -1
View File
@@ -838,7 +838,7 @@ export class Conversion {
if (this._options.trim?.start !== undefined) {
this._startTimestamp = this._options.trim.start;
} else {
// Compute the start timestamp from the set of filtered tracks. Techncially these can still be narrowed
// Compute the start timestamp from the set of filtered tracks. Technically these can still be narrowed
// down later due to discarded tracks, but we need to fix the start timestamp now due to track processing
// depending on it.
this._startTimestamp = Math.max(
+2 -2
View File
@@ -186,7 +186,7 @@ export class FlacDemuxer extends Demuxer {
// description is required, and has to be the following:
// 1. The bytes 0x66 0x4C 0x61 0x43 ("fLaC" in ASCII)
// 2. A metadata block (called the STREAMINFO block) as described in section 7 of [FLAC]
// 3. Optionaly (sic) other metadata blocks, that are not used by the specification
// 3. Optionally (sic) other metadata blocks, that are not used by the specification
bitstream.skipBits(16 * 8); // md5 hash
@@ -296,7 +296,7 @@ export class FlacDemuxer extends Demuxer {
// we expect that there are at least `minimumFrameSize` bytes left in the file
// Ideally we also want to validate the next header is valid
// to throw out an accidential sync word
// to throw out an accidental sync word
// The shortest valid FLAC header I can think of, based off the code
// of readFlacFrameHeader:
+2 -1
View File
@@ -544,7 +544,8 @@ export class InputVideoTrack extends InputTrack {
}
async hasOnlyKeyPackets() {
return (await this._backing.getHasOnlyKeyPackets?.()) ?? false;
return (await this._backing.getHasOnlyKeyPackets?.())
?? (await this._backing.getCodec() === 'prores'); // Only ProRes is fully intra-frame
}
/** Returns the width in pixels of the track's coded samples, before any transformations or rotations. */
+34
View File
@@ -46,6 +46,7 @@ import {
Rotation,
roundIfAlmostInteger,
textDecoder,
toDataView,
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
UNDETERMINED_LANGUAGE,
} from '../misc';
@@ -141,6 +142,7 @@ type ClusterBlock = {
data: Uint8Array;
lacing: BlockLacing;
decoded: boolean;
postProcessed: boolean; // For codec-specific processing
mainAdditional: Uint8Array | null;
};
@@ -864,6 +866,7 @@ export class MatroskaDemuxer extends Demuxer {
data: frameData,
lacing: BlockLacing.None,
decoded: true,
postProcessed: false,
mainAdditional: originalBlock.mainAdditional,
});
}
@@ -1502,6 +1505,7 @@ export class MatroskaDemuxer extends Demuxer {
data: blockData,
lacing,
decoded: !hasDecodingInstructions,
postProcessed: false,
mainAdditional: null,
});
}; break;
@@ -1538,6 +1542,7 @@ export class MatroskaDemuxer extends Demuxer {
data: blockData,
lacing,
decoded: !hasDecodingInstructions,
postProcessed: false,
mainAdditional: null,
};
trackData.blocks.push(this.currentBlock);
@@ -2194,6 +2199,35 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
block.decoded = true;
}
if (!block.postProcessed) {
if (this.internalTrack.info?.codec === 'prores') {
// For some reason, ProRes packets are stored in Matroska without the frame container atom. FFmpeg cites
// the "Matroska spec" but the actual spec says nothing about this.
const hasFrameContainer = block.data.length >= 8
&& block.data[4] === 105 // 'i'
&& block.data[5] === 99 // 'c'
&& block.data[6] === 112 // 'p'
&& block.data[7] === 102; // 'f'
if (!hasFrameContainer) {
// Wrap the frame in a frame container
const newData = new Uint8Array(block.data.length + 8);
const newDataView = toDataView(newData);
newDataView.setUint32(0, newData.length, false);
newData[4] = 105; // 'i'
newData[5] = 99; // 'c'
newData[6] = 112; // 'p'
newData[7] = 102; // 'f'
newData.set(block.data, 8);
block.data = newData;
}
}
block.postProcessed = true;
}
const data = options.metadataOnly ? PLACEHOLDER_DATA : block.data;
const timestamp = block.timestamp / this.internalTrack.segment.timestampFactor;
const duration = block.duration / this.internalTrack.segment.timestampFactor;
+12 -1
View File
@@ -897,6 +897,17 @@ export class MatroskaMuxer extends Muxer {
try {
const trackData = this.getVideoTrackData(track, packet, meta);
let packetData = packet.data;
if (track.source._codec === 'prores') {
if (packetData.byteLength < 8) {
throw new Error('ProRes packet too small, expected at least 8 bytes.');
}
// Trim off the frame container atom header. FFmpeg does this too and cites the "Matroska spec" as the
// reason, despite the spec not saying anything about this.
packetData = packetData.subarray(8);
}
const isKeyFrame = packet.type === 'key';
this.validateTimestamp(trackData.track, packet.timestamp, isKeyFrame);
@@ -913,7 +924,7 @@ export class MatroskaMuxer extends Muxer {
? packet.sideData.alpha ?? null
: null;
const videoChunk = this.createInternalChunk(packet.data, timestamp, duration, packet.type, additions);
const videoChunk = this.createInternalChunk(packetData, timestamp, duration, packet.type, additions);
if (track.source._codec === 'vp9') this.fixVP9ColorSpace(trackData, videoChunk);
trackData.chunkQueue.push(videoChunk);
+11 -4
View File
@@ -1011,15 +1011,22 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
if (this.codec === 'avc') {
// Workaround for https://issues.chromium.org/issues/470109459
const filteredNalUnits: Uint8Array[] = [];
let hasFrameData = false;
for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!);
hasFrameData ||= type >= 1 && type <= 5;
if (type === AvcNalUnitType.AUD) {
// If packets contain an AUD and have NALUs before it, this trips up Chromium's key frame
// detector. Clear the NALUs if an AUD is encountered.
// https://github.com/Vanilagy/mediabunny/issues/396
filteredNalUnits.length = 0;
if (hasFrameData) {
// Already has actual frame data, so treat an AUD as simply the end of the packet
break;
} else {
// If packets contain an AUD and have NALUs before it, this trips up Chromium's key
// frame detector. Clear the NALUs if an AUD is encountered.
// https://github.com/Vanilagy/mediabunny/issues/396
filteredNalUnits.length = 0;
}
}
// These trip up Chromium's key frame detection, so let's strip them
+2 -2
View File
@@ -638,11 +638,11 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
const lowPages: Page[] = [lowPage];
// First, let's perform a binary serach (bisection search) on the file to find the approximate page where
// First, let's perform a binary search (bisection search) on the file to find the approximate page where
// we'll find the packet. We want to find a page whose end packet position is less than or equal to the
// packet position we're searching for.
// Outer loop: Does the binary serach
// Outer loop: Does the binary search
outer:
while (lowPage.headerStartPos + lowPage.totalSize < high) {
const low = lowPage.headerStartPos;
+1 -1
View File
@@ -172,7 +172,7 @@ export class AudioResampler {
async add(audioSample: AudioSample) {
if (this.sourceSampleRate === null) {
// This is the first sample, so let's init the missing data. Initting the sample rate from the decoded
// This is the first sample, so let's init the missing data. Initing the sample rate from the decoded
// sample is more reliable than using the file's metadata, because decoders are free to emit any sample rate
// they see fit.
this.sourceSampleRate = audioSample.sampleRate;
+7 -3
View File
@@ -2129,7 +2129,7 @@ class ReadOrchestrator {
}
})
.finally(() => {
if (worker.running || this.workers.length >= this.options.maxWorkerCount) {
if (worker.running) {
// Rare, but can happen with multiple concurrent reads. In this case, don't do anything.
return;
}
@@ -2144,15 +2144,19 @@ class ReadOrchestrator {
}
const queuedRead = this.queuedReads[oldestIndex]!;
this.queuedReads.splice(oldestIndex, 1);
const newWorker = this.createWorker(
queuedRead.hole.start,
queuedRead.hole.end,
queuedRead.strictTarget,
);
assert(newWorker); // We just freed up a worker, so this should never fail
if (!newWorker) {
// In high-contention cases, it could be that we've already reached max worker count, so in this
// case we don't do anything.
return;
}
this.queuedReads.splice(oldestIndex, 1);
newWorker.pendingSlices = queuedRead.pendingSlices;
this.runWorker(newWorker);
}
+13 -13
View File
@@ -177,8 +177,8 @@ test('HLS track assignability is kept #1', async () => {
await output.finalize();
const masterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(1);
const masterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
expect(masterPlaylist.match(/\.m3u8/g)?.length).toBe(1);
using input = new Input({
formats: ALL_FORMATS,
@@ -208,8 +208,8 @@ test('HLS track assignability is kept #1', async () => {
const conversion = await Conversion.init({ input, output: newOutput });
await conversion.execute();
const newMasterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
expect(newMasterPlayist).toBe(masterPlayist);
const newMasterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
expect(newMasterPlaylist).toBe(masterPlaylist);
});
test('HLS track assignability is kept #2', async () => {
@@ -256,8 +256,8 @@ test('HLS track assignability is kept #2', async () => {
await output.finalize();
const masterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(2);
const masterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
expect(masterPlaylist.match(/\.m3u8/g)?.length).toBe(2);
using input = new Input({
formats: ALL_FORMATS,
@@ -287,8 +287,8 @@ test('HLS track assignability is kept #2', async () => {
const conversion = await Conversion.init({ input, output: newOutput });
await conversion.execute();
const newMasterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
expect(newMasterPlayist).toBe(masterPlayist);
const newMasterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
expect(newMasterPlaylist).toBe(masterPlaylist);
});
test('HLS track assignability can be overridden', async () => {
@@ -335,8 +335,8 @@ test('HLS track assignability can be overridden', async () => {
await output.finalize();
const masterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
expect(masterPlayist.match(/\.m3u8/g)?.length).toBe(2);
const masterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('master.m3u8')));
expect(masterPlaylist.match(/\.m3u8/g)?.length).toBe(2);
using input = new Input({
formats: ALL_FORMATS,
@@ -371,9 +371,9 @@ test('HLS track assignability can be overridden', async () => {
});
await conversion.execute();
const newMasterPlayist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
expect(newMasterPlayist).not.toBe(masterPlayist);
expect(newMasterPlayist.match(/\.m3u8/g)?.length).toBe(1);
const newMasterPlaylist = sanitizeMasterPlaylist(new TextDecoder().decode(files.get('new/master.m3u8')));
expect(newMasterPlaylist).not.toBe(masterPlaylist);
expect(newMasterPlaylist.match(/\.m3u8/g)?.length).toBe(1);
});
test('Fractional audio sample boundary', async () => {
+1 -1
View File
@@ -354,7 +354,7 @@ test('MPEG-TS seeking race condition test', async () => {
}
});
test('MPEG-TS video key packets', { timeout: 10_000 }, async () => {
test('MPEG-TS video key packets', { timeout: 30_000 }, async () => {
for (let i = 0; i < 2; i++) {
using input = new Input({
source: new FilePathSource(path.join(__dirname, '../public/trim-buck-bunny-ffmpeg.ts')),
+13 -1
View File
@@ -7,7 +7,7 @@ import { Output } from '../../src/output.js';
import { MkvOutputFormat, MovOutputFormat } from '../../src/output-format.js';
import { BufferTarget } from '../../src/target.js';
import { Conversion } from '../../src/conversion.js';
import { VideoSampleSink } from '../../src/media-sink.js';
import { EncodedPacketSink, VideoSampleSink } from '../../src/media-sink.js';
import { assert } from '../../src/misc.js';
const SAMPLE_URL = 'https://pub-1ee78aacb848486482b20a72b55b3121.r2.dev/turbores-sample.mov';
@@ -88,6 +88,10 @@ test.concurrent('ProRes transmuxing into MKV', { timeout: 10_000 }, async () =>
});
await conversion.execute();
// No 'icpf' means the frame container atom headers were successfully stripped from the ProRes packets
let str = new TextDecoder('ascii').decode(output.target.buffer!);
expect(str.includes('icpf')).toBe(false);
using newInput = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
@@ -100,6 +104,14 @@ test.concurrent('ProRes transmuxing into MKV', { timeout: 10_000 }, async () =>
const decoderConfig = (await videoTrack.getDecoderConfig())!;
expect(decoderConfig.codec).toBe('apch');
expect(decoderConfig.description).toBeUndefined();
const sink = new EncodedPacketSink(videoTrack);
const firstPacket = await sink.getFirstPacket();
assert(firstPacket);
// The frame container atom headers are added back when reading out the packets
str = new TextDecoder('ascii').decode(firstPacket.data);
expect(str.includes('icpf')).toBe(true);
});
test('Custom coder registration', { timeout: 10_000 }, async () => {