Merge main into release for tag v1.45.5

This commit is contained in:
github-actions[bot]
2026-06-02 11:36:44 +00:00
30 changed files with 528 additions and 89 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ import BlogAuthor from './components/BlogAuthor.vue';
<template v-for="(post, i) in data"> <template v-for="(post, i) in data">
<a :href="post.url" class="flex flex-col sm:flex-row items-start sm:gap-8 !text-inherit !no-underline ![font-weight:inherit] group"> <a :href="post.url" class="flex flex-col sm:flex-row items-start sm:gap-8 !text-inherit !no-underline ![font-weight:inherit] group">
<div> <div>
<img src="./assets/on-air.png" class="shrink-0 sm:w-40 rounded" /> <img :src="post.frontmatter.headerImage" class="shrink-0 sm:w-40 rounded" />
<div v-if="false" class="p-1"> <div v-if="false" class="p-1">
<BlogAuthor :frontmatter="post.frontmatter" small /> <BlogAuthor :frontmatter="post.frontmatter" small />
</div> </div>
+2 -1
View File
@@ -7,6 +7,7 @@ author: Vanilagy
authorImage: /vani.png authorImage: /vani.png
authorLink: https://github.com/Vanilagy authorLink: https://github.com/Vanilagy
authorSubtitle: Creator of Mediabunny authorSubtitle: Creator of Mediabunny
headerImage: /on-air.png
excerpt: Mediabunny v1.42.0 adds read/write support for HTTP Live Streaming (HLS). This has been in the making for the last ~4 months and is, by far, the biggest addition to Mediabunny since its original release. excerpt: Mediabunny v1.42.0 adds read/write support for HTTP Live Streaming (HLS). This has been in the making for the last ~4 months and is, by far, the biggest addition to Mediabunny since its original release.
--- ---
@@ -14,7 +15,7 @@ excerpt: Mediabunny v1.42.0 adds read/write support for HTTP Live Streaming (HLS
import BlogAuthor from '../components/BlogAuthor.vue'; import BlogAuthor from '../components/BlogAuthor.vue';
</script> </script>
<img src="../assets/on-air.png" class="rounded-2xl mb-2"> <img :src="$frontmatter.headerImage" class="rounded-2xl mb-2" />
<p class="!m-0 opacity-70">{{ $frontmatter.publishedOn }}</p> <p class="!m-0 opacity-70">{{ $frontmatter.publishedOn }}</p>
+4
View File
@@ -643,6 +643,10 @@ const source = new UrlSource('https://example.com/bigbuckbunny.mp4', {
}); });
``` ```
::: info
All `requestInit` fields are respected except for `signal`, which is overridden by Mediabunny. The same applies to the `signal` value of a `Request` passed as the first constructor argument. To cancel ongoing requests, [dispose of the input](#disposing-inputs).
:::
`getRetryDelay` can be used to control the retry logic used should a request fail. When a request fails, `getRetryDelay` should return the time to wait in seconds before the request will be retried. Returning `null` prevents further retries. `getRetryDelay` can be used to control the retry logic used should a request fail. When a request fails, `getRetryDelay` should return the time to wait in seconds before the request will be retried. Returning `null` prevents further retries.
```ts ```ts
// UrlSource using retry logic with exponential backoff: // UrlSource using retry logic with exponential backoff:
+2
View File
@@ -124,6 +124,8 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/67356781', name: 'Ahmed Rowaihi', url: 'https://github.com/ahmedrowaihi' }, { image: 'https://avatars.githubusercontent.com/u/67356781', name: 'Ahmed Rowaihi', url: 'https://github.com/ahmedrowaihi' },
{ image: 'https://avatars.githubusercontent.com/u/4714175', name: 'Phoomparin Mano', url: 'https://github.com/heypoom' }, { image: 'https://avatars.githubusercontent.com/u/4714175', name: 'Phoomparin Mano', url: 'https://github.com/heypoom' },
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' }, { image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
{ image: 'https://avatars.githubusercontent.com/u/2565549', name: 'MatthewNorton', url: 'https://github.com/MatthewNorton' },
{ image: 'https://avatars.githubusercontent.com/u/272247752', name: 'Motionik', url: 'https://github.com/Motionik' },
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' }, { image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' }, { image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' }, { image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },

Before

Width:  |  Height:  |  Size: 304 KiB

After

Width:  |  Height:  |  Size: 304 KiB

+7 -7
View File
@@ -1,12 +1,12 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"version": "1.45.4", "version": "1.45.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mediabunny", "name": "mediabunny",
"version": "1.45.4", "version": "1.45.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"workspaces": [ "workspaces": [
".", ".",
@@ -12806,7 +12806,7 @@
}, },
"packages/aac-encoder": { "packages/aac-encoder": {
"name": "@mediabunny/aac-encoder", "name": "@mediabunny/aac-encoder",
"version": "1.45.4", "version": "1.45.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"devDependencies": { "devDependencies": {
"@types/emscripten": "^1.40.1" "@types/emscripten": "^1.40.1"
@@ -12821,7 +12821,7 @@
}, },
"packages/ac3": { "packages/ac3": {
"name": "@mediabunny/ac3", "name": "@mediabunny/ac3",
"version": "1.45.4", "version": "1.45.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"devDependencies": { "devDependencies": {
"@types/emscripten": "^1.40.1" "@types/emscripten": "^1.40.1"
@@ -12836,7 +12836,7 @@
}, },
"packages/flac-encoder": { "packages/flac-encoder": {
"name": "@mediabunny/flac-encoder", "name": "@mediabunny/flac-encoder",
"version": "1.45.4", "version": "1.45.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"devDependencies": { "devDependencies": {
"@types/emscripten": "^1.40.1" "@types/emscripten": "^1.40.1"
@@ -12851,7 +12851,7 @@
}, },
"packages/mp3-encoder": { "packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder", "name": "@mediabunny/mp3-encoder",
"version": "1.45.4", "version": "1.45.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"devDependencies": { "devDependencies": {
"@types/emscripten": "^1.40.1" "@types/emscripten": "^1.40.1"
@@ -12866,7 +12866,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@mediabunny/server", "name": "@mediabunny/server",
"version": "1.45.4", "version": "1.45.5",
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"node-av": "^5.2.4" "node-av": "^5.2.4"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mediabunny", "name": "mediabunny",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.45.4", "version": "1.45.5",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.", "description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@mediabunny/aac-encoder", "name": "@mediabunny/aac-encoder",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.45.4", "version": "1.45.5",
"description": "AAC encoder extension for Mediabunny, based on FFmpeg.", "description": "AAC encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-aac-encoder.mjs", "main": "./dist/bundles/mediabunny-aac-encoder.mjs",
"module": "./dist/bundles/mediabunny-aac-encoder.mjs", "module": "./dist/bundles/mediabunny-aac-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@mediabunny/ac3", "name": "@mediabunny/ac3",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.45.4", "version": "1.45.5",
"description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.", "description": "AC-3 and E-AC-3 (Dolby Digital) decoder and encoder extension for Mediabunny, based on FFmpeg.",
"main": "./dist/bundles/mediabunny-ac3.mjs", "main": "./dist/bundles/mediabunny-ac3.mjs",
"module": "./dist/bundles/mediabunny-ac3.mjs", "module": "./dist/bundles/mediabunny-ac3.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@mediabunny/flac-encoder", "name": "@mediabunny/flac-encoder",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.45.4", "version": "1.45.5",
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.", "description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
"main": "./dist/bundles/mediabunny-flac-encoder.mjs", "main": "./dist/bundles/mediabunny-flac-encoder.mjs",
"module": "./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", "name": "@mediabunny/mp3-encoder",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.45.4", "version": "1.45.5",
"description": "MP3 encoder extension for Mediabunny, based on LAME.", "description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs", "main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs", "module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@mediabunny/server", "name": "@mediabunny/server",
"author": "Vanilagy", "author": "Vanilagy",
"version": "1.45.4", "version": "1.45.5",
"description": "Adds full video and audio decoder and encoder support to Mediabunny for use in server-side environments (Node, Bun, Deno). Based on NodeAV.", "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", "main": "./dist/bundles/mediabunny-server.cjs",
"module": "./dist/bundles/mediabunny-server.mjs", "module": "./dist/bundles/mediabunny-server.mjs",
+8 -2
View File
@@ -15,7 +15,7 @@ import { AvFrameAudioSampleResource } from './audio-sample';
export class NodeAvAudioDecoder extends CustomAudioDecoder { export class NodeAvAudioDecoder extends CustomAudioDecoder {
frame!: NodeAv.Frame; frame!: NodeAv.Frame;
packet!: NodeAv.Packet; packet!: NodeAv.Packet;
codecContext!: NodeAv.CodecContext; codecContext: NodeAv.CodecContext | null = null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean { static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
@@ -61,6 +61,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
} }
async decode(packet: EncodedPacket): Promise<void> { async decode(packet: EncodedPacket): Promise<void> {
assert(this.codecContext);
this.packet.isKeyframe = packet.type === 'key'; this.packet.isKeyframe = packet.type === 'key';
this.packet.data = Buffer.from(packet.data); this.packet.data = Buffer.from(packet.data);
this.packet.timeBase = { num: 1, den: this.config.sampleRate }; this.packet.timeBase = { num: 1, den: this.config.sampleRate };
@@ -71,6 +73,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
const ret = await this.codecContext.sendPacket(this.packet); const ret = await this.codecContext.sendPacket(this.packet);
NodeAv.FFmpegError.throwIfError(ret, 'Send packet'); NodeAv.FFmpegError.throwIfError(ret, 'Send packet');
this.packet.unref(); // Don't need the data again, so just unref it
while (true) { while (true) {
const receiveRet = await this.codecContext.receiveFrame(this.frame); const receiveRet = await this.codecContext.receiveFrame(this.frame);
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) { if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
@@ -94,6 +98,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
} }
async flush(): Promise<void> { async flush(): Promise<void> {
assert(this.codecContext);
// Send null packet to signal flush // Send null packet to signal flush
const ret = await this.codecContext.sendPacket(null); const ret = await this.codecContext.sendPacket(null);
NodeAv.FFmpegError.throwIfError(ret, 'Flush decoder'); NodeAv.FFmpegError.throwIfError(ret, 'Flush decoder');
@@ -113,7 +119,7 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
} }
close(): MaybePromise<void> { close(): MaybePromise<void> {
this.codecContext.freeContext(); this.codecContext?.freeContext();
this.frame.free(); this.frame.free();
this.packet.free(); this.packet.free();
} }
+7 -3
View File
@@ -35,16 +35,16 @@ const FRAME_SIZE_FALLBACK = 1024; // Just 'cause
export class NodeAvAudioEncoder extends CustomAudioEncoder { export class NodeAvAudioEncoder extends CustomAudioEncoder {
frame!: NodeAv.Frame; frame!: NodeAv.Frame;
packet!: NodeAv.Packet; packet!: NodeAv.Packet;
avCodec!: NodeAv.Codec;
codecContext: NodeAv.CodecContext | null = null; codecContext: NodeAv.CodecContext | null = null;
resampler: NodeAv.SoftwareResampleContext | null = null;
dstFrame: NodeAv.Frame | null = null;
avCodec!: NodeAv.Codec;
firstExpectedTimestamp: number | null = null; firstExpectedTimestamp: number | null = null;
outputTimestampOffset = 0; outputTimestampOffset = 0;
resampler: NodeAv.SoftwareResampleContext | null = null;
inputParametersKey: string | null = null; inputParametersKey: string | null = null;
resamplerInputSampleRate: number | null = null; resamplerInputSampleRate: number | null = null;
nextResamplerPts: bigint | null = null; nextResamplerPts: bigint | null = null;
dstFrame: NodeAv.Frame | null = null;
packetEmitted = false; packetEmitted = false;
adtsHeaderTemplate: AdtsHeaderTemplate | null = null; adtsHeaderTemplate: AdtsHeaderTemplate | null = null;
@@ -134,6 +134,10 @@ export class NodeAvAudioEncoder extends CustomAudioEncoder {
this.firstExpectedTimestamp ??= audioSample.timestamp; this.firstExpectedTimestamp ??= audioSample.timestamp;
if (audioSample._data instanceof AvFrameAudioSampleResource) { if (audioSample._data instanceof AvFrameAudioSampleResource) {
// Release any buffers still referenced from the previous encode before reffing the new frame, otherwise
// av_frame_ref leaks them
// https://github.com/Vanilagy/mediabunny/issues/392
this.frame.unref();
this.frame.ref(audioSample._data.frame); this.frame.ref(audioSample._data.frame);
} else { } else {
copyAudioSampleToAvFrame(audioSample, this.frame); copyAudioSampleToAvFrame(audioSample, this.frame);
+4
View File
@@ -78,6 +78,8 @@ export { AvFrameAudioSampleResource } from './audio-sample';
export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv.Frame) => { export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv.Frame) => {
if (sample instanceof VideoSample) { if (sample instanceof VideoSample) {
if (sample._data instanceof AvFrameVideoSampleResource) { if (sample._data instanceof AvFrameVideoSampleResource) {
// We're overriding the frame, so release whatever it referenced before, otherwise av_frame_ref leaks it
frame.unref();
frame.ref(sample._data.frame); frame.ref(sample._data.frame);
} else { } else {
if (sample.format === null) { if (sample.format === null) {
@@ -92,6 +94,8 @@ export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv
frame.timeBase = new NodeAv.Rational(1, 1e6); frame.timeBase = new NodeAv.Rational(1, 1e6);
} else { } else {
if (sample._data instanceof AvFrameAudioSampleResource) { if (sample._data instanceof AvFrameAudioSampleResource) {
// We're overriding the frame, so release whatever it referenced before, otherwise av_frame_ref leaks it
frame.unref();
frame.ref(sample._data.frame); frame.ref(sample._data.frame);
} else { } else {
copyAudioSampleToAvFrame(sample, frame); copyAudioSampleToAvFrame(sample, frame);
+4 -1
View File
@@ -91,9 +91,10 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
async decode(packet: EncodedPacket) { async decode(packet: EncodedPacket) {
if (this.codecContext === null) { if (this.codecContext === null) {
await this.initCodecContext(packet); await this.initCodecContext(packet);
assert(this.codecContext);
} }
assert(this.codecContext);
this.packet.isKeyframe = packet.type === 'key'; this.packet.isKeyframe = packet.type === 'key';
this.packet.data = Buffer.from(packet.data); this.packet.data = Buffer.from(packet.data);
this.packet.timeBase = { num: 1, den: 1e6 }; this.packet.timeBase = { num: 1, den: 1e6 };
@@ -144,6 +145,8 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
const ret = await this.codecContext.sendPacket(this.packet); const ret = await this.codecContext.sendPacket(this.packet);
NodeAv.FFmpegError.throwIfError(ret, 'Send packet'); NodeAv.FFmpegError.throwIfError(ret, 'Send packet');
this.packet.unref(); // Don't need the data again, so just unref it
while (true) { while (true) {
const receiveRet = await this.codecContext.receiveFrame(this.frame); const receiveRet = await this.codecContext.receiveFrame(this.frame);
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) { if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
+10 -9
View File
@@ -44,14 +44,13 @@ import { assert, binarySearchLessOrEqual, simplifyRational, toUint8Array } from
export class NodeAvVideoEncoder extends CustomVideoEncoder { export class NodeAvVideoEncoder extends CustomVideoEncoder {
frame!: NodeAv.Frame; frame!: NodeAv.Frame;
packet!: NodeAv.Packet; packet!: NodeAv.Packet;
avCodec!: NodeAv.Codec;
codecContext: NodeAv.CodecContext | null = null; codecContext: NodeAv.CodecContext | null = null;
scaler: NodeAv.SoftwareScaleContext | null = null;
dstFrame: NodeAv.Frame | null = null;
avCodec!: NodeAv.Codec;
lastBuffer: Buffer | null = null; lastBuffer: Buffer | null = null;
packetEmitted = false; packetEmitted = false;
scaler: NodeAv.SoftwareScaleContext | null = null;
lastScalerKey: string | null = null; lastScalerKey: string | null = null;
dstFrame: NodeAv.Frame | null = null;
// Bookkeeping to restore the original timing information // Bookkeeping to restore the original timing information
preciseTimings: { preciseTimings: {
@@ -183,10 +182,14 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
async encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise<void> { async encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise<void> {
if (this.codecContext === null) { if (this.codecContext === null) {
await this.createCodecContext(); await this.createCodecContext();
assert(this.codecContext);
} }
assert(this.codecContext);
if (videoSample._data instanceof AvFrameVideoSampleResource) { if (videoSample._data instanceof AvFrameVideoSampleResource) {
// Release any buffers still referenced from the previous encode before reffing the new frame, otherwise
// av_frame_ref leaks them
// https://github.com/Vanilagy/mediabunny/issues/392
this.frame.unref();
this.frame.ref(videoSample._data.frame); this.frame.ref(videoSample._data.frame);
} else { } else {
if (videoSample.format === null) { if (videoSample.format === null) {
@@ -513,10 +516,8 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
displayAspectHeight: this.config.displayHeight ?? this.codecContext.height, displayAspectHeight: this.config.displayHeight ?? this.codecContext.height,
description: decoderConfigDescription ?? undefined, description: decoderConfigDescription ?? undefined,
colorSpace: { colorSpace: {
primaries: primaries: unmapColorPrimaries(this.codecContext.colorPrimaries) as VideoColorPrimaries,
unmapColorPrimaries(this.codecContext.colorPrimaries) as VideoColorPrimaries, matrix: unmapMatrixCoefficients(this.codecContext.colorSpace) as VideoMatrixCoefficients,
matrix:
unmapMatrixCoefficients(this.codecContext.colorSpace) as VideoMatrixCoefficients,
transfer: transfer:
unmapTransferCharacteristics(this.codecContext.colorTrc) as VideoTransferCharacteristics, unmapTransferCharacteristics(this.codecContext.colorTrc) as VideoTransferCharacteristics,
fullRange: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_JPEG fullRange: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_JPEG
+1 -1
View File
@@ -691,7 +691,7 @@ export class InputVideoTrack extends InputTrack {
const colorSpace = await this._backing.getColorSpace(); const colorSpace = await this._backing.getColorSpace();
return (colorSpace.primaries as string) === 'bt2020' || (colorSpace.primaries as string) === 'smpte432' return (colorSpace.primaries as string) === 'bt2020' || (colorSpace.primaries as string) === 'smpte432'
|| (colorSpace.transfer as string) === 'pg' || (colorSpace.transfer as string) === 'hlg' || (colorSpace.transfer as string) === 'pq' || (colorSpace.transfer as string) === 'hlg'
|| (colorSpace.matrix as string) === 'bt2020-ncl'; || (colorSpace.matrix as string) === 'bt2020-ncl';
} }
+7 -3
View File
@@ -378,12 +378,14 @@ export const free = (size: number): Box => ({ type: 'free', size });
* Movie Box: Used to specify the information that defines a movie - that is, the information that allows * Movie Box: Used to specify the information that defines a movie - that is, the information that allows
* an application to interpret the sample data that is stored elsewhere. * an application to interpret the sample data that is stored elsewhere.
*/ */
export const moov = (muxer: IsobmffMuxer) => box('moov', undefined, [ export const moov = (muxer: IsobmffMuxer) => {
return box('moov', undefined, [
mvhd(muxer.creationTime, muxer.trackDatas), mvhd(muxer.creationTime, muxer.trackDatas),
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)), ...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
muxer.isFragmented ? mvex(muxer.trackDatas) : null, muxer.isFragmented ? mvex(muxer.trackDatas) : null,
udta(muxer), udta(muxer),
]); ]);
};
/** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */ /** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */
export const mvhd = ( export const mvhd = (
@@ -739,11 +741,13 @@ export const pasp = (trackData: IsobmffVideoTrackData) => {
/** Colour Information Box: Specifies the color space of the video. */ /** Colour Information Box: Specifies the color space of the video. */
export const colr = (trackData: IsobmffVideoTrackData) => box('colr', [ export const colr = (trackData: IsobmffVideoTrackData) => box('colr', [
ascii('nclx'), // Colour type ascii(trackData.muxer.isQuickTime ? 'nclc' : 'nclx'), // Colour type
u16(COLOR_PRIMARIES_MAP[trackData.info.decoderConfig.colorSpace!.primaries!]), // Colour primaries u16(COLOR_PRIMARIES_MAP[trackData.info.decoderConfig.colorSpace!.primaries!]), // Colour primaries
u16(TRANSFER_CHARACTERISTICS_MAP[trackData.info.decoderConfig.colorSpace!.transfer!]), // Transfer characteristics u16(TRANSFER_CHARACTERISTICS_MAP[trackData.info.decoderConfig.colorSpace!.transfer!]), // Transfer characteristics
u16(MATRIX_COEFFICIENTS_MAP[trackData.info.decoderConfig.colorSpace!.matrix!]), // Matrix coefficients u16(MATRIX_COEFFICIENTS_MAP[trackData.info.decoderConfig.colorSpace!.matrix!]), // Matrix coefficients
u8((trackData.info.decoderConfig.colorSpace!.fullRange ? 1 : 0) << 7), // Full range flag trackData.muxer.isQuickTime
? [] // Doesn't have it
: u8((trackData.info.decoderConfig.colorSpace!.fullRange ? 1 : 0) << 7), // Full range flag
]); ]);
/** AVC Configuration Box: Provides additional information to the decoder. */ /** AVC Configuration Box: Provides additional information to the decoder. */
+7 -3
View File
@@ -1448,20 +1448,24 @@ export class IsobmffDemuxer extends Demuxer {
assert(track.info?.type === 'video'); assert(track.info?.type === 'video');
const colourType = readAscii(slice, 4); const colourType = readAscii(slice, 4);
if (colourType !== 'nclx') { if (colourType !== 'nclx' && colourType !== 'nclc') {
break; break;
} }
const colourPrimaries = readU16Be(slice); const colourPrimaries = readU16Be(slice);
const transferCharacteristics = readU16Be(slice); const transferCharacteristics = readU16Be(slice);
const matrixCoefficients = readU16Be(slice); const matrixCoefficients = readU16Be(slice);
const fullRangeFlag = Boolean(readU8(slice) & 0x80);
let fullRange: boolean | undefined = undefined;
if (colourType === 'nclx') {
fullRange = Boolean(readU8(slice) & 0x80);
}
track.info.colorSpace = { track.info.colorSpace = {
primaries: COLOR_PRIMARIES_MAP_INVERSE[colourPrimaries], primaries: COLOR_PRIMARIES_MAP_INVERSE[colourPrimaries],
transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics], transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics],
matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients], matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients],
fullRange: fullRangeFlag, fullRange,
} as VideoColorSpaceInit; } as VideoColorSpaceInit;
}; break; }; break;
+28 -1
View File
@@ -22,7 +22,7 @@ import {
vtte, vtte,
} from './isobmff-boxes'; } from './isobmff-boxes';
import { Muxer } from '../muxer'; import { Muxer } from '../muxer';
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output'; import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack, TrackType } from '../output';
import { Writer } from '../writer'; import { Writer } from '../writer';
import { BufferTarget } from '../target'; import { BufferTarget } from '../target';
import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc'; import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc';
@@ -1183,6 +1183,8 @@ export class IsobmffMuxer extends Muxer {
boxWriter.writer.startTrackingWrites(); boxWriter.writer.startTrackingWrites();
} }
this.ensureOneEnabledTrack();
// Write the moov box now that we have all decoder configs // Write the moov box now that we have all decoder configs
const movieBox = moov(this); const movieBox = moov(this);
boxWriter.writeBox(movieBox); boxWriter.writeBox(movieBox);
@@ -1299,6 +1301,8 @@ export class IsobmffMuxer extends Muxer {
if (this.allTracksAreKnown()) { if (this.allTracksAreKnown()) {
if (!this.mdat) { if (!this.mdat) {
this.ensureOneEnabledTrack();
// We finally know all tracks, let's reserve space for the moov box // We finally know all tracks, let's reserve space for the moov box
const moovBox = moov(this); const moovBox = moov(this);
const moovSize = this.boxWriter.measureBox(moovBox); const moovSize = this.boxWriter.measureBox(moovBox);
@@ -1389,11 +1393,34 @@ export class IsobmffMuxer extends Muxer {
release(); release();
} }
ensureOneEnabledTrack() {
// If no track of a given type is enabled, force the first one to be enabled. Otherwise the video won't play in
// players like QuickTime.
// https://github.com/Vanilagy/mediabunny/pull/391
for (const type of ['video', 'audio', 'subtitle'] as TrackType[]) {
const tracks = this.trackDatas.filter(t => t.type === type);
if (tracks.length === 0) {
continue;
}
const hasEnabled = tracks.some(t => t.track.metadata.disposition?.default !== false);
if (!hasEnabled) {
const firstTrack = tracks[0]!;
firstTrack.track.metadata.disposition = {
...firstTrack.track.metadata.disposition,
default: true,
};
}
}
}
/** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */ /** Finalizes the file, making it ready for use. Must be called after all video and audio chunks have been added. */
async finalize() { async finalize() {
const release = await this.mutex.acquire(); const release = await this.mutex.acquire();
this.allTracksKnown.resolve(); this.allTracksKnown.resolve();
this.ensureOneEnabledTrack();
for (const trackData of this.trackDatas) { for (const trackData of this.trackDatas) {
trackData.closed = true; trackData.closed = true;
+9
View File
@@ -8,6 +8,7 @@
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec'; import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
import { import {
AvcNalUnitType,
concatAvcNalUnits, concatAvcNalUnits,
deserializeAvcDecoderConfigurationRecord, deserializeAvcDecoderConfigurationRecord,
determineVideoPacketType, determineVideoPacketType,
@@ -997,6 +998,14 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) { for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!); const type = extractNalUnitTypeForAvc(packet.data[loc.offset]!);
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;
}
// These trip up Chromium's key frame detection, so let's strip them // These trip up Chromium's key frame detection, so let's strip them
if (!(type >= 20 && type <= 31)) { if (!(type >= 20 && type <= 31)) {
filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length)); filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));
+1 -1
View File
@@ -524,7 +524,7 @@ export const mergeRequestInit = (init1: RequestInit, init2: RequestInit): Reques
}; };
/** Normalizes HeadersInit to a Record<string, string> format. */ /** Normalizes HeadersInit to a Record<string, string> format. */
const normalizeHeaders = (headers: HeadersInit): Record<string, string> => { export const normalizeHeaders = (headers: HeadersInit): Record<string, string> => {
if (headers instanceof Headers) { if (headers instanceof Headers) {
const result: Record<string, string> = {}; const result: Record<string, string> = {};
headers.forEach((value, key) => { headers.forEach((value, key) => {
+5 -4
View File
@@ -120,15 +120,16 @@ export class Reader {
return this.requestSlice(0, this.fileSizeNonStrict); return this.requestSlice(0, this.fileSizeNonStrict);
} }
const startOffset = chunks.length * CHUNK_SIZE; let slice = this.requestSliceRange(currentSize, 0, CHUNK_SIZE);
let slice = this.requestSliceRange(startOffset, 0, CHUNK_SIZE);
if (slice instanceof Promise) slice = await slice; if (slice instanceof Promise) slice = await slice;
if (!slice) { if (!slice || slice.length === 0) {
break; break;
} }
chunks.push(readBytes(slice, slice.length)); const chunk = readBytes(slice, slice.length);
chunks.push(chunk);
currentSize += slice.length; currentSize += slice.length;
} }
+116 -24
View File
@@ -17,6 +17,7 @@ import {
isWebKit, isWebKit,
MaybePromise, MaybePromise,
mergeRequestInit, mergeRequestInit,
normalizeHeaders,
polyfillSymbolDispose, polyfillSymbolDispose,
promiseWithResolvers, promiseWithResolvers,
retriedFetch, retriedFetch,
@@ -675,10 +676,10 @@ export type UrlSourceOptions = {
* The [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) used by the Fetch API. Can be * The [`RequestInit`](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit) used by the Fetch API. Can be
* used to further control the requests, such as setting custom headers. * used to further control the requests, such as setting custom headers.
* *
* All fields will work except for `signal` and `headers.Range`; these will be overridden by Mediabunny. If you want * The `signal` field is not available, as Mediabunny controls request cancellation internally. If you want to
* to cancel ongoing requests, use {@link Input.dispose}. * cancel ongoing requests, use {@link Input.dispose}.
*/ */
requestInit?: RequestInit; requestInit?: Omit<RequestInit, 'signal'>;
/** /**
* A function that returns the delay (in seconds) before retrying a failed request. The function is called * A function that returns the delay (in seconds) before retrying a failed request. The function is called
@@ -717,6 +718,12 @@ export class UrlSource extends PathedSource {
/** @internal */ /** @internal */
_options: UrlSourceOptions; _options: UrlSourceOptions;
/** @internal */ /** @internal */
_requestInit: RequestInit;
/** @internal */
_offset = 0;
/** @internal */
_length: number | null = null;
/** @internal */
_orchestrator: ReadOrchestrator; _orchestrator: ReadOrchestrator;
/** /**
* Note that this value being true does NOT mean the file size can't change anymore; it just signals that we have at * Note that this value being true does NOT mean the file size can't change anymore; it just signals that we have at
@@ -728,8 +735,8 @@ export class UrlSource extends PathedSource {
/** /**
* Creates a new {@link UrlSource} backed by the resource at the specified URL. * Creates a new {@link UrlSource} backed by the resource at the specified URL.
* *
* When passing a `Request` instance, note that the `signal` and `headers.Range` options will be overridden by * When passing a `Request` instance, note that its `signal` will be overridden by Mediabunny; if you want to cancel
* Mediabunny. If you want to cancel ongoing requests, use {@link Input.dispose}. * ongoing requests, use {@link Input.dispose}.
*/ */
constructor( constructor(
url: string | URL | Request, url: string | URL | Request,
@@ -777,6 +784,42 @@ export class UrlSource extends PathedSource {
this._options = options; this._options = options;
this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY; this._getRetryDelay = options.getRetryDelay ?? DEFAULT_RETRY_DELAY;
// A user-supplied Range header is interpreted as a byte offset (and optional length) into the resource. We
// pull it out of the request and remember it for subsequent requests.
this._requestInit = { ...options.requestInit };
let rangeHeaderValue: string | null = null;
if (options.requestInit?.headers) {
const headers = { ...normalizeHeaders(options.requestInit.headers) };
const rangeKey = Object.keys(headers).find(key => key.toLowerCase() === 'range');
if (rangeKey !== undefined) {
rangeHeaderValue = headers[rangeKey]!;
delete headers[rangeKey];
this._requestInit.headers = headers;
}
}
if (url instanceof Request) {
const requestRange = url.headers.get('Range');
if (requestRange !== null) {
rangeHeaderValue ??= requestRange;
// Clone the request so we don't mutate the user's object, then strip the Range header
const strippedRequest = new Request(url);
strippedRequest.headers.delete('Range');
this._url = strippedRequest;
}
}
if (rangeHeaderValue !== null) {
const parsed = parseByteRangeHeader(rangeHeaderValue);
if (parsed) {
this._offset = parsed.offset;
this._length = parsed.length;
}
}
// Most files in the real-world have a single sequential access pattern, but having two in parallel can // Most files in the real-world have a single sequential access pattern, but having two in parallel can
// also happen // also happen
const DEFAULT_PARALLELISM = 2; const DEFAULT_PARALLELISM = 2;
@@ -791,9 +834,16 @@ export class UrlSource extends PathedSource {
/** @internal */ /** @internal */
_getFileSize(): number | null | undefined { _getFileSize(): number | null | undefined {
return this._fileSizeDetermined if (!this._fileSizeDetermined) {
? this._orchestrator.fileSize return this._length !== null ? this._length : undefined;
: undefined; }
const baseSize = this._orchestrator.fileSize;
if (baseSize === null) {
return this._length !== null ? this._length : null;
}
return clamp(baseSize - this._offset, 0, this._length ?? Infinity);
} }
/** @internal */ /** @internal */
@@ -803,7 +853,32 @@ export class UrlSource extends PathedSource {
minReadPosition: number, minReadPosition: number,
maxReadPosition: number, maxReadPosition: number,
): MaybePromise<ReadResult | null> { ): MaybePromise<ReadResult | null> {
return this._orchestrator.read(start, end, minReadPosition, maxReadPosition); if (this._length !== null && end > this._length) {
return null;
}
const offset = this._offset;
const result = this._orchestrator.read(
offset + start,
offset + end,
Math.max(offset + minReadPosition, offset),
offset + Math.min(maxReadPosition, this._length ?? Infinity),
);
const processResult = (result: ReadResult | null) => {
if (!result) {
return null;
}
result.offset -= this._offset;
return result;
};
if (result instanceof Promise) {
return result.then(processResult);
} else {
return processResult(result);
}
} }
/** @internal */ /** @internal */
@@ -814,7 +889,7 @@ export class UrlSource extends PathedSource {
const response = await retriedFetch( const response = await retriedFetch(
this._options.fetchFn ?? fetch, this._options.fetchFn ?? fetch,
this._url, this._url,
mergeRequestInit(this._options.requestInit ?? {}, { mergeRequestInit(this._requestInit, {
headers: { headers: {
// Always sending a range request is a good way to probe if the server supports them // Always sending a range request is a good way to probe if the server supports them
Range: `bytes=${worker.currentPos}-`, Range: `bytes=${worker.currentPos}-`,
@@ -972,6 +1047,26 @@ export class UrlSource extends PathedSource {
} }
} }
const BYTE_RANGE_REGEX = /^bytes=(\d+)-(\d*)$/;
const parseByteRangeHeader = (value: string) => {
const match = BYTE_RANGE_REGEX.exec(value.trim());
if (!match) {
return null;
}
const offset = Number(match[1]);
const end = match[2] === '' ? null : Number(match[2]);
if (end !== null && end < offset) {
return null;
}
return {
offset,
length: end !== null ? end - offset + 1 : null,
};
};
/** /**
* Options for {@link FilePathSource}. * Options for {@link FilePathSource}.
* @group Input sources * @group Input sources
@@ -2422,22 +2517,19 @@ export class RangedSource extends Source {
this._offset + maxReadPosition, this._offset + maxReadPosition,
); );
const processResult = (result: ReadResult | null) => {
if (!result) {
return null;
}
result.offset -= this._offset;
return result;
};
if (result instanceof Promise) { if (result instanceof Promise) {
return result.then((result) => { return result.then(processResult);
if (!result) {
return null;
}
result.offset -= this._offset;
return result;
});
} else { } else {
if (!result) { return processResult(result);
return null;
}
result.offset -= this._offset;
return result;
} }
} }
@@ -1,19 +0,0 @@
import { expect, test } from 'vitest';
import { UrlSource } from '../../src/source.js';
import { ALL_FORMATS } from '../../src/input-format.js';
import { Input } from '../../src/input.js';
test('Should be able to load a very small video file via URL (<512 kB)', async () => {
const source = new UrlSource('/frames.webm');
using input = new Input({
source,
formats: ALL_FORMATS,
});
const primaryVideoTrack = await input.getPrimaryVideoTrack();
if (!primaryVideoTrack) {
throw new Error('No video track found');
};
const duration = await primaryVideoTrack.computeDuration();
expect(duration).toBeCloseTo(3.33333);
});
+72
View File
@@ -0,0 +1,72 @@
import { expect, test } from 'vitest';
import { UrlSource } from '../../src/source.js';
import { ALL_FORMATS } from '../../src/input-format.js';
import { Input } from '../../src/input.js';
import { Reader, readBytes } from '../../src/reader.js';
test('Should be able to load a very small video file via URL (<512 kB)', async () => {
const source = new UrlSource('/frames.webm');
using input = new Input({
source,
formats: ALL_FORMATS,
});
const primaryVideoTrack = await input.getPrimaryVideoTrack();
if (!primaryVideoTrack) {
throw new Error('No video track found');
};
const duration = await primaryVideoTrack.computeDuration();
expect(duration).toBeCloseTo(3.33333);
});
test('requestInit with Range', async () => {
const url = makeRampedUrl();
const source = new UrlSource(url, {
requestInit: {
headers: { Range: 'bytes=10-' },
},
});
const reader = new Reader(source);
const slice = await reader.requestSlice(0, 5);
expect(slice).not.toBeNull();
expect([...readBytes(slice!, 5)]).toEqual([10, 11, 12, 13, 14]);
expect(reader.fileSize).toBe(256 - 10);
URL.revokeObjectURL(url);
});
test('Request with Range', async () => {
const url = makeRampedUrl();
const request = new Request(url, {
headers: { Range: 'bytes=20-29' },
});
const source = new UrlSource(request);
const reader = new Reader(source);
const slice = await reader.requestSlice(2, 4);
expect(slice).not.toBeNull();
expect([...readBytes(slice!, 4)]).toEqual([22, 23, 24, 25]);
expect(reader.fileSize).toBe(10);
expect(await reader.requestSlice(8, 4)).toBeNull();
URL.revokeObjectURL(url);
});
const makeRampedUrl = () => {
const data = new Uint8Array(256);
for (let i = 0; i < data.length; i++) {
data[i] = i;
}
const blob = new Blob([data.buffer]);
return URL.createObjectURL(blob);
};
+35
View File
@@ -352,3 +352,38 @@ test('PCM audio, no silence padding with approximate timestamps', async () => {
expect(frameCount).toBe(expectedFrameCount); expect(frameCount).toBe(expectedFrameCount);
}); });
// https://github.com/Vanilagy/mediabunny/pull/391
test('At least one track is enabled even if all are added disabled', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const meta = { decoderConfig: { codec: 'vp8', codedWidth: 1280, codedHeight: 720 } };
const source1 = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source1, { disposition: { default: false } });
const source2 = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source2, { disposition: { default: false } });
await output.start();
await source1.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1), meta);
await source2.add(new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1), meta);
await output.finalize();
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const tracks = await input.getVideoTracks();
expect(tracks.length).toBe(2);
// Even though both tracks were added disabled, the muxer forces the first one to be enabled
expect((await tracks[0]!.getDisposition()).default).toBe(true);
expect((await tracks[1]!.getDisposition()).default).toBe(false);
});
+108 -1
View File
@@ -1,6 +1,19 @@
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
import path from 'node:path'; import path from 'node:path';
import { ALL_FORMATS, MP4, EncodedPacketSink, Input, FilePathSource } from '../../src/index.js'; import {
ALL_FORMATS,
BufferSource,
BufferTarget,
EncodedPacket,
EncodedPacketSink,
EncodedVideoPacketSource,
FilePathSource,
Input,
MP4,
MovOutputFormat,
Mp4OutputFormat,
Output,
} from '../../src/index.js';
const __dirname = new URL('.', import.meta.url).pathname; const __dirname = new URL('.', import.meta.url).pathname;
@@ -33,3 +46,97 @@ test('Should be able to get packets from a .MP4 file', async () => {
0, 0.16, 0.08, 0.04, 0.12, 0.32, 0.24, 0.2, 0.28, 0.48, 0, 0.16, 0.08, 0.04, 0.12, 0.32, 0.24, 0.2, 0.28, 0.48,
]); ]);
}); });
test('MP4 nclx color information', async () => {
const output = new Output({
format: new Mp4OutputFormat(),
target: new BufferTarget(),
});
const source = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source);
await output.start();
await source.add(
new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1),
{
decoderConfig: {
codec: 'vp8',
codedWidth: 1280,
codedHeight: 720,
colorSpace: {
primaries: 'bt2020' as VideoColorPrimaries,
transfer: 'pq' as VideoTransferCharacteristics,
matrix: 'bt2020-ncl' as VideoMatrixCoefficients,
fullRange: false,
},
},
},
);
await output.finalize();
const str = String.fromCharCode(...new Uint8Array(output.target.buffer!));
expect(str.includes('nclc')).toBe(false);
expect(str.includes('nclx')).toBe(true);
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryVideoTrack();
if (!track) throw new Error('No video track found');
expect(await track.getColorSpace()).toEqual({
primaries: 'bt2020',
transfer: 'pq',
matrix: 'bt2020-ncl',
fullRange: false,
});
expect(await track.hasHighDynamicRange()).toBe(true);
});
test('QuickTime nclc color information', async () => {
const output = new Output({
format: new MovOutputFormat(),
target: new BufferTarget(),
});
const source = new EncodedVideoPacketSource('vp8');
output.addVideoTrack(source);
await output.start();
await source.add(
new EncodedPacket(new Uint8Array(1024), 'key', 0, 0.1),
{
decoderConfig: {
codec: 'vp8',
codedWidth: 1280,
codedHeight: 720,
colorSpace: {
primaries: 'bt2020' as VideoColorPrimaries,
transfer: 'pq' as VideoTransferCharacteristics,
matrix: 'bt2020-ncl' as VideoMatrixCoefficients,
fullRange: false,
},
},
},
);
await output.finalize();
const str = String.fromCharCode(...new Uint8Array(output.target.buffer!));
expect(str.includes('nclc')).toBe(true);
expect(str.includes('nclx')).toBe(false);
using input = new Input({
source: new BufferSource(output.target.buffer!),
formats: ALL_FORMATS,
});
const track = await input.getPrimaryVideoTrack();
if (!track) throw new Error('No video track found');
expect(await track.getColorSpace()).toEqual({
primaries: 'bt2020',
transfer: 'pq',
matrix: 'bt2020-ncl',
fullRange: undefined,
});
expect(await track.hasHighDynamicRange()).toBe(true);
});
+82
View File
@@ -871,6 +871,88 @@ describe('Video', async () => {
expect(plane[3]).toBe(0xff); expect(plane[3]).toBe(0xff);
}); });
// https://github.com/Vanilagy/mediabunny/issues/392
test('Memory doesn\'t leak', async () => {
const encoder = new NodeAvVideoEncoder();
// @ts-expect-error Readonly
encoder.codec = 'avc';
// @ts-expect-error Readonly
encoder.config = {
codec: buildVideoCodecString('avc', 1920, 1080, 1e6),
width: 1920,
height: 1080,
bitrate: 1e6,
} satisfies VideoEncoderConfig;
const pendingPackets: EncodedPacket[] = [];
const decodedFrames: VideoSample[] = [];
let decoderConfig: VideoDecoderConfig | null = null;
// @ts-expect-error Readonly
encoder.onPacket = (packet: EncodedPacket, meta: EncodedVideoChunkMetadata) => {
if (meta.decoderConfig) {
decoderConfig = meta.decoderConfig;
}
pendingPackets.push(packet);
};
await encoder.init();
// Encode a single white frame to get one packet (and the decoder config) to drive the loop with
const data = new Uint8Array(1920 * 1080 * 4).fill(0xff); // White
using bootstrapSample = new VideoSample(data, {
format: 'RGBX',
codedWidth: 1920,
codedHeight: 1080,
timestamp: 0,
duration: 1 / 30,
});
await encoder.encode(bootstrapSample, {});
await encoder.flush();
assert(decoderConfig);
assert(pendingPackets.length > 0);
const sourcePacket = pendingPackets[0]!;
pendingPackets.length = 0;
const decoder = new NodeAvVideoDecoder();
// @ts-expect-error Readonly
decoder.codec = 'avc';
// @ts-expect-error Readonly
decoder.config = decoderConfig;
// @ts-expect-error Readonly
decoder.onSample = (sample: VideoSample) => {
decodedFrames.push(sample);
};
await decoder.init();
const iterations = 300;
let baselineRss = 0;
for (let i = 1; i <= iterations; i++) {
await decoder.decode(sourcePacket.clone({ timestamp: i / 30 }));
for (const frame of decodedFrames) {
await encoder.encode(frame, {});
frame.close();
}
decodedFrames.length = 0;
pendingPackets.length = 0;
if (i === 100) { // Should have stabilized by then
baselineRss = process.memoryUsage().rss;
}
}
const finalRss = process.memoryUsage().rss;
const growth = finalRss - baselineRss;
expect(growth).toBeLessThan(10 * 1024 * 1024);
await encoder.close();
await decoder.close();
});
describe('VideoSample transformation', () => { describe('VideoSample transformation', () => {
// 400x400 image: red everywhere, with a 200x200 blue square filling the bottom-left quadrant. // 400x400 image: red everywhere, with a 200x200 blue square filling the bottom-left quadrant.
const TEST_IMAGE = (() => { const TEST_IMAGE = (() => {