mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-09 08:43:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d3af1b043 | ||
|
|
c29adb5997 | ||
|
|
e880f54553 | ||
|
|
21456e3bf6 | ||
|
|
1143984021 | ||
|
|
c4159c1391 | ||
|
|
f2f9b2f0c3 | ||
|
|
203c0f307e | ||
|
|
045ce43eb2 | ||
|
|
bb32f2ba26 | ||
|
|
de4c44021a | ||
|
|
c91f46fd5e | ||
|
|
08658e7728 | ||
|
|
bf0058987e | ||
|
|
088d1f1701 | ||
|
|
1e922e3d72 | ||
|
|
7accface7b | ||
|
|
dcd1f242b4 | ||
|
|
c85a22493a | ||
|
|
26c1debb7a | ||
|
|
548ca74c93 | ||
|
|
249d9444ce | ||
|
|
d58b638277 | ||
|
|
8636297ee0 |
@@ -2,8 +2,9 @@ name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
@@ -2,8 +2,9 @@ name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -36,8 +36,8 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
|
||||
### Silver sponsors
|
||||
|
||||
<div align="center">
|
||||
<a href="https://pqina.nl/" target="_blank" rel="sponsored">
|
||||
<img src="./docs/public/sponsors/pqina.jpg" width="50" height="50" alt="PQINA">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@
|
||||
output,
|
||||
audio: (_, n) => ({
|
||||
discard: n > 1,
|
||||
/*
|
||||
process: (sample) => {
|
||||
return sample;
|
||||
},
|
||||
*/
|
||||
//codec: 'pcm-s16',
|
||||
//sampleRate: 16000,
|
||||
//numberOfChannels: 1,
|
||||
@@ -92,6 +97,24 @@
|
||||
},
|
||||
*/
|
||||
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;
|
||||
},
|
||||
*/
|
||||
width: 300,
|
||||
//alpha: 'keep',
|
||||
//width: 320,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -124,8 +125,16 @@ type ConversionVideoOptions = {
|
||||
codec?: VideoCodec;
|
||||
bitrate?: number | Quality;
|
||||
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:
|
||||
@@ -180,8 +189,43 @@ Use the `codec` property to control the codec of the output track. This should b
|
||||
|
||||
Use the `bitrate` property to control the bitrate of the output video. For example, you can use this field to compress the video track. Accepted values are the number of bits per second or a [subjective quality](./media-sources#subjective-qualities). If this property is set, transcoding will always happen. If this property is not set but transcoding is still required, `QUALITY_HIGH` will be used as the value.
|
||||
|
||||
Use the `keyFrameInterval` property to control the maximum interval in seconds between key frames in the output video. Setting this fields forces a transcode.
|
||||
|
||||
If you want to prevent direct copying of media data and force a transcoding step, use `forceTranscode: true`.
|
||||
|
||||
### 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:
|
||||
@@ -193,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:
|
||||
@@ -230,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
|
||||
```
|
||||
@@ -487,7 +487,7 @@ If you're using this source in the browser and the URL is on a different origin,
|
||||
```ts
|
||||
type UrlSourceOptions = {
|
||||
requestInit?: RequestInit;
|
||||
getRetryDelay?: (previousAttempts: number) => number | null;
|
||||
getRetryDelay?: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null;
|
||||
|
||||
// The maximum number of bytes the cache is allowed to hold
|
||||
// in memory. Defaults to 8 MiB.
|
||||
@@ -517,7 +517,9 @@ const source = new UrlSource('https://example.com/bigbuckbunny.mp4', {
|
||||
});
|
||||
```
|
||||
|
||||
Not setting `getRetryDelay` will default to an infinite, capped exponential backoff pattern.
|
||||
Not setting `getRetryDelay` will lead to the default being used:
|
||||
- Infinite exponential backoff pattern, capped at 16 seconds.
|
||||
- If a CORS error is suspected (`fetch()` did reject even though `navigator.onLine` is true and origin is different), no further retries will be made.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -97,7 +97,7 @@ const sponsors = {
|
||||
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
|
||||
],
|
||||
silver: [
|
||||
{ image: '/sponsors/pqina.jpg', name: 'PQINA', url: 'https://pqina.nl/' },
|
||||
{ image: '/sponsors/pintura-labs.png', name: 'Pintura Labs', url: 'https://pqina.nl/pintura/' },
|
||||
],
|
||||
bronze: [
|
||||
{ image: '/sponsors/rve.svg', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
|
||||
@@ -109,6 +109,7 @@ const sponsors = {
|
||||
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/2698271', name: 'Matthew Gardner', url: 'https://github.com/spheric' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/5475819', name: 'AJ Funk', url: 'https://github.com/AJFunk' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 590 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 KiB |
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.21.1",
|
||||
"version": "1.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.21.1",
|
||||
"version": "1.24.0",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -7749,9 +7749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.21.0",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.21.0.tgz",
|
||||
"integrity": "sha512-/jZ4FoDJVk05wN73GT41rGgsZckeW8CXdSfnjetZN7C4YcWjgsNbN+x5Onw8ybMB5ivSKo+RIdSobRj9xP3hsA==",
|
||||
"version": "1.23.0",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.23.0.tgz",
|
||||
"integrity": "sha512-mWdhhdRquePfTgZ+18DLCFWmqwO6oGP/YudmRU8Puh+whMUJ3VSb9KMAHPl2utim66ixKdZw+DbmdqBfBxRp0A==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -12242,7 +12242,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.21.1",
|
||||
"version": "1.24.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.21.1",
|
||||
"version": "1.24.0",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.21.1",
|
||||
"version": "1.24.0",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
|
||||
+252
-42
@@ -166,8 +166,41 @@ export type ConversionVideoOptions = {
|
||||
* VP9.
|
||||
*/
|
||||
alpha?: 'discard' | 'keep';
|
||||
/**
|
||||
* The interval, in seconds, of how often frames are encoded as a key frame. The default is 5 seconds. Frequent key
|
||||
* frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them
|
||||
* all the same key frame interval.
|
||||
*
|
||||
* Setting this fields forces a transcode.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -188,6 +221,29 @@ 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.
|
||||
*/
|
||||
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) => {
|
||||
@@ -252,6 +308,27 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
||||
if (videoOptions?.alpha !== undefined && !['discard', 'keep'].includes(videoOptions.alpha)) {
|
||||
throw new TypeError('options.video.alpha, when provided, must be either \'discard\' or \'keep\'.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.keyFrameInterval !== undefined
|
||||
&& (!Number.isFinite(videoOptions.keyFrameInterval) || videoOptions.keyFrameInterval < 0)
|
||||
) {
|
||||
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.');
|
||||
}
|
||||
};
|
||||
|
||||
const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
|
||||
@@ -288,6 +365,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;
|
||||
@@ -775,7 +867,9 @@ export class Conversion {
|
||||
const needsTranscode = !!trackOptions.forceTranscode
|
||||
|| this._startTimestamp > 0
|
||||
|| firstTimestamp < 0
|
||||
|| !!trackOptions.frameRate;
|
||||
|| !!trackOptions.frameRate
|
||||
|| trackOptions.keyFrameInterval !== undefined
|
||||
|| trackOptions.process !== undefined;
|
||||
let needsRerender = width !== originalWidth
|
||||
|| height !== originalHeight
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation)
|
||||
@@ -807,10 +901,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;
|
||||
}
|
||||
@@ -821,8 +911,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();
|
||||
@@ -846,7 +940,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,
|
||||
@@ -858,9 +960,9 @@ export class Conversion {
|
||||
const encodingConfig: VideoEncodingConfig = {
|
||||
codec: encodableCodec,
|
||||
bitrate,
|
||||
keyFrameInterval: trackOptions.keyFrameInterval,
|
||||
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
|
||||
alpha,
|
||||
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
|
||||
};
|
||||
|
||||
const source = new VideoSampleSource(encodingConfig);
|
||||
@@ -873,7 +975,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
|
||||
@@ -935,15 +1037,11 @@ export class Conversion {
|
||||
timestamp: lastCanvasTimestamp! + i / frameRate,
|
||||
duration: 1 / frameRate,
|
||||
});
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
}
|
||||
};
|
||||
|
||||
for await (const { canvas, timestamp, duration } of iterator) {
|
||||
if (this._synchronizer.shouldWait(track.id, timestamp)) {
|
||||
await this._synchronizer.wait(timestamp);
|
||||
}
|
||||
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
@@ -975,8 +1073,7 @@ export class Conversion {
|
||||
timestamp: adjustedSampleTimestamp,
|
||||
duration: frameRate !== undefined ? 1 / frameRate : duration,
|
||||
});
|
||||
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
|
||||
if (frameRate !== undefined) {
|
||||
lastCanvas = canvas;
|
||||
@@ -1018,17 +1115,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;
|
||||
@@ -1060,7 +1153,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
sample.setTimestamp(adjustedSampleTimestamp);
|
||||
await source.add(sample);
|
||||
await this._registerVideoSample(track, trackOptions, source, sample);
|
||||
|
||||
if (frameRate !== undefined) {
|
||||
lastSample = sample;
|
||||
@@ -1097,6 +1190,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;
|
||||
@@ -1129,6 +1283,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
|
||||
|
||||
@@ -1146,16 +1301,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();
|
||||
@@ -1182,8 +1337,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,
|
||||
});
|
||||
|
||||
@@ -1191,6 +1350,7 @@ export class Conversion {
|
||||
!encodableCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec))
|
||||
&& audioCodecs.some(codec => (NON_PCM_AUDIO_CODECS as readonly string[]).includes(codec))
|
||||
&& (numberOfChannels !== FALLBACK_NUMBER_OF_CHANNELS || sampleRate !== FALLBACK_SAMPLE_RATE)
|
||||
&& !trackOptions.process
|
||||
) {
|
||||
// We could not find a compatible non-PCM codec despite the container supporting them. This can be
|
||||
// caused by strange channel count or sample rate configurations. Therefore, let's try again but with
|
||||
@@ -1224,12 +1384,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;
|
||||
|
||||
@@ -1238,15 +1404,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();
|
||||
}
|
||||
|
||||
@@ -1267,9 +1429,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,
|
||||
@@ -1278,7 +1493,6 @@ export class Conversion {
|
||||
const source = new AudioSampleSource({
|
||||
codec,
|
||||
bitrate,
|
||||
onEncodedPacket: packet => this._reportProgress(track.id, packet.timestamp + packet.duration),
|
||||
});
|
||||
|
||||
this._trackPromises.push((async () => {
|
||||
@@ -1289,17 +1503,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;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,8 @@ export {
|
||||
export {
|
||||
Target,
|
||||
BufferTarget,
|
||||
FilePathTarget,
|
||||
FilePathTargetOptions,
|
||||
NullTarget,
|
||||
StreamTarget,
|
||||
StreamTargetOptions,
|
||||
|
||||
@@ -539,14 +539,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
// lookup starts sequentially from the start, incrementally summing up all fragment durations. It's sort
|
||||
// of implicit, but it ends up working nicely.
|
||||
|
||||
const lookupEntryIndex = binarySearchExact(
|
||||
track.fragmentLookupTable,
|
||||
fragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
if (lookupEntryIndex !== -1) {
|
||||
const lookupEntry = track.fragmentLookupTable.find(x => x.moofOffset === fragment.moofOffset);
|
||||
if (lookupEntry) {
|
||||
// There's a lookup entry, let's use its timestamp
|
||||
const lookupEntry = track.fragmentLookupTable[lookupEntryIndex]!;
|
||||
offsetFragmentTrackDataByTimestamp(trackData, lookupEntry.timestamp);
|
||||
} else {
|
||||
const lastCacheIndex = binarySearchLessOrEqual(
|
||||
@@ -1742,6 +1737,20 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
moofOffset,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by timestamp in case it's not naturally sorted
|
||||
track.fragmentLookupTable.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
// Remove multiple entries for the same time
|
||||
for (let i = 0; i < track.fragmentLookupTable.length - 1; i++) {
|
||||
const entry1 = track.fragmentLookupTable[i]!;
|
||||
const entry2 = track.fragmentLookupTable[i + 1]!;
|
||||
|
||||
if (entry1.timestamp === entry2.timestamp) {
|
||||
track.fragmentLookupTable.splice(i + 1, 1);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'moof': {
|
||||
@@ -2747,6 +2756,8 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
// The lookup table entry lied to us! We found a lookup entry but no fragment there that satisfied
|
||||
// the match. In this case, let's search again but using the lookup entry before that.
|
||||
const previousLookupEntry = this.internalTrack.fragmentLookupTable[lookupEntryIndex - 1];
|
||||
assert(!previousLookupEntry || previousLookupEntry.timestamp < lookupEntry.timestamp);
|
||||
|
||||
const newSearchTimestamp = previousLookupEntry?.timestamp ?? -Infinity;
|
||||
return this.performFragmentedLookup(
|
||||
null,
|
||||
|
||||
@@ -541,53 +541,49 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
// Put default tracks first
|
||||
this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
||||
|
||||
// Sort cue points by cluster position (required for the next algorithm)
|
||||
this.currentSegment.cuePoints.sort((a, b) => a.clusterPosition - b.clusterPosition);
|
||||
|
||||
// Now, let's distribute the cue points to each track. Ideally, each track has their own cue point, but some
|
||||
// Matroska files may only specify cue points for a single track. In this case, we still wanna use those cue
|
||||
// points for all tracks.
|
||||
const allTrackIds = this.currentSegment.tracks.map(x => x.id);
|
||||
const remainingTrackIds = new Set<number>();
|
||||
let lastClusterPosition: number | null = null;
|
||||
let lastCuePoint: CuePoint | null = null;
|
||||
// Now, let's distribute the cue points to the tracks
|
||||
const idToTrack = new Map(this.currentSegment.tracks.map(x => [x.id, x]));
|
||||
|
||||
// Assign cue points to their respective tracks
|
||||
for (const cuePoint of this.currentSegment.cuePoints) {
|
||||
if (cuePoint.clusterPosition !== lastClusterPosition) {
|
||||
for (const id of remainingTrackIds) {
|
||||
// These tracks didn't receive a cue point for the last cluster, so let's give them one
|
||||
assert(lastCuePoint);
|
||||
const track = this.currentSegment.tracks.find(x => x.id === id)!;
|
||||
track.cuePoints.push(lastCuePoint);
|
||||
}
|
||||
|
||||
for (const id of allTrackIds) {
|
||||
remainingTrackIds.add(id);
|
||||
}
|
||||
const track = idToTrack.get(cuePoint.trackId);
|
||||
if (track) {
|
||||
track.cuePoints.push(cuePoint);
|
||||
}
|
||||
|
||||
lastCuePoint = cuePoint;
|
||||
|
||||
if (!remainingTrackIds.has(cuePoint.trackId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const track = this.currentSegment.tracks.find(x => x.id === cuePoint.trackId)!;
|
||||
track.cuePoints.push(cuePoint);
|
||||
|
||||
remainingTrackIds.delete(cuePoint.trackId);
|
||||
lastClusterPosition = cuePoint.clusterPosition;
|
||||
}
|
||||
|
||||
for (const id of remainingTrackIds) {
|
||||
assert(lastCuePoint);
|
||||
const track = this.currentSegment.tracks.find(x => x.id === id)!;
|
||||
track.cuePoints.push(lastCuePoint);
|
||||
}
|
||||
|
||||
for (const track of this.currentSegment.tracks) {
|
||||
// Sort cue points by time
|
||||
track.cuePoints.sort((a, b) => a.time - b.time);
|
||||
|
||||
// Remove multiple cue points for the same time
|
||||
for (let i = 0; i < track.cuePoints.length - 1; i++) {
|
||||
const cuePoint1 = track.cuePoints[i]!;
|
||||
const cuePoint2 = track.cuePoints[i + 1]!;
|
||||
|
||||
if (cuePoint1.time === cuePoint2.time) {
|
||||
track.cuePoints.splice(i + 1, 1);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let trackWithMostCuePoints: InternalTrack | null = null;
|
||||
let maxCuePointCount = -Infinity;
|
||||
for (const track of this.currentSegment.tracks) {
|
||||
if (track.cuePoints.length > maxCuePointCount) {
|
||||
maxCuePointCount = track.cuePoints.length;
|
||||
trackWithMostCuePoints = track;
|
||||
}
|
||||
}
|
||||
|
||||
// For every track that has received 0 cue points (can happen, often only the video track receives cue points),
|
||||
// we still want to have better seeking. Therefore, let's give it the cue points of the track with the most cue
|
||||
// points, which should provide us with the most fine-grained seeking.
|
||||
for (const track of this.currentSegment.tracks) {
|
||||
if (track.cuePoints.length === 0) {
|
||||
track.cuePoints = trackWithMostCuePoints!.cuePoints;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentSegment = null;
|
||||
@@ -2239,6 +2235,8 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
// The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this
|
||||
// case, let's search again but using the cue point before that.
|
||||
const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];
|
||||
assert(!previousCuePoint || previousCuePoint.time < cuePoint.time);
|
||||
|
||||
const newSearchTimestamp = previousCuePoint?.time ?? -Infinity;
|
||||
return this.performClusterLookup(null, getMatchInCluster, newSearchTimestamp, latestTimestamp, options);
|
||||
}
|
||||
|
||||
+3
-3
@@ -208,7 +208,7 @@ export const TRANSFER_CHARACTERISTICS_MAP = {
|
||||
'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);
|
||||
@@ -578,7 +578,7 @@ export const retriedFetch = async (
|
||||
fetchFn: typeof fetch,
|
||||
url: string | URL | Request,
|
||||
requestInit: RequestInit,
|
||||
getRetryDelay: (previousAttempts: number, error: unknown) => number | null,
|
||||
getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null,
|
||||
) => {
|
||||
let attempts = 0;
|
||||
|
||||
@@ -587,7 +587,7 @@ export const retriedFetch = async (
|
||||
return await fetchFn(url, requestInit);
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
const retryDelayInSeconds = getRetryDelay(attempts, error);
|
||||
const retryDelayInSeconds = getRetryDelay(attempts, error, url);
|
||||
|
||||
if (retryDelayInSeconds === null) {
|
||||
throw error;
|
||||
|
||||
+44
-5
@@ -255,7 +255,38 @@ export class BlobSource extends Source {
|
||||
|
||||
const URL_SOURCE_MIN_LOAD_AMOUNT = 0.5 * 2 ** 20; // 0.5 MiB
|
||||
const DEFAULT_RETRY_DELAY
|
||||
= (previousAttempts => Math.min(2 ** (previousAttempts - 2), 16)) satisfies UrlSourceOptions['getRetryDelay'];
|
||||
= ((previousAttempts, error, src) => {
|
||||
// Check if this could be a CORS error. If so, we cannot recover from it and
|
||||
// should not attempt to retry.
|
||||
// CORS errors are intentionally not opaque, so we need to rely on heuristics.
|
||||
const couldBeCorsError = error instanceof Error && (
|
||||
error.message.includes('Failed to fetch') // Chrome
|
||||
|| error.message.includes('Load failed') // Safari
|
||||
|| error.message.includes('NetworkError when attempting to fetch resource') // Firefox
|
||||
);
|
||||
|
||||
if (couldBeCorsError) {
|
||||
let originOfSrc: string | null = null;
|
||||
// Checking if the origin is different, because only then a CORS error could originate
|
||||
try {
|
||||
if (typeof window !== 'undefined' && typeof window.location !== 'undefined') {
|
||||
originOfSrc = new URL(src instanceof Request ? src.url : src, window.location.href).origin;
|
||||
}
|
||||
} catch {
|
||||
// URL parse failed
|
||||
}
|
||||
|
||||
// If user is offline, it is probably not a CORS error.
|
||||
const isOnline
|
||||
= typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' ? navigator.onLine : true;
|
||||
|
||||
if (isOnline && originOfSrc !== null && originOfSrc !== window.location.origin) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(2 ** (previousAttempts - 2), 16);
|
||||
}) satisfies UrlSourceOptions['getRetryDelay'];
|
||||
|
||||
/**
|
||||
* Options for {@link UrlSource}.
|
||||
@@ -274,9 +305,10 @@ export type UrlSourceOptions = {
|
||||
* with the number of previous, unsuccessful attempts, as well as with the error with which the previous request
|
||||
* failed. If the function returns `null`, no more retries will be made.
|
||||
*
|
||||
* By default, it uses an exponential backoff algorithm that never fully gives up.
|
||||
* By default, it uses an exponential backoff algorithm that never gives up unless
|
||||
* a CORS error is suspected (`fetch()` did reject, `navigator.onLine` is true and origin is different)
|
||||
*/
|
||||
getRetryDelay?: (previousAttempts: number, error: unknown) => number | null;
|
||||
getRetryDelay?: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null;
|
||||
|
||||
/** The maximum number of bytes the cache is allowed to hold in memory. Defaults to 64 MiB. */
|
||||
maxCacheSize?: number;
|
||||
@@ -298,7 +330,7 @@ export class UrlSource extends Source {
|
||||
/** @internal */
|
||||
_url: string | URL | Request;
|
||||
/** @internal */
|
||||
_getRetryDelay: (previousAttempts: number, error: unknown) => number | null;
|
||||
_getRetryDelay: (previousAttempts: number, error: unknown, url: string | URL | Request) => number | null;
|
||||
/** @internal */
|
||||
_options: UrlSourceOptions;
|
||||
/** @internal */
|
||||
@@ -491,7 +523,7 @@ export class UrlSource extends Source {
|
||||
try {
|
||||
readResult = await reader.read();
|
||||
} catch (error) {
|
||||
const retryDelayInSeconds = this._getRetryDelay(1, error);
|
||||
const retryDelayInSeconds = this._getRetryDelay(1, error, this._url);
|
||||
if (retryDelayInSeconds !== null) {
|
||||
console.error('Error while reading response stream. Attempting to resume.', error);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryDelayInSeconds));
|
||||
@@ -1192,6 +1224,7 @@ class ReadOrchestrator {
|
||||
workers: ReadWorker[] = [];
|
||||
cache: CacheEntry[] = [];
|
||||
currentCacheSize = 0;
|
||||
disposed = false;
|
||||
|
||||
constructor(public options: {
|
||||
maxCacheSize: number;
|
||||
@@ -1440,6 +1473,11 @@ class ReadOrchestrator {
|
||||
|
||||
/** Called by a worker when it has read some data. */
|
||||
supplyWorkerData(worker: ReadWorker, bytes: Uint8Array) {
|
||||
if (this.disposed) {
|
||||
// Writes may still come in after disposal, but we just ignore those
|
||||
return;
|
||||
}
|
||||
|
||||
const start = worker.currentPos;
|
||||
const end = start + bytes.length;
|
||||
|
||||
@@ -1613,5 +1651,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.
|
||||
|
||||
Reference in New Issue
Block a user