mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 14:23:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad167c77e7 | ||
|
|
8b9a1acf16 | ||
|
|
0da1107858 | ||
|
|
00599e556e | ||
|
|
e1c6ab1866 | ||
|
|
bc533c71f7 | ||
|
|
879fd75547 | ||
|
|
b4e51077aa | ||
|
|
c423d62fd9 | ||
|
|
0cdf0063e2 | ||
|
|
bb2f5d505d | ||
|
|
ebfb1e6d75 | ||
|
|
8fbd31849b | ||
|
|
d92df801bf | ||
|
|
6832153fef | ||
|
|
82690ef327 | ||
|
|
c0d93f45a5 | ||
|
|
05f8ecd908 | ||
|
|
e0ced9901e | ||
|
|
96d0d9fb22 | ||
|
|
c6c768d93e | ||
|
|
5799cdf630 | ||
|
|
831d395696 | ||
|
|
288372ee71 | ||
|
|
4825818f9b | ||
|
|
b82ccc2c6a | ||
|
|
eb8b5e2534 |
+14
-6
@@ -91,13 +91,16 @@
|
||||
//const startTime = await primaryTrack.getFirstTimestamp();
|
||||
//console.log(startTime)
|
||||
|
||||
let ctx = null;
|
||||
const canvas = new OffscreenCanvas(1280, 720);
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
//let ctx = null;
|
||||
let conversion = await Mediabunny.Conversion.init({
|
||||
input,
|
||||
output,
|
||||
audio: {
|
||||
codec: 'aac',
|
||||
forceTranscode: true,
|
||||
//codec: 'aac',
|
||||
//forceTranscode: true,
|
||||
//forceTranscode: true,
|
||||
//sampleFormat: 's16',
|
||||
},
|
||||
@@ -126,7 +129,10 @@
|
||||
},
|
||||
*/
|
||||
video: {
|
||||
discard: true,
|
||||
process: (sample) => {
|
||||
sample.draw(ctx, 0, 0, 1280, 720);
|
||||
return new Mediabunny.VideoSample(canvas, { timestamp: sample.timestamp, duration: sample.duration });
|
||||
},
|
||||
},
|
||||
tags: {} ?? {
|
||||
title: 'Bigggy',
|
||||
@@ -146,8 +152,10 @@
|
||||
}
|
||||
},
|
||||
trim: {
|
||||
start: 300.14984567374756 - 100,
|
||||
end: 310.1548298151939 - 100,
|
||||
start: -2,
|
||||
end: 10,
|
||||
//start: 300.14984567374756 - 100,
|
||||
//end: 310.1548298151939 - 100,
|
||||
//end: 10,
|
||||
//start: startTime,
|
||||
//end: startTime + 2,
|
||||
|
||||
+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>
|
||||
|
||||
|
||||
@@ -35,6 +35,22 @@ registerMediabunnyServer();
|
||||
|
||||
That's it - you now have access to the full Mediabunny feature set on the server.
|
||||
|
||||
---
|
||||
|
||||
An optional `options` parameter is available for further configuration:
|
||||
```ts
|
||||
import { registerMediabunnyServer } from '@mediabunny/server';
|
||||
import * as NodeAv from 'node-av';
|
||||
|
||||
registerMediabunnyServer({
|
||||
// Use a specific hardware rendering device:
|
||||
hardwareContext: NodeAv.HardwareContext.create(
|
||||
NodeAv.AV_HWDEVICE_TYPE_VAAPI,
|
||||
'/dev/dri/renderD128',
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
## Upload media compression example
|
||||
|
||||
Here, we set up a simple media compression server in Node.js. The client's request body is streamed to Mediabunny, the media gets processed, and the output is streamed directly to the disk. Memory usage is O(1) due to pipelining, and an overly fast uploader is automatically slowed down due to stream backpressure.
|
||||
|
||||
@@ -330,9 +330,12 @@ videoSample.microsecondDuration; // => Duration in microseconds
|
||||
videoSample.colorSpace; // => VideoColorSpace
|
||||
|
||||
videoSample.visibleRect; // Rectangle
|
||||
|
||||
// Encode options used when this sample is passed to an encoder
|
||||
videoSample.encodeOptions; // => VideoEncoderEncodeOptions (defaults to {})
|
||||
```
|
||||
|
||||
While all of these properties are read-only, you can use the `setTimestamp`, `setDuration` and `setRotation` methods to modify some of the metadata of the video sample.
|
||||
While all of these properties are read-only, you can use the `setTimestamp`, `setDuration`, `setRotation` and `setEncodeOptions` methods to modify some of the metadata of the video sample.
|
||||
|
||||
::: warning
|
||||
Timestamps can be [negative](#negative-timestamps).
|
||||
|
||||
@@ -12,7 +12,7 @@ Mediabunny exposes HLS playlists as if they were a single giant input file. Like
|
||||
|
||||
## HLS inputs
|
||||
|
||||
HLS playlists (master & media) are read through the same `Input` interface as all other media files in Mediabunny. HLS must read multiple files, meaning any [`PathedSource`](../api/PathedSource) is required:
|
||||
HLS playlists (master & media) are read through the same `Input` interface as all other media files in Mediabunny. HLS must read multiple files, meaning any [`PathedSource`](../api/PathedSource) (such as `UrlSource`) is required:
|
||||
```ts
|
||||
import { Input, UrlSource, HLS_FORMATS } from 'mediabunny';
|
||||
|
||||
|
||||
@@ -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
+120
-62
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
".",
|
||||
@@ -382,6 +382,7 @@
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz",
|
||||
"integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1113,6 +1114,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@fidm/asn1/-/asn1-1.0.4.tgz",
|
||||
"integrity": "sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
@@ -1122,6 +1124,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@fidm/x509/-/x509-1.2.1.tgz",
|
||||
"integrity": "sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@fidm/asn1": "^1.0.4",
|
||||
"tweetnacl": "^1.0.1"
|
||||
@@ -1439,7 +1442,8 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@mediabunny/aac-encoder": {
|
||||
"resolved": "packages/aac-encoder",
|
||||
@@ -1569,13 +1573,15 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@minhducsun2002/leb128/-/leb128-1.0.0.tgz",
|
||||
"integrity": "sha512-eFrYUPDVHeuwWHluTG1kwNQUEUcFjVKYwPkU8z9DR1JH3AW7JtJsG9cRVGmwz809kKtGfwGJj58juCZxEvnI/g==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
@@ -1591,6 +1597,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -1603,6 +1610,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
|
||||
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
@@ -1616,6 +1624,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
|
||||
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
@@ -1628,6 +1637,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
|
||||
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
@@ -1640,6 +1650,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
|
||||
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pkcs8": "^2.7.0",
|
||||
@@ -1654,6 +1665,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
|
||||
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
@@ -1666,6 +1678,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
|
||||
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.7.0",
|
||||
"@peculiar/asn1-pfx": "^2.7.0",
|
||||
@@ -1682,6 +1695,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
|
||||
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
@@ -1694,6 +1708,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
|
||||
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
"asn1js": "^3.0.6",
|
||||
@@ -1705,6 +1720,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
|
||||
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/utils": "^2.0.2",
|
||||
@@ -1717,6 +1733,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
|
||||
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-schema": "^2.7.0",
|
||||
"@peculiar/asn1-x509": "^2.7.0",
|
||||
@@ -1729,6 +1746,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
@@ -1738,6 +1756,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
|
||||
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@peculiar/asn1-cms": "^2.6.0",
|
||||
"@peculiar/asn1-csr": "^2.6.0",
|
||||
@@ -2210,9 +2229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@seydx/node-av-darwin-arm64": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-darwin-arm64/-/node-av-darwin-arm64-5.2.4.tgz",
|
||||
"integrity": "sha512-uQh6jWXDeXtjuaGOXrBx2rszPDcQbRa4W1YFrmlQG+ffOo+IJyczGjhTWfgzebRksB9tMAe9ViztU39THWZAkw==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-darwin-arm64/-/node-av-darwin-arm64-6.0.0.tgz",
|
||||
"integrity": "sha512-OAXfBGnOk8ddPhoZmqcq8K04m1oLgGsiKnZ9p7oPI8jVsZaI+v7NXr5lum4bVakIh2TOhnD6EMdZl8v0KHgJLA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2226,9 +2245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@seydx/node-av-darwin-x64": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-darwin-x64/-/node-av-darwin-x64-5.2.4.tgz",
|
||||
"integrity": "sha512-S9ptQF7JhvGSbvZH9jVeDAPXyRklA7rySKiFzi4UeLTMJslgFTSWFgY2nJAtHrP4wRdkAPnAVjExqd8U7jRZgg==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-darwin-x64/-/node-av-darwin-x64-6.0.0.tgz",
|
||||
"integrity": "sha512-9SMQkfzEGJgz9GedF43bPIqTp7HbeN/UWwPtAq9tR02mZ4vDGYlzvMvQXG4yx2JEiogcYyg8hDzoLud4zkqKww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2242,9 +2261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@seydx/node-av-linux-arm64": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-linux-arm64/-/node-av-linux-arm64-5.2.4.tgz",
|
||||
"integrity": "sha512-XlQAWIcqFxVItr98VShaxzx57Cfl0y+RPCPciLOHXuEmfeKjON4o/xdbChHg02hHFrVAeSCfaHWQmHNgB9m6eQ==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-linux-arm64/-/node-av-linux-arm64-6.0.0.tgz",
|
||||
"integrity": "sha512-pZsX2PPLxL/KioqZoofbm6B4aFLRGpvAB6JSNvCZIiU5eW2QSBZ68H9gC/IZ5tknH6+mX7ZT5vrJP0B0tSfvyw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2258,9 +2277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@seydx/node-av-linux-x64": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-linux-x64/-/node-av-linux-x64-5.2.4.tgz",
|
||||
"integrity": "sha512-+2LdgZ7irDik++0mO6Bo1odYa2NZvze6xiy4B8jTbcf3F8k46qQEKkPjBwtV0qQu5fn3M+fUc/wk5coHdfxjxA==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-linux-x64/-/node-av-linux-x64-6.0.0.tgz",
|
||||
"integrity": "sha512-tvaH/0o+r2z4qEnHVyeUr6tIAzykjZcjssy93WEO+d2iRKO5araE702JEVBidJNTq2c/hd7p9Ml2KVLqere1ug==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2274,9 +2293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@seydx/node-av-win32-arm64-mingw": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-win32-arm64-mingw/-/node-av-win32-arm64-mingw-5.2.4.tgz",
|
||||
"integrity": "sha512-uVNgRQ44uSdtMcR9O6R+nrBou5BWmroTFnGPI3PX8pHR8oe1Nim5zXtWK0qGTTqOiTPNsK5XnSshM3c5/n0/Fg==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-win32-arm64-mingw/-/node-av-win32-arm64-mingw-6.0.0.tgz",
|
||||
"integrity": "sha512-mE2vSv984b8wrthQedup0ofeyZ8PTj/iwiXpYS1rmZmbmd2XMJ+Q8+oHKSu3+DruErOvURhWw+VflTGUlZtnBQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2290,9 +2309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@seydx/node-av-win32-arm64-msvc": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-win32-arm64-msvc/-/node-av-win32-arm64-msvc-5.2.4.tgz",
|
||||
"integrity": "sha512-Gi+Op8j028WHbdl2jR7MnyHqR2J1TfkEogLPludb3jx54Yi+MO1zvouICFUmtSbxd9JC7CAeoU8++CZAMqcEvA==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-win32-arm64-msvc/-/node-av-win32-arm64-msvc-6.0.0.tgz",
|
||||
"integrity": "sha512-bm9CbvhAneEeF4/k1GNXaFlM050cWDSqZ+hha88uETV4kijolQBc3UseQTFy9QvqhD8FftvMSR7C/TM/fBTimA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2306,9 +2325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@seydx/node-av-win32-x64-mingw": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-win32-x64-mingw/-/node-av-win32-x64-mingw-5.2.4.tgz",
|
||||
"integrity": "sha512-gClAhvV/aJa681tuTlG6dTqqMpnTM5k6QVhnWolBgkbpZXMi0qABzoUBTYpuwxnOEd6j2mBBAVwqx6kut55Ukw==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-win32-x64-mingw/-/node-av-win32-x64-mingw-6.0.0.tgz",
|
||||
"integrity": "sha512-4IaoS2eA2mogjcDkOhJD3vVqGXX420/fb3ZCmgBD0G9HMW+mk7qKWq25b+0V2lg6pT9d8eq1DAEMtQ6UhKwODQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2322,9 +2341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@seydx/node-av-win32-x64-msvc": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-win32-x64-msvc/-/node-av-win32-x64-msvc-5.2.4.tgz",
|
||||
"integrity": "sha512-01s5ij6nudrtZ7ojiAOAgzuUrImvuQYrAE5o6uesoxYqOha6j7yRu5e4qCXRQCDKrh2FBzyO1cGDc7Ht8Ly8wQ==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@seydx/node-av-win32-x64-msvc/-/node-av-win32-x64-msvc-6.0.0.tgz",
|
||||
"integrity": "sha512-fHtjHYrAxjeCjPzHojPCnimh0p+UoiG3Tn0Av/mLSJ8RJcCqryz0iH/zdj6bSgtXHEz5LkHzLoZIVI0BMqY+fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2429,6 +2448,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@shinyoshiaki/binary-data/-/binary-data-0.6.1.tgz",
|
||||
"integrity": "sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"generate-function": "^2.3.1",
|
||||
"is-plain-object": "^2.0.3"
|
||||
@@ -2440,7 +2460,8 @@
|
||||
"node_modules/@shinyoshiaki/jspack": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@shinyoshiaki/jspack/-/jspack-0.0.6.tgz",
|
||||
"integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg=="
|
||||
"integrity": "sha512-SdsNhLjQh4onBlyPrn4ia1Pdx5bXT88G/LIEpOYAjx2u4xeY/m/HB5yHqlkJB1uQR3Zw4R3hBWLj46STRAN0rg==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@stylistic/eslint-plugin": {
|
||||
"version": "4.2.0",
|
||||
@@ -4173,7 +4194,8 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
|
||||
"integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
@@ -4357,6 +4379,7 @@
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"pvtsutils": "^1.3.6",
|
||||
"pvutils": "^1.1.5",
|
||||
@@ -4501,6 +4524,7 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4563,6 +4587,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4587,6 +4612,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
@@ -5534,6 +5560,7 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
@@ -5555,6 +5582,7 @@
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
@@ -5694,6 +5722,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@leichtgewicht/ip-codec": "^2.0.1"
|
||||
},
|
||||
@@ -6395,7 +6424,8 @@
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/fast-fifo": {
|
||||
"version": "1.3.2",
|
||||
@@ -6737,6 +6767,7 @@
|
||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
@@ -7093,6 +7124,7 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7182,7 +7214,8 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz",
|
||||
"integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
@@ -7197,7 +7230,8 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz",
|
||||
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
@@ -7284,6 +7318,7 @@
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz",
|
||||
"integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"isobject": "^3.0.1"
|
||||
},
|
||||
@@ -7295,7 +7330,8 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/is-stream": {
|
||||
"version": "2.0.1",
|
||||
@@ -7340,6 +7376,7 @@
|
||||
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
|
||||
"integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7943,7 +7980,8 @@
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
@@ -8903,7 +8941,8 @@
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/mp4box/-/mp4box-0.5.4.tgz",
|
||||
"integrity": "sha512-GcCH0fySxBurJtvr0dfhz0IxHZjc1RP+F+I8xw+LIwkU1a+7HJx8NCDiww1I5u4Hz6g4eR1JlGADEGJ9r4lSfA==",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
@@ -8918,13 +8957,15 @@
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/multicast-dns": {
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"dns-packet": "^5.2.2",
|
||||
"thunky": "^1.0.2"
|
||||
@@ -8969,24 +9010,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-av": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/node-av/-/node-av-5.2.4.tgz",
|
||||
"integrity": "sha512-L5r+6k+YGvH5MZX8o55JHQbbeRZ+iFieAb1bhKtpa8hrqBEGuSwdkXRwpE4vd414JqVJsq/EQq6s+3qKeviQTg==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-av/-/node-av-6.0.0.tgz",
|
||||
"integrity": "sha512-raMugNMsLlJpmby4vdMyjpNHEw94WYDOpHw4z3ClxowyvWKx880rGbwDdERhS6DudyoLV0DNT+A5h3HBL0xF6Q==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"unzipper": "^0.12.3",
|
||||
"werift": "^0.23.0"
|
||||
"unzipper": "^0.12.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@seydx/node-av-darwin-arm64": "^5.2.4",
|
||||
"@seydx/node-av-darwin-x64": "^5.2.4",
|
||||
"@seydx/node-av-linux-arm64": "^5.2.4",
|
||||
"@seydx/node-av-linux-x64": "^5.2.4",
|
||||
"@seydx/node-av-win32-arm64-mingw": "^5.2.4",
|
||||
"@seydx/node-av-win32-arm64-msvc": "^5.2.4",
|
||||
"@seydx/node-av-win32-x64-mingw": "^5.2.4",
|
||||
"@seydx/node-av-win32-x64-msvc": "^5.2.4"
|
||||
"@seydx/node-av-darwin-arm64": "^6.0.0",
|
||||
"@seydx/node-av-darwin-x64": "^6.0.0",
|
||||
"@seydx/node-av-linux-arm64": "^6.0.0",
|
||||
"@seydx/node-av-linux-x64": "^6.0.0",
|
||||
"@seydx/node-av-win32-arm64-mingw": "^6.0.0",
|
||||
"@seydx/node-av-win32-arm64-msvc": "^6.0.0",
|
||||
"@seydx/node-av-win32-x64-mingw": "^6.0.0",
|
||||
"@seydx/node-av-win32-x64-msvc": "^6.0.0",
|
||||
"werift": "^0.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
@@ -9109,6 +9150,7 @@
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -9689,6 +9731,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
@@ -9698,6 +9741,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
@@ -9801,7 +9845,8 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regex": {
|
||||
"version": "6.0.1",
|
||||
@@ -10066,7 +10111,8 @@
|
||||
"node_modules/rx.mini": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/rx.mini/-/rx.mini-1.4.0.tgz",
|
||||
"integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA=="
|
||||
"integrity": "sha512-8w5cSc1mwNja7fl465DXOkVvIOkpvh2GW4jo31nAIvX4WTXCsRnKJGUfiDBzWtYRInEcHAUYIZfzusjIrea8gA==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/safaridriver": {
|
||||
"version": "1.0.0",
|
||||
@@ -10736,7 +10782,8 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
@@ -10868,7 +10915,8 @@
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.19.4",
|
||||
@@ -10895,6 +10943,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
@@ -10906,13 +10955,15 @@
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||
"license": "Unlicense"
|
||||
"license": "Unlicense",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -12409,6 +12460,7 @@
|
||||
"resolved": "https://registry.npmjs.org/werift/-/werift-0.23.0.tgz",
|
||||
"integrity": "sha512-/WcIN5DHFG9Ri4anGOmIkp8gxBGFMWSIB/m4sfZ5CWlLfD3iMhiaAUuTBuc+KV3SY9NDmvmLtiN2uaM7k3lVzw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@fidm/x509": "^1.2.1",
|
||||
"@minhducsun2002/leb128": "^1.0.0",
|
||||
@@ -12440,6 +12492,7 @@
|
||||
"resolved": "https://registry.npmjs.org/werift-common/-/werift-common-0.0.3.tgz",
|
||||
"integrity": "sha512-ma3E4BqKTyZVLhrdfTVs2T1tg9seeUtKMRn5e64LwgrogWa62+3LAUoLBUSl1yPWhgSkXId7GmcHuWDen9IJeQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@shinyoshiaki/jspack": "^0.0.6",
|
||||
"debug": "^4.4.0"
|
||||
@@ -12453,6 +12506,7 @@
|
||||
"resolved": "https://registry.npmjs.org/werift-dtls/-/werift-dtls-0.5.7.tgz",
|
||||
"integrity": "sha512-z2fjbP7fFUFmu/Ky4bCKXzdgPTtmSY1DYi0TUf3GG2zJT4jMQ3TQmGY8y7BSSNGetvL4h3pRZ5un0EcSOWpPog==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@fidm/x509": "^1.2.1",
|
||||
"@noble/curves": "^1.3.0",
|
||||
@@ -12472,6 +12526,7 @@
|
||||
"resolved": "https://registry.npmjs.org/werift-ice/-/werift-ice-0.2.2.tgz",
|
||||
"integrity": "sha512-td52pHp+JmFnUn5jfDr/SSNO0dMCbknhuPdN1tFp9cfRj5jaktN63qnAdUuZC20QCC3ETWdsOthcm+RalHpFCQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@shinyoshiaki/jspack": "^0.0.6",
|
||||
"buffer-crc32": "^1.0.0",
|
||||
@@ -12489,6 +12544,7 @@
|
||||
"resolved": "https://registry.npmjs.org/werift-rtp/-/werift-rtp-0.8.8.tgz",
|
||||
"integrity": "sha512-GiYMSdvCyScQaw5bnEsraSoHUVZpjfokJAiLV4R1FsiB06t6XiebPYPpkqB9nYNNKiA8Z/cYWsym7wISq1sYSQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@minhducsun2002/leb128": "^1.0.0",
|
||||
"@shinyoshiaki/jspack": "^0.0.6",
|
||||
@@ -12505,6 +12561,7 @@
|
||||
"resolved": "https://registry.npmjs.org/werift-sctp/-/werift-sctp-0.0.11.tgz",
|
||||
"integrity": "sha512-7109yuI5U7NTEHjqjn0A8VeynytkgVaxM6lRr1Ziv0D8bPcaB8A7U/P88M7WaCpWDoELHoXiRUjQycMWStIgjQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@shinyoshiaki/jspack": "^0.0.6"
|
||||
},
|
||||
@@ -12517,6 +12574,7 @@
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -12806,7 +12864,7 @@
|
||||
},
|
||||
"packages/aac-encoder": {
|
||||
"name": "@mediabunny/aac-encoder",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12821,7 +12879,7 @@
|
||||
},
|
||||
"packages/ac3": {
|
||||
"name": "@mediabunny/ac3",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12836,7 +12894,7 @@
|
||||
},
|
||||
"packages/flac-encoder": {
|
||||
"name": "@mediabunny/flac-encoder",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12851,7 +12909,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
@@ -12866,10 +12924,10 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@mediabunny/server",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"node-av": "^5.2.4"
|
||||
"node-av": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"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.47.0",
|
||||
"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.47.0",
|
||||
"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.47.0",
|
||||
"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.47.0",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -39,6 +39,22 @@ registerMediabunnyServer();
|
||||
|
||||
That's it - you now have access to the full Mediabunny feature set on the server.
|
||||
|
||||
---
|
||||
|
||||
An optional `options` parameter is available for further configuration:
|
||||
```ts
|
||||
import { registerMediabunnyServer } from '@mediabunny/server';
|
||||
import * as NodeAv from 'node-av';
|
||||
|
||||
registerMediabunnyServer({
|
||||
// Use a specific hardware rendering device:
|
||||
hardwareContext: NodeAv.HardwareContext.create(
|
||||
NodeAv.AV_HWDEVICE_TYPE_VAAPI,
|
||||
'/dev/dri/renderD128',
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
## Upload media compression example
|
||||
|
||||
Here, we set up a simple media compression server in Node.js. The client's request body is streamed to Mediabunny, the media gets processed, and the output is streamed directly to the disk. Memory usage is O(1) due to pipelining, and an overly fast uploader is automatically slowed down due to stream backpressure.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/server",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.45.4",
|
||||
"version": "1.47.0",
|
||||
"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",
|
||||
@@ -34,7 +34,7 @@
|
||||
"url": "https://github.com/sponsors/Vanilagy"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-av": "^5.2.4"
|
||||
"node-av": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"mediabunny": "^1.45.0"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -42,6 +42,9 @@ export class AvFrameAudioSampleResource extends AudioSampleResource {
|
||||
constructor(frame: NodeAv.Frame) {
|
||||
super();
|
||||
|
||||
if (!(frame instanceof NodeAv.Frame)) {
|
||||
throw new TypeError('frame must be a NodeAv.Frame.');
|
||||
}
|
||||
if (frame.getMediaType() !== NodeAv.AVMEDIA_TYPE_AUDIO) {
|
||||
throw new Error('AvFrameAudioSampleResource must be initialized with an audio frame.');
|
||||
}
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { AudioSample, registerDecoder, registerEncoder, registerVideoSampleTransformer, VideoSample } from 'mediabunny';
|
||||
import {
|
||||
AudioSample,
|
||||
MaybePromise,
|
||||
registerDecoder,
|
||||
registerEncoder,
|
||||
registerVideoSampleTransformer,
|
||||
VideoSample,
|
||||
} from 'mediabunny';
|
||||
import * as NodeAv from 'node-av';
|
||||
import { NodeAvVideoDecoder } from './video-decoder';
|
||||
import { NodeAvVideoEncoder } from './video-encoder';
|
||||
@@ -28,6 +35,29 @@ if ((globalThis as Record<symbol, unknown>)[SERVER_LOADED_SYMBOL]) {
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Options for configuring Mediabunny's server-side polyfills.
|
||||
* @group \@mediabunny/server
|
||||
* @public
|
||||
*/
|
||||
type MediabunnyServerOptions = {
|
||||
/**
|
||||
* The hardware context to use for hardware-accelerated decoding and encoding. When set to a
|
||||
* `NodeAv.HardwareContext`, that context is used directly. When set to a function, the function is invoked (with
|
||||
* no caching) every time a hardware decoder or encoder codec is resolved, receiving the codec ID and returning the
|
||||
* context to use for it (or `null` for no hardware acceleration).
|
||||
*
|
||||
* When not set, a context is automatically detected on first use and then cached.
|
||||
*/
|
||||
hardwareContext?:
|
||||
| NodeAv.HardwareContext
|
||||
| null
|
||||
| ((codecId: NodeAv.AVCodecID) => MaybePromise<NodeAv.HardwareContext | null>);
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export let _serverOptions: MediabunnyServerOptions = {};
|
||||
|
||||
/**
|
||||
* Registers video and audio decoders and encoders for all codecs, using FFmpeg's libavcodec under the hood.
|
||||
* Additionally, a custom `VideoSample` transformer based on libavfilter is registered to enable resizing, rotation and
|
||||
@@ -38,14 +68,32 @@ let registered = false;
|
||||
* The decoders and encoders will automatically detect hardware acceleration support for each codec and platform and
|
||||
* make use of it if applicable.
|
||||
*
|
||||
* You can pass optional {@link MediabunnyServerOptions} for additional configuration.
|
||||
*
|
||||
* @group \@mediabunny/server
|
||||
* @public
|
||||
*/
|
||||
export const registerMediabunnyServer = () => {
|
||||
export const registerMediabunnyServer = (options: MediabunnyServerOptions = {}) => {
|
||||
if (typeof options !== 'object' || !options) {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (
|
||||
options.hardwareContext != null
|
||||
&& !(
|
||||
options.hardwareContext instanceof NodeAv.HardwareContext || typeof options.hardwareContext === 'function'
|
||||
)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'options.hardwareContext, when provided, must be a NodeAv.HardwareContext, a function, or null.',
|
||||
);
|
||||
}
|
||||
|
||||
if (registered) {
|
||||
return;
|
||||
}
|
||||
|
||||
registered = true;
|
||||
_serverOptions = options;
|
||||
|
||||
NodeAv.Log.setLevel(NodeAv.AV_LOG_ERROR);
|
||||
|
||||
@@ -60,6 +108,8 @@ export const registerMediabunnyServer = () => {
|
||||
registerVideoSampleTransformer(transformVideoSample);
|
||||
};
|
||||
|
||||
export type { MediabunnyServerOptions };
|
||||
|
||||
export { AvFrameVideoSampleResource } from './video-sample';
|
||||
export { AvFrameAudioSampleResource } from './audio-sample';
|
||||
|
||||
@@ -76,8 +126,17 @@ export { AvFrameAudioSampleResource } from './audio-sample';
|
||||
* @public
|
||||
*/
|
||||
export const toAvFrame = async (sample: VideoSample | AudioSample, frame: NodeAv.Frame) => {
|
||||
if (!(sample instanceof VideoSample) && !(sample instanceof AudioSample)) {
|
||||
throw new TypeError('sample must be a VideoSample or an AudioSample.');
|
||||
}
|
||||
if (!(frame instanceof NodeAv.Frame)) {
|
||||
throw new TypeError('frame must be a 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 +151,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);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { VideoSamplePixelFormat, MediaCodec } from 'mediabunny';
|
||||
import * as NodeAv from 'node-av';
|
||||
import { _serverOptions } from './index';
|
||||
|
||||
export const CODEC_TO_CODEC_ID: Partial<Record<MediaCodec, NodeAv.AVCodecID>> = {
|
||||
avc: NodeAv.AV_CODEC_ID_H264,
|
||||
@@ -27,14 +28,35 @@ export const CODEC_TO_CODEC_ID: Partial<Record<MediaCodec, NodeAv.AVCodecID>> =
|
||||
|
||||
let cachedHardwareContext: NodeAv.HardwareContext | null | undefined = undefined;
|
||||
export const getHardwareContext = (): NodeAv.HardwareContext | null => {
|
||||
if (_serverOptions.hardwareContext !== undefined && typeof _serverOptions.hardwareContext !== 'function') {
|
||||
// Return the user-provided one
|
||||
return _serverOptions.hardwareContext;
|
||||
}
|
||||
|
||||
if (cachedHardwareContext === undefined) {
|
||||
cachedHardwareContext = NodeAv.HardwareContext.auto();
|
||||
}
|
||||
return cachedHardwareContext;
|
||||
};
|
||||
|
||||
const validateHwContext = (hw: NodeAv.HardwareContext | null) => {
|
||||
if (hw !== null && !(hw instanceof NodeAv.HardwareContext)) {
|
||||
throw new TypeError(
|
||||
'When serverOptions.hardwareContext is a function, it must return or resolve to a'
|
||||
+ ' NodeAv.HardwareContext or null.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hardwareDecoderCodecCache = new Map<NodeAv.AVCodecID, NodeAv.Codec | null>();
|
||||
export const getHardwareDecoderCodec = (codecId: NodeAv.AVCodecID): NodeAv.Codec | null => {
|
||||
export const getHardwareDecoderCodec = async (codecId: NodeAv.AVCodecID): Promise<NodeAv.Codec | null> => {
|
||||
if (typeof _serverOptions.hardwareContext === 'function') {
|
||||
const hw = await _serverOptions.hardwareContext(codecId);
|
||||
validateHwContext(hw);
|
||||
|
||||
return hw?.getDecoderCodec(codecId) ?? null;
|
||||
}
|
||||
|
||||
if (!hardwareDecoderCodecCache.has(codecId)) {
|
||||
const hw = getHardwareContext();
|
||||
hardwareDecoderCodecCache.set(codecId, hw?.getDecoderCodec(codecId) ?? null);
|
||||
@@ -43,7 +65,14 @@ export const getHardwareDecoderCodec = (codecId: NodeAv.AVCodecID): NodeAv.Codec
|
||||
};
|
||||
|
||||
const hardwareEncoderCodecCache = new Map<NodeAv.AVCodecID, NodeAv.Codec | null>();
|
||||
export const getHardwareEncoderCodec = (codecId: NodeAv.AVCodecID): NodeAv.Codec | null => {
|
||||
export const getHardwareEncoderCodec = async (codecId: NodeAv.AVCodecID): Promise<NodeAv.Codec | null> => {
|
||||
if (typeof _serverOptions.hardwareContext === 'function') {
|
||||
const hw = await _serverOptions.hardwareContext(codecId);
|
||||
validateHwContext(hw);
|
||||
|
||||
return hw?.getEncoderCodec(codecId) ?? null;
|
||||
}
|
||||
|
||||
if (!hardwareEncoderCodecCache.has(codecId)) {
|
||||
const hw = getHardwareContext();
|
||||
hardwareEncoderCodecCache.set(codecId, hw?.getEncoderCodec(codecId) ?? null);
|
||||
|
||||
@@ -58,7 +58,7 @@ export class NodeAvVideoDecoder extends CustomVideoDecoder {
|
||||
) {
|
||||
codec = NodeAv.Codec.findDecoder(codecId);
|
||||
} else {
|
||||
codec = getHardwareDecoderCodec(codecId) ?? NodeAv.Codec.findDecoder(codecId);
|
||||
codec = (await getHardwareDecoderCodec(codecId)) ?? NodeAv.Codec.findDecoder(codecId);
|
||||
}
|
||||
|
||||
if (!codec) {
|
||||
@@ -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: {
|
||||
@@ -84,7 +83,7 @@ export class NodeAvVideoEncoder extends CustomVideoEncoder {
|
||||
} else if (this.config.hardwareAcceleration === 'prefer-software') {
|
||||
codec = NodeAv.Codec.findEncoder(codecId);
|
||||
} else {
|
||||
codec = getHardwareEncoderCodec(codecId) ?? NodeAv.Codec.findEncoder(codecId);
|
||||
codec = (await getHardwareEncoderCodec(codecId)) ?? NodeAv.Codec.findEncoder(codecId);
|
||||
}
|
||||
|
||||
if (!codec) {
|
||||
@@ -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,12 +516,10 @@ 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,
|
||||
unmapTransferCharacteristics(this.codecContext.colorTrc) as VideoTransferCharacteristics,
|
||||
fullRange: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_JPEG
|
||||
? true
|
||||
: this.codecContext.colorRange === NodeAv.AVCOL_RANGE_MPEG
|
||||
|
||||
@@ -73,6 +73,9 @@ export class AvFrameVideoSampleResource extends VideoSampleResource {
|
||||
constructor(frame: NodeAv.Frame) {
|
||||
super();
|
||||
|
||||
if (!(frame instanceof NodeAv.Frame)) {
|
||||
throw new TypeError('frame must be a NodeAv.Frame.');
|
||||
}
|
||||
if (frame.getMediaType() !== NodeAv.AVMEDIA_TYPE_VIDEO) {
|
||||
throw new Error('AvFrameVideoSampleResource must be initialized with a video frame.');
|
||||
}
|
||||
|
||||
+108
-215
@@ -14,6 +14,7 @@ import {
|
||||
VideoCodec,
|
||||
} from './codec';
|
||||
import {
|
||||
AudioEncodingConfig,
|
||||
getEncodableAudioCodecs,
|
||||
getFirstEncodableVideoCodec,
|
||||
Quality,
|
||||
@@ -50,17 +51,14 @@ import { Output, OutputTrackGroup, TrackType } from './output';
|
||||
import { Mp4OutputFormat } from './output-format';
|
||||
import {
|
||||
AudioSample,
|
||||
audioSampleToInterleavedFormat,
|
||||
clampCropRectangle,
|
||||
CropRectangle,
|
||||
toInterleavedAudioFormat,
|
||||
validateCropRectangle,
|
||||
VideoSample,
|
||||
VideoSampleResource,
|
||||
} from './sample';
|
||||
import { MetadataTags, validateMetadataTags } from './metadata';
|
||||
import { NullTarget } from './target';
|
||||
import { AudioResampler } from './resample';
|
||||
|
||||
/**
|
||||
* The options for media file conversion.
|
||||
@@ -1342,6 +1340,10 @@ export class Conversion {
|
||||
encodingConfig.transform.frameRate = trackOptions.frameRate;
|
||||
}
|
||||
|
||||
if (trackOptions.process) {
|
||||
encodingConfig.transform.process = trackOptions.process;
|
||||
}
|
||||
|
||||
if (needsRerender) {
|
||||
outputTrackRotation = 0; // Since the rotation is baked into the output
|
||||
|
||||
@@ -1353,6 +1355,12 @@ export class Conversion {
|
||||
encodingConfig.transform.alpha = alpha;
|
||||
}
|
||||
|
||||
// We need to do this because `process` can emit new timestamps
|
||||
let lastSampleTimestamp: number | null = null;
|
||||
encodingConfig.onEncodedSample = (sample) => {
|
||||
lastSampleTimestamp = sample.timestamp;
|
||||
};
|
||||
|
||||
const source = new VideoSampleSource(encodingConfig);
|
||||
videoSource = source;
|
||||
|
||||
@@ -1370,7 +1378,14 @@ export class Conversion {
|
||||
const adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
|
||||
sample.setTimestamp(adjustedSampleTimestamp);
|
||||
|
||||
await this._registerVideoSample(trackOptions, outputTrackId, source, sample);
|
||||
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
|
||||
await source.add(sample);
|
||||
|
||||
if (lastSampleTimestamp !== null) {
|
||||
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
|
||||
await this._synchronizer.wait(lastSampleTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
sample.close();
|
||||
}
|
||||
@@ -1403,69 +1418,6 @@ export class Conversion {
|
||||
this._outputOwnTrackGroups.push(ownGroup);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _registerVideoSample(
|
||||
trackOptions: ConversionVideoOptions,
|
||||
outputTrackId: number,
|
||||
source: VideoSampleSource,
|
||||
sample: VideoSample,
|
||||
) {
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
|
||||
|
||||
let finalSamples: VideoSample[];
|
||||
if (!trackOptions.process) {
|
||||
finalSamples = [sample];
|
||||
} else {
|
||||
let processed = trackOptions.process(sample);
|
||||
if (processed instanceof Promise) processed = await processed;
|
||||
|
||||
if (!Array.isArray(processed)) {
|
||||
processed = processed === null ? [] : [processed];
|
||||
}
|
||||
|
||||
finalSamples = processed.map((x) => {
|
||||
if (x instanceof VideoSample) {
|
||||
return x;
|
||||
}
|
||||
|
||||
if (typeof VideoFrame !== 'undefined' && x instanceof VideoFrame) {
|
||||
return new VideoSample(x);
|
||||
}
|
||||
|
||||
// Calling the VideoSample constructor here will automatically handle input validation for us
|
||||
// (it throws for any non-legal argument).
|
||||
return new VideoSample(x as CanvasImageSource, {
|
||||
timestamp: sample.timestamp,
|
||||
duration: sample.duration,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
for (const finalSample of finalSamples) {
|
||||
if (this._canceled) {
|
||||
break;
|
||||
}
|
||||
|
||||
await source.add(finalSample);
|
||||
|
||||
if (this._synchronizer.shouldWait(outputTrackId, finalSample.timestamp)) {
|
||||
await this._synchronizer.wait(finalSample.timestamp);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const finalSample of finalSamples) {
|
||||
if (finalSample !== sample) {
|
||||
finalSample.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions, outputTrackId: number) {
|
||||
const sourceCodec = await track.getCodec();
|
||||
@@ -1487,16 +1439,18 @@ export class Conversion {
|
||||
|
||||
let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels;
|
||||
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
|
||||
let needsResample = numberOfChannels !== originalNumberOfChannels
|
||||
|| sampleRate !== originalSampleRate
|
||||
|| firstTimestamp < this._startTimestamp
|
||||
|| (firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData);
|
||||
|
||||
const needsTrimming = firstTimestamp < this._startTimestamp;
|
||||
const needsPadding = firstTimestamp > this._startTimestamp && !this.output.format.supportsTimestampedMediaData;
|
||||
|
||||
let audioCodecs = this.output.format.getSupportedAudioCodecs();
|
||||
if (
|
||||
!trackOptions.forceTranscode
|
||||
&& !trackOptions.bitrate
|
||||
&& !needsResample
|
||||
&& numberOfChannels === originalNumberOfChannels
|
||||
&& sampleRate === originalSampleRate
|
||||
&& !needsTrimming
|
||||
&& !needsPadding
|
||||
&& audioCodecs.includes(sourceCodec)
|
||||
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
||||
&& !trackOptions.process
|
||||
@@ -1589,7 +1543,6 @@ export class Conversion {
|
||||
.find(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec));
|
||||
if (nonPcmCodec) {
|
||||
// We are able to encode using a non-PCM codec, but it'll require resampling
|
||||
needsResample = true;
|
||||
codecOfChoice = nonPcmCodec;
|
||||
numberOfChannels = FALLBACK_NUMBER_OF_CHANNELS;
|
||||
sampleRate = FALLBACK_SAMPLE_RATE;
|
||||
@@ -1607,44 +1560,86 @@ export class Conversion {
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsResample) {
|
||||
audioSource = this._resampleAudio(
|
||||
track,
|
||||
trackOptions,
|
||||
outputTrackId,
|
||||
codecOfChoice,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
bitrate,
|
||||
);
|
||||
} else {
|
||||
const source = new AudioSampleSource({
|
||||
codec: codecOfChoice,
|
||||
bitrate,
|
||||
});
|
||||
audioSource = source;
|
||||
const encodingConfig: AudioEncodingConfig = {
|
||||
codec: codecOfChoice,
|
||||
bitrate,
|
||||
transform: {
|
||||
sampleFormat: trackOptions.sampleFormat,
|
||||
process: trackOptions.process,
|
||||
},
|
||||
};
|
||||
assert(encodingConfig.transform);
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
await this._started;
|
||||
if (numberOfChannels !== originalNumberOfChannels) {
|
||||
encodingConfig.transform.numberOfChannels = numberOfChannels;
|
||||
}
|
||||
if (sampleRate !== originalSampleRate) {
|
||||
encodingConfig.transform.sampleRate = sampleRate;
|
||||
}
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
|
||||
if (this._canceled) {
|
||||
sample.close();
|
||||
return;
|
||||
}
|
||||
let lastSampleTimestamp: number | null = null;
|
||||
encodingConfig.onEncodedSample = (sample) => {
|
||||
lastSampleTimestamp = sample.timestamp;
|
||||
};
|
||||
|
||||
// Offset the timestamp as needed
|
||||
sample.setTimestamp(sample.timestamp - this._startTimestamp);
|
||||
const source = new AudioSampleSource(encodingConfig);
|
||||
audioSource = source;
|
||||
|
||||
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
|
||||
this._trackPromises.push((async () => {
|
||||
await this._started;
|
||||
|
||||
if (needsPadding) {
|
||||
const paddingLength = firstTimestamp - this._startTimestamp;
|
||||
const paddingLengthSamples = Math.round(paddingLength * originalSampleRate);
|
||||
|
||||
const silentSample = new AudioSample({
|
||||
data: new Float32Array(paddingLengthSamples * originalNumberOfChannels),
|
||||
format: 'f32-planar',
|
||||
numberOfChannels: originalNumberOfChannels,
|
||||
sampleRate: originalSampleRate,
|
||||
timestamp: 0,
|
||||
});
|
||||
await this._registerAudioSample(silentSample, source, outputTrackId, () => lastSampleTimestamp);
|
||||
}
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
for await (let sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
||||
if (this._canceled) {
|
||||
sample.close();
|
||||
return;
|
||||
}
|
||||
|
||||
source.close();
|
||||
this._synchronizer.closeTrack(outputTrackId);
|
||||
})());
|
||||
}
|
||||
let startFrame = 0;
|
||||
let endFrame = sample.numberOfFrames;
|
||||
|
||||
if (sample.timestamp < this._startTimestamp) {
|
||||
startFrame = Math.round((this._startTimestamp - sample.timestamp) * sample.sampleRate);
|
||||
}
|
||||
if (sample.timestamp + sample.duration > this._endTimestamp) {
|
||||
endFrame = Math.round((this._endTimestamp - sample.timestamp) * sample.sampleRate);
|
||||
}
|
||||
|
||||
if (startFrame > 0 || endFrame < sample.numberOfFrames) {
|
||||
// Trim the sample if it sticks out of the trim region on either end
|
||||
const trimmedSample = sample.trim(startFrame, endFrame);
|
||||
sample.close();
|
||||
sample = trimmedSample;
|
||||
|
||||
if (sample.numberOfFrames === 0) {
|
||||
sample.close();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Offset the timestamp as needed
|
||||
sample.setTimestamp(sample.timestamp - this._startTimestamp);
|
||||
|
||||
await this._registerAudioSample(sample, source, outputTrackId, () => lastSampleTimestamp);
|
||||
}
|
||||
|
||||
source.close();
|
||||
this._synchronizer.closeTrack(outputTrackId);
|
||||
})());
|
||||
}
|
||||
|
||||
let ownGroup: OutputTrackGroup | null = null;
|
||||
@@ -1670,126 +1665,24 @@ export class Conversion {
|
||||
|
||||
/** @internal */
|
||||
async _registerAudioSample(
|
||||
trackOptions: ConversionAudioOptions,
|
||||
outputTrackId: number,
|
||||
sample: AudioSample,
|
||||
source: AudioSampleSource,
|
||||
inputSample: AudioSample,
|
||||
outputTrackId: number,
|
||||
getLastSampleTimestamp: () => number | null,
|
||||
) {
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sample = inputSample;
|
||||
|
||||
if (
|
||||
trackOptions.sampleFormat !== undefined
|
||||
&& toInterleavedAudioFormat(sample.format) !== trackOptions.sampleFormat
|
||||
) {
|
||||
// Do a sample format conversion
|
||||
sample = audioSampleToInterleavedFormat(sample, trackOptions.sampleFormat);
|
||||
}
|
||||
|
||||
this._reportProgress(outputTrackId, sample.timestamp + sample.duration);
|
||||
|
||||
let finalSamples: AudioSample[];
|
||||
if (!trackOptions.process) {
|
||||
finalSamples = [sample];
|
||||
} else {
|
||||
let processed = trackOptions.process(sample);
|
||||
if (processed instanceof Promise) processed = await processed;
|
||||
await source.add(sample);
|
||||
sample.close();
|
||||
|
||||
if (!Array.isArray(processed)) {
|
||||
processed = processed === null ? [] : [processed];
|
||||
}
|
||||
|
||||
if (!processed.every(x => x instanceof AudioSample)) {
|
||||
throw new TypeError(
|
||||
'The audio process function must return an AudioSample, null, or an array of AudioSamples.',
|
||||
);
|
||||
}
|
||||
|
||||
finalSamples = processed;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const finalSample of finalSamples) {
|
||||
if (this._canceled) {
|
||||
break;
|
||||
}
|
||||
|
||||
await source.add(finalSample);
|
||||
|
||||
if (this._synchronizer.shouldWait(outputTrackId, finalSample.timestamp)) {
|
||||
await this._synchronizer.wait(finalSample.timestamp);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (sample !== inputSample) {
|
||||
sample.close();
|
||||
}
|
||||
|
||||
for (const finalSample of finalSamples) {
|
||||
if (finalSample !== inputSample) {
|
||||
finalSample.close();
|
||||
}
|
||||
const lastSampleTimestamp = getLastSampleTimestamp();
|
||||
if (lastSampleTimestamp !== null) {
|
||||
if (this._synchronizer.shouldWait(outputTrackId, lastSampleTimestamp)) {
|
||||
await this._synchronizer.wait(lastSampleTimestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_resampleAudio(
|
||||
track: InputAudioTrack,
|
||||
trackOptions: ConversionAudioOptions,
|
||||
outputTrackId: number,
|
||||
codec: AudioCodec,
|
||||
targetNumberOfChannels: number,
|
||||
targetSampleRate: number,
|
||||
bitrate: number | Quality,
|
||||
) {
|
||||
const source = new AudioSampleSource({
|
||||
codec,
|
||||
bitrate,
|
||||
});
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
await this._started;
|
||||
|
||||
const resampler = new AudioResampler({
|
||||
targetNumberOfChannels,
|
||||
targetSampleRate,
|
||||
startTime: this._startTimestamp,
|
||||
endTime: this._endTimestamp,
|
||||
onSample: async (sample) => {
|
||||
assert(sample.timestamp >= this._startTimestamp);
|
||||
sample.setTimestamp(sample.timestamp - this._startTimestamp);
|
||||
|
||||
await this._registerAudioSample(trackOptions, outputTrackId, source, sample);
|
||||
sample.close();
|
||||
},
|
||||
});
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
|
||||
|
||||
for await (const sample of iterator) {
|
||||
if (this._canceled) {
|
||||
sample.close();
|
||||
return;
|
||||
}
|
||||
|
||||
await resampler.add(sample);
|
||||
sample.close();
|
||||
}
|
||||
|
||||
await resampler.finalize();
|
||||
|
||||
source.close();
|
||||
this._synchronizer.closeTrack(outputTrackId);
|
||||
})());
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_reportProgress(trackId: number, endTimestamp: number) {
|
||||
if (!this._computeProgress) {
|
||||
|
||||
@@ -74,6 +74,8 @@ export type VideoEncodingConfig = {
|
||||
* WebCodecs API, is created.
|
||||
*/
|
||||
onEncoderConfig?: (config: VideoEncoderConfig) => unknown;
|
||||
/** Called right before a sample is passed to the encoder. */
|
||||
onEncodedSample?: (sample: VideoSample) => unknown;
|
||||
} & VideoEncodingAdditionalOptions;
|
||||
|
||||
/**
|
||||
@@ -232,6 +234,9 @@ export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
|
||||
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
|
||||
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
|
||||
}
|
||||
if (config.onEncodedSample !== undefined && typeof config.onEncodedSample !== 'function') {
|
||||
throw new TypeError('config.onEncodedSample, when provided, must be a function.');
|
||||
}
|
||||
|
||||
validateVideoEncodingAdditionalOptions(config.codec, config);
|
||||
};
|
||||
@@ -382,6 +387,8 @@ export type AudioEncodingConfig = {
|
||||
* WebCodecs API, is created.
|
||||
*/
|
||||
onEncoderConfig?: (config: AudioEncoderConfig) => unknown;
|
||||
/** Called right before a sample is passed to the encoder. */
|
||||
onEncodedSample?: (sample: AudioSample) => unknown;
|
||||
} & AudioEncodingAdditionalOptions;
|
||||
|
||||
/**
|
||||
@@ -462,6 +469,9 @@ export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
|
||||
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
|
||||
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
|
||||
}
|
||||
if (config.onEncodedSample !== undefined && typeof config.onEncodedSample !== 'function') {
|
||||
throw new TypeError('config.onEncodedSample, when provided, must be a function.');
|
||||
}
|
||||
|
||||
validateAudioEncodingAdditionalOptions(config.codec, config);
|
||||
};
|
||||
|
||||
@@ -145,6 +145,7 @@ export {
|
||||
export {
|
||||
type AnyIterable,
|
||||
ConcurrentRunner,
|
||||
type DeepReadonly,
|
||||
EventEmitter,
|
||||
type EventListenerOptions,
|
||||
type FilePath,
|
||||
|
||||
+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';
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -527,8 +527,12 @@ export class Input<S extends Source = Source> extends EventEmitter<InputEvents>
|
||||
}
|
||||
this._sourceRefs.length = 0;
|
||||
|
||||
void this._demuxerPromise
|
||||
?.then(demuxer => demuxer.dispose());
|
||||
if (this._demuxerPromise) {
|
||||
// The demuxer promise may already be rejected after failed format detection.
|
||||
void this._demuxerPromise
|
||||
.then(demuxer => demuxer.dispose())
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, [
|
||||
mvhd(muxer.creationTime, muxer.trackDatas),
|
||||
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
|
||||
muxer.isFragmented ? mvex(muxer.trackDatas) : null,
|
||||
udta(muxer),
|
||||
]);
|
||||
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;
|
||||
|
||||
@@ -2065,8 +2069,14 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
// referenced in the track fragment header.
|
||||
if (this.currentTrack) {
|
||||
const trackData = this.currentFragment.trackData.get(this.currentTrack.id);
|
||||
cond:
|
||||
if (trackData) {
|
||||
this.currentFragment.implicitBaseDataOffset = trackData.currentOffset;
|
||||
if (trackData.samples.length === 0) {
|
||||
// Don't associate the fragment with the track if it has no samples, this simplifies
|
||||
// other code
|
||||
this.currentFragment.trackData.delete(this.currentTrack.id);
|
||||
break cond;
|
||||
}
|
||||
|
||||
trackData.presentationTimestamps = trackData.samples
|
||||
.map((x, i) => ({ presentationTimestamp: x.presentationTimestamp, sampleIndex: i }))
|
||||
@@ -2256,12 +2266,6 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
this.currentFragment.trackData.set(track.id, trackData);
|
||||
}
|
||||
|
||||
if (sampleCount === 0) {
|
||||
// Don't associate the fragment with the track if it has no samples, this simplifies other code
|
||||
this.currentFragment.implicitBaseDataOffset = trackData.currentOffset;
|
||||
break;
|
||||
}
|
||||
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
let sampleDuration: number;
|
||||
if (sampleDurationPresent) {
|
||||
@@ -2313,6 +2317,8 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
trackData.currentOffset += sampleSize;
|
||||
trackData.currentTimestamp += sampleDuration;
|
||||
}
|
||||
|
||||
this.currentFragment.implicitBaseDataOffset = trackData.currentOffset;
|
||||
}; break;
|
||||
|
||||
case 'saiz': {
|
||||
|
||||
@@ -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));
|
||||
|
||||
+30
-9
@@ -265,6 +265,7 @@ class VideoEncoderWrapper {
|
||||
* So, we keep track of the encoder error and throw it as soon as we get the chance.
|
||||
*/
|
||||
private error: Error | null = null;
|
||||
private closed = false;
|
||||
|
||||
private lastMuxerPromise: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -429,6 +430,8 @@ class VideoEncoderWrapper {
|
||||
return new VideoSample(x);
|
||||
}
|
||||
|
||||
// Calling the VideoSample constructor here will automatically handle input validation for us
|
||||
// (it throws for any non-legal argument).
|
||||
return new VideoSample(x as CanvasImageSource, {
|
||||
timestamp: videoSample.timestamp,
|
||||
duration: videoSample.duration,
|
||||
@@ -457,20 +460,29 @@ class VideoEncoderWrapper {
|
||||
}
|
||||
assert(this.encoderInitialized);
|
||||
|
||||
if (this.closed) {
|
||||
break;
|
||||
}
|
||||
|
||||
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 2;
|
||||
const multipleOfKeyFrameInterval = Math.floor(sampleToEncode.timestamp / keyFrameInterval);
|
||||
|
||||
// Ensure a key frame every keyFrameInterval seconds. It is important that all video tracks
|
||||
// follow the same "key frame" rhythm, because aligned key frames are required to start new
|
||||
// fragments in ISOBMFF or clusters in Matroska (or at least desirable).
|
||||
const mergedEncodeOptions = { ...sampleToEncode.encodeOptions, ...encodeOptions };
|
||||
|
||||
const finalEncodeOptions = {
|
||||
...encodeOptions,
|
||||
keyFrame: encodeOptions?.keyFrame
|
||||
|| keyFrameInterval === 0
|
||||
|| multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval,
|
||||
...mergedEncodeOptions,
|
||||
keyFrame: mergedEncodeOptions.keyFrame !== undefined
|
||||
? mergedEncodeOptions.keyFrame
|
||||
// Ensure a key frame every keyFrameInterval seconds. It is important that all video tracks
|
||||
// follow the same "key frame" rhythm, because aligned key frames are required to start new
|
||||
// fragments in ISOBMFF or clusters in Matroska (or at least desirable).
|
||||
: keyFrameInterval === 0
|
||||
|| multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval,
|
||||
};
|
||||
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
|
||||
|
||||
this.encodingConfig.onEncodedSample?.(sampleToEncode);
|
||||
|
||||
if (this.customEncoder) {
|
||||
this.customEncoderQueueSize++;
|
||||
|
||||
@@ -840,6 +852,8 @@ class VideoEncoderWrapper {
|
||||
await this.padFrameRate(alignedEnd);
|
||||
}
|
||||
|
||||
this.closed = true;
|
||||
|
||||
this.frameRateLastSample?.close();
|
||||
this.frameRateLastSample = null;
|
||||
|
||||
@@ -2030,6 +2044,7 @@ class AudioEncoderWrapper {
|
||||
*/
|
||||
private error: Error | null = null;
|
||||
private lastMuxerPromise: Promise<void> = Promise.resolve();
|
||||
private closed = false;
|
||||
|
||||
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
|
||||
|
||||
@@ -2067,8 +2082,6 @@ class AudioEncoderWrapper {
|
||||
?? audioSample.numberOfChannels,
|
||||
targetSampleRate: config.transform!.sampleRate
|
||||
?? audioSample.sampleRate,
|
||||
startTime: audioSample.timestamp,
|
||||
endTime: Infinity,
|
||||
onSample: async (sample) => {
|
||||
await this.processAndEncode(sample, true);
|
||||
},
|
||||
@@ -2159,6 +2172,10 @@ class AudioEncoderWrapper {
|
||||
}
|
||||
assert(this.encoderInitialized);
|
||||
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle padding of gaps with silence to avoid audio drift over time, like in
|
||||
// https://github.com/Vanilagy/mediabunny/issues/176
|
||||
// TODO An open question is how encoders deal with the first AudioData having a non-zero timestamp, and with
|
||||
@@ -2194,6 +2211,8 @@ class AudioEncoderWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
this.encodingConfig.onEncodedSample?.(audioSample);
|
||||
|
||||
if (this.customEncoder) {
|
||||
this.customEncoderQueueSize++;
|
||||
|
||||
@@ -2533,6 +2552,8 @@ class AudioEncoderWrapper {
|
||||
}
|
||||
this.resampler = null;
|
||||
|
||||
this.closed = true;
|
||||
|
||||
if (this.customEncoder) {
|
||||
if (!forceClose) {
|
||||
void this.customEncoderCallSerializer.call(() => this.customEncoder!.flush());
|
||||
|
||||
+10
-1
@@ -482,6 +482,15 @@ export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
|
||||
*/
|
||||
export type SetRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Recursively makes all properties of T readonly.
|
||||
* @group Miscellaneous
|
||||
* @public
|
||||
*/
|
||||
export type DeepReadonly<T> = T extends object ? {
|
||||
readonly [K in keyof T]: DeepReadonly<T[K]>;
|
||||
} : T;
|
||||
|
||||
/**
|
||||
* Sets all keys K of T to be optional.
|
||||
* @group Miscellaneous
|
||||
@@ -524,7 +533,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;
|
||||
}
|
||||
|
||||
|
||||
+10
-15
@@ -20,41 +20,32 @@ export class AudioResampler {
|
||||
targetSampleRate: number;
|
||||
sourceNumberOfChannels: number | null = null;
|
||||
targetNumberOfChannels: number;
|
||||
endTime: number;
|
||||
startTime: number | null = null;
|
||||
onSample: (sample: AudioSample) => Promise<void>;
|
||||
|
||||
bufferSizeInFrames: number;
|
||||
bufferSizeInSamples: number;
|
||||
outputBuffer: Float32Array;
|
||||
/** Start frame of current buffer */
|
||||
bufferStartFrame: number;
|
||||
bufferStartFrame = 0;
|
||||
/** The highest index written to in the current buffer */
|
||||
maxWrittenFrame: number | null = null;
|
||||
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
|
||||
tempSourceBuffer!: Float32Array;
|
||||
timestampOffset: number;
|
||||
|
||||
constructor(options: {
|
||||
targetSampleRate: number;
|
||||
targetNumberOfChannels: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
onSample: (sample: AudioSample) => Promise<void>;
|
||||
}) {
|
||||
this.targetSampleRate = options.targetSampleRate;
|
||||
this.targetNumberOfChannels = options.targetNumberOfChannels;
|
||||
this.endTime = options.endTime;
|
||||
this.onSample = options.onSample;
|
||||
|
||||
this.bufferSizeInFrames = Math.floor(this.targetSampleRate * 5.0); // 5 seconds
|
||||
this.bufferSizeInSamples = this.bufferSizeInFrames * this.targetNumberOfChannels;
|
||||
|
||||
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
|
||||
|
||||
this.bufferStartFrame = Math.floor(options.startTime * this.targetSampleRate);
|
||||
// Set to ensure that if the buffer start frame lands on a fractional sample, that the first timestamp still
|
||||
// comes out as exactly startTime
|
||||
this.timestampOffset = options.startTime - this.bufferStartFrame / this.targetSampleRate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +177,7 @@ export class AudioResampler {
|
||||
// they see fit.
|
||||
this.sourceSampleRate = audioSample.sampleRate;
|
||||
this.sourceNumberOfChannels = audioSample.numberOfChannels;
|
||||
this.startTime = audioSample.timestamp;
|
||||
|
||||
// Pre-allocate temporary buffer for source data
|
||||
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
||||
@@ -193,6 +185,8 @@ export class AudioResampler {
|
||||
this.doChannelMixerSetup();
|
||||
}
|
||||
|
||||
assert(this.startTime !== null);
|
||||
|
||||
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
|
||||
this.ensureTempBufferSize(requiredSamples);
|
||||
|
||||
@@ -201,8 +195,8 @@ export class AudioResampler {
|
||||
const sourceView = new Float32Array(this.tempSourceBuffer.buffer, 0, sourceDataSize / 4);
|
||||
audioSample.copyTo(sourceView, { planeIndex: 0, format: 'f32' });
|
||||
|
||||
const inputStartTime = audioSample.timestamp;
|
||||
const inputEndTime = Math.min(audioSample.timestamp + audioSample.duration, this.endTime);
|
||||
const inputStartTime = audioSample.timestamp - this.startTime;
|
||||
const inputEndTime = inputStartTime + audioSample.duration;
|
||||
|
||||
// Compute which output frames are affected by this sample
|
||||
const outputStartFrame = Math.floor(inputStartTime * this.targetSampleRate);
|
||||
@@ -266,17 +260,18 @@ export class AudioResampler {
|
||||
return; // Nothing to finalize
|
||||
}
|
||||
|
||||
assert(this.startTime !== null);
|
||||
|
||||
const samplesWritten = (this.maxWrittenFrame + 1) * this.targetNumberOfChannels;
|
||||
|
||||
const outputData = new Float32Array(samplesWritten);
|
||||
outputData.set(this.outputBuffer.subarray(0, samplesWritten));
|
||||
|
||||
const timestampSeconds = this.bufferStartFrame / this.targetSampleRate;
|
||||
const audioSample = new AudioSample({
|
||||
format: 'f32',
|
||||
sampleRate: this.targetSampleRate,
|
||||
numberOfChannels: this.targetNumberOfChannels,
|
||||
timestamp: timestampSeconds + this.timestampOffset,
|
||||
timestamp: this.startTime + this.bufferStartFrame / this.targetSampleRate,
|
||||
data: outputData,
|
||||
});
|
||||
|
||||
|
||||
+111
-4
@@ -30,6 +30,7 @@ import {
|
||||
roundToMultiple,
|
||||
arrayArgmin,
|
||||
MaybePromise,
|
||||
DeepReadonly,
|
||||
} from './misc';
|
||||
|
||||
polyfillSymbolDispose();
|
||||
@@ -232,6 +233,11 @@ export type VideoSampleInit = {
|
||||
displayWidth?: number | undefined;
|
||||
/** Height of the frame in pixels after applying aspect ratio adjustments and rotation. */
|
||||
displayHeight?: number | undefined;
|
||||
/** The encode options to use when this sample is passed to an encoder. */
|
||||
encodeOptions?: DeepReadonly<VideoEncoderEncodeOptions>;
|
||||
|
||||
/** @internal */
|
||||
_doNotCopy?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -279,6 +285,8 @@ export class VideoSample implements Disposable {
|
||||
readonly duration!: number;
|
||||
/** The color space of the frame. */
|
||||
readonly colorSpace!: VideoSampleColorSpace;
|
||||
/** The encode options to use when this sample is passed to an encoder. */
|
||||
readonly encodeOptions!: DeepReadonly<VideoEncoderEncodeOptions>;
|
||||
|
||||
/** The width of the frame in pixels. */
|
||||
get codedWidth() {
|
||||
@@ -414,7 +422,9 @@ export class VideoSample implements Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
this._data = toUint8Array(data).slice(); // Copy it
|
||||
this._data = init._doNotCopy
|
||||
? toUint8Array(data)
|
||||
: toUint8Array(data).slice(); // Copy it
|
||||
this._layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth!, init.codedHeight!);
|
||||
|
||||
this.format = init.format;
|
||||
@@ -553,7 +563,12 @@ export class VideoSample implements Disposable {
|
||||
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
|
||||
willReadFrequently: true,
|
||||
});
|
||||
assert(context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'OffscreenCanvas must have support for the \'2d\' context in order to create a VideoSample from'
|
||||
+ ' this data.',
|
||||
);
|
||||
}
|
||||
|
||||
// Draw it to a canvas
|
||||
context.drawImage(data, 0, 0);
|
||||
@@ -628,6 +643,8 @@ export class VideoSample implements Disposable {
|
||||
);
|
||||
}
|
||||
|
||||
this.encodeOptions = init?.encodeOptions ?? {};
|
||||
|
||||
this.pixelAspectRatio = simplifyRational({
|
||||
num: this.squarePixelWidth * this.codedHeight,
|
||||
den: this.squarePixelHeight * this.codedWidth,
|
||||
@@ -648,12 +665,14 @@ export class VideoSample implements Disposable {
|
||||
timestamp: this.timestamp,
|
||||
duration: this.duration,
|
||||
rotation: this.rotation,
|
||||
encodeOptions: this.encodeOptions,
|
||||
});
|
||||
} else if (isVideoFrame(this._data)) {
|
||||
return new VideoSample(this._data.clone(), {
|
||||
timestamp: this.timestamp,
|
||||
duration: this.duration,
|
||||
rotation: this.rotation,
|
||||
encodeOptions: this.encodeOptions,
|
||||
});
|
||||
} else if (this._data instanceof Uint8Array) {
|
||||
assert(this._layout);
|
||||
@@ -670,6 +689,10 @@ export class VideoSample implements Disposable {
|
||||
visibleRect: this.visibleRect,
|
||||
displayWidth: this.displayWidth,
|
||||
displayHeight: this.displayHeight,
|
||||
encodeOptions: this.encodeOptions,
|
||||
|
||||
// It's already been copied, if we copy it again we make the clone unnecessarily expensive
|
||||
_doNotCopy: true,
|
||||
});
|
||||
} else {
|
||||
return new VideoSample(this._data, {
|
||||
@@ -683,6 +706,7 @@ export class VideoSample implements Disposable {
|
||||
visibleRect: this.visibleRect,
|
||||
displayWidth: this.displayWidth,
|
||||
displayHeight: this.displayHeight,
|
||||
encodeOptions: this.encodeOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -852,7 +876,7 @@ export class VideoSample implements Disposable {
|
||||
} else {
|
||||
const canvas = this._data;
|
||||
const context = canvas.getContext('2d');
|
||||
assert(context);
|
||||
assert(context); // We already got it earlier so it's definitely available
|
||||
|
||||
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
|
||||
|
||||
@@ -1503,7 +1527,12 @@ export class VideoSample implements Disposable {
|
||||
const context = canvas.getContext('2d', {
|
||||
alpha: true,
|
||||
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
assert(context);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'The \'2d\' canvas context is required to transform VideoSamples. Register a custom transformer using'
|
||||
+ ' registerVideoSampleTransformer to work around this limitation.',
|
||||
);
|
||||
}
|
||||
|
||||
if (description.alpha === 'discard') {
|
||||
context.fillStyle = 'black';
|
||||
@@ -1556,6 +1585,16 @@ export class VideoSample implements Disposable {
|
||||
(this.duration as number) = newDuration;
|
||||
}
|
||||
|
||||
/** Sets the encode options used when this sample is passed to an encoder. */
|
||||
setEncodeOptions(newEncodeOptions: VideoEncoderEncodeOptions) {
|
||||
if (!newEncodeOptions || typeof newEncodeOptions !== 'object') {
|
||||
throw new TypeError('newEncodeOptions must be an object.');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
(this.encodeOptions as DeepReadonly<VideoEncoderEncodeOptions>) = newEncodeOptions;
|
||||
}
|
||||
|
||||
/** Calls `.close()`. */
|
||||
[Symbol.dispose]() {
|
||||
this.close();
|
||||
@@ -2673,6 +2712,74 @@ export class AudioSample implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new {@link AudioSample} containing only the frames in the range [startSample, endSample). Both bounds
|
||||
* must lie within this sample's range of frames. The returned sample's timestamp is shifted to match the start of
|
||||
* the trimmed section.
|
||||
*/
|
||||
trim(startSample: number, endSample = this.numberOfFrames) {
|
||||
if (!Number.isInteger(startSample) || startSample < 0) {
|
||||
throw new TypeError('startSample must be a non-negative integer.');
|
||||
}
|
||||
if (!Number.isInteger(endSample) || endSample < 0) {
|
||||
throw new TypeError('endSample must be a non-negative integer.');
|
||||
}
|
||||
if (startSample > this.numberOfFrames) {
|
||||
throw new RangeError('startSample out of range.');
|
||||
}
|
||||
if (endSample > this.numberOfFrames) {
|
||||
throw new RangeError('endSample out of range.');
|
||||
}
|
||||
if (endSample < startSample) {
|
||||
throw new RangeError('endSample must not be less than startSample.');
|
||||
}
|
||||
|
||||
if (this._closed) {
|
||||
throw new Error('AudioSample is closed.');
|
||||
}
|
||||
|
||||
const frameCount = endSample - startSample;
|
||||
const bytesPerSample = getBytesPerSample(this.format);
|
||||
|
||||
let data: Uint8Array;
|
||||
if (formatIsPlanar(this.format)) {
|
||||
const planeSize = frameCount * bytesPerSample;
|
||||
data = new Uint8Array(planeSize * this.numberOfChannels);
|
||||
|
||||
if (frameCount > 0) {
|
||||
// Copy plane-by-plane
|
||||
for (let i = 0; i < this.numberOfChannels; i++) {
|
||||
this.copyTo(data.subarray(i * planeSize, (i + 1) * planeSize), {
|
||||
planeIndex: i,
|
||||
format: this.format,
|
||||
frameOffset: startSample,
|
||||
frameCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Trivial
|
||||
data = new Uint8Array(frameCount * this.numberOfChannels * bytesPerSample);
|
||||
|
||||
if (frameCount > 0) {
|
||||
this.copyTo(data, {
|
||||
planeIndex: 0,
|
||||
format: this.format,
|
||||
frameOffset: startSample,
|
||||
frameCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return new AudioSample({
|
||||
data,
|
||||
format: this.format,
|
||||
sampleRate: this.sampleRate,
|
||||
numberOfChannels: this.numberOfChannels,
|
||||
timestamp: this.timestamp + startSample / this.sampleRate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes this audio sample, releasing held resources. Audio samples should be closed as soon as they are not
|
||||
* needed anymore.
|
||||
|
||||
+112
-20
@@ -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,
|
||||
);
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.then((result) => {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
result.offset -= this._offset;
|
||||
return result;
|
||||
});
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { AudioSample } from '../../src/sample.js';
|
||||
|
||||
const SAMPLE_RATE = 48000;
|
||||
const NUM_CHANNELS = 2;
|
||||
const NUM_FRAMES = 10;
|
||||
const TIMESTAMP = 1;
|
||||
|
||||
const cellValue = (frame: number, channel: number) => frame * 10 + channel;
|
||||
|
||||
const makeInterleavedSample = () => {
|
||||
const data = new Int16Array(NUM_FRAMES * NUM_CHANNELS);
|
||||
for (let f = 0; f < NUM_FRAMES; f++) {
|
||||
for (let c = 0; c < NUM_CHANNELS; c++) {
|
||||
data[f * NUM_CHANNELS + c] = cellValue(f, c);
|
||||
}
|
||||
}
|
||||
|
||||
return new AudioSample({
|
||||
data,
|
||||
format: 's16',
|
||||
sampleRate: SAMPLE_RATE,
|
||||
numberOfChannels: NUM_CHANNELS,
|
||||
timestamp: TIMESTAMP,
|
||||
});
|
||||
};
|
||||
|
||||
const makePlanarSample = () => {
|
||||
const data = new Float32Array(NUM_FRAMES * NUM_CHANNELS);
|
||||
for (let c = 0; c < NUM_CHANNELS; c++) {
|
||||
for (let f = 0; f < NUM_FRAMES; f++) {
|
||||
data[c * NUM_FRAMES + f] = cellValue(f, c);
|
||||
}
|
||||
}
|
||||
|
||||
return new AudioSample({
|
||||
data,
|
||||
format: 'f32-planar',
|
||||
sampleRate: SAMPLE_RATE,
|
||||
numberOfChannels: NUM_CHANNELS,
|
||||
timestamp: TIMESTAMP,
|
||||
});
|
||||
};
|
||||
|
||||
const readCell = (sample: AudioSample, frame: number, channel: number) => {
|
||||
if (sample.format === 's16') {
|
||||
const out = new Int16Array(sample.numberOfFrames * sample.numberOfChannels);
|
||||
sample.copyTo(out, { planeIndex: 0, format: 's16' });
|
||||
return out[frame * sample.numberOfChannels + channel];
|
||||
} else {
|
||||
const out = new Float32Array(sample.numberOfFrames);
|
||||
sample.copyTo(out, { planeIndex: channel, format: 'f32-planar' });
|
||||
return out[frame];
|
||||
}
|
||||
};
|
||||
|
||||
for (const [label, makeSample] of [
|
||||
['interleaved', makeInterleavedSample],
|
||||
['planar', makePlanarSample],
|
||||
] as const) {
|
||||
test(`trim, normal case (${label})`, () => {
|
||||
using sample = makeSample();
|
||||
using trimmed = sample.trim(3, 8);
|
||||
|
||||
expect(trimmed).not.toBe(sample);
|
||||
expect(trimmed.format).toBe(sample.format);
|
||||
expect(trimmed.sampleRate).toBe(SAMPLE_RATE);
|
||||
expect(trimmed.numberOfChannels).toBe(NUM_CHANNELS);
|
||||
expect(trimmed.numberOfFrames).toBe(5);
|
||||
expect(trimmed.duration).toBeCloseTo(5 / SAMPLE_RATE);
|
||||
expect(trimmed.timestamp).toBeCloseTo(TIMESTAMP + 3 / SAMPLE_RATE);
|
||||
|
||||
// The trimmed frame i corresponds to the original frame i + 3
|
||||
for (let f = 0; f < trimmed.numberOfFrames; f++) {
|
||||
for (let c = 0; c < NUM_CHANNELS; c++) {
|
||||
expect(readCell(trimmed, f, c)).toBe(cellValue(f + 3, c));
|
||||
}
|
||||
}
|
||||
|
||||
// The original sample must be untouched
|
||||
expect(sample.numberOfFrames).toBe(NUM_FRAMES);
|
||||
expect(readCell(sample, 3, 0)).toBe(cellValue(3, 0));
|
||||
});
|
||||
|
||||
test(`trim, full range is an independent copy (${label})`, () => {
|
||||
using sample = makeSample();
|
||||
using trimmed = sample.trim(0, NUM_FRAMES);
|
||||
|
||||
expect(trimmed.numberOfFrames).toBe(NUM_FRAMES);
|
||||
expect(trimmed.timestamp).toBeCloseTo(TIMESTAMP);
|
||||
for (let f = 0; f < NUM_FRAMES; f++) {
|
||||
for (let c = 0; c < NUM_CHANNELS; c++) {
|
||||
expect(readCell(trimmed, f, c)).toBe(cellValue(f, c));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`trim, pathological zero-sample case (${label})`, () => {
|
||||
using sample = makeSample();
|
||||
|
||||
// Empty range in the middle
|
||||
using empty = sample.trim(4, 4);
|
||||
expect(empty.numberOfFrames).toBe(0);
|
||||
expect(empty.duration).toBe(0);
|
||||
expect(empty.format).toBe(sample.format);
|
||||
expect(empty.numberOfChannels).toBe(NUM_CHANNELS);
|
||||
expect(empty.timestamp).toBeCloseTo(TIMESTAMP + 4 / SAMPLE_RATE);
|
||||
|
||||
// Empty range right at the end (startSample === numberOfFrames) is still valid
|
||||
using emptyAtEnd = sample.trim(NUM_FRAMES, NUM_FRAMES);
|
||||
expect(emptyAtEnd.numberOfFrames).toBe(0);
|
||||
expect(emptyAtEnd.timestamp).toBeCloseTo(TIMESTAMP + NUM_FRAMES / SAMPLE_RATE);
|
||||
});
|
||||
|
||||
test(`trim, illegal params (${label})`, () => {
|
||||
using sample = makeSample();
|
||||
|
||||
// Non-integer
|
||||
expect(() => sample.trim(1.5, 5)).toThrow(TypeError);
|
||||
expect(() => sample.trim(1, 5.5)).toThrow(TypeError);
|
||||
|
||||
// Negative
|
||||
expect(() => sample.trim(-1, 5)).toThrow(TypeError);
|
||||
expect(() => sample.trim(0, -1)).toThrow(TypeError);
|
||||
|
||||
// Out of range
|
||||
expect(() => sample.trim(0, NUM_FRAMES + 1)).toThrow(RangeError);
|
||||
expect(() => sample.trim(NUM_FRAMES + 1, NUM_FRAMES + 1)).toThrow(RangeError);
|
||||
|
||||
// End before start
|
||||
expect(() => sample.trim(6, 3)).toThrow(RangeError);
|
||||
});
|
||||
}
|
||||
|
||||
test('trim, throws on a closed sample', () => {
|
||||
const sample = makeInterleavedSample();
|
||||
sample.close();
|
||||
expect(() => sample.trim(0, 5)).toThrow('AudioSample is closed.');
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -1,5 +1,11 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { VideoSample } from '../../src/sample.js';
|
||||
import { VideoSampleSource } from '../../src/media-source.js';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { Mp4OutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget } from '../../src/target.js';
|
||||
import { QUALITY_MEDIUM } from '../../src/encode.js';
|
||||
import { PacketType } from '../../src/packet.js';
|
||||
|
||||
test('allocationSize', async () => {
|
||||
{
|
||||
@@ -443,3 +449,83 @@ test('null format', async () => {
|
||||
await sample.copyTo(buffer, { format: 'BGRA' });
|
||||
await sample.copyTo(buffer, { format: 'BGRX' });
|
||||
});
|
||||
|
||||
test('encodeOptions', () => {
|
||||
const canvas = new OffscreenCanvas(100, 100);
|
||||
canvas.getContext('2d');
|
||||
|
||||
// The default is an empty object
|
||||
using a = new VideoSample(canvas, { timestamp: 0 });
|
||||
expect(a.encodeOptions).toEqual({});
|
||||
|
||||
// It can be overridden via the setter
|
||||
a.setEncodeOptions({ keyFrame: true });
|
||||
expect(a.encodeOptions).toEqual({ keyFrame: true });
|
||||
|
||||
// Cloning carries the encode options over
|
||||
using aClone = a.clone();
|
||||
expect(aClone.encodeOptions).toEqual({ keyFrame: true });
|
||||
|
||||
// It can be set via the constructor
|
||||
using b = new VideoSample(canvas, { timestamp: 0, encodeOptions: { keyFrame: true } });
|
||||
expect(b.encodeOptions).toEqual({ keyFrame: true });
|
||||
});
|
||||
|
||||
test('encodeOptions, key frame forcing', async () => {
|
||||
const samples = Array.from({ length: 5 }, (_, i) => makeRedSample(i / 30, { keyFrame: true }));
|
||||
|
||||
await encodeAndAssertPacketTypes(
|
||||
samples,
|
||||
['key', 'key', 'key', 'key', 'key'],
|
||||
);
|
||||
});
|
||||
|
||||
test('encodeOptions add() override', async () => {
|
||||
const samples = Array.from({ length: 5 }, (_, i) => makeRedSample(i / 30, { keyFrame: true }));
|
||||
|
||||
await encodeAndAssertPacketTypes(
|
||||
samples,
|
||||
['key', 'delta', 'delta', 'delta', 'delta'],
|
||||
samples.map(() => ({ keyFrame: false })),
|
||||
);
|
||||
});
|
||||
|
||||
const makeRedSample = (timestamp: number, encodeOptions?: VideoEncoderEncodeOptions) => {
|
||||
const canvas = new OffscreenCanvas(100, 100);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fillRect(0, 0, 100, 100);
|
||||
|
||||
return new VideoSample(canvas, {
|
||||
timestamp,
|
||||
duration: 1 / 30,
|
||||
encodeOptions,
|
||||
});
|
||||
};
|
||||
|
||||
const encodeAndAssertPacketTypes = async (
|
||||
samples: VideoSample[],
|
||||
expectedPacketTypes: PacketType[],
|
||||
addOptions?: (VideoEncoderEncodeOptions | undefined)[],
|
||||
) => {
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
const videoSource = new VideoSampleSource({
|
||||
codec: 'vp8',
|
||||
bitrate: QUALITY_MEDIUM,
|
||||
onEncodedPacket: packet => expect(packet.type).toBe(expectedPacketTypes[i++]),
|
||||
});
|
||||
output.addVideoTrack(videoSource);
|
||||
await output.start();
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
await videoSource.add(samples[i]!, addOptions?.[i]);
|
||||
samples[i]!.close();
|
||||
}
|
||||
|
||||
await output.finalize();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Input, UnsupportedInputFormatError } from '../../src/input.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { BufferSource } from '../../src/source.js';
|
||||
|
||||
test('Disposing after failed format detection does not emit an unhandled rejection', async () => {
|
||||
const input = new Input({
|
||||
source: new BufferSource(new Uint8Array([1, 2, 3, 4])),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
await expect(input.getFormat()).rejects.toThrow(UnsupportedInputFormatError);
|
||||
|
||||
const unhandledRejections: unknown[] = [];
|
||||
const onUnhandledRejection = (reason: unknown) => {
|
||||
unhandledRejections.push(reason);
|
||||
};
|
||||
|
||||
process.on('unhandledRejection', onUnhandledRejection);
|
||||
try {
|
||||
input.dispose();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
process.off('unhandledRejection', onUnhandledRejection);
|
||||
}
|
||||
|
||||
expect(unhandledRejections).toEqual([]);
|
||||
});
|
||||
@@ -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