mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
Merge main into release for tag v1.45.5
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@ import BlogAuthor from './components/BlogAuthor.vue';
|
||||
<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">
|
||||
<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">
|
||||
<BlogAuthor :frontmatter="post.frontmatter" small />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ author: Vanilagy
|
||||
authorImage: /vani.png
|
||||
authorLink: https://github.com/Vanilagy
|
||||
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.
|
||||
---
|
||||
|
||||
@@ -14,7 +15,7 @@ excerpt: Mediabunny v1.42.0 adds read/write support for HTTP Live Streaming (HLS
|
||||
import BlogAuthor from '../components/BlogAuthor.vue';
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
```ts
|
||||
// UrlSource using retry logic with exponential backoff:
|
||||
|
||||
@@ -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/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/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/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
|
||||
{ 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 |
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -12806,7 +12806,7 @@
|
||||
},
|
||||
"packages/aac-encoder": {
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12821,7 +12821,7 @@
|
||||
},
|
||||
"packages/ac3": {
|
||||
"name": "@mediabunny/ac3",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12836,7 +12836,7 @@
|
||||
},
|
||||
"packages/flac-encoder": {
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12851,7 +12851,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12866,7 +12866,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@mediabunny/server",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"node-av": "^5.2.4"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"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.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"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.45.4",
|
||||
"version": "1.45.5",
|
||||
"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,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"description": "FLAC encoder extension for Mediabunny, based on libFLAC.",
|
||||
"main": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-flac-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.4",
|
||||
"version": "1.45.5",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/server",
|
||||
"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.",
|
||||
"main": "./dist/bundles/mediabunny-server.cjs",
|
||||
"module": "./dist/bundles/mediabunny-server.mjs",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { AvFrameAudioSampleResource } from './audio-sample';
|
||||
export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
frame!: NodeAv.Frame;
|
||||
packet!: NodeAv.Packet;
|
||||
codecContext!: NodeAv.CodecContext;
|
||||
codecContext: NodeAv.CodecContext | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
|
||||
@@ -61,6 +61,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
}
|
||||
|
||||
async decode(packet: EncodedPacket): Promise<void> {
|
||||
assert(this.codecContext);
|
||||
|
||||
this.packet.isKeyframe = packet.type === 'key';
|
||||
this.packet.data = Buffer.from(packet.data);
|
||||
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);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Send packet');
|
||||
|
||||
this.packet.unref(); // Don't need the data again, so just unref it
|
||||
|
||||
while (true) {
|
||||
const receiveRet = await this.codecContext.receiveFrame(this.frame);
|
||||
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
||||
@@ -94,6 +98,8 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
assert(this.codecContext);
|
||||
|
||||
// Send null packet to signal flush
|
||||
const ret = await this.codecContext.sendPacket(null);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Flush decoder');
|
||||
@@ -113,7 +119,7 @@ export class NodeAvAudioDecoder extends CustomAudioDecoder {
|
||||
}
|
||||
|
||||
close(): MaybePromise<void> {
|
||||
this.codecContext.freeContext();
|
||||
this.codecContext?.freeContext();
|
||||
this.frame.free();
|
||||
this.packet.free();
|
||||
}
|
||||
|
||||
@@ -35,16 +35,16 @@ const FRAME_SIZE_FALLBACK = 1024; // Just 'cause
|
||||
export class NodeAvAudioEncoder extends CustomAudioEncoder {
|
||||
frame!: NodeAv.Frame;
|
||||
packet!: NodeAv.Packet;
|
||||
avCodec!: NodeAv.Codec;
|
||||
codecContext: NodeAv.CodecContext | null = null;
|
||||
resampler: NodeAv.SoftwareResampleContext | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
avCodec!: NodeAv.Codec;
|
||||
firstExpectedTimestamp: number | null = null;
|
||||
outputTimestampOffset = 0;
|
||||
|
||||
resampler: NodeAv.SoftwareResampleContext | null = null;
|
||||
inputParametersKey: string | null = null;
|
||||
resamplerInputSampleRate: number | null = null;
|
||||
nextResamplerPts: bigint | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
packetEmitted = false;
|
||||
adtsHeaderTemplate: AdtsHeaderTemplate | null = null;
|
||||
|
||||
@@ -134,6 +134,10 @@ export class NodeAvAudioEncoder extends CustomAudioEncoder {
|
||||
this.firstExpectedTimestamp ??= audioSample.timestamp;
|
||||
|
||||
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);
|
||||
} else {
|
||||
copyAudioSampleToAvFrame(audioSample, this.frame);
|
||||
|
||||
@@ -78,6 +78,8 @@ export { AvFrameAudioSampleResource } from './audio-sample';
|
||||
export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv.Frame) => {
|
||||
if (sample instanceof VideoSample) {
|
||||
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);
|
||||
} else {
|
||||
if (sample.format === null) {
|
||||
@@ -92,6 +94,8 @@ export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv
|
||||
frame.timeBase = new NodeAv.Rational(1, 1e6);
|
||||
} else {
|
||||
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);
|
||||
} else {
|
||||
copyAudioSampleToAvFrame(sample, frame);
|
||||
|
||||
@@ -91,9 +91,10 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
|
||||
async decode(packet: EncodedPacket) {
|
||||
if (this.codecContext === null) {
|
||||
await this.initCodecContext(packet);
|
||||
assert(this.codecContext);
|
||||
}
|
||||
|
||||
assert(this.codecContext);
|
||||
|
||||
this.packet.isKeyframe = packet.type === 'key';
|
||||
this.packet.data = Buffer.from(packet.data);
|
||||
this.packet.timeBase = { num: 1, den: 1e6 };
|
||||
@@ -144,6 +145,8 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
|
||||
const ret = await this.codecContext.sendPacket(this.packet);
|
||||
NodeAv.FFmpegError.throwIfError(ret, 'Send packet');
|
||||
|
||||
this.packet.unref(); // Don't need the data again, so just unref it
|
||||
|
||||
while (true) {
|
||||
const receiveRet = await this.codecContext.receiveFrame(this.frame);
|
||||
if (receiveRet === NodeAv.AVERROR_EAGAIN || receiveRet === NodeAv.AVERROR_EOF) {
|
||||
|
||||
@@ -44,14 +44,13 @@ import { assert, binarySearchLessOrEqual, simplifyRational, toUint8Array } from
|
||||
export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
frame!: NodeAv.Frame;
|
||||
packet!: NodeAv.Packet;
|
||||
avCodec!: NodeAv.Codec;
|
||||
codecContext: NodeAv.CodecContext | null = null;
|
||||
scaler: NodeAv.SoftwareScaleContext | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
avCodec!: NodeAv.Codec;
|
||||
lastBuffer: Buffer | null = null;
|
||||
packetEmitted = false;
|
||||
|
||||
scaler: NodeAv.SoftwareScaleContext | null = null;
|
||||
lastScalerKey: string | null = null;
|
||||
dstFrame: NodeAv.Frame | null = null;
|
||||
|
||||
// Bookkeeping to restore the original timing information
|
||||
preciseTimings: {
|
||||
@@ -183,10 +182,14 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
async encode(videoSample: VideoSample, options: VideoEncoderEncodeOptions): Promise<void> {
|
||||
if (this.codecContext === null) {
|
||||
await this.createCodecContext();
|
||||
assert(this.codecContext);
|
||||
}
|
||||
assert(this.codecContext);
|
||||
|
||||
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);
|
||||
} else {
|
||||
if (videoSample.format === null) {
|
||||
@@ -513,10 +516,8 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
displayAspectHeight: this.config.displayHeight ?? this.codecContext.height,
|
||||
description: decoderConfigDescription ?? undefined,
|
||||
colorSpace: {
|
||||
primaries:
|
||||
unmapColorPrimaries(this.codecContext.colorPrimaries) as VideoColorPrimaries,
|
||||
matrix:
|
||||
unmapMatrixCoefficients(this.codecContext.colorSpace) as VideoMatrixCoefficients,
|
||||
primaries: unmapColorPrimaries(this.codecContext.colorPrimaries) as VideoColorPrimaries,
|
||||
matrix: unmapMatrixCoefficients(this.codecContext.colorSpace) as VideoMatrixCoefficients,
|
||||
transfer:
|
||||
unmapTransferCharacteristics(this.codecContext.colorTrc) as VideoTransferCharacteristics,
|
||||
fullRange: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_JPEG
|
||||
|
||||
+1
-1
@@ -691,7 +691,7 @@ export class InputVideoTrack extends InputTrack {
|
||||
const colorSpace = await this._backing.getColorSpace();
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* 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),
|
||||
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
|
||||
muxer.isFragmented ? mvex(muxer.trackDatas) : null,
|
||||
udta(muxer),
|
||||
]);
|
||||
};
|
||||
|
||||
/** Movie Header Box: Used to specify the characteristics of the entire movie, such as timescale and duration. */
|
||||
export const mvhd = (
|
||||
@@ -739,11 +741,13 @@ export const pasp = (trackData: IsobmffVideoTrackData) => {
|
||||
|
||||
/** Colour Information Box: Specifies the color space of the video. */
|
||||
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(TRANSFER_CHARACTERISTICS_MAP[trackData.info.decoderConfig.colorSpace!.transfer!]), // Transfer characteristics
|
||||
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. */
|
||||
|
||||
@@ -1448,20 +1448,24 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
assert(track.info?.type === 'video');
|
||||
|
||||
const colourType = readAscii(slice, 4);
|
||||
if (colourType !== 'nclx') {
|
||||
if (colourType !== 'nclx' && colourType !== 'nclc') {
|
||||
break;
|
||||
}
|
||||
|
||||
const colourPrimaries = readU16Be(slice);
|
||||
const transferCharacteristics = 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 = {
|
||||
primaries: COLOR_PRIMARIES_MAP_INVERSE[colourPrimaries],
|
||||
transfer: TRANSFER_CHARACTERISTICS_MAP_INVERSE[transferCharacteristics],
|
||||
matrix: MATRIX_COEFFICIENTS_MAP_INVERSE[matrixCoefficients],
|
||||
fullRange: fullRangeFlag,
|
||||
fullRange,
|
||||
} as VideoColorSpaceInit;
|
||||
}; break;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
vtte,
|
||||
} from './isobmff-boxes';
|
||||
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 { BufferTarget } from '../target';
|
||||
import { assert, computeRationalApproximation, last, promiseWithResolvers, Rational, simplifyRational } from '../misc';
|
||||
@@ -1183,6 +1183,8 @@ export class IsobmffMuxer extends Muxer {
|
||||
boxWriter.writer.startTrackingWrites();
|
||||
}
|
||||
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
// Write the moov box now that we have all decoder configs
|
||||
const movieBox = moov(this);
|
||||
boxWriter.writeBox(movieBox);
|
||||
@@ -1299,6 +1301,8 @@ export class IsobmffMuxer extends Muxer {
|
||||
|
||||
if (this.allTracksAreKnown()) {
|
||||
if (!this.mdat) {
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
// We finally know all tracks, let's reserve space for the moov box
|
||||
const moovBox = moov(this);
|
||||
const moovSize = this.boxWriter.measureBox(moovBox);
|
||||
@@ -1389,11 +1393,34 @@ export class IsobmffMuxer extends Muxer {
|
||||
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. */
|
||||
async finalize() {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
this.allTracksKnown.resolve();
|
||||
this.ensureOneEnabledTrack();
|
||||
|
||||
for (const trackData of this.trackDatas) {
|
||||
trackData.closed = true;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
|
||||
import {
|
||||
AvcNalUnitType,
|
||||
concatAvcNalUnits,
|
||||
deserializeAvcDecoderConfigurationRecord,
|
||||
determineVideoPacketType,
|
||||
@@ -997,6 +998,14 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
|
||||
for (const loc of iterateAvcNalUnits(packet.data, this.decoderConfig)) {
|
||||
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
|
||||
if (!(type >= 20 && type <= 31)) {
|
||||
filteredNalUnits.push(packet.data.subarray(loc.offset, loc.offset + loc.length));
|
||||
|
||||
+1
-1
@@ -524,7 +524,7 @@ export const mergeRequestInit = (init1: RequestInit, init2: RequestInit): Reques
|
||||
};
|
||||
|
||||
/** 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) {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
|
||||
+5
-4
@@ -120,15 +120,16 @@ export class Reader {
|
||||
return this.requestSlice(0, this.fileSizeNonStrict);
|
||||
}
|
||||
|
||||
const startOffset = chunks.length * CHUNK_SIZE;
|
||||
let slice = this.requestSliceRange(startOffset, 0, CHUNK_SIZE);
|
||||
let slice = this.requestSliceRange(currentSize, 0, CHUNK_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
|
||||
if (!slice) {
|
||||
if (!slice || slice.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push(readBytes(slice, slice.length));
|
||||
const chunk = readBytes(slice, slice.length);
|
||||
chunks.push(chunk);
|
||||
|
||||
currentSize += slice.length;
|
||||
}
|
||||
|
||||
|
||||
+116
-24
@@ -17,6 +17,7 @@ import {
|
||||
isWebKit,
|
||||
MaybePromise,
|
||||
mergeRequestInit,
|
||||
normalizeHeaders,
|
||||
polyfillSymbolDispose,
|
||||
promiseWithResolvers,
|
||||
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
|
||||
* 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
|
||||
* to cancel ongoing requests, use {@link Input.dispose}.
|
||||
* The `signal` field is not available, as Mediabunny controls request cancellation internally. If you want to
|
||||
* 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
|
||||
@@ -717,6 +718,12 @@ export class UrlSource extends PathedSource {
|
||||
/** @internal */
|
||||
_options: UrlSourceOptions;
|
||||
/** @internal */
|
||||
_requestInit: RequestInit;
|
||||
/** @internal */
|
||||
_offset = 0;
|
||||
/** @internal */
|
||||
_length: number | null = null;
|
||||
/** @internal */
|
||||
_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
|
||||
@@ -728,8 +735,8 @@ export class UrlSource extends PathedSource {
|
||||
/**
|
||||
* 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
|
||||
* Mediabunny. If you want to cancel ongoing requests, use {@link Input.dispose}.
|
||||
* When passing a `Request` instance, note that its `signal` will be overridden by Mediabunny; if you want to cancel
|
||||
* ongoing requests, use {@link Input.dispose}.
|
||||
*/
|
||||
constructor(
|
||||
url: string | URL | Request,
|
||||
@@ -777,6 +784,42 @@ export class UrlSource extends PathedSource {
|
||||
this._options = options;
|
||||
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
|
||||
// also happen
|
||||
const DEFAULT_PARALLELISM = 2;
|
||||
@@ -791,9 +834,16 @@ export class UrlSource extends PathedSource {
|
||||
|
||||
/** @internal */
|
||||
_getFileSize(): number | null | undefined {
|
||||
return this._fileSizeDetermined
|
||||
? this._orchestrator.fileSize
|
||||
: undefined;
|
||||
if (!this._fileSizeDetermined) {
|
||||
return this._length !== null ? this._length : 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 */
|
||||
@@ -803,7 +853,32 @@ export class UrlSource extends PathedSource {
|
||||
minReadPosition: number,
|
||||
maxReadPosition: number,
|
||||
): 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 */
|
||||
@@ -814,7 +889,7 @@ export class UrlSource extends PathedSource {
|
||||
const response = await retriedFetch(
|
||||
this._options.fetchFn ?? fetch,
|
||||
this._url,
|
||||
mergeRequestInit(this._options.requestInit ?? {}, {
|
||||
mergeRequestInit(this._requestInit, {
|
||||
headers: {
|
||||
// Always sending a range request is a good way to probe if the server supports them
|
||||
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}.
|
||||
* @group Input sources
|
||||
@@ -2422,22 +2517,19 @@ export class RangedSource extends Source {
|
||||
this._offset + maxReadPosition,
|
||||
);
|
||||
|
||||
const processResult = (result: ReadResult | null) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
};
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then((result) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
});
|
||||
return result.then(processResult);
|
||||
} else {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
return processResult(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);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -352,3 +352,38 @@ test('PCM audio, no silence padding with approximate timestamps', async () => {
|
||||
|
||||
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
@@ -1,6 +1,19 @@
|
||||
import { expect, test } from 'vitest';
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -871,6 +871,88 @@ describe('Video', async () => {
|
||||
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', () => {
|
||||
// 400x400 image: red everywhere, with a 200x200 blue square filling the bottom-left quadrant.
|
||||
const TEST_IMAGE = (() => {
|
||||
|
||||
Reference in New Issue
Block a user