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">
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 |
Generated
+7
-7
@@ -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
@@ -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,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,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,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,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,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",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
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 { 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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 = (() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user