mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 22:33:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d48d1bf9 | ||
|
|
6684984e7e | ||
|
|
0f030dc2a6 | ||
|
|
02b08e036b | ||
|
|
327696666b | ||
|
|
07b2e70863 | ||
|
|
3cb6ed82ce | ||
|
|
12216ae29e | ||
|
|
198b3d2eae | ||
|
|
e33e9f13fe | ||
|
|
fa1f44cf92 | ||
|
|
966ac133e9 | ||
|
|
23f814679e | ||
|
|
f3a2d57156 | ||
|
|
9d477dfb14 | ||
|
|
5b45c51d40 | ||
|
|
71dd2c565b | ||
|
|
3db4aa8b91 | ||
|
|
8b39eb0d43 | ||
|
|
6022781e8c | ||
|
|
c0da28fb6c | ||
|
|
fd8c74d4db | ||
|
|
6fb3103c01 | ||
|
|
d969266662 | ||
|
|
e0dda582b1 | ||
|
|
5aabf6350a | ||
|
|
21cff32831 | ||
|
|
73e936f325 | ||
|
|
3c8d503158 | ||
|
|
fe4eebd487 | ||
|
|
b5f5b5588c | ||
|
|
b1f17b25e3 | ||
|
|
efcf5d0069 | ||
|
|
aad31009fe | ||
|
|
68c37b49dc | ||
|
|
b12fee70a3 | ||
|
|
90d5aed051 | ||
|
|
d2d893ff30 | ||
|
|
3217490d81 | ||
|
|
5039c730bf | ||
|
|
a14c0885d7 | ||
|
|
c7b45eabf0 | ||
|
|
8629709cd7 | ||
|
|
754af6f90f | ||
|
|
fa1fc27577 | ||
|
|
e676de4129 | ||
|
|
0c4c13cea2 | ||
|
|
3767aea7d9 | ||
|
|
cdeeb6c6fa | ||
|
|
c27042b24d | ||
|
|
f8cd66727c | ||
|
|
6d3af1b043 | ||
|
|
c29adb5997 | ||
|
|
e880f54553 | ||
|
|
21456e3bf6 | ||
|
|
1143984021 | ||
|
|
c4159c1391 | ||
|
|
f2f9b2f0c3 | ||
|
|
203c0f307e | ||
|
|
045ce43eb2 | ||
|
|
bb32f2ba26 |
@@ -39,18 +39,26 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
|
||||
<a href="https://pqina.nl/pintura/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/pintura-labs.png" width="50" height="50" alt="Pintura Labs">
|
||||
</a>
|
||||
|
||||
<a href="https://ponder.ai/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/ponder.png" width="50" height="50" alt="Ponder">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Bronze sponsors
|
||||
|
||||
<div align="center">
|
||||
<a href="https://www.reactvideoeditor.com/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/rve.svg" width="40" height="40" alt="React Video Editor">
|
||||
<img src="./docs/public/sponsors/rve.png" width="40" height="40" alt="React Video Editor">
|
||||
</a>
|
||||
|
||||
<a href="https://www.mux.com/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/mux.jpg" width="40" height="40" alt="Mux">
|
||||
</a>
|
||||
|
||||
<a href="https://jellypod.ai/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/jellypod.png" width="40" height="40" alt="Jellypod">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
|
||||
@@ -200,7 +208,7 @@ npm run lint # ESLint
|
||||
|
||||
npm run docs:generate # Generates API docs
|
||||
npm run docs:dev # Start docs development server
|
||||
npm run dev # Start examples development server
|
||||
|
||||
npm run dev # Start examples development server, will run at http://localhost:5173/examples/[name]/
|
||||
|
||||
npm run docs:build # Build docs and examples
|
||||
```
|
||||
+34
-2
@@ -48,6 +48,7 @@
|
||||
target
|
||||
});
|
||||
|
||||
let ctx = null;
|
||||
const conversion = await Mediabunny.Conversion.init({
|
||||
input: new Mediabunny.Input({
|
||||
formats: Mediabunny.ALL_FORMATS,
|
||||
@@ -56,6 +57,13 @@
|
||||
output,
|
||||
audio: (_, n) => ({
|
||||
discard: n > 1,
|
||||
//codec: 'opus',
|
||||
//codec: 'opus',
|
||||
/*
|
||||
process: (sample) => {
|
||||
return sample;
|
||||
},
|
||||
*/
|
||||
//codec: 'pcm-s16',
|
||||
//sampleRate: 16000,
|
||||
//numberOfChannels: 1,
|
||||
@@ -92,7 +100,31 @@
|
||||
},
|
||||
*/
|
||||
video: () => ({
|
||||
width: 300,
|
||||
width: 720,
|
||||
frameRate: 30,
|
||||
bitrate: Mediabunny.QUALITY_VERY_LOW,
|
||||
//discard: true,
|
||||
/*
|
||||
process: (sample) => {
|
||||
if (!ctx) {
|
||||
// Create a canvas for image compositing
|
||||
const canvas = new OffscreenCanvas(
|
||||
sample.displayWidth,
|
||||
sample.displayHeight,
|
||||
);
|
||||
ctx = canvas.getContext('2d');
|
||||
}
|
||||
|
||||
console.log(ctx.canvas.width, ctx.canvas.height);
|
||||
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
sample.drawWithFit(ctx, { fit: 'fill' });
|
||||
//ctx.drawImage(watermark, 32, 32);
|
||||
|
||||
return ctx.canvas;
|
||||
},
|
||||
*/
|
||||
//width: 300,
|
||||
//alpha: 'keep',
|
||||
//width: 320,
|
||||
//discard: true,
|
||||
@@ -148,7 +180,7 @@
|
||||
},
|
||||
trim: {
|
||||
start: 0,
|
||||
end: 10
|
||||
end: 20
|
||||
},
|
||||
});
|
||||
console.log(conversion);
|
||||
|
||||
+74
-3
@@ -14,6 +14,76 @@
|
||||
source: new Mediabunny.BlobSource(file),
|
||||
});
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet.timestamp, packet.duration, packet.type)
|
||||
}
|
||||
|
||||
/*
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
const sink = new Mediabunny.AudioSampleSink(audioTrack);
|
||||
|
||||
let lastEnd = 0;
|
||||
for await (const sample of sink.samples()) {
|
||||
if (sample.timestamp - lastEnd > 0) {
|
||||
console.warn(sample.timestamp - lastEnd)
|
||||
console.log(sample.timestamp, sample.duration, "diffie", sample.timestamp - lastEnd);
|
||||
}
|
||||
|
||||
lastEnd = sample.timestamp + sample.duration;
|
||||
sample.close();
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet.timestamp, packet.duration, packet.timestamp + packet.duration);
|
||||
|
||||
if (packet.timestamp > 10) break;
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
const sink = new Mediabunny.VideoSampleSink(videoTrack);
|
||||
|
||||
for await (const sample of sink.samples()) {
|
||||
console.log(sample.timestamp, sample.duration);
|
||||
sample.close();
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet.timestamp, packet.timestamp + packet.duration);
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
/*
|
||||
const sink = new Mediabunny.VideoSampleSink(videoTrack);
|
||||
|
||||
console.log(await videoTrack.getDecoderConfig())
|
||||
|
||||
const sink2 = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
for await (const packet of sink2.packets()) {
|
||||
console.log(packet.data.join(', '))//, window.findNalUnitsInLengthPrefixed(packet.data, 4).map(x => window.extractNalUnitTypeForAvc(x)));
|
||||
if (packet.timestamp > 1) break;
|
||||
}
|
||||
|
||||
//return;
|
||||
|
||||
for await (const sample of sink.samples()) {
|
||||
console.log(sample);
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
let total = 0;
|
||||
input.source.onread = (start, end) => {
|
||||
total += end - start;
|
||||
@@ -22,6 +92,7 @@
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
*/
|
||||
|
||||
//console.log(await sink.getPacket(0));
|
||||
/*
|
||||
@@ -38,7 +109,7 @@
|
||||
|
||||
|
||||
|
||||
const stats = await videoTrack.computePacketStats();
|
||||
//const stats = await videoTrack.computePacketStats();
|
||||
|
||||
/*
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
@@ -47,9 +118,9 @@
|
||||
}
|
||||
*/
|
||||
//console.log(await videoTrack.computeDuration());
|
||||
console.log("Done", stats, total, file.size)
|
||||
//console.log("Done", stats, total, file.size)
|
||||
|
||||
console.log(input);
|
||||
//console.log(input);
|
||||
|
||||
/*
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
|
||||
@@ -16,6 +16,7 @@ It has the following features:
|
||||
- Video transparency removal/preservation
|
||||
- Audio resampling
|
||||
- Audio up/downmixing
|
||||
- User-defined video & audio processing
|
||||
|
||||
The conversion API was built to be simple, versatile and extremely performant.
|
||||
|
||||
@@ -126,7 +127,14 @@ type ConversionVideoOptions = {
|
||||
alpha?: 'discard' | 'keep'; // Defaults to 'discard'
|
||||
keyFrameInterval?: number;
|
||||
forceTranscode?: boolean;
|
||||
process?: (sample: VideoSample) => MaybePromise<
|
||||
CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null
|
||||
>;
|
||||
processedWidth?: number;
|
||||
processedHeight?: number;
|
||||
};
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
```
|
||||
|
||||
For example, here we resize the video track to 720p:
|
||||
@@ -185,6 +193,39 @@ Use the `keyFrameInterval` property to control the maximum interval in seconds b
|
||||
|
||||
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
|
||||
|
||||
### 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.
|
||||
|
||||
An example:
|
||||
```ts
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
const conversion = await Conversion.init({
|
||||
video: {
|
||||
process: (sample) => {
|
||||
if (!ctx) {
|
||||
const canvas = new OffscreenCanvas(
|
||||
sample.displayWidth,
|
||||
sample.displayHeight,
|
||||
);
|
||||
ctx = canvas.getContext('2d')!;
|
||||
|
||||
// Convert the video to grayscale
|
||||
ctx.filter = 'saturate(0)';
|
||||
}
|
||||
|
||||
sample.draw(ctx, 0, 0);
|
||||
|
||||
return ctx.canvas;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The function is called for each input video sample after transformations and frame rate corrections. It must return a [`VideoSample`](./packets-and-samples#videosample), something that can convert to a `VideoSample`, an array of them, or `null` for dropping the frame.
|
||||
|
||||
This function can also be used to manually resize frames. When doing so, you should signal the post-process dimensions using the `processedWidth` and `processedHeight` fields, which enables the encoder to better know what to expect.
|
||||
|
||||
## Audio options
|
||||
|
||||
You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks. The options are:
|
||||
@@ -196,7 +237,14 @@ type ConversionAudioOptions = {
|
||||
numberOfChannels?: number;
|
||||
sampleRate?: number;
|
||||
forceTranscode?: boolean;
|
||||
process?: (sample: AudioSample) => MaybePromise<
|
||||
AudioSample | AudioSample[] | null
|
||||
>;
|
||||
processedNumberOfChannels?: number;
|
||||
processedSampleRate?: number;
|
||||
};
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
```
|
||||
|
||||
For example, here we convert the audio track to mono and set a specific sample rate:
|
||||
@@ -233,6 +281,14 @@ Use the `bitrate` property to control the bitrate of the output audio. For examp
|
||||
|
||||
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
|
||||
|
||||
### Processing audio
|
||||
|
||||
The `process` property can be used to define a custom audio sample processing function, e.g. for applying audio effects, transformations, or timestamp modifications. You are expected to perform this processing yourself.
|
||||
|
||||
The function is called for each input audio sample after remixing and resampling. It must return an [`AudioSample`](./packets-and-samples#audiosample), an array of them, or `null` for dropping the sample.
|
||||
|
||||
This function can also be used to manually perform remixing or resampling. When doing so, you should signal the post-process parameters using the `processedNumberOfChannels` and `processedSampleRate` fields, which enables the encoder to better know what to expect.
|
||||
|
||||
## Track-specific options
|
||||
|
||||
You may want to configure your video and audio options differently depending on the specifics of the input track. Or, in case a media file has multiple video or audio tracks, you may want to discard only specific tracks or configure each track separately.
|
||||
|
||||
@@ -578,4 +578,49 @@ await conversion.execute();
|
||||
|
||||
::: info
|
||||
- Check out the <a href="/examples/file-compression">File compression example</a> for this code in action.
|
||||
:::
|
||||
:::
|
||||
|
||||
## Add a video overlay
|
||||
|
||||
```ts
|
||||
import {
|
||||
Input,
|
||||
Output,
|
||||
Conversion,
|
||||
} from 'mediabunny';
|
||||
|
||||
// For example, let's load a watermark image
|
||||
const watermark = new Image();
|
||||
watermark.src = '/watermark.jpg';
|
||||
await new Promise(resolve => watermark.onload = resolve);
|
||||
|
||||
const input = new Input(...);
|
||||
const output = new Output(...);
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
video: {
|
||||
process: (sample) => {
|
||||
if (!ctx) {
|
||||
// Create a canvas for image compositing
|
||||
const canvas = new OffscreenCanvas(
|
||||
sample.displayWidth,
|
||||
sample.displayHeight,
|
||||
);
|
||||
ctx = canvas.getContext('2d')!;
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
sample.draw(ctx, 0, 0);
|
||||
ctx.drawImage(watermark, 32, 32);
|
||||
|
||||
return ctx.canvas;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await conversion.execute();
|
||||
// Conversion is complete
|
||||
```
|
||||
@@ -290,7 +290,7 @@ By default, data will be emitted by the `StreamTarget` as soon as it is availabl
|
||||
new StreamTarget(writable, {
|
||||
chunked: true,
|
||||
chunkSize: 2 ** 20, // Optional; defaults to 16 MiB
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
#### Applying backpressure
|
||||
@@ -329,6 +329,33 @@ const output = new Output({
|
||||
await output.finalize(); // Will automatically close the writable stream
|
||||
```
|
||||
|
||||
### `FilePathTarget`
|
||||
|
||||
This target writes to a file at the specified path. It is intended for server-side usage in Node, Bun, or Deno, and offers a simpler API than `StreamTarget` when you just want to write directly to a file path.
|
||||
|
||||
```ts
|
||||
import { Output, FilePathTarget } from 'mediabunny';
|
||||
|
||||
const output = new Output({
|
||||
target: new FilePathTarget('/path/to/output.mp4'),
|
||||
// ...
|
||||
});
|
||||
|
||||
// ...
|
||||
|
||||
await output.finalize(); // Will automatically close the file handle
|
||||
```
|
||||
|
||||
The internally held file handle will be closed when `finalize` or `cancel` are called on the `Output`.
|
||||
|
||||
Writing is chunked by default, for performance. Like `StreamTarget`, you can configure chunked mode options:
|
||||
```ts
|
||||
new FilePathTarget('/path/to/output.mp4', {
|
||||
chunked: false, // Disable chunking (slower)
|
||||
chunkSize: 2 ** 20, // Optional; defaults to 16 MiB
|
||||
});
|
||||
```
|
||||
|
||||
### `NullTarget`
|
||||
|
||||
This target simply discards all data that is passed into it. It is useful for when you need an `Output` but extract data from it differently, for example through output format-specific callbacks or encoder events.
|
||||
|
||||
+4
-1
@@ -98,12 +98,15 @@ const sponsors = {
|
||||
],
|
||||
silver: [
|
||||
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
|
||||
{ image: '/sponsors/ponder.png', name: 'Ponder', url: 'https://ponder.ai/' },
|
||||
],
|
||||
bronze: [
|
||||
{ image: '/sponsors/rve.svg', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
|
||||
{ image: '/sponsors/rve.png', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
|
||||
{ image: '/sponsors/mux.jpg', name: 'Mux', url: 'https://www.mux.com/' },
|
||||
{ image: '/sponsors/jellypod.png', name: 'Jellypod', url: 'https://jellypod.ai/' },
|
||||
],
|
||||
individual: [
|
||||
{ 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' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="52" height="51" viewBox="0 0 52 51" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.1186 1.50109C23.3805 0.147773 21.1954 -0.253724 19.1203 0.397437L5.08373 4.80289C2.19204 5.71105 0.248047 8.44236 0.248047 11.6022V42.9894C0.248047 45.2558 1.24465 47.3313 2.98266 48.6825C4.72099 50.036 6.9084 50.4373 8.98111 49.7863L19.4595 46.4967C19.1318 45.366 18.9658 44.1862 18.9658 42.9894V28.735C18.9658 25.7417 20.0093 22.8829 21.9088 20.6899C23.303 19.0744 25.1116 17.8826 27.1329 17.2463L27.8534 17.0309V7.19654C27.8534 4.93006 26.8568 2.85458 25.1185 1.50109H25.1186Z" fill="#1C2AC5"/>
|
||||
<path d="M51.2479 24.3293V38.5836C51.2479 41.7411 49.3061 44.4749 46.4122 45.3806L32.3756 49.7862C31.7253 49.9917 31.0632 50.0922 30.4081 50.0922C28.9739 50.0922 27.5703 49.6125 26.3771 48.6824C25.832 48.2565 25.3595 47.7645 24.9711 47.2139C24.4986 46.553 24.1453 45.8164 23.923 45.0257C23.9206 45.0257 23.9184 45.0257 23.916 45.0281L23.9206 45.0232C23.7406 44.3746 23.6423 43.6917 23.6423 42.9893V28.7349C23.6423 27.2712 24.0588 25.8983 24.7935 24.7576C25.528 23.6169 26.5807 22.7066 27.8534 22.1656C28.057 22.08 28.2652 22.0015 28.4782 21.9356L30.1953 21.3971L42.515 17.53C44.5899 16.8788 46.7751 17.2803 48.5132 18.6338C50.2516 19.9873 51.248 22.0628 51.248 24.3293H51.2479Z" fill="#3E8AF5"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.7",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -7749,9 +7749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.21.1",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.21.1.tgz",
|
||||
"integrity": "sha512-heBCNei4nBRJ2jkA08LL2zDZVyV9ADsSwFWOaRn37PEjUr6A46vlD+qlUYVTSSv5iIjIt1YTqTP7Nqo+q1hEvw==",
|
||||
"version": "1.24.6",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.24.6.tgz",
|
||||
"integrity": "sha512-GODsNvKeig2Zhj2NCABBskVgJS1IMltP0uX/I0ltFksPCHpshreZhJEMwc903YbgdsW3RT7e6uvLX6UtlmeIkQ==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -12242,7 +12242,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.7",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.7",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.22.0",
|
||||
"version": "1.24.7",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
+215
-48
@@ -229,7 +229,7 @@ const extractNalUnitTypeForAvc = (data: Uint8Array) => {
|
||||
};
|
||||
|
||||
/** Builds an AvcDecoderConfigurationRecord from an AVC packet in Annex B format. */
|
||||
export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array) => {
|
||||
export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array): AvcDecoderConfigurationRecord | null => {
|
||||
try {
|
||||
const nalUnits = findNalUnitsInAnnexB(packetData);
|
||||
|
||||
@@ -247,60 +247,27 @@ export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array) =>
|
||||
|
||||
// Let's get the first SPS for profile and level information
|
||||
const spsData = spsUnits[0]!;
|
||||
const bitstream = new Bitstream(removeEmulationPreventionBytes(spsData));
|
||||
const spsInfo = parseAvcSps(spsData);
|
||||
assert(spsInfo !== null);
|
||||
|
||||
bitstream.skipBits(1); // forbidden_zero_bit
|
||||
bitstream.skipBits(2); // nal_ref_idc
|
||||
const nal_unit_type = bitstream.readBits(5);
|
||||
const hasExtendedData = spsInfo.profileIdc === 100
|
||||
|| spsInfo.profileIdc === 110
|
||||
|| spsInfo.profileIdc === 122
|
||||
|| spsInfo.profileIdc === 144;
|
||||
|
||||
if (nal_unit_type !== 7) { // SPS NAL unit type is 7
|
||||
console.error('Invalid SPS NAL unit type');
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile_idc = bitstream.readAlignedByte();
|
||||
const constraint_flags = bitstream.readAlignedByte();
|
||||
const level_idc = bitstream.readAlignedByte();
|
||||
|
||||
const record: AvcDecoderConfigurationRecord = {
|
||||
return {
|
||||
configurationVersion: 1,
|
||||
avcProfileIndication: profile_idc,
|
||||
profileCompatibility: constraint_flags,
|
||||
avcLevelIndication: level_idc,
|
||||
avcProfileIndication: spsInfo.profileIdc,
|
||||
profileCompatibility: spsInfo.constraintFlags,
|
||||
avcLevelIndication: spsInfo.levelIdc,
|
||||
lengthSizeMinusOne: 3, // Typically 4 bytes for length field
|
||||
sequenceParameterSets: spsUnits,
|
||||
pictureParameterSets: ppsUnits,
|
||||
chromaFormat: null,
|
||||
bitDepthLumaMinus8: null,
|
||||
bitDepthChromaMinus8: null,
|
||||
sequenceParameterSetExt: null,
|
||||
chromaFormat: hasExtendedData ? spsInfo.chromaFormatIdc : null,
|
||||
bitDepthLumaMinus8: hasExtendedData ? spsInfo.bitDepthLumaMinus8 : null,
|
||||
bitDepthChromaMinus8: hasExtendedData ? spsInfo.bitDepthChromaMinus8 : null,
|
||||
sequenceParameterSetExt: hasExtendedData ? spsExtUnits : null,
|
||||
};
|
||||
|
||||
if (
|
||||
profile_idc === 100
|
||||
|| profile_idc === 110
|
||||
|| profile_idc === 122
|
||||
|| profile_idc === 144
|
||||
) {
|
||||
readExpGolomb(bitstream); // seq_parameter_set_id
|
||||
|
||||
const chroma_format_idc = readExpGolomb(bitstream);
|
||||
|
||||
if (chroma_format_idc === 3) {
|
||||
bitstream.skipBits(1); // separate_colour_plane_flag
|
||||
}
|
||||
|
||||
const bit_depth_luma_minus8 = readExpGolomb(bitstream);
|
||||
|
||||
const bit_depth_chroma_minus8 = readExpGolomb(bitstream);
|
||||
|
||||
record.chromaFormat = chroma_format_idc;
|
||||
record.bitDepthLumaMinus8 = bit_depth_luma_minus8;
|
||||
record.bitDepthChromaMinus8 = bit_depth_chroma_minus8;
|
||||
record.sequenceParameterSetExt = spsExtUnits;
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
console.error('Error building AVC Decoder Configuration Record:', error);
|
||||
return null;
|
||||
@@ -377,6 +344,206 @@ export const serializeAvcDecoderConfigurationRecord = (record: AvcDecoderConfigu
|
||||
return new Uint8Array(bytes);
|
||||
};
|
||||
|
||||
/** Deserializes an AvcDecoderConfigurationRecord from the format specified in Section 5.3.3.1 of ISO 14496-15. */
|
||||
export const deserializeAvcDecoderConfigurationRecord = (data: Uint8Array): AvcDecoderConfigurationRecord | null => {
|
||||
try {
|
||||
const view = toDataView(data);
|
||||
let offset = 0;
|
||||
|
||||
// Read header
|
||||
const configurationVersion = view.getUint8(offset++);
|
||||
const avcProfileIndication = view.getUint8(offset++);
|
||||
const profileCompatibility = view.getUint8(offset++);
|
||||
const avcLevelIndication = view.getUint8(offset++);
|
||||
const lengthSizeMinusOne = view.getUint8(offset++) & 0x03;
|
||||
|
||||
const numOfSequenceParameterSets = view.getUint8(offset++) & 0x1F;
|
||||
|
||||
// Read SPS
|
||||
const sequenceParameterSets: Uint8Array[] = [];
|
||||
for (let i = 0; i < numOfSequenceParameterSets; i++) {
|
||||
const length = view.getUint16(offset, false);
|
||||
offset += 2;
|
||||
|
||||
sequenceParameterSets.push(data.subarray(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
|
||||
const numOfPictureParameterSets = view.getUint8(offset++);
|
||||
|
||||
// Read PPS
|
||||
const pictureParameterSets: Uint8Array[] = [];
|
||||
for (let i = 0; i < numOfPictureParameterSets; i++) {
|
||||
const length = view.getUint16(offset, false);
|
||||
offset += 2;
|
||||
|
||||
pictureParameterSets.push(data.subarray(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
|
||||
const record: AvcDecoderConfigurationRecord = {
|
||||
configurationVersion,
|
||||
avcProfileIndication,
|
||||
profileCompatibility,
|
||||
avcLevelIndication,
|
||||
lengthSizeMinusOne,
|
||||
sequenceParameterSets,
|
||||
pictureParameterSets,
|
||||
chromaFormat: null,
|
||||
bitDepthLumaMinus8: null,
|
||||
bitDepthChromaMinus8: null,
|
||||
sequenceParameterSetExt: null,
|
||||
};
|
||||
|
||||
// Check if there are extended profile fields
|
||||
if (
|
||||
(
|
||||
avcProfileIndication === 100
|
||||
|| avcProfileIndication === 110
|
||||
|| avcProfileIndication === 122
|
||||
|| avcProfileIndication === 144
|
||||
)
|
||||
&& offset + 4 <= data.length
|
||||
) {
|
||||
const chromaFormat = view.getUint8(offset++) & 0x03;
|
||||
const bitDepthLumaMinus8 = view.getUint8(offset++) & 0x07;
|
||||
const bitDepthChromaMinus8 = view.getUint8(offset++) & 0x07;
|
||||
const numOfSequenceParameterSetExt = view.getUint8(offset++);
|
||||
|
||||
record.chromaFormat = chromaFormat;
|
||||
record.bitDepthLumaMinus8 = bitDepthLumaMinus8;
|
||||
record.bitDepthChromaMinus8 = bitDepthChromaMinus8;
|
||||
|
||||
// Read SPS Ext
|
||||
const sequenceParameterSetExt: Uint8Array[] = [];
|
||||
for (let i = 0; i < numOfSequenceParameterSetExt; i++) {
|
||||
const length = view.getUint16(offset, false);
|
||||
offset += 2;
|
||||
|
||||
sequenceParameterSetExt.push(data.subarray(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
|
||||
record.sequenceParameterSetExt = sequenceParameterSetExt;
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
console.error('Error deserializing AVC Decoder Configuration Record:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export type AvcSpsInfo = {
|
||||
profileIdc: number;
|
||||
constraintFlags: number;
|
||||
levelIdc: number;
|
||||
frameMbsOnlyFlag: number;
|
||||
chromaFormatIdc: number | null;
|
||||
bitDepthLumaMinus8: number | null;
|
||||
bitDepthChromaMinus8: number | null;
|
||||
};
|
||||
|
||||
/** Parses an AVC SPS (Sequence Parameter Set) to extract basic information. */
|
||||
export const parseAvcSps = (sps: Uint8Array): AvcSpsInfo | null => {
|
||||
try {
|
||||
const bitstream = new Bitstream(removeEmulationPreventionBytes(sps));
|
||||
|
||||
bitstream.skipBits(1); // forbidden_zero_bit
|
||||
bitstream.skipBits(2); // nal_ref_idc
|
||||
const nalUnitType = bitstream.readBits(5);
|
||||
|
||||
if (nalUnitType !== 7) { // SPS NAL unit type is 7
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileIdc = bitstream.readAlignedByte();
|
||||
const constraintFlags = bitstream.readAlignedByte();
|
||||
const levelIdc = bitstream.readAlignedByte();
|
||||
|
||||
readExpGolomb(bitstream); // seq_parameter_set_id
|
||||
|
||||
let chromaFormatIdc: number | null = null;
|
||||
let bitDepthLumaMinus8: number | null = null;
|
||||
let bitDepthChromaMinus8: number | null = null;
|
||||
|
||||
// Handle high profile chroma_format_idc
|
||||
if (
|
||||
profileIdc === 100
|
||||
|| profileIdc === 110
|
||||
|| profileIdc === 122
|
||||
|| profileIdc === 244
|
||||
|| profileIdc === 44
|
||||
|| profileIdc === 83
|
||||
|| profileIdc === 86
|
||||
|| profileIdc === 118
|
||||
|| profileIdc === 128
|
||||
) {
|
||||
chromaFormatIdc = readExpGolomb(bitstream);
|
||||
if (chromaFormatIdc === 3) {
|
||||
bitstream.skipBits(1); // separate_colour_plane_flag
|
||||
}
|
||||
bitDepthLumaMinus8 = readExpGolomb(bitstream);
|
||||
bitDepthChromaMinus8 = readExpGolomb(bitstream);
|
||||
bitstream.skipBits(1); // qpprime_y_zero_transform_bypass_flag
|
||||
const seqScalingMatrixPresentFlag = bitstream.readBits(1);
|
||||
if (seqScalingMatrixPresentFlag) {
|
||||
for (let i = 0; i < (chromaFormatIdc !== 3 ? 8 : 12); i++) {
|
||||
const seqScalingListPresentFlag = bitstream.readBits(1);
|
||||
if (seqScalingListPresentFlag) {
|
||||
const sizeOfScalingList = i < 6 ? 16 : 64;
|
||||
let lastScale = 8;
|
||||
let nextScale = 8;
|
||||
for (let j = 0; j < sizeOfScalingList; j++) {
|
||||
if (nextScale !== 0) {
|
||||
const deltaScale = readSignedExpGolomb(bitstream);
|
||||
nextScale = (lastScale + deltaScale + 256) % 256;
|
||||
}
|
||||
lastScale = nextScale === 0 ? lastScale : nextScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readExpGolomb(bitstream); // log2_max_frame_num_minus4
|
||||
|
||||
const picOrderCntType = readExpGolomb(bitstream);
|
||||
if (picOrderCntType === 0) {
|
||||
readExpGolomb(bitstream); // log2_max_pic_order_cnt_lsb_minus4
|
||||
} else if (picOrderCntType === 1) {
|
||||
bitstream.skipBits(1); // delta_pic_order_always_zero_flag
|
||||
readSignedExpGolomb(bitstream); // offset_for_non_ref_pic
|
||||
readSignedExpGolomb(bitstream); // offset_for_top_to_bottom_field
|
||||
const numRefFramesInPicOrderCntCycle = readExpGolomb(bitstream);
|
||||
for (let i = 0; i < numRefFramesInPicOrderCntCycle; i++) {
|
||||
readSignedExpGolomb(bitstream); // offset_for_ref_frame[i]
|
||||
}
|
||||
}
|
||||
|
||||
readExpGolomb(bitstream); // max_num_ref_frames
|
||||
bitstream.skipBits(1); // gaps_in_frame_num_value_allowed_flag
|
||||
|
||||
readExpGolomb(bitstream); // pic_width_in_mbs_minus1
|
||||
readExpGolomb(bitstream); // pic_height_in_map_units_minus1
|
||||
|
||||
const frameMbsOnlyFlag = bitstream.readBits(1);
|
||||
|
||||
return {
|
||||
profileIdc,
|
||||
constraintFlags,
|
||||
levelIdc,
|
||||
frameMbsOnlyFlag,
|
||||
chromaFormatIdc,
|
||||
bitDepthLumaMinus8,
|
||||
bitDepthChromaMinus8,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error parsing AVC SPS:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Data specified in ISO 14496-15
|
||||
export type HevcDecoderConfigurationRecord = {
|
||||
configurationVersion: number;
|
||||
|
||||
+53
-2
@@ -337,6 +337,7 @@ export const extractVideoCodecString = (trackInfo: {
|
||||
codec: VideoCodec | null;
|
||||
codecDescription: Uint8Array | null;
|
||||
colorSpace: VideoColorSpaceInit | null;
|
||||
avcType: 1 | 3 | null;
|
||||
avcCodecInfo: AvcDecoderConfigurationRecord | null;
|
||||
hevcCodecInfo: HevcDecoderConfigurationRecord | null;
|
||||
vp9CodecInfo: Vp9CodecInfo | null;
|
||||
@@ -345,6 +346,8 @@ export const extractVideoCodecString = (trackInfo: {
|
||||
const { codec, codecDescription, colorSpace, avcCodecInfo, hevcCodecInfo, vp9CodecInfo, av1CodecInfo } = trackInfo;
|
||||
|
||||
if (codec === 'avc') {
|
||||
assert(trackInfo.avcType !== null);
|
||||
|
||||
if (avcCodecInfo) {
|
||||
const bytes = new Uint8Array([
|
||||
avcCodecInfo.avcProfileIndication,
|
||||
@@ -352,14 +355,14 @@ export const extractVideoCodecString = (trackInfo: {
|
||||
avcCodecInfo.avcLevelIndication,
|
||||
]);
|
||||
|
||||
return `avc1.${bytesToHexString(bytes)}`;
|
||||
return `avc${trackInfo.avcType}.${bytesToHexString(bytes)}`;
|
||||
}
|
||||
|
||||
if (!codecDescription || codecDescription.byteLength < 4) {
|
||||
throw new TypeError('AVC decoder description is not provided or is not at least 4 bytes long.');
|
||||
}
|
||||
|
||||
return `avc1.${bytesToHexString(codecDescription.subarray(1, 4))}`;
|
||||
return `avc${trackInfo.avcType}.${bytesToHexString(codecDescription.subarray(1, 4))}`;
|
||||
} else if (codec === 'hevc') {
|
||||
let generalProfileSpace: number;
|
||||
let generalProfileIdc: number;
|
||||
@@ -621,6 +624,54 @@ export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null): AacAudioS
|
||||
};
|
||||
};
|
||||
|
||||
export const buildAacAudioSpecificConfig = (config: {
|
||||
objectType: number;
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
}) => {
|
||||
let frequencyIndex = aacFrequencyTable.indexOf(config.sampleRate);
|
||||
let customSampleRate: number | null = null;
|
||||
|
||||
if (frequencyIndex === -1) {
|
||||
frequencyIndex = 15;
|
||||
customSampleRate = config.sampleRate;
|
||||
}
|
||||
|
||||
const channelConfiguration = aacChannelMap.indexOf(config.numberOfChannels);
|
||||
if (channelConfiguration === -1) {
|
||||
throw new TypeError(`Unsupported number of channels: ${config.numberOfChannels}`);
|
||||
}
|
||||
|
||||
let bitCount = 5 + 4 + 4;
|
||||
if (config.objectType >= 32) {
|
||||
bitCount += 6;
|
||||
}
|
||||
if (frequencyIndex === 15) {
|
||||
bitCount += 24;
|
||||
}
|
||||
|
||||
const byteCount = Math.ceil(bitCount / 8);
|
||||
const bytes = new Uint8Array(byteCount);
|
||||
const bitstream = new Bitstream(bytes);
|
||||
|
||||
if (config.objectType < 32) {
|
||||
bitstream.writeBits(5, config.objectType);
|
||||
} else {
|
||||
bitstream.writeBits(5, 31);
|
||||
bitstream.writeBits(6, config.objectType - 32);
|
||||
}
|
||||
|
||||
bitstream.writeBits(4, frequencyIndex);
|
||||
|
||||
if (frequencyIndex === 15) {
|
||||
bitstream.writeBits(24, customSampleRate!);
|
||||
}
|
||||
|
||||
bitstream.writeBits(4, channelConfiguration);
|
||||
|
||||
return bytes;
|
||||
};
|
||||
|
||||
export const OPUS_SAMPLE_RATE = 48_000;
|
||||
|
||||
const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
|
||||
|
||||
+244
-46
@@ -176,6 +176,32 @@ export type ConversionVideoOptions = {
|
||||
keyFrameInterval?: number;
|
||||
/** When `true`, video will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
forceTranscode?: boolean;
|
||||
/**
|
||||
* Allows for custom user-defined processing of video frames, e.g. for applying overlays, color transformations, or
|
||||
* timestamp modifications. Will be called for each input video sample after transformations and frame rate
|
||||
* corrections.
|
||||
*
|
||||
* Must return a {@link VideoSample} or a `CanvasImageSource`, an array of them, or `null` for dropping the frame.
|
||||
* When non-timestamped data is returned, the timestamp and duration from the source sample will be used. Rotation
|
||||
* metadata of the returned sample will be ignored.
|
||||
*
|
||||
* This function can also be used to manually resize frames. When doing so, you should signal the post-process
|
||||
* dimensions using the `processedWidth` and `processedHeight` fields, which enables the encoder to better know what
|
||||
* to expect. If these fields aren't set, Mediabunny will assume you won't perform any resizing.
|
||||
*/
|
||||
process?: (sample: VideoSample) => MaybePromise<
|
||||
CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null
|
||||
>;
|
||||
/**
|
||||
* An optional hint specifying the width of video samples returned by the `process` function, for better
|
||||
* encoder configuration.
|
||||
*/
|
||||
processedWidth?: number;
|
||||
/**
|
||||
* An optional hint specifying the height of video samples returned by the `process` function, for better
|
||||
* encoder configuration.
|
||||
*/
|
||||
processedHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -196,6 +222,30 @@ export type ConversionAudioOptions = {
|
||||
bitrate?: number | Quality;
|
||||
/** When `true`, audio will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
forceTranscode?: boolean;
|
||||
/**
|
||||
* Allows for custom user-defined processing of audio samples, e.g. for applying audio effects, transformations, or
|
||||
* timestamp modifications. Will be called for each input audio sample after remixing and resampling.
|
||||
*
|
||||
* Must return an {@link AudioSample}, an array of them, or `null` for dropping the sample.
|
||||
*
|
||||
* This function can also be used to manually perform remixing or resampling. When doing so, you should signal the
|
||||
* post-process parameters using the `processedNumberOfChannels` and `processedSampleRate` fields, which enables the
|
||||
* encoder to better know what to expect. If these fields aren't set, Mediabunny will assume you won't perform
|
||||
* remixing or resampling.
|
||||
*/
|
||||
process?: (sample: AudioSample) => MaybePromise<
|
||||
AudioSample | AudioSample[] | null
|
||||
>;
|
||||
/**
|
||||
* An optional hint specifying the channel count of audio samples returned by the `process` function, for better
|
||||
* encoder configuration.
|
||||
*/
|
||||
processedNumberOfChannels?: number;
|
||||
/**
|
||||
* An optional hint specifying the sample rate of audio samples returned by the `process` function, for better
|
||||
* encoder configuration.
|
||||
*/
|
||||
processedSampleRate?: number;
|
||||
};
|
||||
|
||||
const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) => {
|
||||
@@ -264,7 +314,22 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
||||
videoOptions?.keyFrameInterval !== undefined
|
||||
&& (!Number.isFinite(videoOptions.keyFrameInterval) || videoOptions.keyFrameInterval < 0)
|
||||
) {
|
||||
throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.');
|
||||
throw new TypeError('options.video.keyFrameInterval, when provided, must be a non-negative number.');
|
||||
}
|
||||
if (videoOptions?.process !== undefined && typeof videoOptions.process !== 'function') {
|
||||
throw new TypeError('options.video.process, when provided, must be a function.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.processedWidth !== undefined
|
||||
&& (!Number.isInteger(videoOptions.processedWidth) || videoOptions.processedWidth <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.processedWidth, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.processedHeight !== undefined
|
||||
&& (!Number.isInteger(videoOptions.processedHeight) || videoOptions.processedHeight <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.processedHeight, when provided, must be a positive integer.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -302,6 +367,21 @@ const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined)
|
||||
) {
|
||||
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
|
||||
}
|
||||
if (audioOptions?.process !== undefined && typeof audioOptions.process !== 'function') {
|
||||
throw new TypeError('options.audio.process, when provided, must be a function.');
|
||||
}
|
||||
if (
|
||||
audioOptions?.processedNumberOfChannels !== undefined
|
||||
&& (!Number.isInteger(audioOptions.processedNumberOfChannels) || audioOptions.processedNumberOfChannels <= 0)
|
||||
) {
|
||||
throw new TypeError('options.audio.processedNumberOfChannels, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
audioOptions?.processedSampleRate !== undefined
|
||||
&& (!Number.isInteger(audioOptions.processedSampleRate) || audioOptions.processedSampleRate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.audio.processedSampleRate, when provided, must be a positive integer.');
|
||||
}
|
||||
};
|
||||
|
||||
const FALLBACK_NUMBER_OF_CHANNELS = 2;
|
||||
@@ -790,10 +870,14 @@ export class Conversion {
|
||||
|| this._startTimestamp > 0
|
||||
|| firstTimestamp < 0
|
||||
|| !!trackOptions.frameRate
|
||||
|| trackOptions.keyFrameInterval !== undefined;
|
||||
|| trackOptions.keyFrameInterval !== undefined
|
||||
|| trackOptions.process !== undefined;
|
||||
let needsRerender = width !== originalWidth
|
||||
|| height !== originalHeight
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation)
|
||||
// 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))
|
||||
|| !!crop;
|
||||
|
||||
const alpha = trackOptions.alpha ?? 'discard';
|
||||
@@ -822,10 +906,6 @@ export class Conversion {
|
||||
: undefined;
|
||||
|
||||
for await (const packet of sink.packets(undefined, endPacket, { verifyKeyPackets: true })) {
|
||||
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
||||
await this._synchronizer.wait(packet.timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
@@ -836,8 +916,12 @@ export class Conversion {
|
||||
delete packet.sideData.alphaByteLength;
|
||||
}
|
||||
|
||||
this._reportProgress(track.id, packet.timestamp);
|
||||
await source.add(packet, meta);
|
||||
this._reportProgress(track.id, packet.timestamp + packet.duration);
|
||||
|
||||
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
||||
await this._synchronizer.wait(packet.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
source.close();
|
||||
@@ -861,7 +945,15 @@ export class Conversion {
|
||||
|
||||
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
||||
|
||||
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, { width, height, bitrate });
|
||||
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, {
|
||||
width: trackOptions.process && trackOptions.processedWidth
|
||||
? trackOptions.processedWidth
|
||||
: width,
|
||||
height: trackOptions.process && trackOptions.processedHeight
|
||||
? trackOptions.processedHeight
|
||||
: height,
|
||||
bitrate,
|
||||
});
|
||||
if (!encodableCodec) {
|
||||
this.discardedTracks.push({
|
||||
track,
|
||||
@@ -876,7 +968,6 @@ export class Conversion {
|
||||
keyFrameInterval: trackOptions.keyFrameInterval,
|
||||
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
||||
alpha,
|
||||
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
|
||||
};
|
||||
|
||||
const source = new VideoSampleSource(encodingConfig);
|
||||
@@ -889,7 +980,7 @@ export class Conversion {
|
||||
// back to the rerender path.
|
||||
//
|
||||
// Creating a new temporary Output is sort of hacky, but due to a lack of an isolated encoder API right
|
||||
// now, this is the simplest way. Will refactor in the future!
|
||||
// now, this is the simplest way. Will refactor in the future! TODO
|
||||
|
||||
const tempOutput = new Output({
|
||||
format: new Mp4OutputFormat(), // Supports all video codecs
|
||||
@@ -951,15 +1042,12 @@ export class Conversion {
|
||||
timestamp: lastCanvasTimestamp! + i / frameRate,
|
||||
duration: 1 / frameRate,
|
||||
});
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
sample.close();
|
||||
}
|
||||
};
|
||||
|
||||
for await (const { canvas, timestamp, duration } of iterator) {
|
||||
if (this._synchronizer.shouldWait(track.id, timestamp)) {
|
||||
await this._synchronizer.wait(timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
@@ -991,14 +1079,12 @@ export class Conversion {
|
||||
timestamp: adjustedSampleTimestamp,
|
||||
duration: frameRate !== undefined ? 1 / frameRate : duration,
|
||||
});
|
||||
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
sample.close();
|
||||
|
||||
if (frameRate !== undefined) {
|
||||
lastCanvas = canvas;
|
||||
lastCanvasTimestamp = adjustedSampleTimestamp;
|
||||
} else {
|
||||
sample.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1034,17 +1120,13 @@ export class Conversion {
|
||||
for (let i = 1; i < frameDifference; i++) {
|
||||
lastSample.setTimestamp(lastSampleTimestamp! + i / frameRate);
|
||||
lastSample.setDuration(1 / frameRate);
|
||||
await source.add(lastSample);
|
||||
await this._registerVideoSample(track, trackOptions, source, lastSample);
|
||||
}
|
||||
|
||||
lastSample.close();
|
||||
};
|
||||
|
||||
for await (const sample of sink.samples(this._startTimestamp, this._endTimestamp)) {
|
||||
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
||||
await this._synchronizer.wait(sample.timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
lastSample?.close();
|
||||
return;
|
||||
@@ -1076,7 +1158,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
sample.setTimestamp(adjustedSampleTimestamp);
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
|
||||
if (frameRate !== undefined) {
|
||||
lastSample = sample;
|
||||
@@ -1113,6 +1195,67 @@ export class Conversion {
|
||||
this.utilizedTracks.push(track);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _registerVideoSample(
|
||||
track: InputVideoTrack,
|
||||
trackOptions: ConversionVideoOptions,
|
||||
source: VideoSampleSource,
|
||||
sample: VideoSample,
|
||||
) {
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._reportProgress(track.id, sample.timestamp);
|
||||
|
||||
let finalSamples: VideoSample[];
|
||||
if (!trackOptions.process) {
|
||||
finalSamples = [sample];
|
||||
} else {
|
||||
let processed = trackOptions.process(sample);
|
||||
if (processed instanceof Promise) processed = await processed;
|
||||
|
||||
if (!Array.isArray(processed)) {
|
||||
processed = processed === null ? [] : [processed];
|
||||
}
|
||||
|
||||
finalSamples = processed.map((x) => {
|
||||
if (x instanceof VideoSample) {
|
||||
return x;
|
||||
}
|
||||
|
||||
if (typeof VideoFrame !== 'undefined' && x instanceof VideoFrame) {
|
||||
return new VideoSample(x);
|
||||
}
|
||||
|
||||
// Calling the VideoSample constructor here will automatically handle input validation for us
|
||||
// (it throws for any non-legal argument).
|
||||
return new VideoSample(x, {
|
||||
timestamp: sample.timestamp,
|
||||
duration: sample.duration,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const finalSample of finalSamples) {
|
||||
if (this._canceled) {
|
||||
break;
|
||||
}
|
||||
|
||||
await source.add(finalSample);
|
||||
|
||||
if (this._synchronizer.shouldWait(track.id, finalSample.timestamp)) {
|
||||
await this._synchronizer.wait(finalSample.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
for (const finalSample of finalSamples) {
|
||||
if (finalSample !== sample) {
|
||||
finalSample.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions) {
|
||||
const sourceCodec = track.codec;
|
||||
@@ -1145,6 +1288,7 @@ export class Conversion {
|
||||
&& !needsResample
|
||||
&& audioCodecs.includes(sourceCodec)
|
||||
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
||||
&& !trackOptions.process
|
||||
) {
|
||||
// Fast path, we can simply copy over the encoded packets
|
||||
|
||||
@@ -1162,16 +1306,16 @@ export class Conversion {
|
||||
: undefined;
|
||||
|
||||
for await (const packet of sink.packets(undefined, endPacket)) {
|
||||
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
||||
await this._synchronizer.wait(packet.timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._reportProgress(track.id, packet.timestamp);
|
||||
await source.add(packet, meta);
|
||||
this._reportProgress(track.id, packet.timestamp + packet.duration);
|
||||
|
||||
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
|
||||
await this._synchronizer.wait(packet.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
source.close();
|
||||
@@ -1198,8 +1342,12 @@ export class Conversion {
|
||||
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
||||
|
||||
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
numberOfChannels: trackOptions.process && trackOptions.processedNumberOfChannels
|
||||
? trackOptions.processedNumberOfChannels
|
||||
: numberOfChannels,
|
||||
sampleRate: trackOptions.process && trackOptions.processedSampleRate
|
||||
? trackOptions.processedSampleRate
|
||||
: sampleRate,
|
||||
bitrate,
|
||||
});
|
||||
|
||||
@@ -1240,12 +1388,18 @@ export class Conversion {
|
||||
}
|
||||
|
||||
if (needsResample) {
|
||||
audioSource = this._resampleAudio(track, codecOfChoice, numberOfChannels, sampleRate, bitrate);
|
||||
audioSource = this._resampleAudio(
|
||||
track,
|
||||
trackOptions,
|
||||
codecOfChoice,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
bitrate,
|
||||
);
|
||||
} else {
|
||||
const source = new AudioSampleSource({
|
||||
codec: codecOfChoice,
|
||||
bitrate,
|
||||
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
||||
});
|
||||
audioSource = source;
|
||||
|
||||
@@ -1254,15 +1408,11 @@ export class Conversion {
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
for await (const sample of sink.samples(undefined, this._endTimestamp)) {
|
||||
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
||||
await this._synchronizer.wait(sample.timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await source.add(sample);
|
||||
await this._registerAudioSample(track, trackOptions, source, sample);
|
||||
sample.close();
|
||||
}
|
||||
|
||||
@@ -1283,9 +1433,62 @@ export class Conversion {
|
||||
this.utilizedTracks.push(track);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _registerAudioSample(
|
||||
track: InputAudioTrack,
|
||||
trackOptions: ConversionAudioOptions,
|
||||
source: AudioSampleSource,
|
||||
sample: AudioSample,
|
||||
) {
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._reportProgress(track.id, sample.timestamp);
|
||||
|
||||
let finalSamples: AudioSample[];
|
||||
if (!trackOptions.process) {
|
||||
finalSamples = [sample];
|
||||
} else {
|
||||
let processed = trackOptions.process(sample);
|
||||
if (processed instanceof Promise) processed = await processed;
|
||||
|
||||
if (!Array.isArray(processed)) {
|
||||
processed = processed === null ? [] : [processed];
|
||||
}
|
||||
|
||||
if (!processed.every(x => x instanceof AudioSample)) {
|
||||
throw new TypeError(
|
||||
'The audio process function must return an AudioSample, null, or an array of AudioSamples.',
|
||||
);
|
||||
}
|
||||
|
||||
finalSamples = processed;
|
||||
}
|
||||
|
||||
for (const finalSample of finalSamples) {
|
||||
if (this._canceled) {
|
||||
break;
|
||||
}
|
||||
|
||||
await source.add(finalSample);
|
||||
|
||||
if (this._synchronizer.shouldWait(track.id, finalSample.timestamp)) {
|
||||
await this._synchronizer.wait(finalSample.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
for (const finalSample of finalSamples) {
|
||||
if (finalSample !== sample) {
|
||||
finalSample.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_resampleAudio(
|
||||
track: InputAudioTrack,
|
||||
trackOptions: ConversionAudioOptions,
|
||||
codec: AudioCodec,
|
||||
targetNumberOfChannels: number,
|
||||
targetSampleRate: number,
|
||||
@@ -1294,7 +1497,6 @@ export class Conversion {
|
||||
const source = new AudioSampleSource({
|
||||
codec,
|
||||
bitrate,
|
||||
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
||||
});
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
@@ -1305,17 +1507,13 @@ export class Conversion {
|
||||
targetSampleRate,
|
||||
startTime: this._startTimestamp,
|
||||
endTime: this._endTimestamp,
|
||||
onSample: sample => source.add(sample),
|
||||
onSample: sample => this._registerAudioSample(track, trackOptions, source, sample),
|
||||
});
|
||||
|
||||
const sink = new AudioSampleSink(track);
|
||||
const iterator = sink.samples(this._startTimestamp, this._endTimestamp);
|
||||
|
||||
for await (const sample of iterator) {
|
||||
if (this._synchronizer.shouldWait(track.id, sample.timestamp)) {
|
||||
await this._synchronizer.wait(sample.timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
+15
-8
@@ -115,9 +115,15 @@ export type VideoEncodingAdditionalOptions = {
|
||||
* format that supports transparency (such as WebM or Matroska).
|
||||
*/
|
||||
alpha?: 'discard' | 'keep';
|
||||
/** Configures the bitrate mode. */
|
||||
/** Configures the bitrate mode; defaults to `'variable'`. */
|
||||
bitrateMode?: 'constant' | 'variable';
|
||||
/** The latency mode used by the encoder; controls the performance-quality tradeoff. */
|
||||
/**
|
||||
* The latency mode used by the encoder; controls the performance-quality tradeoff.
|
||||
*
|
||||
* - `'quality'` (default): The encoder prioritizes quality over latency, and no frames can be dropped.
|
||||
* - `'realtime'`: The encoder prioritizes low latency over quality, and may drop frames if the encoder becomes
|
||||
* overloaded to keep up with real-time requirements.
|
||||
*/
|
||||
latencyMode?: 'quality' | 'realtime';
|
||||
/**
|
||||
* The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
|
||||
@@ -125,7 +131,8 @@ export type VideoEncodingAdditionalOptions = {
|
||||
*/
|
||||
fullCodecString?: string;
|
||||
/**
|
||||
* A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`.
|
||||
* A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`,
|
||||
* the default.
|
||||
*/
|
||||
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
|
||||
/**
|
||||
@@ -403,31 +410,31 @@ export class Quality {
|
||||
* @group Encoding
|
||||
* @public
|
||||
*/
|
||||
export const QUALITY_VERY_LOW = new Quality(0.3);
|
||||
export const QUALITY_VERY_LOW = /* #__PURE__ */ new Quality(0.3);
|
||||
/**
|
||||
* Represents a low media quality.
|
||||
* @group Encoding
|
||||
* @public
|
||||
*/
|
||||
export const QUALITY_LOW = new Quality(0.6);
|
||||
export const QUALITY_LOW = /* #__PURE__ */ new Quality(0.6);
|
||||
/**
|
||||
* Represents a medium media quality.
|
||||
* @group Encoding
|
||||
* @public
|
||||
*/
|
||||
export const QUALITY_MEDIUM = new Quality(1);
|
||||
export const QUALITY_MEDIUM = /* #__PURE__ */ new Quality(1);
|
||||
/**
|
||||
* Represents a high media quality.
|
||||
* @group Encoding
|
||||
* @public
|
||||
*/
|
||||
export const QUALITY_HIGH = new Quality(2);
|
||||
export const QUALITY_HIGH = /* #__PURE__ */ new Quality(2);
|
||||
/**
|
||||
* Represents a very high media quality.
|
||||
* @group Encoding
|
||||
* @public
|
||||
*/
|
||||
export const QUALITY_VERY_HIGH = new Quality(4);
|
||||
export const QUALITY_VERY_HIGH = /* #__PURE__ */ new Quality(4);
|
||||
|
||||
/**
|
||||
* Checks if the browser is able to encode the given codec.
|
||||
|
||||
@@ -328,27 +328,47 @@ export class FlacDemuxer extends Demuxer {
|
||||
|
||||
const nextByte = readU8(slice);
|
||||
if (nextByte === 0xff) {
|
||||
const positionBeforeReading = slice.filePos;
|
||||
|
||||
const byteAfterNextByte = readU8(slice);
|
||||
|
||||
const expected = this.blockingBit === 1 ? 0b1111_1001 : 0b1111_1000;
|
||||
if (byteAfterNextByte !== expected) {
|
||||
slice.skip(-1);
|
||||
slice.filePos = positionBeforeReading;
|
||||
continue;
|
||||
}
|
||||
|
||||
slice.skip(-2);
|
||||
const lengthIfNextFlacFrameHeaderIsLegit = slice.filePos - startPos;
|
||||
|
||||
const nextIsLegit = this.readFlacFrameHeader({
|
||||
const nextFrameHeader = this.readFlacFrameHeader({
|
||||
slice,
|
||||
isFirstPacket: false,
|
||||
});
|
||||
|
||||
if (!nextIsLegit) {
|
||||
slice.skip(-1);
|
||||
if (!nextFrameHeader) {
|
||||
slice.filePos = positionBeforeReading;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure the frameOrSampleNum is consecutive.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/194
|
||||
|
||||
if (this.blockingBit === 0) {
|
||||
// Case A: If the stream is fixed block size, this is the frame number, which increments by 1
|
||||
if (nextFrameHeader.num - frameHeader.num !== 1) {
|
||||
slice.filePos = positionBeforeReading;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Case B: If the stream is variable block size, this is the sample number, which increments by
|
||||
// amount of samples in a frame.
|
||||
if (nextFrameHeader.num - frameHeader.num !== frameHeader.blockSize) {
|
||||
slice.filePos = positionBeforeReading;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
num: frameHeader.num,
|
||||
blockSize: frameHeader.blockSize,
|
||||
@@ -442,6 +462,11 @@ export class FlacDemuxer extends Demuxer {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sampleRate !== this.audioInfo.sampleRate) {
|
||||
// This cannot be a valid FLAC frame, the sample rate is not the same as in the stream info
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = slice.filePos - startOffset;
|
||||
const crc = readU8(slice);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
readCodedNumber,
|
||||
} from './flac-misc';
|
||||
|
||||
const FLAC_HEADER = new Uint8Array([0x66, 0x4c, 0x61, 0x43]); // 'fLaC'
|
||||
const FLAC_HEADER = /* #__PURE__ */ new Uint8Array([0x66, 0x4c, 0x61, 0x43]); // 'fLaC'
|
||||
const STREAMINFO_SIZE = 38;
|
||||
const STREAMINFO_BLOCK_SIZE = 34;
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@ export {
|
||||
export {
|
||||
Target,
|
||||
BufferTarget,
|
||||
FilePathTarget,
|
||||
FilePathTargetOptions,
|
||||
NullTarget,
|
||||
StreamTarget,
|
||||
StreamTargetOptions,
|
||||
|
||||
+9
-9
@@ -481,56 +481,56 @@ export class AdtsInputFormat extends InputFormat {
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const MP4 = new Mp4InputFormat();
|
||||
export const MP4 = /* #__PURE__ */ new Mp4InputFormat();
|
||||
/**
|
||||
* QuickTime File Format input format singleton.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const QTFF = new QuickTimeInputFormat();
|
||||
export const QTFF = /* #__PURE__ */ new QuickTimeInputFormat();
|
||||
/**
|
||||
* Matroska input format singleton.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const MATROSKA = new MatroskaInputFormat();
|
||||
export const MATROSKA = /* #__PURE__ */ new MatroskaInputFormat();
|
||||
/**
|
||||
* WebM input format singleton.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const WEBM = new WebMInputFormat();
|
||||
export const WEBM = /* #__PURE__ */ new WebMInputFormat();
|
||||
/**
|
||||
* MP3 input format singleton.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const MP3 = new Mp3InputFormat();
|
||||
export const MP3 = /* #__PURE__ */ new Mp3InputFormat();
|
||||
/**
|
||||
* WAVE input format singleton.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const WAVE = new WaveInputFormat();
|
||||
export const WAVE = /* #__PURE__ */ new WaveInputFormat();
|
||||
/**
|
||||
* Ogg input format singleton.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const OGG = new OggInputFormat();
|
||||
export const OGG = /* #__PURE__ */ new OggInputFormat();
|
||||
/**
|
||||
* ADTS input format singleton.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const ADTS = new AdtsInputFormat();
|
||||
export const ADTS = /* #__PURE__ */ new AdtsInputFormat();
|
||||
|
||||
/**
|
||||
* FLAC input format singleton.
|
||||
* @group Input formats
|
||||
* @public
|
||||
*/
|
||||
export const FLAC = new FlacInputFormat();
|
||||
export const FLAC = /* #__PURE__ */ new FlacInputFormat();
|
||||
|
||||
/**
|
||||
* List of all input format singletons. If you don't need to support all input formats, you should specify the
|
||||
|
||||
@@ -135,8 +135,8 @@ export class IsobmffBoxWriter {
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(8);
|
||||
const view = new DataView(bytes.buffer);
|
||||
const bytes = /* #__PURE__ */ new Uint8Array(8);
|
||||
const view = /* #__PURE__ */ new DataView(bytes.buffer);
|
||||
|
||||
const u8 = (value: number) => {
|
||||
return [(value % 0x100 + 0x100) % 0x100];
|
||||
@@ -243,7 +243,7 @@ const rotationMatrix = (rotationInDegrees: number): TransformationMatrix => {
|
||||
0, 0, 1,
|
||||
];
|
||||
};
|
||||
const IDENTITY_MATRIX = rotationMatrix(0);
|
||||
const IDENTITY_MATRIX = /* #__PURE__ */ rotationMatrix(0);
|
||||
|
||||
const matrixToBytes = (matrix: TransformationMatrix) => {
|
||||
return [
|
||||
@@ -577,7 +577,7 @@ export const stsd = (trackData: IsobmffTrackData) => {
|
||||
|
||||
if (trackData.type === 'video') {
|
||||
sampleDescription = videoSampleDescription(
|
||||
VIDEO_CODEC_TO_BOX_NAME[trackData.track.source._codec],
|
||||
videoCodecToBoxName(trackData.track.source._codec, trackData.info.decoderConfig.codec),
|
||||
trackData,
|
||||
);
|
||||
} else if (trackData.type === 'audio') {
|
||||
@@ -1572,12 +1572,14 @@ const dataStringBoxLong = (value: string) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const VIDEO_CODEC_TO_BOX_NAME: Record<VideoCodec, string> = {
|
||||
avc: 'avc1',
|
||||
hevc: 'hvc1',
|
||||
vp8: 'vp08',
|
||||
vp9: 'vp09',
|
||||
av1: 'av01',
|
||||
const videoCodecToBoxName = (codec: VideoCodec, fullCodecString: string) => {
|
||||
switch (codec) {
|
||||
case 'avc': return fullCodecString.startsWith('avc3') ? 'avc3' : 'avc1';
|
||||
case 'hevc': return 'hvc1';
|
||||
case 'vp8': return 'vp08';
|
||||
case 'vp9': return 'vp09';
|
||||
case 'av1': return 'av01';
|
||||
}
|
||||
};
|
||||
|
||||
const VIDEO_CODEC_TO_CONFIGURATION_BOX: Record<VideoCodec, (trackData: IsobmffVideoTrackData) => Box | null> = {
|
||||
|
||||
@@ -51,13 +51,13 @@ import {
|
||||
MATRIX_COEFFICIENTS_MAP_INVERSE,
|
||||
normalizeRotation,
|
||||
roundToMultiple,
|
||||
roundToPrecision,
|
||||
Rotation,
|
||||
textDecoder,
|
||||
TransformationMatrix,
|
||||
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
||||
UNDETERMINED_LANGUAGE,
|
||||
toDataView,
|
||||
roundIfAlmostInteger,
|
||||
} from '../misc';
|
||||
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
|
||||
import { buildIsobmffMimeType } from './isobmff-misc';
|
||||
@@ -126,6 +126,7 @@ type InternalTrack = {
|
||||
codec: VideoCodec | null;
|
||||
codecDescription: Uint8Array | null;
|
||||
colorSpace: VideoColorSpaceInit | null;
|
||||
avcType: 1 | 3 | null;
|
||||
avcCodecInfo: AvcDecoderConfigurationRecord | null;
|
||||
hevcCodecInfo: HevcDecoderConfigurationRecord | null;
|
||||
vp9CodecInfo: Vp9CodecInfo | null;
|
||||
@@ -836,6 +837,7 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
codec: null,
|
||||
codecDescription: null,
|
||||
colorSpace: null,
|
||||
avcType: null,
|
||||
avcCodecInfo: null,
|
||||
hevcCodecInfo: null,
|
||||
vp9CodecInfo: null,
|
||||
@@ -890,8 +892,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
const lowercaseBoxName = sampleBoxInfo.name.toLowerCase();
|
||||
|
||||
if (track.info.type === 'video') {
|
||||
if (lowercaseBoxName === 'avc1') {
|
||||
if (lowercaseBoxName === 'avc1' || lowercaseBoxName === 'avc3') {
|
||||
track.info.codec = 'avc';
|
||||
track.info.avcType = lowercaseBoxName === 'avc1' ? 1 : 3;
|
||||
} else if (lowercaseBoxName === 'hvc1' || lowercaseBoxName === 'hev1') {
|
||||
track.info.codec = 'hevc';
|
||||
} else if (lowercaseBoxName === 'vp08') {
|
||||
@@ -2385,7 +2388,7 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
|
||||
// that the number was originally an integer divided by the timescale. For stability, it's best
|
||||
// to return the integer in this case.
|
||||
return roundToPrecision(timestamp * this.internalTrack.timescale, 14) + this.internalTrack.editListOffset;
|
||||
return roundIfAlmostInteger(timestamp * this.internalTrack.timescale) + this.internalTrack.editListOffset;
|
||||
}
|
||||
|
||||
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
|
||||
|
||||
@@ -70,6 +70,11 @@ export const readDataBox = (slice: FileSlice) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (slice.remainingLength < 8) {
|
||||
// Box is too small
|
||||
return null;
|
||||
}
|
||||
|
||||
const typeIndicator = readU32Be(slice);
|
||||
slice.skip(4); // Locale indicator
|
||||
const data = readBytes(slice, header.contentSize - 8);
|
||||
|
||||
@@ -463,7 +463,7 @@ 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 = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
|
||||
export const MAX_HEADER_SIZE = /* #__PURE__ */ 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
|
||||
|
||||
export const readVarIntSize = (slice: FileSlice) => {
|
||||
const firstByte = readU8(slice);
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
MATRIX_COEFFICIENTS_MAP_INVERSE,
|
||||
normalizeRotation,
|
||||
Rotation,
|
||||
roundToPrecision,
|
||||
roundIfAlmostInteger,
|
||||
TRANSFER_CHARACTERISTICS_MAP_INVERSE,
|
||||
UNDETERMINED_LANGUAGE,
|
||||
} from '../misc';
|
||||
@@ -61,7 +61,6 @@ import {
|
||||
readElementHeader,
|
||||
readElementId,
|
||||
readFloat,
|
||||
readSignedInt,
|
||||
readUnsignedInt,
|
||||
readVarInt,
|
||||
resync,
|
||||
@@ -137,7 +136,6 @@ type ClusterBlock = {
|
||||
timestamp: number;
|
||||
duration: number;
|
||||
isKeyFrame: boolean;
|
||||
referencedTimestamps: number[];
|
||||
data: Uint8Array;
|
||||
lacing: BlockLacing;
|
||||
decoded: boolean;
|
||||
@@ -230,7 +228,7 @@ const METADATA_ELEMENTS = [
|
||||
{ id: EBMLId.Tracks, flag: 'tracksSeen' },
|
||||
{ id: EBMLId.Cues, flag: 'cuesSeen' },
|
||||
] as const;
|
||||
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
|
||||
const MAX_RESYNC_LENGTH = /* #__PURE__ */ 10 * 2 ** 20; // 10 MiB
|
||||
|
||||
export class MatroskaDemuxer extends Demuxer {
|
||||
reader: Reader;
|
||||
@@ -637,7 +635,10 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
this.currentCluster = cluster;
|
||||
|
||||
if (dataSlice) {
|
||||
this.readContiguousElements(dataSlice);
|
||||
// Read the children of the cluster, stopping early at level 0 or 1 EBML elements. We do this because some
|
||||
// clusters have incorrect sizes that are too large
|
||||
const endPos = this.readContiguousElements(dataSlice, LEVEL_0_AND_1_EBML_IDS);
|
||||
cluster.elementEndPos = endPos;
|
||||
}
|
||||
|
||||
for (const [, trackData] of cluster.trackData) {
|
||||
@@ -646,21 +647,15 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
// This must hold, as track datas only get created if a block for that track is encountered
|
||||
assert(trackData.blocks.length > 0);
|
||||
|
||||
let blockReferencesExist = false;
|
||||
let hasLacedBlocks = false;
|
||||
|
||||
for (let i = 0; i < trackData.blocks.length; i++) {
|
||||
const block = trackData.blocks[i]!;
|
||||
block.timestamp += cluster.timestamp;
|
||||
|
||||
blockReferencesExist ||= block.referencedTimestamps.length > 0;
|
||||
hasLacedBlocks ||= block.lacing !== BlockLacing.None;
|
||||
}
|
||||
|
||||
if (blockReferencesExist) {
|
||||
trackData.blocks = sortBlocksByReferences(trackData.blocks);
|
||||
}
|
||||
|
||||
trackData.presentationTimestamps = trackData.blocks
|
||||
.map((block, i) => ({ timestamp: block.timestamp, blockIndex: i }))
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
@@ -841,13 +836,13 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
|
||||
blocks.splice(blockIndex, 1); // Remove the original block
|
||||
|
||||
const blockDuration = originalBlock.duration || frameCount * (track.defaultDuration ?? 0);
|
||||
|
||||
// Now, let's insert each frame as its own block
|
||||
for (let i = 0; i < frameCount; i++) {
|
||||
const frameSize = frameSizes[i]!;
|
||||
const frameData = readBytes(slice, frameSize);
|
||||
|
||||
const blockDuration = originalBlock.duration || (frameCount * (track.defaultDuration ?? 0));
|
||||
|
||||
// Distribute timestamps evenly across the block duration
|
||||
const frameTimestamp = originalBlock.timestamp + (blockDuration * i / frameCount);
|
||||
const frameDuration = blockDuration / frameCount;
|
||||
@@ -856,7 +851,6 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
timestamp: frameTimestamp,
|
||||
duration: frameDuration,
|
||||
isKeyFrame: originalBlock.isKeyFrame,
|
||||
referencedTimestamps: originalBlock.referencedTimestamps,
|
||||
data: frameData,
|
||||
lacing: BlockLacing.None,
|
||||
decoded: true,
|
||||
@@ -913,24 +907,31 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
}
|
||||
}
|
||||
|
||||
readContiguousElements(slice: FileSlice) {
|
||||
readContiguousElements(slice: FileSlice, stopIds?: number[]) {
|
||||
const startIndex = slice.filePos;
|
||||
|
||||
while (slice.filePos - startIndex <= slice.length - MIN_HEADER_SIZE) {
|
||||
const foundElement = this.traverseElement(slice);
|
||||
const startPos = slice.filePos;
|
||||
const foundElement = this.traverseElement(slice, stopIds);
|
||||
|
||||
if (!foundElement) {
|
||||
break;
|
||||
return startPos;
|
||||
}
|
||||
}
|
||||
|
||||
return slice.filePos;
|
||||
}
|
||||
|
||||
traverseElement(slice: FileSlice): boolean {
|
||||
traverseElement(slice: FileSlice, stopIds?: number[]): boolean {
|
||||
const header = readElementHeader(slice);
|
||||
if (!header) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stopIds && stopIds.includes(header.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { id, size } = header;
|
||||
const dataStartPos = slice.filePos;
|
||||
assertDefinedSize(size);
|
||||
@@ -1373,9 +1374,17 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
const relativeTimestamp = readI16Be(slice);
|
||||
|
||||
const flags = readU8(slice);
|
||||
const isKeyFrame = !!(flags & 0x80);
|
||||
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
|
||||
|
||||
let isKeyFrame = !!(flags & 0x80);
|
||||
if (trackData.track.info?.type === 'audio' && trackData.track.info.codec) {
|
||||
// Some files don't mark their audio packets as key packets (I'm looking at you, Firefox). But, we
|
||||
// can fix this in most cases: if we recognize the codec of the track, then we know every packet is
|
||||
// necessarily a key packet, no matter what the container says.
|
||||
// https://github.com/Vanilagy/mediabunny/issues/192
|
||||
isKeyFrame = true;
|
||||
}
|
||||
|
||||
const blockData = readBytes(slice, size - (slice.filePos - dataStartPos));
|
||||
const hasDecodingInstructions = trackData.track.decodingInstructions.length > 0;
|
||||
|
||||
@@ -1383,7 +1392,6 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
|
||||
duration: 0, // Will set later
|
||||
isKeyFrame,
|
||||
referencedTimestamps: [],
|
||||
data: blockData,
|
||||
lacing,
|
||||
decoded: !hasDecodingInstructions,
|
||||
@@ -1396,13 +1404,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
|
||||
this.readContiguousElements(slice.slice(dataStartPos, size));
|
||||
|
||||
if (this.currentBlock) {
|
||||
for (let i = 0; i < this.currentBlock.referencedTimestamps.length; i++) {
|
||||
this.currentBlock.referencedTimestamps[i]! += this.currentBlock.timestamp;
|
||||
}
|
||||
|
||||
this.currentBlock = null;
|
||||
}
|
||||
this.currentBlock = null;
|
||||
}; break;
|
||||
|
||||
case EBMLId.Block: {
|
||||
@@ -1426,7 +1428,6 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
|
||||
duration: 0, // Will set later
|
||||
isKeyFrame: true,
|
||||
referencedTimestamps: [],
|
||||
data: blockData,
|
||||
lacing,
|
||||
decoded: !hasDecodingInstructions,
|
||||
@@ -1477,11 +1478,8 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
if (!this.currentBlock) break;
|
||||
|
||||
this.currentBlock.isKeyFrame = false;
|
||||
|
||||
const relativeTimestamp = readSignedInt(slice, size);
|
||||
|
||||
// We'll offset this by the block's timestamp later
|
||||
this.currentBlock.referencedTimestamps.push(relativeTimestamp);
|
||||
// We ignore the actual value here, we just use the reference as an indicator for "not a key frame".
|
||||
// This is in line with FFmpeg's behavior.
|
||||
}; break;
|
||||
|
||||
case EBMLId.Tag: {
|
||||
@@ -1876,7 +1874,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
|
||||
// that the number was originally an integer divided by the timescale. For stability, it's best
|
||||
// to return the integer in this case.
|
||||
return roundToPrecision(timestamp * this.internalTrack.segment.timestampFactor, 14);
|
||||
return roundIfAlmostInteger(timestamp * this.internalTrack.segment.timestampFactor);
|
||||
}
|
||||
|
||||
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
|
||||
@@ -2174,6 +2172,8 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
|
||||
if (id === EBMLId.Cluster) {
|
||||
currentCluster = await demuxer.readCluster(elementStartPos, segment);
|
||||
// readCluster computes the proper size even if it's undefined in the header, so let's use that instead
|
||||
size = currentCluster.elementEndPos - dataStartPos;
|
||||
|
||||
const { blockIndex, correctBlockFound } = getMatchInCluster(currentCluster);
|
||||
if (correctBlockFound) {
|
||||
@@ -2190,44 +2190,37 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
// 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.
|
||||
|
||||
if (id === EBMLId.Cluster) {
|
||||
// The cluster should have already computed its length, we can just copy that result
|
||||
assert(currentCluster);
|
||||
size = currentCluster.elementEndPos - dataStartPos;
|
||||
} else {
|
||||
// Search for the next element at level 0 or 1
|
||||
const nextElementPos = await searchForNextElementId(
|
||||
demuxer.reader,
|
||||
dataStartPos,
|
||||
LEVEL_0_AND_1_EBML_IDS,
|
||||
segment.elementEndPos,
|
||||
);
|
||||
assert(id !== EBMLId.Cluster); // Undefined cluster sizes are fixed further up
|
||||
|
||||
size = nextElementPos.pos - dataStartPos;
|
||||
}
|
||||
// Search for the next element at level 0 or 1
|
||||
const nextElementPos = await searchForNextElementId(
|
||||
demuxer.reader,
|
||||
dataStartPos,
|
||||
LEVEL_0_AND_1_EBML_IDS,
|
||||
segment.elementEndPos,
|
||||
);
|
||||
|
||||
const endPos = dataStartPos + size;
|
||||
if (segment.elementEndPos !== null && endPos > segment.elementEndPos - MIN_HEADER_SIZE) {
|
||||
// No more elements fit in this segment
|
||||
size = nextElementPos.pos - dataStartPos;
|
||||
}
|
||||
|
||||
const endPos = dataStartPos + size;
|
||||
if (segment.elementEndPos === null) {
|
||||
// Check the next element. If it's a new segment, we know this segment ends here. The new
|
||||
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
|
||||
// the first segment.
|
||||
|
||||
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
const elementId = readElementId(slice);
|
||||
if (elementId === EBMLId.Segment) {
|
||||
segment.elementEndPos = endPos; // We now know the segment's size
|
||||
break;
|
||||
} else {
|
||||
// Check the next element. If it's a new segment, we know this segment ends here. The new
|
||||
// segment is just ignored, since we're likely in a livestreamed file and thus only care about
|
||||
// the first segment.
|
||||
|
||||
let slice = demuxer.reader.requestSliceRange(endPos, MIN_HEADER_SIZE, MAX_HEADER_SIZE);
|
||||
if (slice instanceof Promise) slice = await slice;
|
||||
if (!slice) break;
|
||||
|
||||
const elementId = readElementId(slice);
|
||||
if (elementId === EBMLId.Segment) {
|
||||
segment.elementEndPos = endPos;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentPos = dataStartPos + size;
|
||||
currentPos = endPos;
|
||||
}
|
||||
|
||||
// Catch faulty cue points
|
||||
@@ -2314,6 +2307,7 @@ class MatroskaVideoTrackBacking extends MatroskaTrackBacking implements InputVid
|
||||
codec: this.internalTrack.info.codec,
|
||||
codecDescription: this.internalTrack.info.codecDescription,
|
||||
colorSpace: this.internalTrack.info.colorSpace,
|
||||
avcType: 1, // We don't know better (or do we?) so just assume 'avc1'
|
||||
avcCodecInfo: this.internalTrack.info.codec === 'avc' && firstPacket
|
||||
? extractAvcDecoderConfigurationRecord(firstPacket.data)
|
||||
: null,
|
||||
@@ -2374,43 +2368,3 @@ class MatroskaAudioTrackBacking extends MatroskaTrackBacking implements InputAud
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Sorts blocks such that referenced blocks come before the blocks that reference them. */
|
||||
const sortBlocksByReferences = (blocks: ClusterBlock[]) => {
|
||||
const timestampToBlock = new Map<number, ClusterBlock>();
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i]!;
|
||||
timestampToBlock.set(block.timestamp, block);
|
||||
}
|
||||
|
||||
const processedBlocks = new Set<ClusterBlock>();
|
||||
const result: ClusterBlock[] = [];
|
||||
|
||||
const processBlock = (block: ClusterBlock) => {
|
||||
if (processedBlocks.has(block)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Marking the block as processed here already; prevents this algorithm from dying on cycles
|
||||
processedBlocks.add(block);
|
||||
|
||||
for (let j = 0; j < block.referencedTimestamps.length; j++) {
|
||||
const timestamp = block.referencedTimestamps[j]!;
|
||||
const otherBlock = timestampToBlock.get(timestamp);
|
||||
if (!otherBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
processBlock(otherBlock);
|
||||
}
|
||||
|
||||
result.push(block);
|
||||
};
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
processBlock(blocks[i]!);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -65,8 +65,8 @@ import { EncodedPacket } from '../packet';
|
||||
import { parseOpusIdentificationHeader } from '../codec-data';
|
||||
import { AttachedFile } from '../tags';
|
||||
|
||||
const MIN_CLUSTER_TIMESTAMP_MS = -(2 ** 15);
|
||||
const MAX_CLUSTER_TIMESTAMP_MS = 2 ** 15 - 1;
|
||||
const MIN_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ -(2 ** 15);
|
||||
const MAX_CLUSTER_TIMESTAMP_MS = /* #__PURE__ */ 2 ** 15 - 1;
|
||||
const APP_NAME = 'Mediabunny';
|
||||
const SEGMENT_SIZE_BYTES = 6;
|
||||
const CLUSTER_SIZE_BYTES = 5;
|
||||
|
||||
+25
-5
@@ -8,10 +8,12 @@
|
||||
|
||||
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
|
||||
import {
|
||||
deserializeAvcDecoderConfigurationRecord,
|
||||
determineVideoPacketType,
|
||||
extractHevcNalUnits,
|
||||
extractNalUnitTypeForHevc,
|
||||
HevcNalUnitType,
|
||||
parseAvcSps,
|
||||
} from './codec-data';
|
||||
import { CustomVideoDecoder, customVideoDecoders, CustomAudioDecoder, customAudioDecoders } from './custom-coder';
|
||||
import { InputDisposedError } from './input';
|
||||
@@ -24,15 +26,17 @@ import {
|
||||
getInt24,
|
||||
getUint24,
|
||||
insertSorted,
|
||||
isChromium,
|
||||
isFirefox,
|
||||
isNumber,
|
||||
isSafari,
|
||||
isWebKit,
|
||||
last,
|
||||
mapAsyncGenerator,
|
||||
promiseWithResolvers,
|
||||
Rotation,
|
||||
toAsyncIterator,
|
||||
toDataView,
|
||||
toUint8Array,
|
||||
validateAnyIterable,
|
||||
} from './misc';
|
||||
import { EncodedPacket } from './packet';
|
||||
@@ -872,6 +876,22 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
}
|
||||
};
|
||||
|
||||
if (codec === 'avc' && this.decoderConfig.description && isChromium()) {
|
||||
// Chromium has/had a bug with playing interlaced AVC (https://issues.chromium.org/issues/456919096)
|
||||
// which can be worked around by requesting that software decoding be used. So, here we peek into the
|
||||
// AVC description, if present, and switch to software decoding if we find interlaced content.
|
||||
const record = deserializeAvcDecoderConfigurationRecord(toUint8Array(this.decoderConfig.description));
|
||||
if (record && record.sequenceParameterSets.length > 0) {
|
||||
const sps = parseAvcSps(record.sequenceParameterSets[0]!);
|
||||
if (sps && sps.frameMbsOnlyFlag === 0) {
|
||||
this.decoderConfig = {
|
||||
...this.decoderConfig,
|
||||
hardwareAcceleration: 'prefer-software',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.decoder = new VideoDecoder({
|
||||
output: (frame) => {
|
||||
try {
|
||||
@@ -882,7 +902,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
},
|
||||
error: onError,
|
||||
});
|
||||
this.decoder.configure(decoderConfig);
|
||||
this.decoder.configure(this.decoderConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,7 +938,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
} else {
|
||||
assert(this.decoder);
|
||||
|
||||
if (!isSafari()) {
|
||||
if (!isWebKit()) {
|
||||
insertSorted(this.inputTimestamps, packet.timestamp, x => x);
|
||||
}
|
||||
|
||||
@@ -1049,7 +1069,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
|
||||
/** Handler for the WebCodecs VideoDecoder for ironing out browser differences. */
|
||||
sampleHandler(sample: VideoSample) {
|
||||
if (isSafari()) {
|
||||
if (isWebKit()) {
|
||||
// For correct B-frame handling, we don't just hand over the frames directly but instead add them to
|
||||
// a queue, because we want to ensure frames are emitted in presentation order. We flush the queue
|
||||
// each time we receive a frame with a timestamp larger than the highest we've seen so far, as we
|
||||
@@ -1137,7 +1157,7 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
this.alphaRaslSkipped = false;
|
||||
}
|
||||
|
||||
if (isSafari()) {
|
||||
if (isWebKit()) {
|
||||
for (const sample of this.sampleQueue) {
|
||||
this.finalizeAndEmitSample(sample);
|
||||
}
|
||||
|
||||
+69
-4
@@ -9,6 +9,8 @@
|
||||
import {
|
||||
AUDIO_CODECS,
|
||||
AudioCodec,
|
||||
buildAacAudioSpecificConfig,
|
||||
parseAacAudioSpecificConfig,
|
||||
parsePcmCodec,
|
||||
PCM_AUDIO_CODECS,
|
||||
PcmAudioCodec,
|
||||
@@ -24,9 +26,11 @@ import {
|
||||
CallSerializer,
|
||||
clamp,
|
||||
isFirefox,
|
||||
last,
|
||||
promiseWithResolvers,
|
||||
setInt24,
|
||||
setUint24,
|
||||
toUint8Array,
|
||||
} from './misc';
|
||||
import { Muxer } from './muxer';
|
||||
import { SubtitleParser } from './subtitles';
|
||||
@@ -1159,7 +1163,7 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
||||
type: 'videoTrack',
|
||||
trackId: this._workerTrackId,
|
||||
track: this._track,
|
||||
}, [this._track]);
|
||||
});
|
||||
|
||||
this._workerListener = (event: MessageEvent) => {
|
||||
const message = event.data as MediaStreamTrackProcessorWorkerMessage;
|
||||
@@ -1290,6 +1294,8 @@ class AudioEncoderWrapper {
|
||||
private customEncoderCallSerializer = new CallSerializer();
|
||||
private customEncoderQueueSize = 0;
|
||||
|
||||
private lastEndSampleIndex: number | null = null;
|
||||
|
||||
/**
|
||||
* Encoders typically throw their errors "out of band", meaning asynchronously in some other execution context.
|
||||
* However, we want to surface these errors to the user within the normal control flow, so they don't go uncaught.
|
||||
@@ -1338,6 +1344,35 @@ class AudioEncoderWrapper {
|
||||
}
|
||||
assert(this.encoderInitialized);
|
||||
|
||||
// Handle padding of gaps with silence to avoid audio drift over time, like in
|
||||
// https://github.com/Vanilagy/mediabunny/issues/176
|
||||
// TODO An open question is how encoders deal with the first AudioData having a non-zero timestamp, and with
|
||||
// AudioDatas that have an overlapping timestamp range.
|
||||
{
|
||||
const startSampleIndex = Math.round(
|
||||
audioSample.timestamp * audioSample.sampleRate,
|
||||
);
|
||||
const endSampleIndex = Math.round(
|
||||
(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,
|
||||
});
|
||||
|
||||
await this.add(fillSample, true); // Recursive call
|
||||
}
|
||||
|
||||
this.lastEndSampleIndex = endSampleIndex;
|
||||
}
|
||||
|
||||
if (this.customEncoder) {
|
||||
this.customEncoderQueueSize++;
|
||||
|
||||
@@ -1515,6 +1550,32 @@ class AudioEncoderWrapper {
|
||||
|
||||
this.encoder = new AudioEncoder({
|
||||
output: (chunk, meta) => {
|
||||
// WebKit emits an invalid description for AAC (https://bugs.webkit.org/show_bug.cgi?id=302253),
|
||||
// which we try to detect here. If detected, we'll provide our own description instead, derived
|
||||
// from the codec string and audio parameters.
|
||||
if (this.encodingConfig.codec === 'aac' && meta?.decoderConfig) {
|
||||
let needsDescriptionOverwrite = false;
|
||||
if (!meta.decoderConfig.description || meta.decoderConfig.description.byteLength < 2) {
|
||||
needsDescriptionOverwrite = true;
|
||||
} else {
|
||||
const audioSpecificConfig = parseAacAudioSpecificConfig(
|
||||
toUint8Array(meta.decoderConfig.description),
|
||||
);
|
||||
|
||||
needsDescriptionOverwrite = audioSpecificConfig.objectType === 0;
|
||||
}
|
||||
|
||||
if (needsDescriptionOverwrite) {
|
||||
const objectType = Number(last(encoderConfig.codec.split('.')));
|
||||
|
||||
meta.decoderConfig.description = buildAacAudioSpecificConfig({
|
||||
objectType,
|
||||
numberOfChannels: meta.decoderConfig.numberOfChannels,
|
||||
sampleRate: meta.decoderConfig.sampleRate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const packet = EncodedPacket.fromEncodedChunk(chunk);
|
||||
|
||||
this.encodingConfig.onEncodedPacket?.(packet, meta);
|
||||
@@ -1978,17 +2039,19 @@ const mediaStreamTrackProcessorWorkerCode = () => {
|
||||
});
|
||||
|
||||
const abortControllers = new Map<number, AbortController>();
|
||||
const stoppedTracks = new Set<number>();
|
||||
const activeTracks = new Map<number, MediaStreamVideoTrack>();
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
const message = event.data as MediaStreamTrackProcessorControllerMessage;
|
||||
|
||||
switch (message.type) {
|
||||
case 'videoTrack': {
|
||||
activeTracks.set(message.trackId, message.track);
|
||||
|
||||
const processor = new MediaStreamTrackProcessor({ track: message.track });
|
||||
const consumer = new WritableStream<VideoFrame>({
|
||||
write: (videoFrame) => {
|
||||
if (stoppedTracks.has(message.trackId)) {
|
||||
if (!activeTracks.has(message.trackId)) {
|
||||
videoFrame.close();
|
||||
return;
|
||||
}
|
||||
@@ -2026,7 +2089,9 @@ const mediaStreamTrackProcessorWorkerCode = () => {
|
||||
abortControllers.delete(message.trackId);
|
||||
}
|
||||
|
||||
stoppedTracks.add(message.trackId);
|
||||
const track = activeTracks.get(message.trackId);
|
||||
track?.stop();
|
||||
activeTracks.delete(message.trackId);
|
||||
|
||||
sendMessage({
|
||||
type: 'trackStopped',
|
||||
|
||||
+30
-24
@@ -174,8 +174,8 @@ export const toDataView = (source: AllowSharedBufferSource) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const textDecoder = new TextDecoder();
|
||||
export const textEncoder = new TextEncoder();
|
||||
export const textDecoder = /* #__PURE__ */ new TextDecoder();
|
||||
export const textEncoder = /* #__PURE__ */ new TextEncoder();
|
||||
|
||||
export const isIso88591Compatible = (text: string) => {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
@@ -201,17 +201,17 @@ export const COLOR_PRIMARIES_MAP = {
|
||||
bt2020: 9, // ITU-R BT.202
|
||||
smpte432: 12, // SMPTE EG 432-1
|
||||
};
|
||||
export const COLOR_PRIMARIES_MAP_INVERSE = invertObject(COLOR_PRIMARIES_MAP);
|
||||
export const COLOR_PRIMARIES_MAP_INVERSE = /* #__PURE__ */ invertObject(COLOR_PRIMARIES_MAP);
|
||||
|
||||
export const TRANSFER_CHARACTERISTICS_MAP = {
|
||||
'bt709': 1, // ITU-R BT.709
|
||||
'smpte170m': 6, // SMPTE 170M
|
||||
'linear': 8, // Linear transfer characteristics
|
||||
'iec61966-2-1': 13, // IEC 61966-2-1
|
||||
'pg': 16, // Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system
|
||||
'pq': 16, // Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system
|
||||
'hlg': 18, // Rec. ITU-R BT.2100-2 hybrid loggamma (HLG) system
|
||||
};
|
||||
export const TRANSFER_CHARACTERISTICS_MAP_INVERSE = invertObject(TRANSFER_CHARACTERISTICS_MAP);
|
||||
export const TRANSFER_CHARACTERISTICS_MAP_INVERSE = /* #__PURE__ */ invertObject(TRANSFER_CHARACTERISTICS_MAP);
|
||||
|
||||
export const MATRIX_COEFFICIENTS_MAP = {
|
||||
'rgb': 0, // Identity
|
||||
@@ -220,7 +220,7 @@ export const MATRIX_COEFFICIENTS_MAP = {
|
||||
'smpte170m': 6, // SMPTE 170M
|
||||
'bt2020-ncl': 9, // ITU-R BT.2020-2 (non-constant luminance)
|
||||
};
|
||||
export const MATRIX_COEFFICIENTS_MAP_INVERSE = invertObject(MATRIX_COEFFICIENTS_MAP);
|
||||
export const MATRIX_COEFFICIENTS_MAP_INVERSE = /* #__PURE__ */ invertObject(MATRIX_COEFFICIENTS_MAP);
|
||||
|
||||
export type RequiredNonNull<T> = {
|
||||
[K in keyof T]-?: NonNullable<T[K]>;
|
||||
@@ -486,9 +486,14 @@ export const clamp = (value: number, min: number, max: number) => {
|
||||
|
||||
export const UNDETERMINED_LANGUAGE = 'und';
|
||||
|
||||
export const roundToPrecision = (value: number, digits: number) => {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
export const roundIfAlmostInteger = (value: number) => {
|
||||
const rounded = Math.round(value);
|
||||
|
||||
if (Math.abs(value / rounded - 1) < 10 * Number.EPSILON) {
|
||||
return rounded;
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
export const roundToMultiple = (value: number, multiple: number) => {
|
||||
@@ -510,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 = 1e6 * (1 + Number.EPSILON);
|
||||
export const SECOND_TO_MICROSECOND_FACTOR = /* #__PURE__ */ 1e6 * (1 + Number.EPSILON);
|
||||
|
||||
/**
|
||||
* Sets all keys K of T to be required.
|
||||
@@ -658,22 +663,14 @@ export class CallSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
let isSafariCache: boolean | null = null;
|
||||
export const isSafari = () => {
|
||||
if (isSafariCache !== null) {
|
||||
return isSafariCache;
|
||||
let isWebKitCache: boolean | null = null;
|
||||
export const isWebKit = () => {
|
||||
if (isWebKitCache !== null) {
|
||||
return isWebKitCache;
|
||||
}
|
||||
|
||||
const result = !!(
|
||||
typeof navigator !== 'undefined'
|
||||
&& navigator.vendor?.match(/apple/i)
|
||||
&& !navigator.userAgent?.match(/crios/i)
|
||||
&& !navigator.userAgent?.match(/fxios/i)
|
||||
&& !navigator.userAgent?.match(/Opera|OPT\//)
|
||||
);
|
||||
|
||||
isSafariCache = result;
|
||||
return result;
|
||||
// This even returns true for WebKit-wrapping browsers such as Chrome on iOS
|
||||
return isWebKitCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.match(/apple/i));
|
||||
};
|
||||
|
||||
let isFirefoxCache: boolean | null = null;
|
||||
@@ -685,6 +682,15 @@ export const isFirefox = () => {
|
||||
return isFirefoxCache = typeof navigator !== 'undefined' && navigator.userAgent?.includes('Firefox');
|
||||
};
|
||||
|
||||
let isChromiumCache: boolean | null = null;
|
||||
export const isChromium = () => {
|
||||
if (isChromiumCache !== null) {
|
||||
return isChromiumCache;
|
||||
}
|
||||
|
||||
return isChromiumCache = !!(typeof navigator !== 'undefined' && navigator.vendor?.includes('Google Inc'));
|
||||
};
|
||||
|
||||
/**
|
||||
* T or a promise that resolves to T.
|
||||
* @group Miscellaneous
|
||||
|
||||
+11
-11
@@ -46,21 +46,21 @@ export abstract class Muxer {
|
||||
|
||||
private trackTimestampInfo = new WeakMap<OutputTrack, {
|
||||
maxTimestamp: number;
|
||||
maxTimestampBeforeLastKeyFrame: number;
|
||||
maxTimestampBeforeLastKeyPacket: number;
|
||||
}>();
|
||||
|
||||
protected validateAndNormalizeTimestamp(track: OutputTrack, timestampInSeconds: number, isKeyFrame: boolean) {
|
||||
protected validateAndNormalizeTimestamp(track: OutputTrack, timestampInSeconds: number, isKeyPacket: boolean) {
|
||||
timestampInSeconds += track.source._timestampOffset;
|
||||
|
||||
let timestampInfo = this.trackTimestampInfo.get(track);
|
||||
if (!timestampInfo) {
|
||||
if (!isKeyFrame) {
|
||||
throw new Error('First frame must be a key frame.');
|
||||
if (!isKeyPacket) {
|
||||
throw new Error('First packet must be a key packet.');
|
||||
}
|
||||
|
||||
timestampInfo = {
|
||||
maxTimestamp: timestampInSeconds,
|
||||
maxTimestampBeforeLastKeyFrame: timestampInSeconds,
|
||||
maxTimestampBeforeLastKeyPacket: timestampInSeconds,
|
||||
};
|
||||
this.trackTimestampInfo.set(track, timestampInfo);
|
||||
}
|
||||
@@ -69,15 +69,15 @@ export abstract class Muxer {
|
||||
throw new Error(`Timestamps must be non-negative (got ${timestampInSeconds}s).`);
|
||||
}
|
||||
|
||||
if (isKeyFrame) {
|
||||
timestampInfo.maxTimestampBeforeLastKeyFrame = timestampInfo.maxTimestamp;
|
||||
if (isKeyPacket) {
|
||||
timestampInfo.maxTimestampBeforeLastKeyPacket = timestampInfo.maxTimestamp;
|
||||
}
|
||||
|
||||
if (timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyFrame) {
|
||||
if (timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyPacket) {
|
||||
throw new Error(
|
||||
`Timestamps cannot be smaller than the highest timestamp of the previous GOP (a GOP begins with a key`
|
||||
+ ` frame and ends right before the next key frame). Got ${timestampInSeconds}s, but highest timestamp`
|
||||
+ ` is ${timestampInfo.maxTimestampBeforeLastKeyFrame}s.`,
|
||||
`Timestamps cannot be smaller than the largest timestamp of the previous GOP (a GOP begins with a key`
|
||||
+ ` packet and ends right before the next key packet). Got ${timestampInSeconds}s, but largest`
|
||||
+ ` timestamp is ${timestampInfo.maxTimestampBeforeLastKeyPacket}s.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
binarySearchLessOrEqual,
|
||||
findLast,
|
||||
last,
|
||||
roundToPrecision,
|
||||
roundIfAlmostInteger,
|
||||
toDataView,
|
||||
UNDETERMINED_LANGUAGE,
|
||||
} from '../misc';
|
||||
@@ -577,7 +577,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
|
||||
return this.getPacketSequential(timestamp, options);
|
||||
}
|
||||
|
||||
const timestampInSamples = roundToPrecision(timestamp * this.internalSampleRate, 14);
|
||||
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
|
||||
if (timestampInSamples === 0) {
|
||||
// Fast path for timestamp 0 - avoids binary search when playing back from the start
|
||||
return this.getFirstPacket(options);
|
||||
@@ -910,7 +910,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
|
||||
const release = await this.sequentialScanMutex.acquire(); // Requires exclusivity because we write to a cache
|
||||
|
||||
try {
|
||||
const timestampInSamples = roundToPrecision(timestamp * this.internalSampleRate, 14);
|
||||
const timestampInSamples = roundIfAlmostInteger(timestamp * this.internalSampleRate);
|
||||
timestamp = timestampInSamples / this.internalSampleRate;
|
||||
|
||||
const index = binarySearchLessOrEqual(
|
||||
|
||||
@@ -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 = MAX_PAGE_HEADER_SIZE + 255 * 255;
|
||||
export const MAX_PAGE_SIZE = /* #__PURE__ */ MAX_PAGE_HEADER_SIZE + 255 * 255;
|
||||
|
||||
export type Page = {
|
||||
headerStartPos: number;
|
||||
|
||||
+5
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
import { SECOND_TO_MICROSECOND_FACTOR } from './misc';
|
||||
|
||||
export const PLACEHOLDER_DATA = new Uint8Array(0);
|
||||
export const PLACEHOLDER_DATA = /* #__PURE__ */ new Uint8Array(0);
|
||||
|
||||
/**
|
||||
* The type of a packet. Key packets can be decoded without previous packets, while delta packets depend on previous
|
||||
@@ -126,7 +126,10 @@ export class EncodedPacket {
|
||||
}
|
||||
}
|
||||
|
||||
/** If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. */
|
||||
/**
|
||||
* If this packet is a metadata-only packet. Metadata-only packets don't contain their packet data. They are the
|
||||
* result of retrieving packets with {@link PacketRetrievalOptions.metadataOnly} set to `true`.
|
||||
*/
|
||||
get isMetadataOnly() {
|
||||
return this.data === PLACEHOLDER_DATA;
|
||||
}
|
||||
|
||||
+4
-1
@@ -552,7 +552,6 @@ export class VideoSample implements Disposable {
|
||||
dHeight,
|
||||
);
|
||||
|
||||
// Restore the previous transformation state
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -644,6 +643,8 @@ export class VideoSample implements Disposable {
|
||||
dy = (canvasHeight - newHeight) / 2;
|
||||
}
|
||||
|
||||
context.save();
|
||||
|
||||
const aspectRatioChange = rotation % 180 === 0 ? 1 : newWidth / newHeight;
|
||||
context.translate(canvasWidth / 2, canvasHeight / 2);
|
||||
context.rotate(rotation * Math.PI / 180);
|
||||
@@ -655,6 +656,8 @@ export class VideoSample implements Disposable {
|
||||
// Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it
|
||||
// ourselves here
|
||||
context.drawImage(this.toCanvasImageSource(), sx, sy, sWidth, sHeight, dx, dy, newWidth, newHeight);
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
|
||||
+43
-4
@@ -12,6 +12,7 @@ import {
|
||||
binarySearchLessOrEqual,
|
||||
closedIntervalsOverlap,
|
||||
isNumber,
|
||||
isWebKit,
|
||||
MaybePromise,
|
||||
mergeRequestInit,
|
||||
promiseWithResolvers,
|
||||
@@ -209,7 +210,14 @@ export class BlobSource extends Source {
|
||||
private async _runWorker(worker: ReadWorker) {
|
||||
let reader = this._readers.get(worker);
|
||||
if (reader === undefined) {
|
||||
if ('stream' in this._blob) {
|
||||
// https://github.com/Vanilagy/mediabunny/issues/184
|
||||
// WebKit has critical bugs with blob.stream():
|
||||
// - WebKitBlobResource error 1 when streaming large files
|
||||
// - Memory buildup and reload loops on iOS (network process crashes)
|
||||
// - ReadableStream stalls under backpressure (especially video)
|
||||
// Affects Safari and all iOS browsers (Chrome, Firefox, etc.).
|
||||
// Use arrayBuffer() fallback for WebKit browsers.
|
||||
if ('stream' in this._blob && !isWebKit()) {
|
||||
// Get a reader of the blob starting at the required offset, and then keep it around
|
||||
const slice = this._blob.slice(worker.currentPos);
|
||||
reader = slice.stream().getReader();
|
||||
@@ -234,11 +242,19 @@ export class BlobSource extends Source {
|
||||
break;
|
||||
}
|
||||
|
||||
if (worker.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.onread?.(worker.currentPos, worker.currentPos + value.length);
|
||||
this._orchestrator.supplyWorkerData(worker, value);
|
||||
} else {
|
||||
const data = await this._blob.slice(worker.currentPos, worker.targetPos).arrayBuffer();
|
||||
|
||||
if (worker.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.onread?.(worker.currentPos, worker.currentPos + data.byteLength);
|
||||
this._orchestrator.supplyWorkerData(worker, new Uint8Array(data));
|
||||
}
|
||||
@@ -253,7 +269,7 @@ export class BlobSource extends Source {
|
||||
}
|
||||
}
|
||||
|
||||
const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
|
||||
const URL_SOURCE_MIN_LOAD_AMOUNT = /* #__PURE__ */ 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
|
||||
@@ -457,7 +473,7 @@ export class UrlSource extends Source {
|
||||
/** @internal */
|
||||
private async _runWorker(worker: ReadWorker) {
|
||||
// The outer loop is for resuming a request if it dies mid-response
|
||||
while (!worker.aborted) {
|
||||
while (true) {
|
||||
const existing = this._existingResponses.get(worker);
|
||||
this._existingResponses.delete(worker);
|
||||
|
||||
@@ -534,6 +550,10 @@ export class UrlSource extends Source {
|
||||
}
|
||||
}
|
||||
|
||||
if (worker.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
const { done, value } = readResult;
|
||||
|
||||
if (done) {
|
||||
@@ -552,6 +572,10 @@ export class UrlSource extends Source {
|
||||
this.onread?.(worker.currentPos, worker.currentPos + value.length);
|
||||
this._orchestrator.supplyWorkerData(worker, value);
|
||||
}
|
||||
|
||||
if (worker.aborted) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
worker.running = false;
|
||||
@@ -796,6 +820,10 @@ export class StreamSource extends Source {
|
||||
let data = this._options.read(worker.currentPos, originalTargetPos);
|
||||
if (data instanceof Promise) data = await data;
|
||||
|
||||
if (worker.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (data instanceof Uint8Array) {
|
||||
data = toUint8Array(data); // Normalize things like Node.js Buffer to Uint8Array
|
||||
|
||||
@@ -833,6 +861,10 @@ export class StreamSource extends Source {
|
||||
throw new TypeError('ReadableStream returned by options.read must yield Uint8Array chunks.');
|
||||
}
|
||||
|
||||
if (worker.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
const data = toUint8Array(value); // Normalize things like Node.js Buffer to Uint8Array
|
||||
|
||||
this.onread?.(worker.currentPos, worker.currentPos + data.length);
|
||||
@@ -1224,6 +1256,7 @@ class ReadOrchestrator {
|
||||
workers: ReadWorker[] = [];
|
||||
cache: CacheEntry[] = [];
|
||||
currentCacheSize = 0;
|
||||
disposed = false;
|
||||
|
||||
constructor(public options: {
|
||||
maxCacheSize: number;
|
||||
@@ -1419,7 +1452,10 @@ class ReadOrchestrator {
|
||||
currentPos: startPos,
|
||||
targetPos,
|
||||
running: false,
|
||||
aborted: false,
|
||||
// Due to async shenanigans, it can happen that workers are started after disposal. In this case, instead of
|
||||
// simply not creating the worker, we allow it to run but immediately label it as aborted, so it can then
|
||||
// shut itself down.
|
||||
aborted: this.disposed,
|
||||
pendingSlices: [],
|
||||
age: this.nextAge++,
|
||||
};
|
||||
@@ -1472,6 +1508,8 @@ class ReadOrchestrator {
|
||||
|
||||
/** Called by a worker when it has read some data. */
|
||||
supplyWorkerData(worker: ReadWorker, bytes: Uint8Array) {
|
||||
assert(!worker.aborted);
|
||||
|
||||
const start = worker.currentPos;
|
||||
const end = start + bytes.length;
|
||||
|
||||
@@ -1645,5 +1683,6 @@ class ReadOrchestrator {
|
||||
|
||||
this.workers.length = 0;
|
||||
this.cache.length = 0;
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import type { FileHandle } from 'node:fs/promises';
|
||||
import { BufferTargetWriter, NullTargetWriter, StreamTargetWriter, Writer } from './writer';
|
||||
import { Output } from './output';
|
||||
import * as nodeAlias from './node';
|
||||
import { assert } from './misc';
|
||||
|
||||
const node = typeof nodeAlias !== 'undefined'
|
||||
? nodeAlias // Aliasing it prevents some bundler warnings
|
||||
: undefined!;
|
||||
|
||||
/**
|
||||
* Base class for targets, specifying where output files are written.
|
||||
@@ -121,6 +128,68 @@ export class StreamTarget extends Target {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link FilePathTarget}.
|
||||
* @group Output targets
|
||||
* @public
|
||||
*/
|
||||
export type FilePathTargetOptions = StreamTargetOptions;
|
||||
|
||||
/**
|
||||
* A target that writes to a file at the specified path. Intended for server-side usage in Node, Bun, or Deno.
|
||||
*
|
||||
* Writing is chunked by default. The internally held file handle will be closed when `.finalize()` or `.cancel()` are
|
||||
* called on the corresponding {@link Output}.
|
||||
* @group Output targets
|
||||
* @public
|
||||
*/
|
||||
export class FilePathTarget extends Target {
|
||||
/** @internal */
|
||||
_streamTarget: StreamTarget;
|
||||
/** @internal */
|
||||
_fileHandle: FileHandle | null = null;
|
||||
|
||||
/** Creates a new {@link FilePathTarget} that writes to the file at the specified file path. */
|
||||
constructor(filePath: string, options: FilePathTargetOptions = {}) {
|
||||
if (typeof filePath !== 'string') {
|
||||
throw new TypeError('filePath must be a string.');
|
||||
}
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
|
||||
super();
|
||||
|
||||
// Let's back this target with a StreamTarget, makes the implementation very simple
|
||||
const writable = new WritableStream<StreamTargetChunk>({
|
||||
start: async () => {
|
||||
this._fileHandle = await node.fs.open(filePath, 'w');
|
||||
},
|
||||
write: async (chunk) => {
|
||||
assert(this._fileHandle);
|
||||
await this._fileHandle.write(chunk.data, 0, chunk.data.byteLength, chunk.position);
|
||||
},
|
||||
close: async () => {
|
||||
if (this._fileHandle) {
|
||||
await this._fileHandle.close();
|
||||
this._fileHandle = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this._streamTarget = new StreamTarget(writable, {
|
||||
chunked: true,
|
||||
...options,
|
||||
});
|
||||
this._streamTarget._output = this._output;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_createWriter(): Writer {
|
||||
return this._streamTarget._createWriter();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This target just discards all incoming data. It is useful for when you need an {@link Output} but extract data from
|
||||
* it differently, for example through format-specific callbacks (`onMoof`, `onMdat`, ...) or encoder events.
|
||||
|
||||
+3
-3
@@ -89,8 +89,8 @@ export abstract class Writer {
|
||||
}
|
||||
}
|
||||
|
||||
const ARRAY_BUFFER_INITIAL_SIZE = 2 ** 16;
|
||||
const ARRAY_BUFFER_MAX_SIZE = 2 ** 32;
|
||||
const ARRAY_BUFFER_INITIAL_SIZE = /* #__PURE__ */ 2 ** 16;
|
||||
const ARRAY_BUFFER_MAX_SIZE = /* #__PURE__ */ 2 ** 32;
|
||||
|
||||
export class BufferTargetWriter extends Writer {
|
||||
private pos = 0;
|
||||
@@ -184,7 +184,7 @@ export class BufferTargetWriter extends Writer {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 2 ** 24;
|
||||
const DEFAULT_CHUNK_SIZE = /* #__PURE__ */ 2 ** 24;
|
||||
const MAX_CHUNKS_AT_ONCE = 2;
|
||||
|
||||
interface Chunk {
|
||||
|
||||
@@ -139,17 +139,19 @@ test('Can encode transparent video', async () => {
|
||||
probeContext.drawImage(video, 0, 0);
|
||||
|
||||
let imageData = probeContext.getImageData(0, 0, probeCanvas.width, probeCanvas.height);
|
||||
expect(imageData.data[3]).lessThanOrEqual(2); // Transparent (within error)
|
||||
const pos1 = { x: 100, y: 100 };
|
||||
const index1 = (pos1.x + pos1.y * probeCanvas.width) * 4;
|
||||
expect(imageData.data[index1 + 3]).toBe(0); // Transparent
|
||||
|
||||
const pos = { x: 300, y: 300 }; // Dead center in the red square
|
||||
const index = (pos.x + pos.y * probeCanvas.width) * 4;
|
||||
const pos2 = { x: 300, y: 300 }; // Dead center in the red square
|
||||
const index2 = (pos2.x + pos2.y * probeCanvas.width) * 4;
|
||||
|
||||
// Red (within error)
|
||||
expect(imageData.data[index + 0]).greaterThanOrEqual(253);
|
||||
expect(imageData.data[index + 1]).lessThanOrEqual(2);
|
||||
expect(imageData.data[index + 2]).lessThanOrEqual(2);
|
||||
expect(imageData.data[index2 + 0]).greaterThanOrEqual(253);
|
||||
expect(imageData.data[index2 + 1]).lessThanOrEqual(3);
|
||||
expect(imageData.data[index2 + 2]).lessThanOrEqual(3);
|
||||
|
||||
expect(imageData.data[index + 3]).greaterThanOrEqual(253); // Opaque (within error)
|
||||
expect(imageData.data[index2 + 3]).toBe(255); // Opaque
|
||||
|
||||
// Let's also check it's read correctly by Mediabunny
|
||||
using input = new Input({
|
||||
@@ -169,7 +171,8 @@ test('Can encode transparent video', async () => {
|
||||
firstSample.draw(probeContext, 0, 0);
|
||||
|
||||
imageData = probeContext.getImageData(0, 0, probeCanvas.width, probeCanvas.height);
|
||||
expect(imageData.data[3]).lessThanOrEqual(2); // Transparent (within error)
|
||||
|
||||
expect(imageData.data[3]).toBe(0); // Transparent
|
||||
});
|
||||
|
||||
test('Can encode video with alternating transparency', async () => {
|
||||
|
||||
Reference in New Issue
Block a user