mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 08:43:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
868578685d | ||
|
|
e1478f8ebd | ||
|
|
8aac07eb61 | ||
|
|
267c09abf8 | ||
|
|
a89101ff40 | ||
|
|
2ab1be3834 | ||
|
|
571fbb3198 | ||
|
|
32d8b1544d | ||
|
|
b8f104d8b4 | ||
|
|
7db0583058 | ||
|
|
24af125415 | ||
|
|
78e78e5b4f | ||
|
|
1d774b5fbb | ||
|
|
d6b518b9fd | ||
|
|
4f5f30bc03 | ||
|
|
f6ca3ece50 | ||
|
|
7b036fe8ac | ||
|
|
2bb84fd35b | ||
|
|
91e7812fb2 | ||
|
|
d29f4f33b5 | ||
|
|
7580935639 | ||
|
|
53824536fa | ||
|
|
8f43086e3d | ||
|
|
9343607fe1 | ||
|
|
f877fabc60 | ||
|
|
a2115b1de7 | ||
|
|
8d46d50c39 | ||
|
|
19219fc1ce | ||
|
|
05070f7c88 | ||
|
|
736cc50fa7 | ||
|
|
9ba63f5d95 | ||
|
|
17c6bff1dc | ||
|
|
c86af052f5 | ||
|
|
831e838f74 | ||
|
|
b730a24138 | ||
|
|
ccbf14565c | ||
|
|
15bdd072e1 | ||
|
|
4dd747a0a6 | ||
|
|
228721d68f | ||
|
|
ea9ba96ae3 | ||
|
|
f9207396d7 | ||
|
|
84dec01dfd | ||
|
|
1ff8abdaab | ||
|
|
cc649ba192 | ||
|
|
b73bf06ba7 | ||
|
|
49af8d5435 | ||
|
|
a09cb4aebf | ||
|
|
0500beaf34 | ||
|
|
f424f6e840 | ||
|
|
d949d1eadb | ||
|
|
11970675c6 | ||
|
|
d6c223b0f6 | ||
|
|
5b703a1c78 | ||
|
|
3dd7a9cb58 | ||
|
|
7b3f5dad2a | ||
|
|
dceb67a065 | ||
|
|
dd39b8b2ca | ||
|
|
71963cbd92 | ||
|
|
816bb020fb | ||
|
|
e5aeff89e3 | ||
|
|
3620b04caf | ||
|
|
c0c353dbe8 | ||
|
|
75096edb23 | ||
|
|
f33dd4f2cd | ||
|
|
cc3d48451e | ||
|
|
34b3ef0d44 | ||
|
|
896242a5a5 | ||
|
|
ef7a40944e | ||
|
|
f62ecb0eb6 | ||
|
|
da159bb063 | ||
|
|
c5d1efc18d | ||
|
|
9464adf22f |
@@ -5,6 +5,7 @@ on:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
@@ -14,7 +15,7 @@ jobs:
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -27,12 +28,15 @@ jobs:
|
||||
git push origin release
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Get package.json version
|
||||
id: package-json-version
|
||||
run: echo "version=v$(cat package.json | jq '.version' --raw-output)" >> $GITHUB_OUTPUT
|
||||
@@ -58,11 +62,7 @@ jobs:
|
||||
run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
|
||||
|
||||
- name: Publish Mediabunny to npm
|
||||
run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
- name: Publish workspace packages to npm
|
||||
run: npm publish --provenance --access public --workspaces
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public --workspaces
|
||||
|
||||
@@ -17,7 +17,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
|
||||
|
||||
<div align="center">
|
||||
<a href="https://remotion.dev/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/remotion.png" width="60" height="60" alt="Remotion">
|
||||
<picture>
|
||||
<source srcset="./docs/public/sponsors/remotion-dark.png" media="(prefers-color-scheme: dark)">
|
||||
<img src="./docs/public/sponsors/remotion-light.png" width="60" height="60" alt="Remotion">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<a href="https://www.gling.ai/" target="_blank" rel="sponsored">
|
||||
|
||||
+6
-4
@@ -100,9 +100,11 @@
|
||||
},
|
||||
*/
|
||||
video: () => ({
|
||||
width: 720,
|
||||
frameRate: 30,
|
||||
bitrate: Mediabunny.QUALITY_VERY_LOW,
|
||||
forceTranscode: true,
|
||||
allowRotationMetadata: false,
|
||||
//width: 720,
|
||||
//frameRate: 30,
|
||||
//bitrate: Mediabunny.QUALITY_VERY_LOW,
|
||||
//discard: true,
|
||||
/*
|
||||
process: (sample) => {
|
||||
@@ -180,7 +182,7 @@
|
||||
},
|
||||
trim: {
|
||||
start: 0,
|
||||
end: 20
|
||||
//end: 4
|
||||
},
|
||||
});
|
||||
console.log(conversion);
|
||||
|
||||
+16
-1
@@ -14,7 +14,22 @@
|
||||
source: new Mediabunny.BlobSource(file),
|
||||
});
|
||||
|
||||
console.log(await input.getTracks());
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet);
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
return;
|
||||
|
||||
+22
-7
@@ -1,6 +1,7 @@
|
||||
<button>Go</button>
|
||||
|
||||
<script src="../dist/bundles/mediabunny.cjs"></script>
|
||||
<script src="../packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js"></script>
|
||||
|
||||
<script type="module">
|
||||
function download(blob, filename) {
|
||||
@@ -12,6 +13,8 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
MediabunnyMp3Encoder.registerMp3Encoder();
|
||||
|
||||
const button = document.querySelector('button');
|
||||
button.addEventListener('click', async () => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
||||
@@ -22,29 +25,41 @@
|
||||
target: new Mediabunny.BufferTarget(),
|
||||
format: new Mediabunny.Mp4OutputFormat(),
|
||||
});
|
||||
let videoSource = null;
|
||||
let audioSource = null;
|
||||
if (videoTrack) {
|
||||
const source = new Mediabunny.MediaStreamVideoTrackSource(videoTrack, {
|
||||
videoSource = new Mediabunny.MediaStreamVideoTrackSource(videoTrack, {
|
||||
codec: 'avc',
|
||||
bitrate: Mediabunny.QUALITY_MEDIUM
|
||||
});
|
||||
|
||||
source.errorPromise.catch((d) => console.log("Hello?????", d));
|
||||
videoSource.errorPromise.catch((d) => console.log("Hello?????", d));
|
||||
|
||||
output.addVideoTrack(source);
|
||||
output.addVideoTrack(videoSource);
|
||||
}
|
||||
if (audioTrack) {
|
||||
const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
|
||||
codec: 'aac',
|
||||
audioSource = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
|
||||
codec: 'mp3',
|
||||
bitrate: Mediabunny.QUALITY_MEDIUM
|
||||
});
|
||||
|
||||
source.errorPromise.catch((d) => console.log("Hello!!???", d));
|
||||
audioSource.errorPromise.catch((d) => console.log("Hello!!???", d));
|
||||
|
||||
output.addAudioTrack(source);
|
||||
output.addAudioTrack(audioSource);
|
||||
}
|
||||
|
||||
await output.start();
|
||||
|
||||
setTimeout(() => {
|
||||
videoSource?.pause();
|
||||
audioSource?.pause();
|
||||
|
||||
setTimeout(() => {
|
||||
videoSource?.resume();
|
||||
audioSource?.resume();
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
|
||||
await output.finalize();
|
||||
|
||||
@@ -14,6 +14,9 @@ export default withMermaid({
|
||||
title: 'Mediabunny',
|
||||
description: DESCRIPTION,
|
||||
cleanUrls: true,
|
||||
sitemap: {
|
||||
hostname: 'https://mediabunny.dev',
|
||||
},
|
||||
head: [
|
||||
['link', { rel: 'icon', type: 'image/png', href: '/mediabunny-logo.png' }],
|
||||
['link', { rel: 'icon', type: 'image/svg+xml', href: '/mediabunny-logo.svg' }],
|
||||
@@ -34,7 +37,7 @@ export default withMermaid({
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: 'Guide', link: '/guide/introduction', activeMatch: '/guide' },
|
||||
{ text: 'API', link: '/api', activeMatch: '/api' },
|
||||
{ text: 'API', link: '/api/', activeMatch: '/api' },
|
||||
{ text: 'LLMs', link: '/llms', activeMatch: '/llms' },
|
||||
{ text: 'Examples', link: '/examples', activeMatch: '/examples' },
|
||||
{ text: 'Sponsors', link: '/#sponsors', activeMatch: '/#sponsors' },
|
||||
|
||||
+6
-6
@@ -9,37 +9,37 @@ hero:
|
||||
features:
|
||||
- title: Metadata extraction
|
||||
details: Extract various metadata from an input media file.
|
||||
link: /examples/metadata-extraction
|
||||
link: /examples/metadata-extraction/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--file-info-line.svg
|
||||
- title: Thumbnail generation
|
||||
details: Generate multiple small thumbnails for a video track.
|
||||
link: /examples/thumbnail-generation
|
||||
link: /examples/thumbnail-generation/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--photo-album-line.svg
|
||||
- title: Media player (advanced)
|
||||
details: "A full video & audio media player, implemented from scratch with Mediabunny, with microsecond playback accuracy."
|
||||
link: /examples/media-player
|
||||
link: /examples/media-player/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--video-line.svg
|
||||
- title: File compression
|
||||
details: Convert an input file to a highly-compressed MP4 file.
|
||||
link: /examples/file-compression
|
||||
link: /examples/file-compression/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--file-zip-line.svg
|
||||
- title: Procedural video generation
|
||||
details: Generate a video file as fast as the hardware allows.
|
||||
link: /examples/procedural-generation
|
||||
link: /examples/procedural-generation/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--magic-3-line.svg
|
||||
- title: Live recording & streaming
|
||||
details: Record a video from live sources and stream it to a video element.
|
||||
link: /examples/live-recording
|
||||
link: /examples/live-recording/
|
||||
target: _self
|
||||
icon:
|
||||
src: /mingcute--microphone-line.svg
|
||||
|
||||
@@ -108,7 +108,7 @@ Sometimes, you may want to cancel an ongoing conversion process. For this, use t
|
||||
await conversion.cancel(); // Resolves once the conversion is canceled
|
||||
```
|
||||
|
||||
This automatically frees up all resources used by the conversion process.
|
||||
This automatically frees up all resources used by the conversion process and will cause any ongoing call to `execute` to throw a `ConversionCanceledError`.
|
||||
|
||||
## Video options
|
||||
|
||||
@@ -120,11 +120,13 @@ type ConversionVideoOptions = {
|
||||
height?: number;
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
rotate?: 0 | 90 | 180 | 270;
|
||||
allowRotationMetadata?: boolean;
|
||||
crop?: { left: number; top: number; width: number; height: number };
|
||||
frameRate?: number;
|
||||
codec?: VideoCodec;
|
||||
bitrate?: number | Quality;
|
||||
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
|
||||
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
|
||||
keyFrameInterval?: number;
|
||||
forceTranscode?: boolean;
|
||||
process?: (sample: VideoSample) => MaybePromise<
|
||||
@@ -175,6 +177,8 @@ In the rare case that the input video changes size over time, the `fit` field ca
|
||||
|
||||
`rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file and happens before cropping and resizing.
|
||||
|
||||
By default, Mediabunny will try to make use of rotation metadata in the output file to perform the rotation whenever possible. However, if you don't want this to happen, or you want to use Mediabunny to strip all rotation metadata from a file, you can set `allowRotationMetadata` to `false`.
|
||||
|
||||
### Cropping video
|
||||
|
||||
`crop` can be used to extract a rectangular region from the original video. The rectangle is specified using `left`, `top`, `width` and `height` and is clamped to the dimensions of the video. Cropping is applied after rotation but before resizing.
|
||||
@@ -190,9 +194,10 @@ Use the `codec` property to control the codec of the output track. This should b
|
||||
Use the `bitrate` property to control the bitrate of the output video. For example, you can use this field to compress the video track. Accepted values are the number of bits per second or a [subjective quality](./media-sources#subjective-qualities). If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `QUALITY_HIGH` will be used as the value.
|
||||
|
||||
Use the `keyFrameInterval` property to control the maximum interval in seconds between key frames in the output video. Setting this fields forces a transcode.
|
||||
|
||||
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
|
||||
|
||||
Use the `hardwareAcceleration` property to control whether hardware or software acceleration is used for video transcoding.
|
||||
|
||||
### Processing video
|
||||
|
||||
The `process` property can be used to define a custom video sample processing function, e.g. for [applying overlays](./quick-start#add-a-video-overlay), color transformations, or timestamp modifications. You are expected to perform this processing yourself, for example using the Canvas API.
|
||||
@@ -445,4 +450,4 @@ On the flip side, you can always query which input tracks made it into the outpu
|
||||
```ts
|
||||
const conversion = await Conversion.init({ input, output });
|
||||
conversion.utilizedTracks; // => InputTrack[]
|
||||
```
|
||||
```
|
||||
|
||||
@@ -187,6 +187,16 @@ videoTrackSource.errorPromise.catch((error) => ...);
|
||||
|
||||
This source requires no additional method calls; data will automatically be captured and piped to the output file as soon as `start()` is called on the `Output`. Make sure to `stop()` on `videoTrack` after finalizing the `Output` if you don't need the user's media anymore.
|
||||
|
||||
If you want to temporarily stop capturing video frames from this source, you can use the `pause()` and `resume()` methods:
|
||||
```ts
|
||||
videoTrackSource.pause();
|
||||
|
||||
// Later:
|
||||
videoTrackSource.resume();
|
||||
```
|
||||
|
||||
While paused, video frames emitted by the stream will be ignored. When resumed, video frames are let through again, offset in timestamp such that the result plays back continuously with no gap in playback. Note that pausing does *not* stop the underlying media stream.
|
||||
|
||||
::: info
|
||||
If this source is the only MediaStreamTrack source in the `Output`, then the first video sample added by it starts at timestamp 0. If there are multiple, then the earliest media sample across all tracks starts at timestamp 0, and all tracks will be perfectly synchronized with each other.
|
||||
:::
|
||||
@@ -344,6 +354,16 @@ audioTrackSource.errorPromise.catch((error) => ...);
|
||||
|
||||
This source requires no additional method calls; data will automatically be captured and piped to the output file as soon as `start()` is called on the `Output`. Make sure to `stop()` on `audioTrack` after finalizing the `Output` if you don't need the user's media anymore.
|
||||
|
||||
If you want to temporarily stop capturing audio data from this source, you can use the `pause()` and `resume()` methods:
|
||||
```ts
|
||||
audioTrackSource.pause();
|
||||
|
||||
// Later:
|
||||
audioTrackSource.resume();
|
||||
```
|
||||
|
||||
While paused, audio data emitted by the stream will be ignored. When resumed, audio data are let through again, offset in timestamp such that the result plays back continuously with no gap in playback. Note that pausing does *not* stop the underlying media stream.
|
||||
|
||||
::: info
|
||||
If this source is the only MediaStreamTrack source in the `Output`, then the first audio sample added by it starts at timestamp 0. If there are multiple, then the earliest media sample across all tracks starts at timestamp 0, and all tracks will be perfectly synchronized with each other.
|
||||
:::
|
||||
@@ -433,4 +453,12 @@ await textSource.add('00:00:00.000 --> 00:00:02.000\nHello there!\n\n');
|
||||
await textSource.add('00:00:02.500 --> 00:00:04.000\nChunky chunks.\n\n');
|
||||
```
|
||||
|
||||
The chunks have certain constraints: A cue must be fully contained within a chunk and cannot be split across multiple smaller chunks (although a chunk can contain multiple cues). Also, the WebVTT preamble must be added first and all at once.
|
||||
The chunks have certain constraints: A cue must be fully contained within a chunk and cannot be split across multiple smaller chunks (although a chunk can contain multiple cues). Also, the WebVTT preamble must be added first and all at once.
|
||||
|
||||
::: info
|
||||
For QuickTime to display WebVTT subtitles, it typically expects alignment information to be specified:
|
||||
```
|
||||
00:00:00.000 --> 00:00:02.000 align:center
|
||||
This is your last chance.
|
||||
```
|
||||
:::
|
||||
@@ -194,9 +194,12 @@ This format ensures [append-only writing](#append-only-writing).
|
||||
The following options are available:
|
||||
```ts
|
||||
type OggOutputFormatOptions = {
|
||||
maximumPageDuration?: number;
|
||||
onPage?: (data: Uint8Array, position: number, source: MediaSource) => unknown;
|
||||
};
|
||||
```
|
||||
- `maximumPageDuration`\
|
||||
The maximum duration in seconds of each Ogg page. Pages will be flushed early if adding another packet would cause the page to exceed this duration. This is useful for streaming contexts where more frequent page output is desired. By default, pages are only flushed when they exceed a certain size.
|
||||
- `onPage`\
|
||||
Will be called for each finalized Ogg page of the output file. The [media source](./media-sources) backing the page's track (logical bitstream) is also passed.
|
||||
|
||||
@@ -272,6 +275,10 @@ const output = new Output({
|
||||
});
|
||||
```
|
||||
|
||||
::: info
|
||||
This format ensures [append-only writing](#append-only-writing).
|
||||
:::
|
||||
|
||||
The following options are available:
|
||||
```ts
|
||||
type AdtsOutputFormatOptions = {
|
||||
|
||||
@@ -378,13 +378,11 @@ const bytesNeeded = videoSample.allocationSize(); // => number
|
||||
Then, use `copyTo` to copy the pixel data into the destination buffer:
|
||||
```ts
|
||||
const bytes = new Uint8Array(bytesNeeded);
|
||||
videoSample.copyTo(bytes);
|
||||
const planeLayout = await videoSample.copyTo(bytes);
|
||||
```
|
||||
|
||||
::: info
|
||||
The data will always be in the pixel format specified in the `format` field.
|
||||
|
||||
To convert the data into a different pixel format, or to extract only a section of the frame, please use the [`allocationSize`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/allocationSize) and [`copyTo`](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/copyTo) methods on `VideoFrame` instead. Get a `VideoFrame` by running `videoSample.toVideoFrame()`.
|
||||
You can pass additional options to `allocationSize` and `copyTo` to extract data in a different pixel format.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
+8
-1
@@ -91,7 +91,7 @@ const bundleSizes = [
|
||||
|
||||
const sponsors = {
|
||||
gold: [
|
||||
{ image: '/sponsors/remotion.png', name: 'Remotion', url: 'https://remotion.dev/' },
|
||||
{ image: '/sponsors/remotion-light.png', name: 'Remotion', url: 'https://remotion.dev/' },
|
||||
{ image: '/sponsors/gling.svg', name: 'Gling AI', url: 'https://www.gling.ai/' },
|
||||
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
|
||||
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
|
||||
@@ -106,6 +106,8 @@ const sponsors = {
|
||||
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
|
||||
],
|
||||
individual: [
|
||||
{ image: 'https://avatars.githubusercontent.com/u/82552321', name: 'Polotno', url: 'https://github.com/polotno-project' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/489051', name: 'Roman Rädle', url: 'https://github.com/raedle' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/197597', name: 'Christopher Chedeau', url: 'https://github.com/vjeux' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
|
||||
@@ -114,6 +116,8 @@ const sponsors = {
|
||||
{ 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' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/139718', name: 'Anton Kosiakin', url: 'https://github.com/deil' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/56988069', name: 'SyhabouthAlex', url: 'https://github.com/SyhabouthAlex' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' },
|
||||
@@ -123,6 +127,9 @@ const sponsors = {
|
||||
{ image: 'https://avatars.githubusercontent.com/u/97225946', name: '808vita', url: 'https://github.com/808vita' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/3709646', name: 'Rodrigo Belfiore', url: 'https://github.com/roprgm' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/31102694', name: 'Aiden Liu', url: 'https://github.com/aidenlx' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/41021374', name: 'arthco', url: 'https://github.com/arthtyagi' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/36898190', name: 'alakhpc', url: 'https://github.com/alakhpc' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/5907357', name: 'Harvey Zhao', url: 'https://github.com/zhw2590582' },
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -21,7 +21,7 @@ const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!
|
||||
|
||||
const frameRate = 30;
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
||||
let recording = false;
|
||||
let output: Output;
|
||||
let videoSource: CanvasSource;
|
||||
|
||||
@@ -97,7 +97,7 @@ const extractMetadata = (resource: File | string) => {
|
||||
'Lyrics': tags.lyrics,
|
||||
'Comment': tags.comment,
|
||||
'Images': tags.images?.map((image) => {
|
||||
const blob = new Blob([image.data], { type: image.mimeType });
|
||||
const blob = new Blob([image.data.slice()], { type: image.mimeType });
|
||||
const element = new Image();
|
||||
element.src = URL.createObjectURL(blob);
|
||||
|
||||
|
||||
Generated
+285
-462
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.25.0",
|
||||
"version": "1.28.0",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -70,7 +70,7 @@
|
||||
"@eslint/js": "^9.22.0",
|
||||
"@fontsource-variable/rubik": "^5.2.6",
|
||||
"@fontsource/dm-mono": "^5.2.6",
|
||||
"@microsoft/api-extractor": "^7.52.1",
|
||||
"@microsoft/api-extractor": "^7.55.1",
|
||||
"@stylistic/eslint-plugin": "^4.2.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/markdown-it-footnote": "^3.0.4",
|
||||
@@ -84,8 +84,8 @@
|
||||
"mermaid": "^11.6.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.26.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitepress": "^1.6.3",
|
||||
"vitepress-plugin-llms": "^1.5.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.25.0",
|
||||
"version": "1.28.0",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
@@ -22,7 +22,7 @@ class Mp3Encoder extends CustomAudioEncoder {
|
||||
|
||||
private buffer = new Uint8Array(2 ** 16);
|
||||
private currentBufferOffset = 0;
|
||||
private currentTimestamp = 0;
|
||||
private currentTimestamp: number | null = null;
|
||||
private chunkMetadata: EncodedAudioChunkMetadata = {};
|
||||
|
||||
static override supports(codec: AudioCodec, config: AudioDecoderConfig): boolean {
|
||||
@@ -79,6 +79,11 @@ class Mp3Encoder extends CustomAudioEncoder {
|
||||
}
|
||||
|
||||
async encode(audioSample: AudioSample) {
|
||||
if (this.currentTimestamp === null) {
|
||||
// The first sample's timestamp determines where we start
|
||||
this.currentTimestamp = audioSample.timestamp;
|
||||
}
|
||||
|
||||
const sizePerChannel = audioSample.allocationSize({
|
||||
format: 's16-planar',
|
||||
planeIndex: 0,
|
||||
@@ -123,6 +128,8 @@ class Mp3Encoder extends CustomAudioEncoder {
|
||||
* these chunks and extract the MP3 frames only when they're complete.
|
||||
*/
|
||||
private digestOutput(bytes: Uint8Array) {
|
||||
assert(this.currentTimestamp !== null);
|
||||
|
||||
const requiredBufferSize = this.currentBufferOffset + bytes.length;
|
||||
if (requiredBufferSize > this.buffer.length) {
|
||||
// Grow the buffer to the required size
|
||||
|
||||
+186
-29
@@ -22,6 +22,9 @@ import {
|
||||
textEncoder,
|
||||
toDataView,
|
||||
toUint8Array,
|
||||
getChromiumVersion,
|
||||
isChromium,
|
||||
setUint24,
|
||||
} from './misc';
|
||||
import { PacketType } from './packet';
|
||||
import { MetadataTags } from './metadata';
|
||||
@@ -34,6 +37,7 @@ import { MetadataTags } from './metadata';
|
||||
|
||||
export enum AvcNalUnitType {
|
||||
IDR = 5,
|
||||
SEI = 6,
|
||||
SPS = 7,
|
||||
PPS = 8,
|
||||
SPS_EXT = 13,
|
||||
@@ -158,38 +162,54 @@ const removeEmulationPreventionBytes = (data: Uint8Array) => {
|
||||
return new Uint8Array(result);
|
||||
};
|
||||
|
||||
/** Converts an AVC packet in Annex B format to length-prefixed format. */
|
||||
export const transformAnnexBToLengthPrefixed = (packetData: Uint8Array) => {
|
||||
const NAL_UNIT_LENGTH_SIZE = 4;
|
||||
const ANNEX_B_START_CODE = new Uint8Array([0, 0, 0, 1]);
|
||||
|
||||
const nalUnits = findNalUnitsInAnnexB(packetData);
|
||||
|
||||
if (nalUnits.length === 0) {
|
||||
// If no NAL units were found, it's not valid Annex B data
|
||||
return null;
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
for (const nalUnit of nalUnits) {
|
||||
totalSize += NAL_UNIT_LENGTH_SIZE + nalUnit.byteLength;
|
||||
}
|
||||
|
||||
const avccData = new Uint8Array(totalSize);
|
||||
const dataView = new DataView(avccData.buffer);
|
||||
export const concatNalUnitsInAnnexB = (nalUnits: Uint8Array[]) => {
|
||||
const totalLength = nalUnits.reduce((a, b) => a + ANNEX_B_START_CODE.byteLength + b.byteLength, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
// Write each NAL unit with its length prefix
|
||||
for (const nalUnit of nalUnits) {
|
||||
const length = nalUnit.byteLength;
|
||||
result.set(ANNEX_B_START_CODE, offset);
|
||||
offset += ANNEX_B_START_CODE.byteLength;
|
||||
|
||||
dataView.setUint32(offset, length, false);
|
||||
offset += 4;
|
||||
|
||||
avccData.set(nalUnit, offset);
|
||||
result.set(nalUnit, offset);
|
||||
offset += nalUnit.byteLength;
|
||||
}
|
||||
|
||||
return avccData;
|
||||
return result;
|
||||
};
|
||||
|
||||
export const concatNalUnitsInLengthPrefixed = (nalUnits: Uint8Array[], lengthSize: 1 | 2 | 3 | 4) => {
|
||||
const totalLength = nalUnits.reduce((a, b) => a + lengthSize + b.byteLength, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const nalUnit of nalUnits) {
|
||||
const dataView = new DataView(result.buffer, result.byteOffset, result.byteLength);
|
||||
|
||||
switch (lengthSize) {
|
||||
case 1:
|
||||
dataView.setUint8(offset, nalUnit.byteLength);
|
||||
break;
|
||||
case 2:
|
||||
dataView.setUint16(offset, nalUnit.byteLength, false);
|
||||
break;
|
||||
case 3:
|
||||
setUint24(dataView, offset, nalUnit.byteLength, false);
|
||||
break;
|
||||
case 4:
|
||||
dataView.setUint32(offset, nalUnit.byteLength, false);
|
||||
break;
|
||||
}
|
||||
|
||||
offset += lengthSize;
|
||||
|
||||
result.set(nalUnit, offset);
|
||||
offset += nalUnit.byteLength;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Data specified in ISO 14496-15
|
||||
@@ -224,7 +244,22 @@ export const extractAvcNalUnits = (packetData: Uint8Array, decoderConfig: VideoD
|
||||
}
|
||||
};
|
||||
|
||||
const extractNalUnitTypeForAvc = (data: Uint8Array) => {
|
||||
export const concatAvcNalUnits = (nalUnits: Uint8Array[], decoderConfig: VideoDecoderConfig) => {
|
||||
if (decoderConfig.description) {
|
||||
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
|
||||
|
||||
const bytes = toUint8Array(decoderConfig.description);
|
||||
const lengthSizeMinusOne = bytes[4]! & 0b11;
|
||||
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
|
||||
|
||||
return concatNalUnitsInLengthPrefixed(nalUnits, lengthSize);
|
||||
} else {
|
||||
// Stream is in Annex B format
|
||||
return concatNalUnitsInAnnexB(nalUnits);
|
||||
}
|
||||
};
|
||||
|
||||
export const extractNalUnitTypeForAvc = (data: Uint8Array) => {
|
||||
return data[0]! & 0x1F;
|
||||
};
|
||||
|
||||
@@ -590,9 +625,7 @@ export const extractNalUnitTypeForHevc = (data: Uint8Array) => {
|
||||
};
|
||||
|
||||
/** Builds a HevcDecoderConfigurationRecord from an HEVC packet in Annex B format. */
|
||||
export const extractHevcDecoderConfigurationRecord = (
|
||||
packetData: Uint8Array,
|
||||
) => {
|
||||
export const extractHevcDecoderConfigurationRecord = (packetData: Uint8Array) => {
|
||||
try {
|
||||
const nalUnits = findNalUnitsInAnnexB(packetData);
|
||||
|
||||
@@ -1456,6 +1489,69 @@ export const extractAv1CodecInfoFromPacket = (
|
||||
}
|
||||
}
|
||||
|
||||
// Frame size
|
||||
const frameWidthBitsMinus1 = bitstream.readBits(4);
|
||||
const frameHeightBitsMinus1 = bitstream.readBits(4);
|
||||
const n1 = frameWidthBitsMinus1 + 1;
|
||||
bitstream.skipBits(n1); // max_frame_width_minus_1
|
||||
const n2 = frameHeightBitsMinus1 + 1;
|
||||
bitstream.skipBits(n2); // max_frame_height_minus_1
|
||||
|
||||
// Frame IDs
|
||||
let frameIdNumbersPresentFlag = 0;
|
||||
if (reducedStillPictureHeader) {
|
||||
frameIdNumbersPresentFlag = 0;
|
||||
} else {
|
||||
frameIdNumbersPresentFlag = bitstream.readBits(1);
|
||||
}
|
||||
|
||||
if (frameIdNumbersPresentFlag) {
|
||||
bitstream.skipBits(4); // delta_frame_id_length_minus_2
|
||||
bitstream.skipBits(3); // additional_frame_id_length_minus_1
|
||||
}
|
||||
|
||||
bitstream.skipBits(1); // use_128x128_superblock
|
||||
bitstream.skipBits(1); // enable_filter_intra
|
||||
bitstream.skipBits(1); // enable_intra_edge_filter
|
||||
|
||||
if (!reducedStillPictureHeader) {
|
||||
bitstream.skipBits(1); // enable_interintra_compound
|
||||
bitstream.skipBits(1); // enable_masked_compound
|
||||
bitstream.skipBits(1); // enable_warped_motion
|
||||
bitstream.skipBits(1); // enable_dual_filter
|
||||
const enableOrderHint = bitstream.readBits(1);
|
||||
|
||||
if (enableOrderHint) {
|
||||
bitstream.skipBits(1); // enable_jnt_comp
|
||||
bitstream.skipBits(1); // enable_ref_frame_mvs
|
||||
}
|
||||
|
||||
const seqChooseScreenContentTools = bitstream.readBits(1);
|
||||
let seqForceScreenContentTools = 0;
|
||||
|
||||
if (seqChooseScreenContentTools) {
|
||||
seqForceScreenContentTools = 2; // SELECT_SCREEN_CONTENT_TOOLS
|
||||
} else {
|
||||
seqForceScreenContentTools = bitstream.readBits(1);
|
||||
}
|
||||
|
||||
if (seqForceScreenContentTools > 0) {
|
||||
const seqChooseIntegerMv = bitstream.readBits(1);
|
||||
if (!seqChooseIntegerMv) {
|
||||
bitstream.skipBits(1); // seq_force_integer_mv
|
||||
}
|
||||
}
|
||||
|
||||
if (enableOrderHint) {
|
||||
bitstream.skipBits(3); // order_hint_bits_minus_1
|
||||
}
|
||||
}
|
||||
|
||||
bitstream.skipBits(1); // enable_superres
|
||||
bitstream.skipBits(1); // enable_cdef
|
||||
bitstream.skipBits(1); // enable_restoration
|
||||
|
||||
// color_config()
|
||||
const highBitdepth = bitstream.readBits(1);
|
||||
|
||||
let bitDepth = 8;
|
||||
@@ -1653,7 +1749,68 @@ export const determineVideoPacketType = (
|
||||
switch (codec) {
|
||||
case 'avc': {
|
||||
const nalUnits = extractAvcNalUnits(packetData, decoderConfig);
|
||||
const isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === AvcNalUnitType.IDR);
|
||||
let isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === AvcNalUnitType.IDR);
|
||||
|
||||
if (!isKeyframe && (!isChromium() || getChromiumVersion()! >= 144)) {
|
||||
// In addition to IDR, Recovery Point SEI also counts as a valid H.264 keyframe by current consensus.
|
||||
// See https://github.com/w3c/webcodecs/issues/650 for the relevant discussion. WebKit and Firefox have
|
||||
// always supported them, but Chromium hasn't, therefore the (admittedly dirty) version check.
|
||||
|
||||
for (const nalUnit of nalUnits) {
|
||||
const type = extractNalUnitTypeForAvc(nalUnit);
|
||||
if (type !== AvcNalUnitType.SEI) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bytes = removeEmulationPreventionBytes(nalUnit);
|
||||
let pos = 1; // Skip NALU header
|
||||
|
||||
// sei_rbsp()
|
||||
do {
|
||||
// sei_message()
|
||||
let payloadType = 0;
|
||||
while (true) {
|
||||
const nextByte = bytes[pos++];
|
||||
if (nextByte === undefined) break;
|
||||
payloadType += nextByte;
|
||||
|
||||
if (nextByte < 255) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let payloadSize = 0;
|
||||
while (true) {
|
||||
const nextByte = bytes[pos++];
|
||||
if (nextByte === undefined) break;
|
||||
payloadSize += nextByte;
|
||||
|
||||
if (nextByte < 255) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// sei_payload()
|
||||
const PAYLOAD_TYPE_RECOVERY_POINT = 6;
|
||||
if (payloadType === PAYLOAD_TYPE_RECOVERY_POINT) {
|
||||
const bitstream = new Bitstream(bytes);
|
||||
bitstream.pos = 8 * pos;
|
||||
|
||||
const recoveryFrameCount = readExpGolomb(bitstream);
|
||||
const exactMatchFlag = bitstream.readBits(1);
|
||||
|
||||
if (recoveryFrameCount === 0 && exactMatchFlag === 1) {
|
||||
// https://github.com/w3c/webcodecs/pull/910
|
||||
// "recovery_frame_cnt == 0 and exact_match_flag=1 in the SEI recovery payload"
|
||||
isKeyframe = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
pos += payloadSize;
|
||||
} while (pos < bytes.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return isKeyframe ? 'key' : 'delta';
|
||||
};
|
||||
|
||||
+63
-9
@@ -82,10 +82,16 @@ export type ConversionOptions = {
|
||||
|
||||
/** Options to trim the input file. */
|
||||
trim?: {
|
||||
/** The time in the input file in seconds at which the output file should start. Must be less than `end`. */
|
||||
start: number;
|
||||
/** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */
|
||||
end: number;
|
||||
/**
|
||||
* The time in the input file in seconds at which the output file should start. Must be less than `end`.
|
||||
* Defaults to 0 when omitted.
|
||||
*/
|
||||
start?: number;
|
||||
/**
|
||||
* The time in the input file in seconds at which the output file should end. Must be greater than `start`.
|
||||
* Defaults to the duration of the input when omitted.
|
||||
*/
|
||||
end?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -137,6 +143,12 @@ export type ConversionVideoOptions = {
|
||||
* This rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
|
||||
*/
|
||||
rotate?: Rotation;
|
||||
/**
|
||||
* Defaults to `true`. When enabaled, Mediabunny will use the rotation metadata in the output file to perform video
|
||||
* rotation whenever possible. Set this field to `false` if you want to ensure the output file does not make use of
|
||||
* rotation metadata and that any rotation is baked into the video frames directly.
|
||||
*/
|
||||
allowRotationMetadata?: boolean;
|
||||
/**
|
||||
* Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
|
||||
* the dimensions of the input video track. Cropping is performed after rotation but before resizing.
|
||||
@@ -174,6 +186,11 @@ export type ConversionVideoOptions = {
|
||||
* Setting this fields forces a transcode.
|
||||
*/
|
||||
keyFrameInterval?: number;
|
||||
/**
|
||||
* A hint that configures the hardware acceleration method used when transcoding. This is best left on
|
||||
* `'no-preference'`, the default.
|
||||
*/
|
||||
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
|
||||
/** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
forceTranscode?: boolean;
|
||||
/**
|
||||
@@ -298,6 +315,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
||||
if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
|
||||
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
|
||||
}
|
||||
if (videoOptions?.allowRotationMetadata !== undefined && typeof videoOptions.allowRotationMetadata !== 'boolean') {
|
||||
throw new TypeError('options.video.allowRotationMetadata, when provided, must be a boolean.');
|
||||
}
|
||||
if (videoOptions?.crop !== undefined) {
|
||||
validateCropRectangle(videoOptions.crop, 'options.video.');
|
||||
}
|
||||
@@ -331,6 +351,15 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
||||
) {
|
||||
throw new TypeError('options.video.processedHeight, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.hardwareAcceleration !== undefined
|
||||
&& !['no-preference', 'prefer-hardware', 'prefer-software'].includes(videoOptions.hardwareAcceleration)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'options.video.hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or'
|
||||
+ ' \'prefer-software\'.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
|
||||
@@ -793,7 +822,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
await new Promise(() => {}); // Never resolve
|
||||
throw new ConversionCanceledError();
|
||||
}
|
||||
|
||||
await this.output.finalize();
|
||||
@@ -803,7 +832,10 @@ export class Conversion {
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels the conversion process. Does nothing if the conversion is already complete. */
|
||||
/**
|
||||
* Cancels the conversion process, causing any ongoing `execute` call to throw a `ConversionCanceledError`.
|
||||
* Does nothing if the conversion is already complete.
|
||||
*/
|
||||
async cancel() {
|
||||
if (this.output.state === 'finalizing' || this.output.state === 'finalized') {
|
||||
return;
|
||||
@@ -832,7 +864,8 @@ export class Conversion {
|
||||
let videoSource: VideoSource;
|
||||
|
||||
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
|
||||
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
|
||||
const canUseRotationMetadata = this.output.format.supportsVideoRotationMetadata
|
||||
&& (trackOptions.allowRotationMetadata ?? true);
|
||||
|
||||
const [rotatedWidth, rotatedHeight] = totalRotation % 180 === 0
|
||||
? [track.codedWidth, track.codedHeight]
|
||||
@@ -877,7 +910,7 @@ export class Conversion {
|
||||
// TODO This is suboptimal: Forcing a rerender when both rotation and process are set is not
|
||||
// performance-optimal, but right now there's no other way because we can't change the track rotation
|
||||
// metadata after the output has already started. Should be possible with API changes in v2, though!
|
||||
|| (totalRotation !== 0 && (!outputSupportsRotation || trackOptions.process !== undefined))
|
||||
|| (totalRotation !== 0 && (!canUseRotationMetadata || trackOptions.process !== undefined))
|
||||
|| !!crop;
|
||||
|
||||
const alpha = trackOptions.alpha ?? 'discard';
|
||||
@@ -968,6 +1001,7 @@ export class Conversion {
|
||||
keyFrameInterval: trackOptions.keyFrameInterval,
|
||||
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
||||
alpha,
|
||||
hardwareAcceleration: trackOptions.hardwareAcceleration,
|
||||
};
|
||||
|
||||
const source = new VideoSampleSource(encodingConfig);
|
||||
@@ -1128,6 +1162,7 @@ export class Conversion {
|
||||
|
||||
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
||||
if (this._canceled) {
|
||||
sample.close();
|
||||
lastSample?.close();
|
||||
return;
|
||||
}
|
||||
@@ -1410,6 +1445,7 @@ export class Conversion {
|
||||
const sink = new AudioSampleSink(track);
|
||||
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
|
||||
if (this._canceled) {
|
||||
sample.close();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1509,7 +1545,10 @@ export class Conversion {
|
||||
targetSampleRate,
|
||||
startTime: this._startTimestamp,
|
||||
endTime: this._endTimestamp,
|
||||
onSample: sample => this._registerAudioSample(track, trackOptions, source, sample),
|
||||
onSample: async (sample) => {
|
||||
await this._registerAudioSample(track, trackOptions, source, sample);
|
||||
sample.close();
|
||||
},
|
||||
});
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
@@ -1517,10 +1556,12 @@ export class Conversion {
|
||||
|
||||
for await (const sample of iterator) {
|
||||
if (this._canceled) {
|
||||
sample.close();
|
||||
return;
|
||||
}
|
||||
|
||||
await resampler.add(sample);
|
||||
sample.close();
|
||||
}
|
||||
|
||||
await resampler.finalize();
|
||||
@@ -1554,6 +1595,19 @@ export class Conversion {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a conversion couldn't complete due to being canceled.
|
||||
* @group Conversion
|
||||
* @public
|
||||
*/
|
||||
export class ConversionCanceledError extends Error {
|
||||
/** Creates a new {@link ConversionCanceledError}. */
|
||||
constructor(message = 'Conversion has been canceled.') {
|
||||
super(message);
|
||||
this.name = 'ConversionCanceledError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_TIMESTAMP_GAP = 5;
|
||||
|
||||
/**
|
||||
|
||||
+40
-1
@@ -22,6 +22,7 @@ import {
|
||||
VideoCodec,
|
||||
} from './codec';
|
||||
import { customAudioEncoders, customVideoEncoders } from './custom-coder';
|
||||
import { isFirefox } from './misc';
|
||||
import { EncodedPacket } from './packet';
|
||||
|
||||
/**
|
||||
@@ -529,7 +530,45 @@ export const canEncodeVideo = async (
|
||||
});
|
||||
|
||||
const support = await VideoEncoder.isConfigSupported(encoderConfig);
|
||||
return support.supported === true;
|
||||
if (!support.supported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isFirefox()) {
|
||||
// isConfigSupported on Firefox appears to unreliably indicate if encoding will actually succeed. Therefore, we
|
||||
// just try encoding a frame to see if it actually works.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/222
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
|
||||
return new Promise<boolean>(async (resolve) => {
|
||||
try {
|
||||
const encoder = new VideoEncoder({
|
||||
output: () => {},
|
||||
error: () => resolve(false),
|
||||
});
|
||||
encoder.configure(encoderConfig);
|
||||
|
||||
const frameData = new Uint8Array(width * height * 4);
|
||||
const frame = new VideoFrame(frameData, {
|
||||
format: 'RGBA',
|
||||
codedWidth: width,
|
||||
codedHeight: height,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
encoder.encode(frame);
|
||||
frame.close();
|
||||
|
||||
await encoder.flush();
|
||||
|
||||
resolve(true);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -168,7 +168,10 @@ export {
|
||||
AudioSampleCopyToOptions,
|
||||
VideoSample,
|
||||
VideoSampleInit,
|
||||
VideoSamplePixelFormat,
|
||||
VideoSampleColorSpace,
|
||||
CropRectangle,
|
||||
VIDEO_SAMPLE_PIXEL_FORMATS,
|
||||
} from './sample';
|
||||
export {
|
||||
AudioBufferSink,
|
||||
@@ -187,6 +190,7 @@ export {
|
||||
ConversionOptions,
|
||||
ConversionVideoOptions,
|
||||
ConversionAudioOptions,
|
||||
ConversionCanceledError,
|
||||
DiscardedTrack,
|
||||
} from './conversion';
|
||||
export {
|
||||
|
||||
+2
-2
@@ -155,7 +155,7 @@ export class MatroskaInputFormat extends InputFormat {
|
||||
}
|
||||
|
||||
const dataSize = readElementSize(headerSlice);
|
||||
if (dataSize === null) {
|
||||
if (typeof dataSize !== 'number') {
|
||||
return false; // Miss me with that shit
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ export class MatroskaInputFormat extends InputFormat {
|
||||
|
||||
const { id, size } = header;
|
||||
const dataStartPos = dataSlice.filePos;
|
||||
if (size === null) return false;
|
||||
if (size === undefined) return false;
|
||||
|
||||
switch (id) {
|
||||
case EBMLId.EBMLVersion: {
|
||||
|
||||
@@ -850,7 +850,7 @@ export const dOps = (trackData: IsobmffAudioTrackData) => {
|
||||
let inputSampleRate = trackData.info.sampleRate;
|
||||
let outputGain = 0;
|
||||
let channelMappingFamily = 0;
|
||||
let channelMappingTable = new Uint8Array(0);
|
||||
let channelMappingTable: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
||||
|
||||
// Read preskip and from codec private data from the encoder
|
||||
// https://www.rfc-editor.org/rfc/rfc7845#section-5
|
||||
|
||||
@@ -2489,11 +2489,8 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
||||
|
||||
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
||||
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
||||
const keyFrameSampleIndex = sampleIndex === -1
|
||||
? -1
|
||||
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
|
||||
const regularPacket = await this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
|
||||
const sampleIndex = getKeyframeSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
||||
const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
|
||||
|
||||
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
|
||||
// Prefer the non-fragmented packet
|
||||
@@ -2913,7 +2910,45 @@ const getSampleIndexForTimestamp = (sampleTable: SampleTable, timescaleUnits: nu
|
||||
|
||||
const entry = sampleTable.sampleTimingEntries[index]!;
|
||||
return entry.startIndex
|
||||
+ Math.min(Math.floor((timescaleUnits - entry.startDecodeTimestamp) / entry.delta), entry.count - 1);
|
||||
+ Math.min(
|
||||
Math.floor((timescaleUnits - entry.startDecodeTimestamp) / entry.delta),
|
||||
entry.count - 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getKeyframeSampleIndexForTimestamp = (sampleTable: SampleTable, timescaleUnits: number) => {
|
||||
if (!sampleTable.keySampleIndices) {
|
||||
// Every sample is a keyframe
|
||||
return getSampleIndexForTimestamp(sampleTable, timescaleUnits);
|
||||
}
|
||||
|
||||
if (sampleTable.presentationTimestamps) {
|
||||
const index = binarySearchLessOrEqual(
|
||||
sampleTable.presentationTimestamps,
|
||||
timescaleUnits,
|
||||
x => x.presentationTimestamp,
|
||||
);
|
||||
if (index === -1) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Walk the samples in presentation order until we find one that's a keyframe
|
||||
for (let i = index; i >= 0; i--) {
|
||||
const sampleIndex = sampleTable.presentationTimestamps[i]!.sampleIndex;
|
||||
const isKeyFrame = binarySearchExact(sampleTable.keySampleIndices, sampleIndex, x => x) !== -1;
|
||||
|
||||
if (isKeyFrame) {
|
||||
return sampleIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
} else {
|
||||
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timescaleUnits);
|
||||
|
||||
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, x => x);
|
||||
return sampleTable.keySampleIndices[index] ?? -1;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3002,15 +3037,6 @@ const getSampleInfo = (sampleTable: SampleTable, sampleIndex: number): SampleInf
|
||||
};
|
||||
};
|
||||
|
||||
const getRelevantKeyframeIndexForSample = (sampleTable: SampleTable, sampleIndex: number) => {
|
||||
if (!sampleTable.keySampleIndices) {
|
||||
return sampleIndex;
|
||||
}
|
||||
|
||||
const index = binarySearchLessOrEqual(sampleTable.keySampleIndices, sampleIndex, x => x);
|
||||
return sampleTable.keySampleIndices[index] ?? -1;
|
||||
};
|
||||
|
||||
const getNextKeyframeIndexForSample = (sampleTable: SampleTable, sampleIndex: number) => {
|
||||
if (!sampleTable.keySampleIndices) {
|
||||
return sampleIndex + 1;
|
||||
|
||||
@@ -25,11 +25,12 @@ import {
|
||||
import { BufferTarget } from '../target';
|
||||
import { EncodedPacket, PacketType } from '../packet';
|
||||
import {
|
||||
concatNalUnitsInLengthPrefixed,
|
||||
extractAvcDecoderConfigurationRecord,
|
||||
extractHevcDecoderConfigurationRecord,
|
||||
findNalUnitsInAnnexB,
|
||||
serializeAvcDecoderConfigurationRecord,
|
||||
serializeHevcDecoderConfigurationRecord,
|
||||
transformAnnexBToLengthPrefixed,
|
||||
} from '../codec-data';
|
||||
import { buildIsobmffMimeType } from './isobmff-misc';
|
||||
import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE } from './isobmff-reader';
|
||||
@@ -464,15 +465,18 @@ export class IsobmffMuxer extends Muxer {
|
||||
|
||||
let packetData = packet.data;
|
||||
if (trackData.info.requiresAnnexBTransformation) {
|
||||
const transformedData = transformAnnexBToLengthPrefixed(packetData);
|
||||
if (!transformedData) {
|
||||
const nalUnits = findNalUnitsInAnnexB(packetData);
|
||||
if (nalUnits.length === 0) {
|
||||
// It's not valid Annex B data
|
||||
throw new Error(
|
||||
'Failed to transform packet data. Make sure all packets are provided in Annex B format, as'
|
||||
+ ' specified in ITU-T-REC-H.264 and ITU-T-REC-H.265.',
|
||||
);
|
||||
}
|
||||
|
||||
packetData = transformedData;
|
||||
// We don't strip things like SPS or PPS NALUs here, mainly because they can also appear in the middle
|
||||
// of a stream and potentially modify the parameters of it. So, let's just leave them in to be sure.
|
||||
packetData = concatNalUnitsInLengthPrefixed(nalUnits, 4);
|
||||
}
|
||||
|
||||
const timestamp = this.validateAndNormalizeTimestamp(
|
||||
|
||||
+56
-19
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { MediaCodec } from '../codec';
|
||||
import { assertNever, textDecoder, textEncoder } from '../misc';
|
||||
import { assert, assertNever, textDecoder, textEncoder } from '../misc';
|
||||
import { FileSlice, readBytes, Reader, readF32Be, readF64Be, readU8 } from '../reader';
|
||||
import { Writer } from '../writer';
|
||||
|
||||
@@ -467,9 +467,13 @@ export class EBMLWriter {
|
||||
|
||||
export const MAX_VAR_INT_SIZE = 8;
|
||||
export const MIN_HEADER_SIZE = 2; // 1-byte ID and 1-byte size
|
||||
export const MAX_HEADER_SIZE = /* #__PURE__ */ 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
|
||||
export const MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
|
||||
|
||||
export const readVarIntSize = (slice: FileSlice) => {
|
||||
if (slice.remainingLength < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstByte = readU8(slice);
|
||||
slice.skip(-1);
|
||||
|
||||
@@ -484,10 +488,19 @@ export const readVarIntSize = (slice: FileSlice) => {
|
||||
mask >>= 1;
|
||||
}
|
||||
|
||||
// Check if we have enough bytes to read the full varint
|
||||
if (slice.remainingLength < width) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return width;
|
||||
};
|
||||
|
||||
export const readVarInt = (slice: FileSlice) => {
|
||||
if (slice.remainingLength < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read the first byte to determine the width of the variable-length integer
|
||||
const firstByte = readU8(slice);
|
||||
|
||||
@@ -503,6 +516,11 @@ export const readVarInt = (slice: FileSlice) => {
|
||||
mask >>= 1;
|
||||
}
|
||||
|
||||
if (slice.remainingLength < width - 1) {
|
||||
// Not enough bytes
|
||||
return null;
|
||||
}
|
||||
|
||||
// First byte's value needs the marker bit cleared
|
||||
let value = firstByte & (mask - 1);
|
||||
|
||||
@@ -563,39 +581,58 @@ export const readElementId = (slice: FileSlice) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (slice.remainingLength < size) {
|
||||
return null; // It don't fit
|
||||
}
|
||||
|
||||
const id = readUnsignedInt(slice, size);
|
||||
return id;
|
||||
};
|
||||
|
||||
export const readElementSize = (slice: FileSlice) => {
|
||||
let size: number | null = readU8(slice);
|
||||
/** Returns `undefined` to indicate the EBML undefined size. Returns `null` if the size couldn't be read. */
|
||||
export const readElementSize = (slice: FileSlice): number | undefined | null => {
|
||||
// Need at least 1 byte to read the size
|
||||
if (slice.remainingLength < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (size === 0xff) {
|
||||
size = null;
|
||||
} else {
|
||||
slice.skip(-1);
|
||||
size = readVarInt(slice);
|
||||
const firstByte = readU8(slice);
|
||||
|
||||
// In some (livestreamed) files, this is the value of the size field. While this technically is just a very
|
||||
// large number, it is intended to behave like the reserved size 0xFF, meaning the size is undefined. We
|
||||
// catch the number here. Note that it cannot be perfectly represented as a double, but the comparison works
|
||||
// nonetheless.
|
||||
// eslint-disable-next-line no-loss-of-precision
|
||||
if (size === 0x00ffffffffffffff) {
|
||||
size = null;
|
||||
}
|
||||
if (firstByte === 0xff) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
slice.skip(-1);
|
||||
const size = readVarInt(slice);
|
||||
|
||||
if (size === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In some (livestreamed) files, this is the value of the size field. While this technically is just a very
|
||||
// large number, it is intended to behave like the reserved size 0xFF, meaning the size is undefined. We
|
||||
// catch the number here. Note that it cannot be perfectly represented as a double, but the comparison works
|
||||
// nonetheless.
|
||||
// eslint-disable-next-line no-loss-of-precision
|
||||
if (size === 0x00ffffffffffffff) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return size;
|
||||
};
|
||||
|
||||
export const readElementHeader = (slice: FileSlice) => {
|
||||
assert(slice.remainingLength >= MIN_HEADER_SIZE);
|
||||
|
||||
const id = readElementId(slice);
|
||||
if (id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = readElementSize(slice);
|
||||
if (size === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { id, size };
|
||||
};
|
||||
@@ -720,8 +757,8 @@ export const CODEC_STRING_MAP: Partial<Record<MediaCodec, string>> = {
|
||||
'webvtt': 'S_TEXT/WEBVTT',
|
||||
};
|
||||
|
||||
export function assertDefinedSize(size: number | null): asserts size is number {
|
||||
if (size === null) {
|
||||
export function assertDefinedSize(size: number | undefined): asserts size is number {
|
||||
if (size === undefined) {
|
||||
throw new Error('Undefined element size is used in a place where it is not supported.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -193,6 +193,7 @@ type InternalTrack = {
|
||||
codecId: string | null;
|
||||
codecPrivate: Uint8Array | null;
|
||||
defaultDuration: number | null;
|
||||
defaultDurationNs: number | null;
|
||||
name: string | null;
|
||||
languageCode: string;
|
||||
decodingInstructions: DecodingInstruction[];
|
||||
@@ -228,7 +229,7 @@ const METADATA_ELEMENTS = [
|
||||
{ id: EBMLId.Tracks, flag: 'tracksSeen' },
|
||||
{ id: EBMLId.Cues, flag: 'cuesSeen' },
|
||||
] as const;
|
||||
const MAX_RESYNC_LENGTH = /* #__PURE__ */ 10 * 2 ** 20; // 10 MiB
|
||||
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
|
||||
|
||||
export class MatroskaDemuxer extends Demuxer {
|
||||
reader: Reader;
|
||||
@@ -346,7 +347,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
} else if (id === EBMLId.Segment) { // Segment found!
|
||||
await this.readSegment(dataStartPos, size);
|
||||
|
||||
if (size === null) {
|
||||
if (size === undefined) {
|
||||
// Segment sizes can be undefined (common in livestreamed files), so assume this is the last
|
||||
// and only segment
|
||||
break;
|
||||
@@ -364,7 +365,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
// doesn't contain any of the clusters that follow it. In the case, we apply the following logic: if
|
||||
// we find a top-level cluster, attribute it to the previous segment.
|
||||
|
||||
if (size === null) {
|
||||
if (size === undefined) {
|
||||
// Just in case this is one of those weird sizeless clusters, let's do our best and still try to
|
||||
// determine its size.
|
||||
const nextElementPos = await searchForNextElementId(
|
||||
@@ -389,7 +390,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
})();
|
||||
}
|
||||
|
||||
async readSegment(segmentDataStart: number, dataSize: number | null) {
|
||||
async readSegment(segmentDataStart: number, dataSize: number | undefined) {
|
||||
this.currentSegment = {
|
||||
seekHeadSeen: false,
|
||||
infoSeen: false,
|
||||
@@ -406,7 +407,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
cuePoints: [],
|
||||
|
||||
dataStartPos: segmentDataStart,
|
||||
elementEndPos: dataSize === null
|
||||
elementEndPos: dataSize === undefined
|
||||
? null // Assume it goes until the end of the file
|
||||
: segmentDataStart + dataSize,
|
||||
clusterSeekStartPos: segmentDataStart,
|
||||
@@ -483,7 +484,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
break; // Stop at the first cluster
|
||||
}
|
||||
|
||||
if (size === null) {
|
||||
if (size === undefined) {
|
||||
break;
|
||||
} else {
|
||||
currentPos = dataStartPos + size;
|
||||
@@ -536,6 +537,13 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
this.currentSegment.timestampFactor = 1e9 / 1e6;
|
||||
}
|
||||
|
||||
// Compute default duration for all tracks now that we have the timestamp factor
|
||||
for (const track of this.currentSegment.tracks) {
|
||||
if (track.defaultDurationNs !== null) {
|
||||
track.defaultDuration = (this.currentSegment.timestampFactor * track.defaultDurationNs) / 1e9;
|
||||
}
|
||||
}
|
||||
|
||||
// Put default tracks first
|
||||
this.currentSegment.tracks.sort((a, b) => Number(b.disposition.default) - Number(a.disposition.default));
|
||||
|
||||
@@ -606,7 +614,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
let size = elementHeader.size;
|
||||
const dataStartPos = headerSlice.filePos;
|
||||
|
||||
if (size === null) {
|
||||
if (size === undefined) {
|
||||
// The cluster's size is undefined (can happen in livestreamed files). We'd still like to know the size of
|
||||
// it, so we have no other choice but to iterate over the EBML structure until we find an element at level
|
||||
// 0 or 1, indicating the end of the cluster (all elements inside the cluster are at level 2).
|
||||
@@ -908,9 +916,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
readContiguousElements(slice: FileSlice, stopIds?: number[]) {
|
||||
const startIndex = slice.filePos;
|
||||
|
||||
while (slice.filePos - startIndex <= slice.length - MIN_HEADER_SIZE) {
|
||||
while (slice.remainingLength >= MIN_HEADER_SIZE) {
|
||||
const startPos = slice.filePos;
|
||||
const foundElement = this.traverseElement(slice, stopIds);
|
||||
|
||||
@@ -996,6 +1002,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
codecId: null,
|
||||
codecPrivate: null,
|
||||
defaultDuration: null,
|
||||
defaultDurationNs: null,
|
||||
name: null,
|
||||
languageCode: UNDETERMINED_LANGUAGE,
|
||||
decodingInstructions: [],
|
||||
@@ -1005,6 +1012,11 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
|
||||
this.readContiguousElements(slice.slice(dataStartPos, size));
|
||||
|
||||
// Check if track was disabled during parsing (e.g., by FlagEnabled being 0)
|
||||
if (!this.currentTrack) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.currentTrack.decodingInstructions.some((instruction) => {
|
||||
return instruction.data?.type !== 'decompress'
|
||||
|| instruction.scope !== ContentEncodingScope.Block
|
||||
@@ -1148,7 +1160,6 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
|
||||
const enabled = readUnsignedInt(slice, size);
|
||||
if (!enabled) {
|
||||
this.currentSegment!.tracks.pop();
|
||||
this.currentTrack = null;
|
||||
}
|
||||
}; break;
|
||||
@@ -1203,9 +1214,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
|
||||
case EBMLId.DefaultDuration: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.defaultDuration
|
||||
= this.currentTrack.segment.timestampFactor * readUnsignedInt(slice, size) / 1e9;
|
||||
this.currentTrack.defaultDurationNs = readUnsignedInt(slice, size);
|
||||
}; break;
|
||||
|
||||
case EBMLId.Name: {
|
||||
@@ -2222,7 +2231,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
}
|
||||
}
|
||||
|
||||
if (size === null) {
|
||||
if (size === undefined) {
|
||||
// Undefined element size (can happen in livestreamed files). In this case, we need to do some
|
||||
// searching to determine the actual size of the element.
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ import { EncodedPacket } from '../packet';
|
||||
import { parseOpusIdentificationHeader } from '../codec-data';
|
||||
import { AttachedFile } from '../metadata';
|
||||
|
||||
const MIN_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ -(2 ** 15);
|
||||
const MAX_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ 2 ** 15 - 1;
|
||||
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
|
||||
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
|
||||
const APP_NAME = 'Mediabunny';
|
||||
const SEGMENT_SIZE_BYTES = 6;
|
||||
const CLUSTER_SIZE_BYTES = 5;
|
||||
|
||||
+52
-33
@@ -8,9 +8,12 @@
|
||||
|
||||
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
|
||||
import {
|
||||
concatAvcNalUnits,
|
||||
deserializeAvcDecoderConfigurationRecord,
|
||||
determineVideoPacketType,
|
||||
extractAvcNalUnits,
|
||||
extractHevcNalUnits,
|
||||
extractNalUnitTypeForAvc,
|
||||
extractNalUnitTypeForHevc,
|
||||
HevcNalUnitType,
|
||||
parseAvcSps,
|
||||
@@ -201,9 +204,10 @@ export class EncodedPacketSink {
|
||||
}
|
||||
|
||||
const packet = await this._track._backing.getKeyPacket(timestamp, options);
|
||||
if (!packet || packet.type === 'delta') {
|
||||
if (!packet) {
|
||||
return packet;
|
||||
}
|
||||
assert(packet.type === 'key');
|
||||
|
||||
const determinedType = await this._track.determinePacketType(packet);
|
||||
if (determinedType === 'delta') {
|
||||
@@ -235,9 +239,10 @@ export class EncodedPacketSink {
|
||||
}
|
||||
|
||||
const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
|
||||
if (!nextPacket || nextPacket.type === 'delta') {
|
||||
if (!nextPacket) {
|
||||
return nextPacket;
|
||||
}
|
||||
assert(nextPacket.type === 'key');
|
||||
|
||||
const determinedType = await this._track.determinePacketType(nextPacket);
|
||||
if (determinedType === 'delta') {
|
||||
@@ -427,7 +432,6 @@ export abstract class BaseMediaSampleSink<
|
||||
|
||||
// The following is the "pump" process that keeps pumping packets into the decoder
|
||||
(async () => {
|
||||
const decoderError = new Error();
|
||||
const decoder = await this._createDecoder((sample) => {
|
||||
onQueueDequeue();
|
||||
if (sample.timestamp >= endTimestamp) {
|
||||
@@ -465,7 +469,6 @@ export abstract class BaseMediaSampleSink<
|
||||
}
|
||||
}, (error) => {
|
||||
if (!outOfBandError) {
|
||||
error.stack = decoderError.stack; // Provide a more useful stack trace
|
||||
outOfBandError = error;
|
||||
onQueueNotEmpty();
|
||||
}
|
||||
@@ -474,31 +477,18 @@ export abstract class BaseMediaSampleSink<
|
||||
const packetSink = this._createPacketSink();
|
||||
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true })
|
||||
?? await packetSink.getFirstPacket();
|
||||
if (!keyPacket) {
|
||||
return;
|
||||
}
|
||||
|
||||
let currentPacket: EncodedPacket | null = keyPacket;
|
||||
|
||||
let endPacket: EncodedPacket | undefined = undefined;
|
||||
if (endTimestamp < Infinity) {
|
||||
// When an end timestamp is set, we cannot simply use that for the packet iterator due to out-of-order
|
||||
// frames (B-frames). Instead, we'll need to keep decoding packets until we get a frame that exceeds
|
||||
// this end time. However, we can still put a bound on it: Since key frames are by definition never
|
||||
// out of order, we can stop at the first key frame after the end timestamp.
|
||||
const packet = await packetSink.getPacket(endTimestamp);
|
||||
const keyPacket = !packet
|
||||
? null
|
||||
: packet.type === 'key' && packet.timestamp === endTimestamp
|
||||
? packet
|
||||
: await packetSink.getNextKeyPacket(packet, { verifyKeyPackets: true });
|
||||
// B-frames make it exceedingly difficult to properly define an upper bound for packet iteration if an end
|
||||
// timestamp is set, so we just don't do it. The case that makes it especially tricky is when the frames
|
||||
// following a key frame have a lower timestamp than the keyframe; something that quite frequently happens
|
||||
// in HEVC streams. The price to pay for not upper-bounding the packet iterator is a slight increase in
|
||||
// decoder work at the end of the range, but the added correctness and reliability makes this tradeoff worth
|
||||
// it.
|
||||
const endPacket = undefined;
|
||||
|
||||
if (keyPacket) {
|
||||
endPacket = keyPacket;
|
||||
}
|
||||
}
|
||||
|
||||
const packets = packetSink.packets(keyPacket, endPacket);
|
||||
const packets = packetSink.packets(keyPacket ?? undefined, endPacket);
|
||||
await packets.next(); // Skip the start packet as we already have it
|
||||
|
||||
while (currentPacket && !ended && !this._track.input._disposed) {
|
||||
@@ -614,7 +604,6 @@ export abstract class BaseMediaSampleSink<
|
||||
|
||||
// The following is the "pump" process that keeps pumping packets into the decoder
|
||||
(async () => {
|
||||
const decoderError = new Error();
|
||||
const decoder = await this._createDecoder((sample) => {
|
||||
onQueueDequeue();
|
||||
|
||||
@@ -642,7 +631,6 @@ export abstract class BaseMediaSampleSink<
|
||||
}
|
||||
}, (error) => {
|
||||
if (!outOfBandError) {
|
||||
error.stack = decoderError.stack; // Provide a more useful stack trace
|
||||
outOfBandError = error;
|
||||
onQueueNotEmpty();
|
||||
}
|
||||
@@ -892,6 +880,8 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
}
|
||||
}
|
||||
|
||||
const stack = new Error('Decoding error').stack;
|
||||
|
||||
this.decoder = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
try {
|
||||
@@ -900,7 +890,10 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
this.onError(error as Error);
|
||||
}
|
||||
},
|
||||
error: onError,
|
||||
error: (error) => {
|
||||
error.stack = stack; // Provide a more useful stack trace, the default one sucks
|
||||
this.onError(error);
|
||||
},
|
||||
});
|
||||
this.decoder.configure(this.decoderConfig);
|
||||
}
|
||||
@@ -928,8 +921,6 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
this.raslSkipped = true;
|
||||
}
|
||||
|
||||
this.currentPacketIndex++;
|
||||
|
||||
if (this.customDecoder) {
|
||||
this.customDecoderQueueSize++;
|
||||
void this.customDecoderCallSerializer
|
||||
@@ -942,9 +933,24 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
insertSorted(this.inputTimestamps, packet.timestamp, x => x);
|
||||
}
|
||||
|
||||
// Workaround for https://issues.chromium.org/issues/470109459
|
||||
if (isChromium() && this.currentPacketIndex === 0 && this.codec === 'avc') {
|
||||
const nalUnits = extractAvcNalUnits(packet.data, this.decoderConfig);
|
||||
const filteredNalUnits = nalUnits.filter((x) => {
|
||||
const type = extractNalUnitTypeForAvc(x);
|
||||
// These trip up Chromium's key frame detection, so let's strip them
|
||||
return !(type >= 20 && type <= 31);
|
||||
});
|
||||
|
||||
const newData = concatAvcNalUnits(filteredNalUnits, this.decoderConfig);
|
||||
packet = new EncodedPacket(newData, packet.type, packet.timestamp, packet.duration);
|
||||
}
|
||||
|
||||
this.decoder.decode(packet.toEncodedVideoChunk());
|
||||
this.decodeAlphaData(packet);
|
||||
}
|
||||
|
||||
this.currentPacketIndex++;
|
||||
}
|
||||
|
||||
decodeAlphaData(packet: EncodedPacket) {
|
||||
@@ -1000,6 +1006,8 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
}
|
||||
};
|
||||
|
||||
const stack = new Error('Decoding error').stack;
|
||||
|
||||
this.alphaDecoder = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
try {
|
||||
@@ -1008,7 +1016,10 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
this.onError(error as Error);
|
||||
}
|
||||
},
|
||||
error: this.onError,
|
||||
error: (error) => {
|
||||
error.stack = stack; // Provide a more useful stack trace, the default one sucks
|
||||
this.onError(error);
|
||||
},
|
||||
});
|
||||
this.alphaDecoder.configure(this.decoderConfig);
|
||||
}
|
||||
@@ -1764,6 +1775,8 @@ class AudioDecoderWrapper extends DecoderWrapper<AudioSample> {
|
||||
|
||||
void this.customDecoderCallSerializer.call(() => this.customDecoder!.init());
|
||||
} else {
|
||||
const stack = new Error('Decoding error').stack;
|
||||
|
||||
this.decoder = new AudioDecoder({
|
||||
output: (data) => {
|
||||
try {
|
||||
@@ -1772,7 +1785,10 @@ class AudioDecoderWrapper extends DecoderWrapper<AudioSample> {
|
||||
this.onError(error as Error);
|
||||
}
|
||||
},
|
||||
error: onError,
|
||||
error: (error) => {
|
||||
error.stack = stack; // Provide a more useful stack trace, the default one sucks
|
||||
this.onError(error);
|
||||
},
|
||||
});
|
||||
this.decoder.configure(decoderConfig);
|
||||
}
|
||||
@@ -2127,11 +2143,14 @@ export class AudioBufferSink {
|
||||
|
||||
/** @internal */
|
||||
_audioSampleToWrappedArrayBuffer(sample: AudioSample): WrappedAudioBuffer {
|
||||
return {
|
||||
const result: WrappedAudioBuffer = {
|
||||
buffer: sample.toAudioBuffer(),
|
||||
timestamp: sample.timestamp,
|
||||
duration: sample.duration,
|
||||
};
|
||||
|
||||
sample.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+165
-72
@@ -134,12 +134,10 @@ export abstract class MediaSource {
|
||||
|
||||
/** @internal */
|
||||
async _flushOrWaitForOngoingClose(forceClose: boolean) {
|
||||
if (this._closingPromise) {
|
||||
// Since closing also flushes, we don't want to do it twice
|
||||
return this._closingPromise;
|
||||
} else {
|
||||
return this._flushAndClose(forceClose);
|
||||
}
|
||||
return this._closingPromise ??= (async () => {
|
||||
await this._flushAndClose(forceClose);
|
||||
this._closed = true;
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1053,12 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
private _promiseWithResolvers = promiseWithResolvers();
|
||||
/** @internal */
|
||||
private _errorPromiseAccessed = false;
|
||||
/** @internal */
|
||||
private _paused = false;
|
||||
/** @internal */
|
||||
private _lastSampleTimestamp: number | null = null;
|
||||
/** @internal */
|
||||
private _pauseOffset = 0;
|
||||
|
||||
/** A promise that rejects upon any error within this source. This promise never resolves. */
|
||||
get errorPromise() {
|
||||
@@ -1062,6 +1066,11 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
return this._promiseWithResolvers.promise;
|
||||
}
|
||||
|
||||
/** Whether this source is currently paused as a result of calling `.pause()`. */
|
||||
get paused() {
|
||||
return this._paused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link MediaStreamVideoTrackSource} from a
|
||||
* [`MediaStreamVideoTrack`](https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack), which will pull
|
||||
@@ -1103,8 +1112,29 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimestamp = videoFrame.timestamp / 1e6;
|
||||
|
||||
if (this._paused) {
|
||||
const frameSeen = firstVideoFrameTimestamp !== null;
|
||||
if (frameSeen) {
|
||||
if (this._lastSampleTimestamp !== null) {
|
||||
// In addition to dropping this frame, let's also keep track of the time we have lost due to the
|
||||
// pause. Doing it like this instead of simply keeping track of the paused time is better since
|
||||
// it retains the frame rate of the underlying source.
|
||||
const timeDelta = currentTimestamp - this._lastSampleTimestamp;
|
||||
// We modify this field instead of _timestampOffset since we still might have data in flight
|
||||
// in the encoder, with which we don't want to mess.
|
||||
this._pauseOffset -= timeDelta;
|
||||
}
|
||||
this._lastSampleTimestamp = currentTimestamp;
|
||||
}
|
||||
|
||||
videoFrame.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstVideoFrameTimestamp === null) {
|
||||
firstVideoFrameTimestamp = videoFrame.timestamp / 1e6;
|
||||
firstVideoFrameTimestamp = currentTimestamp;
|
||||
|
||||
const muxer = this._connectedTrack!.output._muxer;
|
||||
if (muxer.firstMediaStreamTimestamp === null) {
|
||||
@@ -1116,13 +1146,19 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
}
|
||||
}
|
||||
|
||||
this._lastSampleTimestamp = currentTimestamp;
|
||||
|
||||
if (this._encoder.getQueueSize() >= 4) {
|
||||
// Drop frames if the encoder is overloaded
|
||||
videoFrame.close();
|
||||
return;
|
||||
}
|
||||
|
||||
void this._encoder.add(new VideoSample(videoFrame), true)
|
||||
const sample = new VideoSample(videoFrame, {
|
||||
timestamp: currentTimestamp + this._pauseOffset,
|
||||
});
|
||||
|
||||
void this._encoder.add(sample, true)
|
||||
.catch((error) => {
|
||||
errored = true;
|
||||
|
||||
@@ -1182,6 +1218,19 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the capture of video frames - any video frames emitted by the underlying media stream will be ignored
|
||||
* while paused. This does *not* close the underlying `MediaStreamVideoTrack`, it just ignores its output.
|
||||
*/
|
||||
pause() {
|
||||
this._paused = true;
|
||||
}
|
||||
|
||||
/** Resumes the capture of video frames after being paused. */
|
||||
resume() {
|
||||
this._paused = false;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override async _flushAndClose(forceClose: boolean) {
|
||||
if (this._abortController) {
|
||||
@@ -1356,21 +1405,27 @@ class AudioEncoderWrapper {
|
||||
(audioSample.timestamp + audioSample.duration) * audioSample.sampleRate,
|
||||
);
|
||||
|
||||
if (this.lastEndSampleIndex !== null && startSampleIndex > this.lastEndSampleIndex) {
|
||||
const sampleCount = startSampleIndex - this.lastEndSampleIndex;
|
||||
const fillSample = new AudioSample({
|
||||
data: new Float32Array(sampleCount * audioSample.numberOfChannels),
|
||||
format: 'f32-planar',
|
||||
sampleRate: audioSample.sampleRate,
|
||||
numberOfChannels: audioSample.numberOfChannels,
|
||||
numberOfFrames: sampleCount,
|
||||
timestamp: this.lastEndSampleIndex / audioSample.sampleRate,
|
||||
});
|
||||
if (this.lastEndSampleIndex === null) {
|
||||
this.lastEndSampleIndex = endSampleIndex;
|
||||
} else {
|
||||
const sampleDiff = startSampleIndex - this.lastEndSampleIndex;
|
||||
|
||||
await this.add(fillSample, true); // Recursive call
|
||||
if (sampleDiff >= 64) {
|
||||
// The gap is big enough, let's add a correction sample
|
||||
const fillSample = new AudioSample({
|
||||
data: new Float32Array(sampleDiff * audioSample.numberOfChannels),
|
||||
format: 'f32-planar',
|
||||
sampleRate: audioSample.sampleRate,
|
||||
numberOfChannels: audioSample.numberOfChannels,
|
||||
numberOfFrames: sampleDiff,
|
||||
timestamp: this.lastEndSampleIndex / audioSample.sampleRate,
|
||||
});
|
||||
|
||||
await this.add(fillSample, true); // Recursive call
|
||||
}
|
||||
|
||||
this.lastEndSampleIndex += audioSample.numberOfFrames;
|
||||
}
|
||||
|
||||
this.lastEndSampleIndex = endSampleIndex;
|
||||
}
|
||||
|
||||
if (this.customEncoder) {
|
||||
@@ -1851,6 +1906,12 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
private _promiseWithResolvers = promiseWithResolvers();
|
||||
/** @internal */
|
||||
private _errorPromiseAccessed = false;
|
||||
/** @internal */
|
||||
private _paused = false;
|
||||
/** @internal */
|
||||
private _lastSampleTimestamp: number | null = null;
|
||||
/** @internal */
|
||||
private _pauseOffset = 0;
|
||||
|
||||
/** A promise that rejects upon any error within this source. This promise never resolves. */
|
||||
get errorPromise() {
|
||||
@@ -1858,6 +1919,11 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
return this._promiseWithResolvers.promise;
|
||||
}
|
||||
|
||||
/** Whether this source is currently paused as a result of calling `.pause()`. */
|
||||
get paused() {
|
||||
return this._paused;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link MediaStreamAudioTrackSource} from a `MediaStreamAudioTrack`, which will pull audio samples
|
||||
* from the stream in real time and encode them according to {@link AudioEncodingConfig}.
|
||||
@@ -1884,38 +1950,74 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
|
||||
this._abortController = new AbortController();
|
||||
|
||||
let firstAudioDataTimestamp: number | null = null;
|
||||
let errored = false;
|
||||
|
||||
const onAudioSample = (audioSample: AudioSample) => {
|
||||
if (errored) {
|
||||
audioSample.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimestamp = audioSample.timestamp;
|
||||
|
||||
if (this._paused) {
|
||||
const dataSeen = firstAudioDataTimestamp !== null;
|
||||
if (dataSeen) {
|
||||
if (this._lastSampleTimestamp !== null) {
|
||||
// In addition to dropping this sample, let's also keep track of the time we have lost due to
|
||||
// the pause. Doing it like this instead of simply keeping track of the paused time is better
|
||||
// since it retains the sample rate of the underlying source.
|
||||
const timeDelta = currentTimestamp - this._lastSampleTimestamp;
|
||||
// We modify this field instead of _timestampOffset since we still might have data in flight
|
||||
// in the encoder, with which we don't want to mess.
|
||||
this._pauseOffset -= timeDelta;
|
||||
}
|
||||
this._lastSampleTimestamp = currentTimestamp;
|
||||
}
|
||||
|
||||
audioSample.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (firstAudioDataTimestamp === null) {
|
||||
firstAudioDataTimestamp = audioSample.timestamp;
|
||||
|
||||
const muxer = this._connectedTrack!.output._muxer;
|
||||
if (muxer.firstMediaStreamTimestamp === null) {
|
||||
muxer.firstMediaStreamTimestamp = performance.now() / 1000;
|
||||
this._timestampOffset = -firstAudioDataTimestamp;
|
||||
} else {
|
||||
this._timestampOffset = (performance.now() / 1000 - muxer.firstMediaStreamTimestamp)
|
||||
- firstAudioDataTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
this._lastSampleTimestamp = currentTimestamp;
|
||||
|
||||
if (this._encoder.getQueueSize() >= 4) {
|
||||
// Drop data if the encoder is overloaded
|
||||
audioSample.close();
|
||||
return;
|
||||
}
|
||||
|
||||
audioSample.setTimestamp(currentTimestamp + this._pauseOffset);
|
||||
|
||||
void this._encoder.add(audioSample, true)
|
||||
.catch((error) => {
|
||||
errored = true;
|
||||
|
||||
this._abortController?.abort();
|
||||
this._promiseWithResolvers.reject(error);
|
||||
void this._audioContext?.suspend();
|
||||
});
|
||||
};
|
||||
|
||||
if (typeof MediaStreamTrackProcessor !== 'undefined') {
|
||||
// Great, MediaStreamTrackProcessor is supported, this is the preferred way of doing things
|
||||
let firstAudioDataTimestamp: number | null = null;
|
||||
|
||||
const processor = new MediaStreamTrackProcessor({ track: this._track });
|
||||
const consumer = new WritableStream<AudioData>({
|
||||
write: (audioData) => {
|
||||
if (firstAudioDataTimestamp === null) {
|
||||
firstAudioDataTimestamp = audioData.timestamp / 1e6;
|
||||
|
||||
const muxer = this._connectedTrack!.output._muxer;
|
||||
if (muxer.firstMediaStreamTimestamp === null) {
|
||||
muxer.firstMediaStreamTimestamp = performance.now() / 1000;
|
||||
this._timestampOffset = -firstAudioDataTimestamp;
|
||||
} else {
|
||||
this._timestampOffset = (performance.now() / 1000 - muxer.firstMediaStreamTimestamp)
|
||||
- firstAudioDataTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._encoder.getQueueSize() >= 4) {
|
||||
// Drop data if the encoder is overloaded
|
||||
audioData.close();
|
||||
return;
|
||||
}
|
||||
|
||||
void this._encoder.add(new AudioSample(audioData), true)
|
||||
.catch((error) => {
|
||||
this._abortController?.abort();
|
||||
this._promiseWithResolvers.reject(error);
|
||||
});
|
||||
},
|
||||
write: audioData => onAudioSample(new AudioSample(audioData)),
|
||||
});
|
||||
|
||||
processor.readable.pipeTo(consumer, {
|
||||
@@ -1943,7 +2045,6 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
sourceNode.connect(this._scriptProcessorNode);
|
||||
this._scriptProcessorNode.connect(this._audioContext.destination);
|
||||
|
||||
let audioReceived = false;
|
||||
let totalDuration = 0;
|
||||
|
||||
this._scriptProcessorNode.onaudioprocess = (event) => {
|
||||
@@ -1951,33 +2052,25 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
totalDuration += event.inputBuffer.duration;
|
||||
|
||||
for (const audioSample of iterator) {
|
||||
if (!audioReceived) {
|
||||
audioReceived = true;
|
||||
|
||||
const muxer = this._connectedTrack!.output._muxer;
|
||||
if (muxer.firstMediaStreamTimestamp === null) {
|
||||
muxer.firstMediaStreamTimestamp = performance.now() / 1000;
|
||||
} else {
|
||||
this._timestampOffset = performance.now() / 1000 - muxer.firstMediaStreamTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._encoder.getQueueSize() >= 4) {
|
||||
// Drop data if the encoder is overloaded
|
||||
audioSample.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
void this._encoder.add(audioSample, true)
|
||||
.catch((error) => {
|
||||
void this._audioContext!.suspend();
|
||||
this._promiseWithResolvers.reject(error);
|
||||
});
|
||||
onAudioSample(audioSample);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the capture of audio data - any audio data emitted by the underlying media stream will be ignored
|
||||
* while paused. This does *not* close the underlying `MediaStreamAudioTrack`, it just ignores its output.
|
||||
*/
|
||||
pause() {
|
||||
this._paused = true;
|
||||
}
|
||||
|
||||
/** Resumes the capture of audio data after being paused. */
|
||||
resume() {
|
||||
this._paused = false;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
override async _flushAndClose(forceClose: boolean) {
|
||||
if (this._abortController) {
|
||||
|
||||
+48
-10
@@ -157,20 +157,20 @@ export const writeBits = (bytes: Uint8Array, start: number, end: number, value:
|
||||
export const toUint8Array = (source: AllowSharedBufferSource): Uint8Array => {
|
||||
if (source.constructor === Uint8Array) { // We want a true Uint8Array, not something that extends it like Buffer
|
||||
return source;
|
||||
} else if (source instanceof ArrayBuffer) {
|
||||
return new Uint8Array(source);
|
||||
} else {
|
||||
} else if (ArrayBuffer.isView(source)) {
|
||||
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
|
||||
} else {
|
||||
return new Uint8Array(source);
|
||||
}
|
||||
};
|
||||
|
||||
export const toDataView = (source: AllowSharedBufferSource) => {
|
||||
export const toDataView = (source: AllowSharedBufferSource): DataView => {
|
||||
if (source.constructor === DataView) {
|
||||
return source;
|
||||
} else if (source instanceof ArrayBuffer) {
|
||||
return new DataView(source);
|
||||
} else {
|
||||
} else if (ArrayBuffer.isView(source)) {
|
||||
return new DataView(source.buffer, source.byteOffset, source.byteLength);
|
||||
} else {
|
||||
return new DataView(source);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -515,7 +515,7 @@ export const isIso639Dash2LanguageCode = (x: string) => {
|
||||
};
|
||||
|
||||
// Since the result will be truncated, add a bit of eps to compensate for floating point errors
|
||||
export const SECOND_TO_MICROSECOND_FACTOR = /* #__PURE__ */ 1e6 * (1 + Number.EPSILON);
|
||||
export const SECOND_TO_MICROSECOND_FACTOR = 1e6 * (1 + Number.EPSILON);
|
||||
|
||||
/**
|
||||
* Sets all keys K of T to be required.
|
||||
@@ -584,6 +584,7 @@ export const retriedFetch = async (
|
||||
url: string | URL | Request,
|
||||
requestInit: RequestInit,
|
||||
getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null,
|
||||
shouldStop: () => boolean,
|
||||
) => {
|
||||
let attempts = 0;
|
||||
|
||||
@@ -591,6 +592,10 @@ export const retriedFetch = async (
|
||||
try {
|
||||
return await fetchFn(url, requestInit);
|
||||
} catch (error) {
|
||||
if (shouldStop()) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
const retryDelayInSeconds = getRetryDelay(attempts, error, url);
|
||||
|
||||
@@ -607,6 +612,10 @@ export const retriedFetch = async (
|
||||
if (retryDelayInSeconds > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds));
|
||||
}
|
||||
|
||||
if (shouldStop()) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -670,7 +679,15 @@ export const isWebKit = () => {
|
||||
}
|
||||
|
||||
// This even returns true for WebKit-wrapping browsers such as Chrome on iOS
|
||||
return isWebKitCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.match(/apple/i));
|
||||
return isWebKitCache = !!(
|
||||
typeof navigator !== 'undefined'
|
||||
&& (
|
||||
navigator.vendor?.match(/apple/i)
|
||||
// Or, in workers:
|
||||
|| (/AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent))
|
||||
|| /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
let isFirefoxCache: boolean | null = null;
|
||||
@@ -688,7 +705,28 @@ export const isChromium = () => {
|
||||
return isChromiumCache;
|
||||
}
|
||||
|
||||
return isChromiumCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.includes('Google Inc'));
|
||||
return isChromiumCache = !!(
|
||||
typeof navigator !== 'undefined'
|
||||
&& (navigator.vendor?.includes('Google Inc') || /Chrome/.test(navigator.userAgent))
|
||||
);
|
||||
};
|
||||
|
||||
let chromiumVersionCache: number | null = null;
|
||||
export const getChromiumVersion = () => {
|
||||
if (chromiumVersionCache !== null) {
|
||||
return chromiumVersionCache;
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = /\bChrome\/(\d+)/.exec(navigator.userAgent);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chromiumVersionCache = Number(match[1]!);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -322,6 +322,10 @@ export class OggDemuxer extends Demuxer {
|
||||
}
|
||||
|
||||
const totalPacketSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
if (totalPacketSize === 0) {
|
||||
return null; // Invalid packet, treat it as end of stream
|
||||
}
|
||||
|
||||
const packetData = new Uint8Array(totalPacketSize);
|
||||
|
||||
let offset = 0;
|
||||
|
||||
+34
-17
@@ -47,12 +47,13 @@ type OggTrackData = {
|
||||
currentPageData: Uint8Array[];
|
||||
currentPageSize: number;
|
||||
currentPageStartsWithFreshPacket: boolean;
|
||||
currentPageStartTimestampInSamples: number;
|
||||
};
|
||||
|
||||
type Packet = {
|
||||
data: Uint8Array;
|
||||
endGranulePosition: number;
|
||||
timestamp: number;
|
||||
timestampInSamples: number;
|
||||
durationInSamples: number;
|
||||
forcePageFlush: boolean;
|
||||
};
|
||||
|
||||
@@ -132,6 +133,7 @@ export class OggMuxer extends Muxer {
|
||||
currentPageData: [],
|
||||
currentPageSize: 27,
|
||||
currentPageStartsWithFreshPacket: true,
|
||||
currentPageStartTimestampInSamples: 0,
|
||||
};
|
||||
|
||||
this.queueHeaderPackets(newTrackData, meta);
|
||||
@@ -199,18 +201,18 @@ export class OggMuxer extends Muxer {
|
||||
|
||||
trackData.packetQueue.push({
|
||||
data: identificationHeader,
|
||||
endGranulePosition: 0,
|
||||
timestamp: 0,
|
||||
timestampInSamples: 0,
|
||||
durationInSamples: 0,
|
||||
forcePageFlush: true,
|
||||
}, {
|
||||
data: commentHeader,
|
||||
endGranulePosition: 0,
|
||||
timestamp: 0,
|
||||
timestampInSamples: 0,
|
||||
durationInSamples: 0,
|
||||
forcePageFlush: false,
|
||||
}, {
|
||||
data: setupHeader,
|
||||
endGranulePosition: 0,
|
||||
timestamp: 0,
|
||||
timestampInSamples: 0,
|
||||
durationInSamples: 0,
|
||||
forcePageFlush: true, // The last header packet must flush the page
|
||||
});
|
||||
|
||||
@@ -239,13 +241,13 @@ export class OggMuxer extends Muxer {
|
||||
|
||||
trackData.packetQueue.push({
|
||||
data: identificationHeader,
|
||||
endGranulePosition: 0,
|
||||
timestamp: 0,
|
||||
timestampInSamples: 0,
|
||||
durationInSamples: 0,
|
||||
forcePageFlush: true,
|
||||
}, {
|
||||
data: commentHeader,
|
||||
endGranulePosition: 0,
|
||||
timestamp: 0,
|
||||
timestampInSamples: 0,
|
||||
durationInSamples: 0,
|
||||
forcePageFlush: true, // The last header packet must flush the page
|
||||
});
|
||||
|
||||
@@ -275,8 +277,8 @@ export class OggMuxer extends Muxer {
|
||||
|
||||
trackData.packetQueue.push({
|
||||
data: packet.data,
|
||||
endGranulePosition: trackData.currentTimestampInSamples,
|
||||
timestamp: currentTimestampInSamples / trackData.internalSampleRate,
|
||||
timestampInSamples: currentTimestampInSamples,
|
||||
durationInSamples,
|
||||
forcePageFlush: false,
|
||||
});
|
||||
|
||||
@@ -338,10 +340,10 @@ export class OggMuxer extends Muxer {
|
||||
|
||||
if (
|
||||
trackData.packetQueue.length > 0
|
||||
&& trackData.packetQueue[0]!.timestamp < minTimestamp
|
||||
&& trackData.packetQueue[0]!.timestampInSamples < minTimestamp
|
||||
) {
|
||||
trackWithMinTimestamp = trackData;
|
||||
minTimestamp = trackData.packetQueue[0]!.timestamp;
|
||||
minTimestamp = trackData.packetQueue[0]!.timestampInSamples;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +363,20 @@ export class OggMuxer extends Muxer {
|
||||
}
|
||||
|
||||
writePacket(trackData: OggTrackData, packet: Packet, isFinalPacket: boolean) {
|
||||
const packetEndTimestampInSamples = packet.timestampInSamples + packet.durationInSamples;
|
||||
|
||||
if (this.format._options.maximumPageDuration !== undefined) {
|
||||
const maxDurationInSamples = this.format._options.maximumPageDuration * trackData.internalSampleRate;
|
||||
|
||||
if (
|
||||
trackData.currentLacingValues.length > 0
|
||||
&& packetEndTimestampInSamples - trackData.currentPageStartTimestampInSamples > maxDurationInSamples
|
||||
) {
|
||||
// Flush the current page early to avoid exceeding the maximum page duration
|
||||
this.writePage(trackData, false);
|
||||
}
|
||||
}
|
||||
|
||||
let remainingLength = packet.data.length;
|
||||
let dataStartOffset = 0;
|
||||
let dataOffset = 0;
|
||||
@@ -401,7 +417,7 @@ export class OggMuxer extends Muxer {
|
||||
const slice = packet.data.subarray(dataStartOffset);
|
||||
trackData.currentPageData.push(slice);
|
||||
trackData.currentPageSize += slice.length;
|
||||
trackData.currentGranulePosition = packet.endGranulePosition;
|
||||
trackData.currentGranulePosition = packetEndTimestampInSamples;
|
||||
|
||||
if (trackData.currentPageSize >= PAGE_SIZE_TARGET || packet.forcePageFlush) {
|
||||
this.writePage(trackData, isFinalPacket);
|
||||
@@ -452,6 +468,7 @@ export class OggMuxer extends Muxer {
|
||||
trackData.currentPageData.length = 0;
|
||||
trackData.currentPageSize = 27;
|
||||
trackData.currentPageStartsWithFreshPacket = true;
|
||||
trackData.currentPageStartTimestampInSamples = trackData.currentGranulePosition;
|
||||
|
||||
if (this.format._options.onPage) {
|
||||
this.writer.startTrackingWrites();
|
||||
|
||||
@@ -11,7 +11,7 @@ import { OGGS } from './ogg-misc';
|
||||
|
||||
export const MIN_PAGE_HEADER_SIZE = 27;
|
||||
export const MAX_PAGE_HEADER_SIZE = 27 + 255;
|
||||
export const MAX_PAGE_SIZE = /* #__PURE__ */ MAX_PAGE_HEADER_SIZE + 255 * 255;
|
||||
export const MAX_PAGE_SIZE = MAX_PAGE_HEADER_SIZE + 255 * 255;
|
||||
|
||||
export type Page = {
|
||||
headerStartPos: number;
|
||||
|
||||
@@ -725,6 +725,12 @@ export class WavOutputFormat extends OutputFormat {
|
||||
* @public
|
||||
*/
|
||||
export type OggOutputFormatOptions = {
|
||||
/**
|
||||
* The maximum duration of each Ogg page, in seconds. This is useful for streaming contexts where more frequent page
|
||||
* output is desired. By default, pages are only flushed when they exceed a certain size.
|
||||
*/
|
||||
maximumPageDuration?: number;
|
||||
|
||||
/**
|
||||
* Will be called for each Ogg page that is written.
|
||||
*
|
||||
@@ -749,6 +755,12 @@ export class OggOutputFormat extends OutputFormat {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (
|
||||
options.maximumPageDuration !== undefined
|
||||
&& (!Number.isFinite(options.maximumPageDuration) || options.maximumPageDuration <= 0)
|
||||
) {
|
||||
throw new TypeError('options.maximumPageDuration, when provided, must be a positive number.');
|
||||
}
|
||||
if (options.onPage !== undefined && typeof options.onPage !== 'function') {
|
||||
throw new TypeError('options.onPage, when provided, must be a function.');
|
||||
}
|
||||
|
||||
+536
-71
@@ -17,10 +17,111 @@ import {
|
||||
SetRequired,
|
||||
isFirefox,
|
||||
polyfillSymbolDispose,
|
||||
assertNever,
|
||||
isWebKit,
|
||||
} from './misc';
|
||||
|
||||
polyfillSymbolDispose();
|
||||
|
||||
type FinalizationRegistryValue = {
|
||||
type: 'video';
|
||||
data: VideoFrame | OffscreenCanvas | Uint8Array;
|
||||
} | {
|
||||
type: 'audio';
|
||||
data: AudioData | Uint8Array;
|
||||
};
|
||||
|
||||
// Let's manually handle logging the garbage collection errors that are typically logged by the browser. This way, they
|
||||
// also kick for audio samples (which is normally not the case), making sure any incorrect code is quickly caught.
|
||||
let lastVideoGcErrorLog = -Infinity;
|
||||
let lastAudioGcErrorLog = -Infinity;
|
||||
let finalizationRegistry: FinalizationRegistry<FinalizationRegistryValue> | null = null;
|
||||
if (typeof FinalizationRegistry !== 'undefined') {
|
||||
finalizationRegistry = new FinalizationRegistry<FinalizationRegistryValue>((value) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (value.type === 'video') {
|
||||
if (now - lastVideoGcErrorLog >= 1000) {
|
||||
// This error is annoying but oh so important
|
||||
console.error(
|
||||
`A VideoSample was garbage collected without first being closed. For proper resource management,`
|
||||
+ ` make sure to call close() on all your VideoSamples as soon as you're done using them.`,
|
||||
);
|
||||
|
||||
lastVideoGcErrorLog = now;
|
||||
}
|
||||
|
||||
if (typeof VideoFrame !== 'undefined' && value.data instanceof VideoFrame) {
|
||||
value.data.close(); // Prevent the browser error since we're logging our own
|
||||
}
|
||||
} else {
|
||||
if (now - lastAudioGcErrorLog >= 1000) {
|
||||
console.error(
|
||||
`An AudioSample was garbage collected without first being closed. For proper resource management,`
|
||||
+ ` make sure to call close() on all your AudioSamples as soon as you're done using them.`,
|
||||
);
|
||||
|
||||
lastAudioGcErrorLog = now;
|
||||
}
|
||||
|
||||
if (typeof AudioData !== 'undefined' && value.data instanceof AudioData) {
|
||||
value.data.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of {@link VideoSample} pixel formats.
|
||||
* @group Samples
|
||||
* @public
|
||||
*/
|
||||
export const VIDEO_SAMPLE_PIXEL_FORMATS = [
|
||||
// 4:2:0 Y, U, V
|
||||
'I420',
|
||||
'I420P10',
|
||||
'I420P12',
|
||||
// 4:2:0 Y, U, V, A
|
||||
'I420A',
|
||||
'I420AP10',
|
||||
'I420AP12',
|
||||
// 4:2:2 Y, U, V
|
||||
'I422',
|
||||
'I422P10',
|
||||
'I422P12',
|
||||
// 4:2:2 Y, U, V, A
|
||||
'I422A',
|
||||
'I422AP10',
|
||||
'I422AP12',
|
||||
// 4:4:4 Y, U, V
|
||||
'I444',
|
||||
'I444P10',
|
||||
'I444P12',
|
||||
// 4:4:4 Y, U, V, A
|
||||
'I444A',
|
||||
'I444AP10',
|
||||
'I444AP12',
|
||||
// 4:2:0 Y, UV
|
||||
'NV12',
|
||||
// 4:4:4 RGBA
|
||||
'RGBA',
|
||||
// 4:4:4 RGBX (opaque)
|
||||
'RGBX',
|
||||
// 4:4:4 BGRA
|
||||
'BGRA',
|
||||
// 4:4:4 BGRX (opaque)
|
||||
'BGRX',
|
||||
] as const;
|
||||
const VIDEO_SAMPLE_PIXEL_FORMATS_SET = new Set(VIDEO_SAMPLE_PIXEL_FORMATS);
|
||||
|
||||
/**
|
||||
* The internal pixel format with which a {@link VideoSample} is stored.
|
||||
* [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format) for more.
|
||||
* @group Samples
|
||||
* @public
|
||||
*/
|
||||
export type VideoSamplePixelFormat = typeof VIDEO_SAMPLE_PIXEL_FORMATS[number];
|
||||
|
||||
/**
|
||||
* Metadata used for VideoSample initialization.
|
||||
* @group Samples
|
||||
@@ -29,9 +130,9 @@ polyfillSymbolDispose();
|
||||
export type VideoSampleInit = {
|
||||
/**
|
||||
* The internal pixel format in which the frame is stored.
|
||||
* [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format)
|
||||
* [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format)
|
||||
*/
|
||||
format?: VideoPixelFormat;
|
||||
format?: VideoSamplePixelFormat;
|
||||
/** The width of the frame in pixels. */
|
||||
codedWidth?: number;
|
||||
/** The height of the frame in pixels. */
|
||||
@@ -44,6 +145,8 @@ export type VideoSampleInit = {
|
||||
duration?: number;
|
||||
/** The color space of the frame. */
|
||||
colorSpace?: VideoColorSpaceInit;
|
||||
/** The byte layout of the planes of the frame. */
|
||||
layout?: PlaneLayout[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -55,14 +158,20 @@ export type VideoSampleInit = {
|
||||
export class VideoSample implements Disposable {
|
||||
/** @internal */
|
||||
_data!: VideoFrame | OffscreenCanvas | Uint8Array | null;
|
||||
/**
|
||||
* Used for the ArrayBuffer-backed case.
|
||||
* @internal
|
||||
*/
|
||||
_layout!: PlaneLayout[] | null;
|
||||
/** @internal */
|
||||
_closed: boolean = false;
|
||||
|
||||
/**
|
||||
* The internal pixel format in which the frame is stored.
|
||||
* [See pixel formats](https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/format)
|
||||
* The internal pixel format in which the frame is stored. Will be `null` if it's using an arbitrary internal
|
||||
* format not representable by `VideoPixelFormat`.
|
||||
* [See pixel formats](https://www.w3.org/TR/webcodecs/#pixel-format)
|
||||
*/
|
||||
readonly format!: VideoPixelFormat | null;
|
||||
readonly format!: VideoSamplePixelFormat | null;
|
||||
/** The width of the frame in pixels. */
|
||||
readonly codedWidth!: number;
|
||||
/** The height of the frame in pixels. */
|
||||
@@ -77,7 +186,7 @@ export class VideoSample implements Disposable {
|
||||
/** The duration of the frame in seconds. */
|
||||
readonly duration!: number;
|
||||
/** The color space of the frame. */
|
||||
readonly colorSpace!: VideoColorSpace;
|
||||
readonly colorSpace!: VideoSampleColorSpace;
|
||||
|
||||
/** The width of the frame in pixels after rotation. */
|
||||
get displayWidth() {
|
||||
@@ -133,12 +242,16 @@ export class VideoSample implements Disposable {
|
||||
data: VideoFrame | CanvasImageSource | AllowSharedBufferSource,
|
||||
init?: VideoSampleInit,
|
||||
) {
|
||||
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
||||
if (
|
||||
data instanceof ArrayBuffer
|
||||
|| (typeof SharedArrayBuffer !== 'undefined' && data instanceof SharedArrayBuffer)
|
||||
|| ArrayBuffer.isView(data)
|
||||
) {
|
||||
if (!init || typeof init !== 'object') {
|
||||
throw new TypeError('init must be an object.');
|
||||
}
|
||||
if (!('format' in init) || typeof init.format !== 'string') {
|
||||
throw new TypeError('init.format must be a string.');
|
||||
if (init.format === undefined || !VIDEO_SAMPLE_PIXEL_FORMATS_SET.has(init.format)) {
|
||||
throw new TypeError('init.format must be one of: ' + VIDEO_SAMPLE_PIXEL_FORMATS.join(', '));
|
||||
}
|
||||
if (!Number.isInteger(init.codedWidth) || init.codedWidth! <= 0) {
|
||||
throw new TypeError('init.codedWidth must be a positive integer.');
|
||||
@@ -157,6 +270,7 @@ export class VideoSample implements Disposable {
|
||||
}
|
||||
|
||||
this._data = toUint8Array(data).slice(); // Copy it
|
||||
this._layout = init.layout ?? createDefaultPlaneLayout(init.format, init.codedWidth!, init.codedHeight!);
|
||||
|
||||
this.format = init.format;
|
||||
this.codedWidth = init.codedWidth!;
|
||||
@@ -164,7 +278,7 @@ export class VideoSample implements Disposable {
|
||||
this.rotation = init.rotation ?? 0;
|
||||
this.timestamp = init.timestamp!;
|
||||
this.duration = init.duration ?? 0;
|
||||
this.colorSpace = new VideoColorSpace(init.colorSpace);
|
||||
this.colorSpace = new VideoSampleColorSpace(init.colorSpace);
|
||||
} else if (typeof VideoFrame !== 'undefined' && data instanceof VideoFrame) {
|
||||
if (init?.rotation !== undefined && ![0, 90, 180, 270].includes(init.rotation)) {
|
||||
throw new TypeError('init.rotation, when provided, must be 0, 90, 180, or 270.');
|
||||
@@ -177,6 +291,7 @@ export class VideoSample implements Disposable {
|
||||
}
|
||||
|
||||
this._data = data;
|
||||
this._layout = null;
|
||||
|
||||
this.format = data.format;
|
||||
// Copying the display dimensions here, assuming no innate VideoFrame rotation
|
||||
@@ -187,7 +302,7 @@ export class VideoSample implements Disposable {
|
||||
this.rotation = init?.rotation ?? 0;
|
||||
this.timestamp = init?.timestamp ?? data.timestamp / 1e6;
|
||||
this.duration = init?.duration ?? (data.duration ?? 0) / 1e6;
|
||||
this.colorSpace = data.colorSpace;
|
||||
this.colorSpace = new VideoSampleColorSpace(data.colorSpace);
|
||||
} else if (
|
||||
(typeof HTMLImageElement !== 'undefined' && data instanceof HTMLImageElement)
|
||||
|| (typeof SVGImageElement !== 'undefined' && data instanceof SVGImageElement)
|
||||
@@ -249,6 +364,7 @@ export class VideoSample implements Disposable {
|
||||
// Draw it to a canvas
|
||||
context.drawImage(data, 0, 0);
|
||||
this._data = canvas;
|
||||
this._layout = null;
|
||||
|
||||
this.format = 'RGBX';
|
||||
this.codedWidth = width;
|
||||
@@ -256,7 +372,7 @@ export class VideoSample implements Disposable {
|
||||
this.rotation = init.rotation ?? 0;
|
||||
this.timestamp = init.timestamp!;
|
||||
this.duration = init.duration ?? 0;
|
||||
this.colorSpace = new VideoColorSpace({
|
||||
this.colorSpace = new VideoSampleColorSpace({
|
||||
matrix: 'rgb',
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
@@ -265,6 +381,8 @@ export class VideoSample implements Disposable {
|
||||
} else {
|
||||
throw new TypeError('Invalid data type: Must be a BufferSource or CanvasImageSource.');
|
||||
}
|
||||
|
||||
finalizationRegistry?.register(this, { type: 'video', data: this._data }, this);
|
||||
}
|
||||
|
||||
/** Clones this video sample. */
|
||||
@@ -282,8 +400,11 @@ export class VideoSample implements Disposable {
|
||||
rotation: this.rotation,
|
||||
});
|
||||
} else if (this._data instanceof Uint8Array) {
|
||||
return new VideoSample(this._data.slice(), {
|
||||
assert(this._layout);
|
||||
|
||||
return new VideoSample(this._data, {
|
||||
format: this.format!,
|
||||
layout: this._layout,
|
||||
codedWidth: this.codedWidth,
|
||||
codedHeight: this.codedHeight,
|
||||
timestamp: this.timestamp,
|
||||
@@ -313,6 +434,8 @@ export class VideoSample implements Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
finalizationRegistry?.unregister(this);
|
||||
|
||||
if (isVideoFrame(this._data)) {
|
||||
this._data.close();
|
||||
} else {
|
||||
@@ -322,16 +445,42 @@ export class VideoSample implements Disposable {
|
||||
this._closed = true;
|
||||
}
|
||||
|
||||
/** Returns the number of bytes required to hold this video sample's pixel data. */
|
||||
allocationSize() {
|
||||
/**
|
||||
* Returns the number of bytes required to hold this video sample's pixel data. Throws if `format` is `null`.
|
||||
*/
|
||||
allocationSize(options: VideoFrameCopyToOptions = {}): number {
|
||||
validateVideoFrameCopyToOptions(options);
|
||||
|
||||
if (this._closed) {
|
||||
throw new Error('VideoSample is closed.');
|
||||
}
|
||||
if (this.format === null) {
|
||||
// https://github.com/Vanilagy/mediabunny/issues/267
|
||||
// https://github.com/w3c/webcodecs/issues/920
|
||||
throw new Error('Cannot get allocation size when format is null. Sorry!');
|
||||
}
|
||||
|
||||
assert(this._data !== null);
|
||||
|
||||
if (!isVideoFrame(this._data)) {
|
||||
if (
|
||||
options.colorSpace
|
||||
|| (options.format && options.format !== this.format)
|
||||
|| options.layout
|
||||
|| options.rect
|
||||
) {
|
||||
// Temporarily convert to VideoFrame to get it done
|
||||
// TODO: Compute this directly without needing to go through VideoFrame
|
||||
const videoFrame = this.toVideoFrame();
|
||||
const size = videoFrame.allocationSize(options);
|
||||
videoFrame.close();
|
||||
|
||||
return size;
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideoFrame(this._data)) {
|
||||
return this._data.allocationSize();
|
||||
return this._data.allocationSize(options);
|
||||
} else if (this._data instanceof Uint8Array) {
|
||||
return this._data.byteLength;
|
||||
} else {
|
||||
@@ -339,23 +488,51 @@ export class VideoSample implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. */
|
||||
async copyTo(destination: AllowSharedBufferSource) {
|
||||
/**
|
||||
* Copies this video sample's pixel data to an ArrayBuffer or ArrayBufferView. Throws if `format` is `null`.
|
||||
* @returns The byte layout of the planes of the copied data.
|
||||
*/
|
||||
async copyTo(destination: AllowSharedBufferSource, options: VideoFrameCopyToOptions = {}): Promise<PlaneLayout[]> {
|
||||
if (!isAllowSharedBufferSource(destination)) {
|
||||
throw new TypeError('destination must be an ArrayBuffer or an ArrayBuffer view.');
|
||||
}
|
||||
validateVideoFrameCopyToOptions(options);
|
||||
|
||||
if (this._closed) {
|
||||
throw new Error('VideoSample is closed.');
|
||||
}
|
||||
if (this.format === null) {
|
||||
throw new Error('Cannot copy video sample data when format is null. Sorry!');
|
||||
}
|
||||
|
||||
assert(this._data !== null);
|
||||
|
||||
if (!isVideoFrame(this._data)) {
|
||||
if (
|
||||
options.colorSpace
|
||||
|| (options.format && options.format !== this.format)
|
||||
|| options.layout
|
||||
|| options.rect
|
||||
) {
|
||||
// Temporarily convert to VideoFrame to get it done
|
||||
// TODO: Do this directly without needing to go through VideoFrame
|
||||
const videoFrame = this.toVideoFrame();
|
||||
const layout = await videoFrame.copyTo(destination, options);
|
||||
videoFrame.close();
|
||||
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideoFrame(this._data)) {
|
||||
await this._data.copyTo(destination);
|
||||
return this._data.copyTo(destination, options);
|
||||
} else if (this._data instanceof Uint8Array) {
|
||||
assert(this._layout);
|
||||
|
||||
const dest = toUint8Array(destination);
|
||||
dest.set(this._data);
|
||||
|
||||
return this._layout;
|
||||
} else {
|
||||
const canvas = this._data;
|
||||
const context = canvas.getContext('2d');
|
||||
@@ -364,6 +541,11 @@ export class VideoSample implements Disposable {
|
||||
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
|
||||
const dest = toUint8Array(destination);
|
||||
dest.set(imageData.data);
|
||||
|
||||
return [{
|
||||
offset: 0,
|
||||
stride: 4 * this.codedWidth,
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,7 +567,7 @@ export class VideoSample implements Disposable {
|
||||
});
|
||||
} else if (this._data instanceof Uint8Array) {
|
||||
return new VideoFrame(this._data, {
|
||||
format: this.format!,
|
||||
format: this.format! as VideoPixelFormat,
|
||||
codedWidth: this.codedWidth,
|
||||
codedHeight: this.codedHeight,
|
||||
timestamp: this.microsecondTimestamp,
|
||||
@@ -749,6 +931,40 @@ export class VideoSample implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the color space of a {@link VideoSample}. Corresponds to the WebCodecs API's VideoColorSpace.
|
||||
* @group Samples
|
||||
* @public
|
||||
*/
|
||||
export class VideoSampleColorSpace {
|
||||
/** The color primaries standard used. */
|
||||
readonly primaries: VideoColorPrimaries | null;
|
||||
/** The transfer characteristics used. */
|
||||
readonly transfer: VideoTransferCharacteristics | null;
|
||||
/** The color matrix coefficients used. */
|
||||
readonly matrix: VideoMatrixCoefficients | null;
|
||||
/** Whether the color values use the full range or limited range. */
|
||||
readonly fullRange: boolean | null;
|
||||
|
||||
/** Creates a new VideoSampleColorSpace. */
|
||||
constructor(init?: VideoColorSpaceInit) {
|
||||
this.primaries = init?.primaries ?? null;
|
||||
this.transfer = init?.transfer ?? null;
|
||||
this.matrix = init?.matrix ?? null;
|
||||
this.fullRange = init?.fullRange ?? null;
|
||||
}
|
||||
|
||||
/** Serializes the color space to a JSON object. */
|
||||
toJSON(): VideoColorSpaceInit {
|
||||
return {
|
||||
primaries: this.primaries,
|
||||
transfer: this.transfer,
|
||||
matrix: this.matrix,
|
||||
fullRange: this.fullRange,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const isVideoFrame = (x: unknown): x is VideoFrame => {
|
||||
return typeof VideoFrame !== 'undefined' && x instanceof VideoFrame;
|
||||
};
|
||||
@@ -797,7 +1013,168 @@ export const validateCropRectangle = (crop: CropRectangle, prefix: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const AUDIO_SAMPLE_FORMATS = new Set(
|
||||
const validateVideoFrameCopyToOptions = (options: VideoFrameCopyToOptions) => {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (options.colorSpace !== undefined && !['display-p3', 'srgb'].includes(options.colorSpace)) {
|
||||
throw new TypeError('options.colorSpace, when provided, must be \'display-p3\' or \'srgb\'.');
|
||||
}
|
||||
if (options.format !== undefined && typeof options.format !== 'string') {
|
||||
throw new TypeError('options.format, when provided, must be a string.');
|
||||
}
|
||||
if (options.layout !== undefined) {
|
||||
if (!Array.isArray(options.layout)) {
|
||||
throw new TypeError('options.layout, when provided, must be an array.');
|
||||
}
|
||||
|
||||
for (const plane of options.layout) {
|
||||
if (!plane || typeof plane !== 'object') {
|
||||
throw new TypeError('Each entry in options.layout must be an object.');
|
||||
}
|
||||
if (!Number.isInteger(plane.offset) || plane.offset < 0) {
|
||||
throw new TypeError('plane.offset must be a non-negative integer.');
|
||||
}
|
||||
if (!Number.isInteger(plane.stride) || plane.stride < 0) {
|
||||
throw new TypeError('plane.stride must be a non-negative integer.');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.rect !== undefined) {
|
||||
if (!options.rect || typeof options.rect !== 'object') {
|
||||
throw new TypeError('options.rect, when provided, must be an object.');
|
||||
}
|
||||
if (options.rect.x !== undefined && (!Number.isInteger(options.rect.x) || options.rect.x < 0)) {
|
||||
throw new TypeError('options.rect.x, when provided, must be a non-negative integer.');
|
||||
}
|
||||
if (options.rect.y !== undefined && (!Number.isInteger(options.rect.y) || options.rect.y < 0)) {
|
||||
throw new TypeError('options.rect.y, when provided, must be a non-negative integer.');
|
||||
}
|
||||
if (options.rect.width !== undefined && (!Number.isInteger(options.rect.width) || options.rect.width < 0)) {
|
||||
throw new TypeError('options.rect.width, when provided, must be a non-negative integer.');
|
||||
}
|
||||
if (options.rect.height !== undefined && (!Number.isInteger(options.rect.height) || options.rect.height < 0)) {
|
||||
throw new TypeError('options.rect.height, when provided, must be a non-negative integer.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Implements logic from WebCodecs § 9.4.6 "Compute Layout and Allocation Size" */
|
||||
const createDefaultPlaneLayout = (
|
||||
format: VideoSamplePixelFormat,
|
||||
codedWidth: number,
|
||||
codedHeight: number,
|
||||
): PlaneLayout[] => {
|
||||
const planes = getPlaneConfigs(format);
|
||||
const layouts: PlaneLayout[] = [];
|
||||
let currentOffset = 0;
|
||||
|
||||
for (const plane of planes) {
|
||||
// Per § 9.8, dimensions are usually "rounded up to the nearest integer".
|
||||
const planeWidth = Math.ceil(codedWidth / plane.widthDivisor);
|
||||
const planeHeight = Math.ceil(codedHeight / plane.heightDivisor);
|
||||
|
||||
const stride = planeWidth * plane.sampleBytes;
|
||||
|
||||
// Tight packing
|
||||
const planeSize = stride * planeHeight;
|
||||
|
||||
layouts.push({
|
||||
offset: currentOffset,
|
||||
stride: stride,
|
||||
});
|
||||
|
||||
currentOffset += planeSize;
|
||||
}
|
||||
|
||||
return layouts;
|
||||
};
|
||||
|
||||
type PlaneConfig = {
|
||||
sampleBytes: number;
|
||||
widthDivisor: number; // Horizontal sub-sampling factor
|
||||
heightDivisor: number; // Vertical sub-sampling factor
|
||||
};
|
||||
|
||||
/** Helper to retrieve plane configurations based on WebCodecs § 9.8 Pixel Format definitions. */
|
||||
const getPlaneConfigs = (format: VideoSamplePixelFormat): PlaneConfig[] => {
|
||||
// Helper for standard YUV planes
|
||||
const yuv = (
|
||||
yBytes: number,
|
||||
uvBytes: number,
|
||||
subX: number,
|
||||
subY: number,
|
||||
hasAlpha: boolean,
|
||||
): PlaneConfig[] => {
|
||||
const configs: PlaneConfig[] = [
|
||||
{ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 },
|
||||
{ sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },
|
||||
{ sampleBytes: uvBytes, widthDivisor: subX, heightDivisor: subY },
|
||||
];
|
||||
|
||||
if (hasAlpha) {
|
||||
// Match luma dimensions
|
||||
configs.push({ sampleBytes: yBytes, widthDivisor: 1, heightDivisor: 1 });
|
||||
}
|
||||
|
||||
return configs;
|
||||
};
|
||||
|
||||
switch (format) {
|
||||
case 'I420':
|
||||
return yuv(1, 1, 2, 2, false);
|
||||
case 'I420P10':
|
||||
case 'I420P12':
|
||||
return yuv(2, 2, 2, 2, false);
|
||||
case 'I420A':
|
||||
return yuv(1, 1, 2, 2, true);
|
||||
case 'I420AP10':
|
||||
case 'I420AP12':
|
||||
return yuv(2, 2, 2, 2, true);
|
||||
|
||||
case 'I422':
|
||||
return yuv(1, 1, 2, 1, false);
|
||||
case 'I422P10':
|
||||
case 'I422P12':
|
||||
return yuv(2, 2, 2, 1, false);
|
||||
case 'I422A':
|
||||
return yuv(1, 1, 2, 1, true);
|
||||
case 'I422AP10':
|
||||
case 'I422AP12':
|
||||
return yuv(2, 2, 2, 1, true);
|
||||
|
||||
case 'I444':
|
||||
return yuv(1, 1, 1, 1, false);
|
||||
case 'I444P10':
|
||||
case 'I444P12':
|
||||
return yuv(2, 2, 1, 1, false);
|
||||
case 'I444A':
|
||||
return yuv(1, 1, 1, 1, true);
|
||||
case 'I444AP10':
|
||||
case 'I444AP12':
|
||||
return yuv(2, 2, 1, 1, true);
|
||||
|
||||
case 'NV12':
|
||||
return [
|
||||
{ sampleBytes: 1, widthDivisor: 1, heightDivisor: 1 },
|
||||
{ sampleBytes: 2, widthDivisor: 2, heightDivisor: 2 }, // Interleaved U and V
|
||||
];
|
||||
|
||||
case 'RGBA':
|
||||
case 'RGBX':
|
||||
case 'BGRA':
|
||||
case 'BGRX':
|
||||
return [
|
||||
{ sampleBytes: 4, widthDivisor: 1, heightDivisor: 1 },
|
||||
];
|
||||
|
||||
default:
|
||||
assertNever(format);
|
||||
assert(false);
|
||||
}
|
||||
};
|
||||
|
||||
const AUDIO_SAMPLE_FORMATS = new Set<AudioSampleFormat>(
|
||||
['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'],
|
||||
);
|
||||
|
||||
@@ -955,6 +1332,8 @@ export class AudioSample implements Disposable {
|
||||
|
||||
this._data = dataBuffer;
|
||||
}
|
||||
|
||||
finalizationRegistry?.register(this, { type: 'audio', data: this._data }, this);
|
||||
}
|
||||
|
||||
/** Returns the number of bytes required to hold the audio sample's data as specified by the given options. */
|
||||
@@ -1034,6 +1413,7 @@ export class AudioSample implements Disposable {
|
||||
|
||||
const { planeIndex, format, frameCount: optFrameCount, frameOffset: optFrameOffset } = options;
|
||||
|
||||
const srcFormat = this.format;
|
||||
const destFormat = format ?? this.format;
|
||||
if (!destFormat) throw new Error('Destination format not determined');
|
||||
|
||||
@@ -1068,59 +1448,31 @@ export class AudioSample implements Disposable {
|
||||
const writeFn = getWriteFunction(destFormat);
|
||||
|
||||
if (isAudioData(this._data)) {
|
||||
if (destIsPlanar) {
|
||||
if (destFormat === 'f32-planar') {
|
||||
// Simple, since the browser must support f32-planar, we can just delegate here
|
||||
this._data.copyTo(destination, {
|
||||
planeIndex,
|
||||
frameOffset,
|
||||
frameCount: copyFrameCount,
|
||||
format: 'f32-planar',
|
||||
});
|
||||
} else {
|
||||
// Allocate temporary buffer for f32-planar data
|
||||
const tempBuffer = new ArrayBuffer(copyFrameCount * 4);
|
||||
const tempArray = new Float32Array(tempBuffer);
|
||||
this._data.copyTo(tempArray, {
|
||||
planeIndex,
|
||||
frameOffset,
|
||||
frameCount: copyFrameCount,
|
||||
format: 'f32-planar',
|
||||
});
|
||||
|
||||
// Convert each f32 sample to destination format
|
||||
const tempView = new DataView(tempBuffer);
|
||||
for (let i = 0; i < copyFrameCount; i++) {
|
||||
const destOffset = i * destBytesPerSample;
|
||||
const sample = tempView.getFloat32(i * 4, true);
|
||||
writeFn(destView, destOffset, sample);
|
||||
}
|
||||
}
|
||||
if (isWebKit() && numChannels > 2 && destFormat !== srcFormat) {
|
||||
// WebKit bug workaround
|
||||
doAudioDataCopyToWebKitWorkaround(
|
||||
this._data,
|
||||
destView,
|
||||
srcFormat,
|
||||
destFormat,
|
||||
numChannels,
|
||||
planeIndex,
|
||||
frameOffset,
|
||||
copyFrameCount,
|
||||
);
|
||||
} else {
|
||||
// Destination is interleaved.
|
||||
// Allocate a temporary Float32Array to hold one channel's worth of data.
|
||||
const numCh = numChannels;
|
||||
const temp = new Float32Array(copyFrameCount);
|
||||
for (let ch = 0; ch < numCh; ch++) {
|
||||
this._data.copyTo(temp, {
|
||||
planeIndex: ch,
|
||||
frameOffset,
|
||||
frameCount: copyFrameCount,
|
||||
format: 'f32-planar',
|
||||
});
|
||||
for (let i = 0; i < copyFrameCount; i++) {
|
||||
const destIndex = i * numCh + ch;
|
||||
const destOffset = destIndex * destBytesPerSample;
|
||||
writeFn(destView, destOffset, temp[i]!);
|
||||
}
|
||||
}
|
||||
// Per spec, only f32-planar conversion must be supported, but in practice, all browsers support all
|
||||
// destination formats, so let's just delegate here:
|
||||
this._data.copyTo(destination, {
|
||||
planeIndex,
|
||||
frameOffset,
|
||||
frameCount: copyFrameCount,
|
||||
format: destFormat,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Branch for Uint8Array data (non-AudioData)
|
||||
const uint8Data = this._data;
|
||||
const srcView = new DataView(uint8Data.buffer, uint8Data.byteOffset, uint8Data.byteLength);
|
||||
|
||||
const srcFormat = this.format;
|
||||
const srcView = toDataView(uint8Data);
|
||||
const readFn = getReadFunction(srcFormat);
|
||||
const srcBytesPerSample = getBytesPerSample(srcFormat);
|
||||
const srcIsPlanar = formatIsPlanar(srcFormat);
|
||||
@@ -1188,6 +1540,8 @@ export class AudioSample implements Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
finalizationRegistry?.unregister(this);
|
||||
|
||||
if (isAudioData(this._data)) {
|
||||
this._data.close();
|
||||
} else {
|
||||
@@ -1250,7 +1604,9 @@ export class AudioSample implements Disposable {
|
||||
numberOfFrames: this.numberOfFrames,
|
||||
numberOfChannels: this.numberOfChannels,
|
||||
timestamp: this.microsecondTimestamp,
|
||||
data: this._data,
|
||||
data: this._data.buffer instanceof ArrayBuffer
|
||||
? this._data.buffer
|
||||
: this._data.slice(), // In the case of SharedArrayBuffer, convert to ArrayBuffer
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1459,3 +1815,112 @@ const getWriteFunction = (format: AudioSampleFormat): (view: DataView, offset: n
|
||||
const isAudioData = (x: unknown): x is AudioData => {
|
||||
return typeof AudioData !== 'undefined' && x instanceof AudioData;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebKit has a bug where calling AudioData.copyTo with a format different from the source format
|
||||
* crashes the tab when there are more than 2 channels. This function works around that by always
|
||||
* copying with the source format and then manually converting to the destination format.
|
||||
*
|
||||
* See https://bugs.webkit.org/show_bug.cgi?id=302521.
|
||||
*/
|
||||
const doAudioDataCopyToWebKitWorkaround = (
|
||||
audioData: AudioData,
|
||||
destView: DataView,
|
||||
srcFormat: AudioSampleFormat,
|
||||
destFormat: AudioSampleFormat,
|
||||
numChannels: number,
|
||||
planeIndex: number,
|
||||
frameOffset: number,
|
||||
copyFrameCount: number,
|
||||
) => {
|
||||
const readFn = getReadFunction(srcFormat);
|
||||
const writeFn = getWriteFunction(destFormat);
|
||||
const srcBytesPerSample = getBytesPerSample(srcFormat);
|
||||
const destBytesPerSample = getBytesPerSample(destFormat);
|
||||
const srcIsPlanar = formatIsPlanar(srcFormat);
|
||||
const destIsPlanar = formatIsPlanar(destFormat);
|
||||
|
||||
if (destIsPlanar) {
|
||||
if (srcIsPlanar) {
|
||||
// src planar -> dest planar: copy single plane and convert
|
||||
const data = new ArrayBuffer(copyFrameCount * srcBytesPerSample);
|
||||
const dataView = toDataView(data);
|
||||
|
||||
audioData.copyTo(data, {
|
||||
planeIndex,
|
||||
frameOffset,
|
||||
frameCount: copyFrameCount,
|
||||
format: srcFormat,
|
||||
});
|
||||
|
||||
for (let i = 0; i < copyFrameCount; i++) {
|
||||
const srcOffset = i * srcBytesPerSample;
|
||||
const destOffset = i * destBytesPerSample;
|
||||
const sample = readFn(dataView, srcOffset);
|
||||
writeFn(destView, destOffset, sample);
|
||||
}
|
||||
} else {
|
||||
// src interleaved -> dest planar: copy all interleaved data, extract one channel
|
||||
const data = new ArrayBuffer(copyFrameCount * numChannels * srcBytesPerSample);
|
||||
const dataView = toDataView(data);
|
||||
|
||||
audioData.copyTo(data, {
|
||||
planeIndex: 0,
|
||||
frameOffset,
|
||||
frameCount: copyFrameCount,
|
||||
format: srcFormat,
|
||||
});
|
||||
|
||||
for (let i = 0; i < copyFrameCount; i++) {
|
||||
const srcOffset = (i * numChannels + planeIndex) * srcBytesPerSample;
|
||||
const destOffset = i * destBytesPerSample;
|
||||
const sample = readFn(dataView, srcOffset);
|
||||
writeFn(destView, destOffset, sample);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (srcIsPlanar) {
|
||||
// src planar -> dest interleaved: copy each plane and interleave
|
||||
const planeSize = copyFrameCount * srcBytesPerSample;
|
||||
const data = new ArrayBuffer(planeSize);
|
||||
const dataView = toDataView(data);
|
||||
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
audioData.copyTo(data, {
|
||||
planeIndex: ch,
|
||||
frameOffset,
|
||||
frameCount: copyFrameCount,
|
||||
format: srcFormat,
|
||||
});
|
||||
|
||||
for (let i = 0; i < copyFrameCount; i++) {
|
||||
const srcOffset = i * srcBytesPerSample;
|
||||
const destOffset = (i * numChannels + ch) * destBytesPerSample;
|
||||
const sample = readFn(dataView, srcOffset);
|
||||
writeFn(destView, destOffset, sample);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// src interleaved -> dest interleaved: copy all and convert
|
||||
const data = new ArrayBuffer(copyFrameCount * numChannels * srcBytesPerSample);
|
||||
const dataView = toDataView(data);
|
||||
|
||||
audioData.copyTo(data, {
|
||||
planeIndex: 0,
|
||||
frameOffset,
|
||||
frameCount: copyFrameCount,
|
||||
format: srcFormat,
|
||||
});
|
||||
|
||||
for (let i = 0; i < copyFrameCount; i++) {
|
||||
for (let ch = 0; ch < numChannels; ch++) {
|
||||
const idx = i * numChannels + ch;
|
||||
const srcOffset = idx * srcBytesPerSample;
|
||||
const destOffset = idx * destBytesPerSample;
|
||||
const sample = readFn(dataView, srcOffset);
|
||||
writeFn(destView, destOffset, sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+51
-48
@@ -102,10 +102,17 @@ export class BufferSource extends Source {
|
||||
/** @internal */
|
||||
_onreadCalled = false;
|
||||
|
||||
/** Creates a new {@link BufferSource} backed the specified `ArrayBuffer` or `ArrayBufferView`. */
|
||||
constructor(buffer: ArrayBuffer | ArrayBufferView) {
|
||||
if (!(buffer instanceof ArrayBuffer) && !ArrayBuffer.isView(buffer)) {
|
||||
throw new TypeError('buffer must be an ArrayBuffer or ArrayBufferView.');
|
||||
/**
|
||||
* Creates a new {@link BufferSource} backed by the specified `ArrayBuffer`, `SharedArrayBuffer`,
|
||||
* or `ArrayBufferView`.
|
||||
*/
|
||||
constructor(buffer: AllowSharedBufferSource) {
|
||||
if (
|
||||
!(buffer instanceof ArrayBuffer)
|
||||
&& !(typeof SharedArrayBuffer !== 'undefined' && buffer instanceof SharedArrayBuffer)
|
||||
&& !ArrayBuffer.isView(buffer)
|
||||
) {
|
||||
throw new TypeError('buffer must be an ArrayBuffer, SharedArrayBuffer, or ArrayBufferView.');
|
||||
}
|
||||
|
||||
super();
|
||||
@@ -234,12 +241,7 @@ export class BlobSource extends Source {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
this._orchestrator.forgetWorker(worker);
|
||||
|
||||
if (worker.currentPos < worker.targetPos) { // I think this `if` should always hit?
|
||||
throw new Error('Blob reader stopped unexpectedly before all requested data was read.');
|
||||
}
|
||||
|
||||
break;
|
||||
throw new Error('Blob reader stopped unexpectedly before all requested data was read.');
|
||||
}
|
||||
|
||||
if (worker.aborted) {
|
||||
@@ -261,6 +263,11 @@ export class BlobSource extends Source {
|
||||
}
|
||||
|
||||
worker.running = false;
|
||||
|
||||
if (worker.aborted) {
|
||||
// MDN: "Calling this method signals a loss of interest in the stream by a consumer."
|
||||
await reader?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
@@ -269,7 +276,7 @@ export class BlobSource extends Source {
|
||||
}
|
||||
}
|
||||
|
||||
const URL_SOURCE_MIN_LOAD_AMOUNT = /* #__PURE__ */ 0.5 * 2 ** 20; // 0.5 MiB
|
||||
const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
|
||||
const DEFAULT_RETRY_DELAY
|
||||
= ((previousAttempts, error, src) => {
|
||||
// Check if this could be a CORS error. If so, we cannot recover from it and
|
||||
@@ -297,6 +304,10 @@ const DEFAULT_RETRY_DELAY
|
||||
= typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true;
|
||||
|
||||
if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) {
|
||||
console.warn(
|
||||
`Request will not be retried because a CORS error was suspected due to different origins. You can`
|
||||
+ ` modify this behavior by providing your own function for the 'getRetryDelay' option.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -425,6 +436,7 @@ export class UrlSource extends Source {
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
this._getRetryDelay,
|
||||
() => this._disposed,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -436,7 +448,7 @@ export class UrlSource extends Source {
|
||||
let fileSize: number;
|
||||
|
||||
if (response.status === 206) {
|
||||
fileSize = this._getPartialLengthFromRangeResponse(response);
|
||||
fileSize = this._getTotalLengthFromRangeResponse(response);
|
||||
worker = this._orchestrator.createWorker(0, Math.min(fileSize, URL_SOURCE_MIN_LOAD_AMOUNT));
|
||||
} else {
|
||||
// Server probably returned a 200.
|
||||
@@ -492,6 +504,7 @@ export class UrlSource extends Source {
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
this._getRetryDelay,
|
||||
() => this._disposed,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -509,14 +522,6 @@ export class UrlSource extends Source {
|
||||
);
|
||||
}
|
||||
|
||||
const length = this._getPartialLengthFromRangeResponse(response);
|
||||
const required = worker.targetPos - worker.currentPos;
|
||||
if (length < required) {
|
||||
throw new Error(
|
||||
`HTTP response unexpectedly too short: Needed at least ${required} bytes, got only ${length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(
|
||||
'Missing HTTP response body stream. The used fetch function must provide the response body as a'
|
||||
@@ -539,6 +544,11 @@ export class UrlSource extends Source {
|
||||
try {
|
||||
readResult = await reader.read();
|
||||
} catch (error) {
|
||||
if (this._disposed) {
|
||||
// No need to try to retry
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);
|
||||
if (retryDelayInSeconds !== null) {
|
||||
console.error('Error while reading response stream. Attempting to resume.', error);
|
||||
@@ -551,60 +561,53 @@ export class UrlSource extends Source {
|
||||
}
|
||||
|
||||
if (worker.aborted) {
|
||||
break;
|
||||
continue; // Cleanup happens in next iteration
|
||||
}
|
||||
|
||||
const { done, value } = readResult;
|
||||
|
||||
if (done) {
|
||||
this._orchestrator.forgetWorker(worker);
|
||||
|
||||
if (worker.currentPos < worker.targetPos) {
|
||||
throw new Error(
|
||||
'Response stream reader stopped unexpectedly before all requested data was read.',
|
||||
);
|
||||
if (worker.currentPos >= worker.targetPos) {
|
||||
// All data was delivered, we're good
|
||||
this._orchestrator.forgetWorker(worker);
|
||||
worker.running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
worker.running = false;
|
||||
return;
|
||||
// The response stopped early, before the target. This can happen if server decides to cap range
|
||||
// requests arbitrarily, even if the request had an uncapped end. In this case, let's fetch the rest
|
||||
// of the data using a new request.
|
||||
break;
|
||||
}
|
||||
|
||||
this.onread?.(worker.currentPos, worker.currentPos + value.length);
|
||||
this._orchestrator.supplyWorkerData(worker, value);
|
||||
}
|
||||
|
||||
if (worker.aborted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
worker.running = false;
|
||||
|
||||
// The previous UrlSource had logic for circumventing https://issues.chromium.org/issues/436025873; I haven't
|
||||
// been able to observe this bug with the new UrlSource (maybe because we're using response streaming), so the
|
||||
// logic for that has vanished for now. Leaving a comment here if this becomes relevant again.
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
private _getPartialLengthFromRangeResponse(response: Response) {
|
||||
private _getTotalLengthFromRangeResponse(response: Response) {
|
||||
const contentRange = response.headers.get('Content-Range');
|
||||
if (contentRange) {
|
||||
const match = /\/(\d+)/.exec(contentRange);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
} else {
|
||||
throw new Error(`Invalid Content-Range header: ${contentRange}`);
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
return Number(contentLength);
|
||||
} else {
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength) {
|
||||
return Number(contentLength);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Partial HTTP response (status 206) must surface either Content-Range or'
|
||||
+ ' Content-Length header.',
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
'Partial HTTP response (status 206) must surface either Content-Range or'
|
||||
+ ' Content-Length header.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +642,7 @@ export class FilePathSource extends Source {
|
||||
_fileHandle: FileHandle | null = null;
|
||||
|
||||
/** Creates a new {@link FilePathSource} backed by the file at the specified file path. */
|
||||
constructor(filePath: string, options: BlobSourceOptions = {}) {
|
||||
constructor(filePath: string, options: FilePathSourceOptions = {}) {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw new TypeError('filePath must be a string.');
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export type StreamTargetChunk = {
|
||||
/** The operation type. */
|
||||
type: 'write'; // This ensures automatic compatibility with FileSystemWritableFileStream
|
||||
/** The data to write. */
|
||||
data: Uint8Array;
|
||||
data: Uint8Array<ArrayBuffer>;
|
||||
/** The byte offset in the output file at which to write the data. */
|
||||
position: number;
|
||||
};
|
||||
|
||||
+5
-5
@@ -89,8 +89,8 @@ export abstract class Writer {
|
||||
}
|
||||
}
|
||||
|
||||
const ARRAY_BUFFER_INITIAL_SIZE = /* #__PURE__ */ 2 ** 16;
|
||||
const ARRAY_BUFFER_MAX_SIZE = /* #__PURE__ */ 2 ** 32;
|
||||
const ARRAY_BUFFER_INITIAL_SIZE = 2 ** 16;
|
||||
const ARRAY_BUFFER_MAX_SIZE = 2 ** 32;
|
||||
|
||||
export class BufferTargetWriter extends Writer {
|
||||
private pos = 0;
|
||||
@@ -184,13 +184,13 @@ export class BufferTargetWriter extends Writer {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = /* #__PURE__ */ 2 ** 24;
|
||||
const DEFAULT_CHUNK_SIZE = 2 ** 24;
|
||||
const MAX_CHUNKS_AT_ONCE = 2;
|
||||
|
||||
interface Chunk {
|
||||
start: number;
|
||||
written: ChunkSection[];
|
||||
data: Uint8Array;
|
||||
data: Uint8Array<ArrayBuffer>;
|
||||
shouldFlush: boolean;
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ export class StreamTargetWriter extends Writer {
|
||||
const chunks: {
|
||||
start: number;
|
||||
size: number;
|
||||
data?: Uint8Array;
|
||||
data?: Uint8Array<ArrayBuffer>;
|
||||
}[] = [];
|
||||
const sorted = [...this.sections].sort((a, b) => a.start - b.start);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ test('can decode samples from a FLAC file', async () => {
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
|
||||
const sample = await sink.getSample(1);
|
||||
using sample = await sink.getSample(1);
|
||||
assert(sample);
|
||||
expect(sample.timestamp).toBe(0.9287981859410431);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'vitest';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { WebMOutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget } from '../../src/target.js';
|
||||
import { VideoSampleSource } from '../../src/media-source.js';
|
||||
import { VideoSample } from '../../src/sample.js';
|
||||
import { QUALITY_MEDIUM } from '../../src/encode.js';
|
||||
|
||||
test('VideoSampleSource.close() should be idempotent after finalize()', async () => {
|
||||
const output = new Output({
|
||||
format: new WebMOutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const videoSource = new VideoSampleSource({
|
||||
codec: 'vp8',
|
||||
bitrate: QUALITY_MEDIUM,
|
||||
});
|
||||
|
||||
output.addVideoTrack(videoSource);
|
||||
await output.start();
|
||||
|
||||
const canvas = new OffscreenCanvas(100, 100);
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.fillStyle = 'red';
|
||||
ctx.fillRect(0, 0, 100, 100);
|
||||
|
||||
const sample = new VideoSample(canvas, { timestamp: 0, duration: 1 / 30 });
|
||||
await videoSource.add(sample);
|
||||
sample.close();
|
||||
|
||||
await output.finalize();
|
||||
|
||||
videoSource.close(); // This previously threw
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { UrlSource } from '../../src/source.js';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { AudioBufferSink } from '../../src/media-sink.js';
|
||||
import { assert } from '../../src/misc.js';
|
||||
|
||||
// VLC creates OGG files with an empty EOS page, which previously caused decoding errors
|
||||
test('can decode OGG Vorbis file with empty EOS page', async () => {
|
||||
using input = new Input({
|
||||
source: new UrlSource('/vorbis-eos.ogg'),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
const track = await input.getPrimaryAudioTrack();
|
||||
assert(track);
|
||||
|
||||
const sink = new AudioBufferSink(track);
|
||||
const buffers: AudioBuffer[] = [];
|
||||
|
||||
for await (const { buffer } of sink.buffers(4, 10)) {
|
||||
buffers.push(buffer);
|
||||
}
|
||||
|
||||
expect(buffers.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { OggOutputFormat } from '../../src/output-format.js';
|
||||
import { NullTarget } from '../../src/target.js';
|
||||
import { AudioBufferSource } from '../../src/media-source.js';
|
||||
|
||||
test('maximumPageDuration option', async () => {
|
||||
const sampleRate = 48000;
|
||||
const durationSeconds = 2;
|
||||
const audioBuffer = new AudioBuffer({ numberOfChannels: 1, length: sampleRate * durationSeconds, sampleRate });
|
||||
|
||||
// First, create an Ogg file without the maximumPageDuration option
|
||||
let pageCountWithoutOption = 0;
|
||||
{
|
||||
const output = new Output({
|
||||
format: new OggOutputFormat({
|
||||
onPage: () => {
|
||||
pageCountWithoutOption++;
|
||||
},
|
||||
}),
|
||||
target: new NullTarget(),
|
||||
});
|
||||
|
||||
const audioSource = new AudioBufferSource({ codec: 'opus', bitrate: 64000 });
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
await output.start();
|
||||
await audioSource.add(audioBuffer);
|
||||
audioSource.close();
|
||||
await output.finalize();
|
||||
}
|
||||
|
||||
// Then, create an Ogg file with maximumPageDuration set to 0.1 seconds
|
||||
let pageCountWithOption = 0;
|
||||
{
|
||||
const output = new Output({
|
||||
format: new OggOutputFormat({
|
||||
maximumPageDuration: 0.1,
|
||||
onPage: () => {
|
||||
pageCountWithOption++;
|
||||
},
|
||||
}),
|
||||
target: new NullTarget(),
|
||||
});
|
||||
|
||||
const audioSource = new AudioBufferSource({ codec: 'opus', bitrate: 64000 });
|
||||
output.addAudioTrack(audioSource);
|
||||
|
||||
await output.start();
|
||||
await audioSource.add(audioBuffer);
|
||||
audioSource.close();
|
||||
await output.finalize();
|
||||
}
|
||||
|
||||
expect(pageCountWithoutOption).toBe(3);
|
||||
expect(pageCountWithOption).toBe(23); // It created more pages
|
||||
});
|
||||
@@ -21,7 +21,7 @@ test('Can decode transparent video', async () => {
|
||||
expect(await videoTrack.canBeTransparent()).toBe(true);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(0.5))!;
|
||||
using sample = (await sink.getSample(0.5))!;
|
||||
|
||||
expect(sample.format).toContain('A'); // Probably RGBA
|
||||
expect(sample.hasAlpha).toBe(true);
|
||||
@@ -47,10 +47,10 @@ test('Can decode faulty transparent video and behaves gracefully', async () => {
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
|
||||
const startSample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
using startSample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(startSample.format).toContain('A');
|
||||
|
||||
const secondSample = (await sink.getSample(secondKeyPacket.timestamp))!;
|
||||
using secondSample = (await sink.getSample(secondKeyPacket.timestamp))!;
|
||||
expect(secondSample.format).not.toContain('A'); // There was no alpha key frame for this one
|
||||
expect(secondSample.hasAlpha).toBe(false);
|
||||
});
|
||||
@@ -164,7 +164,7 @@ test('Can encode transparent video', async () => {
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
|
||||
const firstSample = (await sink.getSample(0))!;
|
||||
using firstSample = (await sink.getSample(0))!;
|
||||
expect(firstSample.format).toContain('A');
|
||||
|
||||
probeContext.clearRect(0, 0, probeCanvas.width, probeCanvas.height);
|
||||
@@ -201,7 +201,7 @@ test('Can encode video with alternating transparency', async () => {
|
||||
await output.start();
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const sample = new VideoSample(new Uint8Array(640 * 480 * 4), {
|
||||
using sample = new VideoSample(new Uint8Array(640 * 480 * 4), {
|
||||
format: i % 2 ? 'RGBX' : 'RGBA',
|
||||
codedWidth: 640,
|
||||
codedHeight: 480,
|
||||
@@ -235,7 +235,7 @@ test('Can encode video with alternating transparency', async () => {
|
||||
const sampleSink = new VideoSampleSink(videoTrack);
|
||||
|
||||
i = 0;
|
||||
for await (const sample of sampleSink.samples()) {
|
||||
for await (using sample of sampleSink.samples()) {
|
||||
if (i % 2) {
|
||||
expect(sample.format).not.toContain('A');
|
||||
} else {
|
||||
@@ -299,7 +299,7 @@ test('Can transmux transparent video, discards alpha by default', async () => {
|
||||
expect(await videoTrack.canBeTransparent()).toBe(false);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.hasAlpha).toBe(false);
|
||||
});
|
||||
|
||||
@@ -331,7 +331,7 @@ test('Can transmux transparent video, can keep alpha', async () => {
|
||||
expect(await videoTrack.canBeTransparent()).toBe(true);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.format).toContain('A');
|
||||
expect(sample.hasAlpha).toBe(true);
|
||||
});
|
||||
@@ -370,7 +370,7 @@ test('Can reencode transparent video, keeping alpha', async () => {
|
||||
expect(videoTrack.displayWidth).toBe(320);
|
||||
|
||||
const sink = new VideoSampleSink(videoTrack);
|
||||
const sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
using sample = (await sink.getSample(await videoTrack.getFirstTimestamp()))!;
|
||||
expect(sample.format).toContain('A');
|
||||
expect(sample.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { VideoSample } from '../../src/sample.js';
|
||||
|
||||
test('allocationSize', async () => {
|
||||
{
|
||||
const canvas = new OffscreenCanvas(1280, 720);
|
||||
canvas.getContext('2d');
|
||||
|
||||
using sample = new VideoSample(canvas, { timestamp: 0 });
|
||||
|
||||
const size1 = sample.allocationSize();
|
||||
expect(size1).toBe(1280 * 720 * 4);
|
||||
|
||||
const size2 = sample.allocationSize({
|
||||
layout: [{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}],
|
||||
});
|
||||
expect(size2).toBe(1300 * 720 * 4);
|
||||
}
|
||||
|
||||
{
|
||||
const data = new Uint8Array(1280 * 720 * 4);
|
||||
using sample = new VideoSample(data, {
|
||||
timestamp: 0,
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
format: 'RGBA',
|
||||
});
|
||||
|
||||
const size1 = sample.allocationSize();
|
||||
expect(size1).toBe(1280 * 720 * 4);
|
||||
|
||||
const size2 = sample.allocationSize({
|
||||
layout: [{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}],
|
||||
});
|
||||
expect(size2).toBe(1300 * 720 * 4);
|
||||
}
|
||||
|
||||
{
|
||||
const data = new Uint8Array(1280 * 720 * 1.5);
|
||||
using sample = new VideoSample(data, {
|
||||
timestamp: 0,
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
format: 'I420',
|
||||
});
|
||||
|
||||
const size1 = sample.allocationSize();
|
||||
expect(size1).toBe(1280 * 720 * 1.5);
|
||||
|
||||
const size2 = sample.allocationSize({ format: 'RGBA' });
|
||||
expect(size2).toBe(1280 * 720 * 4);
|
||||
|
||||
const size3 = sample.allocationSize({
|
||||
format: 'RGBA',
|
||||
layout: [{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}],
|
||||
});
|
||||
expect(size3).toBe(1300 * 720 * 4);
|
||||
}
|
||||
});
|
||||
|
||||
test('copyTo and plane layouts', async () => {
|
||||
const buffer = new ArrayBuffer(1e7);
|
||||
|
||||
{
|
||||
const canvas = new OffscreenCanvas(1280, 720);
|
||||
canvas.getContext('2d');
|
||||
|
||||
using sample = new VideoSample(canvas, { timestamp: 0 });
|
||||
const layout = await sample.copyTo(buffer);
|
||||
|
||||
expect(layout).toEqual([{
|
||||
offset: 0,
|
||||
stride: 1280 * 4,
|
||||
}]);
|
||||
}
|
||||
|
||||
{
|
||||
const canvas = new OffscreenCanvas(1280, 720);
|
||||
canvas.getContext('2d');
|
||||
|
||||
using sample = new VideoSample(canvas, { timestamp: 0 });
|
||||
const layout = await sample.copyTo(buffer, {
|
||||
layout: [{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}],
|
||||
});
|
||||
|
||||
expect(layout).toEqual([{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}]);
|
||||
}
|
||||
|
||||
{
|
||||
const data = new Uint8Array(1280 * 720 * 4);
|
||||
using sample = new VideoSample(data, {
|
||||
timestamp: 0,
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
format: 'RGBA',
|
||||
});
|
||||
const layout = await sample.copyTo(buffer);
|
||||
|
||||
expect(layout).toEqual([{
|
||||
offset: 0,
|
||||
stride: 1280 * 4,
|
||||
}]);
|
||||
}
|
||||
|
||||
{
|
||||
const data = new Uint8Array(1280 * 720 * 4);
|
||||
using sample = new VideoSample(data, {
|
||||
timestamp: 0,
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
format: 'RGBA',
|
||||
});
|
||||
const layout = await sample.copyTo(buffer, {
|
||||
layout: [{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}],
|
||||
});
|
||||
|
||||
expect(layout).toEqual([{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}]);
|
||||
}
|
||||
|
||||
{
|
||||
const data = new Uint8Array(1300 * 720 * 4);
|
||||
using sample = new VideoSample(data, {
|
||||
timestamp: 0,
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
format: 'RGBA',
|
||||
layout: [{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}],
|
||||
});
|
||||
const layout = await sample.copyTo(buffer);
|
||||
|
||||
expect(layout).toEqual([{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}]);
|
||||
|
||||
using clone = sample.clone();
|
||||
const clonedLayout = await clone.copyTo(buffer);
|
||||
|
||||
expect(clonedLayout).toEqual([{
|
||||
offset: 0,
|
||||
stride: 1300 * 4,
|
||||
}]);
|
||||
}
|
||||
|
||||
{
|
||||
const data = new Uint8Array(1280 * 720 * 1.5);
|
||||
using sample = new VideoSample(data, {
|
||||
timestamp: 0,
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
format: 'I420',
|
||||
});
|
||||
const layout = await sample.copyTo(buffer);
|
||||
|
||||
expect(layout).toEqual([{
|
||||
offset: 0,
|
||||
stride: 1280,
|
||||
}, {
|
||||
offset: 1280 * 720,
|
||||
stride: 1280 / 2,
|
||||
}, {
|
||||
offset: 1280 * 720 + (1280 / 2) * (720 / 2),
|
||||
stride: 1280 / 2,
|
||||
}]);
|
||||
}
|
||||
});
|
||||
|
||||
test('null format', async () => {
|
||||
const canvas = new OffscreenCanvas(1280, 720);
|
||||
canvas.getContext('2d');
|
||||
|
||||
const frame = new VideoFrame(canvas, { timestamp: 0 });
|
||||
using sample = new VideoSample(frame);
|
||||
// @ts-expect-error Sybau
|
||||
sample.format = null;
|
||||
|
||||
expect(() => sample.allocationSize()).toThrow('when format is null');
|
||||
await expect(async () => sample.copyTo(new ArrayBuffer())).rejects.toThrow('when format is null');
|
||||
|
||||
// Even this throws :(
|
||||
// See https://github.com/Vanilagy/mediabunny/issues/267
|
||||
expect(() => sample.allocationSize({ format: 'RGBA' })).toThrow('when format is null');
|
||||
|
||||
// Uncomment this if the RGBA conversion works again:
|
||||
/*
|
||||
const size = sample.allocationSize({ format: 'RGBA' });
|
||||
expect(size).toBe(1280 * 720 * 4);
|
||||
const buffer = new ArrayBuffer(size);
|
||||
const layout = await sample.copyTo(buffer, { format: 'RGBA' });
|
||||
|
||||
expect(layout).toEqual([{
|
||||
offset: 0,
|
||||
stride: 1280 * 4,
|
||||
}]);
|
||||
|
||||
sample.allocationSize({ format: 'RGBX' });
|
||||
sample.allocationSize({ format: 'BGRA' });
|
||||
sample.allocationSize({ format: 'BGRX' });
|
||||
await sample.copyTo(buffer, { format: 'RGBX' });
|
||||
await sample.copyTo(buffer, { format: 'BGRA' });
|
||||
await sample.copyTo(buffer, { format: 'BGRX' });
|
||||
*/
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { Input } from '../../src/input.js';
|
||||
import { BufferSource, FilePathSource } from '../../src/source.js';
|
||||
import path from 'node:path';
|
||||
import { ALL_FORMATS } from '../../src/input-format.js';
|
||||
import { Output } from '../../src/output.js';
|
||||
import { Mp4OutputFormat } from '../../src/output-format.js';
|
||||
import { BufferTarget } from '../../src/target.js';
|
||||
import { Conversion } from '../../src/conversion.js';
|
||||
import { EncodedPacketSink } from '../../src/media-sink.js';
|
||||
import { extractAvcNalUnits } from '../../src/codec-data.js';
|
||||
|
||||
const __dirname = new URL('.', import.meta.url).pathname;
|
||||
|
||||
test('Annex B to length-prefixed conversion, MP4', async () => {
|
||||
using originalInput = new Input({
|
||||
source: new FilePathSource(path.join(__dirname, '..', 'public/annex-b-avc.mkv')),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const originalVideoTrack = (await originalInput.getPrimaryVideoTrack())!;
|
||||
const originalDecoderConfig = (await originalVideoTrack.getDecoderConfig())!;
|
||||
expect(originalDecoderConfig.description).toBeUndefined();
|
||||
expect(originalVideoTrack.codec).toBe('avc');
|
||||
|
||||
const originalSink = new EncodedPacketSink(originalVideoTrack);
|
||||
const originalFirstPacket = await originalSink.getFirstPacket();
|
||||
expect([...originalFirstPacket!.data.slice(0, 4)]).toEqual([0, 0, 0, 1]);
|
||||
|
||||
const originalNalUnits = extractAvcNalUnits(originalFirstPacket!.data, originalDecoderConfig);
|
||||
|
||||
const output = new Output({
|
||||
format: new Mp4OutputFormat(),
|
||||
target: new BufferTarget(),
|
||||
});
|
||||
|
||||
const conversion = await Conversion.init({ input: originalInput, output });
|
||||
await conversion.execute();
|
||||
|
||||
using newInput = new Input({
|
||||
source: new BufferSource(output.target.buffer!),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const newVideoTrack = (await newInput.getPrimaryVideoTrack())!;
|
||||
const newDecoderConfig = (await newVideoTrack.getDecoderConfig())!;
|
||||
expect(newDecoderConfig.description).toBeDefined();
|
||||
expect(newVideoTrack.codec).toBe('avc');
|
||||
|
||||
const newSink = new EncodedPacketSink(newVideoTrack);
|
||||
const newFirstPacket = await newSink.getFirstPacket();
|
||||
expect([...newFirstPacket!.data.slice(0, 4)]).not.toEqual([0, 0, 0, 1]); // Successfully converted
|
||||
|
||||
const newNalUnits = extractAvcNalUnits(newFirstPacket!.data, newDecoderConfig);
|
||||
expect(newNalUnits).toEqual(originalNalUnits); // Content is the same though
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'vitest';
|
||||
import { AudioSample, VideoSample } from '../../src/sample.js';
|
||||
|
||||
test('VideoSample creation from bytes', async () => {
|
||||
const bytes = new Uint8Array(1024);
|
||||
const sample = new VideoSample(bytes, {
|
||||
codedWidth: 1280,
|
||||
codedHeight: 720,
|
||||
format: 'RGBA',
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
sample.close();
|
||||
});
|
||||
|
||||
test('AudioSample creation from bytes', async () => {
|
||||
const bytes = new Uint8Array(1024);
|
||||
const sample = new AudioSample({
|
||||
data: bytes,
|
||||
numberOfChannels: 2,
|
||||
format: 's16',
|
||||
sampleRate: 48000,
|
||||
timestamp: 0,
|
||||
});
|
||||
|
||||
sample.close();
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user