mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 14:23:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e89511ca6 | ||
|
|
e0a4169bf8 | ||
|
|
c9dcebed6f | ||
|
|
ae0266df52 | ||
|
|
1492fd5c6f | ||
|
|
6d447660c2 | ||
|
|
b224ac0dc6 | ||
|
|
47c955524f | ||
|
|
80c6fa3201 | ||
|
|
11c15c2392 | ||
|
|
975c1c42cb | ||
|
|
a9cec387d4 | ||
|
|
12df436e15 | ||
|
|
56ae97f994 | ||
|
|
5608f519a7 | ||
|
|
230f02fffc | ||
|
|
51059c4b82 | ||
|
|
db437bba01 |
+4
-3
@@ -24,7 +24,7 @@
|
||||
chunked: true,
|
||||
chunkSize: 2**20
|
||||
});
|
||||
const outputFormat = new Mediabunny.Mp3OutputFormat({});
|
||||
const outputFormat = new Mediabunny.Mp4OutputFormat({});
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Cancel';
|
||||
@@ -41,6 +41,7 @@
|
||||
target
|
||||
}),
|
||||
audio: {
|
||||
discard: true,
|
||||
//codec: 'opus',
|
||||
//bitrate: 128000,
|
||||
//numberOfChannels: 1,
|
||||
@@ -72,7 +73,7 @@
|
||||
bitrate: 320000
|
||||
},
|
||||
*/
|
||||
video: {
|
||||
video: () => ({
|
||||
//frameRate: 27.123,
|
||||
//width: 320,
|
||||
//forceTranscode: true,
|
||||
@@ -91,7 +92,7 @@
|
||||
//height: 512,
|
||||
//width: 200,
|
||||
//height: 100,
|
||||
},
|
||||
}),
|
||||
trim: {
|
||||
start: 0,
|
||||
end: 20
|
||||
|
||||
@@ -16,6 +16,38 @@
|
||||
source
|
||||
});
|
||||
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
|
||||
|
||||
for await (const packet of sink.packets(undefined, undefined, { verifyKeyPackets: false })) {
|
||||
console.log(packet);
|
||||
if (packet.timestamp >= 2.4) break;
|
||||
}
|
||||
|
||||
/*
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
|
||||
|
||||
console.log(await sink.getPacket(100))
|
||||
*/
|
||||
|
||||
/*
|
||||
for await (const packet of sink.packets()) {
|
||||
//console.log(packet);
|
||||
//if (packet.timestamp >= 2.4) break;
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
const sink = new Mediabunny.VideoSampleSink(videoTrack);
|
||||
|
||||
for await (const sample of sink.samples(0, 2)) {
|
||||
console.log(sample.timestamp);
|
||||
sample.close();
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
|
||||
|
||||
@@ -92,22 +92,18 @@ This automatically frees up all resources used by the conversion process.
|
||||
|
||||
## Video options
|
||||
|
||||
You can set the `video` property in the conversion options to configure the converter's behavior for video tracks:
|
||||
You can set the `video` property in the conversion options to configure the converter's behavior for video tracks. The options are:
|
||||
```ts
|
||||
type ConversionOptions = {
|
||||
// ...
|
||||
video?: {
|
||||
discard?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
rotate?: 0 | 90 | 180 | 270;
|
||||
frameRate?: number;
|
||||
codec?: VideoCodec;
|
||||
bitrate?: number | Quality;
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
// ...
|
||||
type ConversionVideoOptions = {
|
||||
discard?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
rotate?: 0 | 90 | 180 | 270;
|
||||
frameRate?: number;
|
||||
codec?: VideoCodec;
|
||||
bitrate?: number | Quality;
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -125,7 +121,7 @@ const conversion = await Conversion.init({
|
||||
```
|
||||
|
||||
::: info
|
||||
The provided configuration will apply equally to all video tracks of the input.
|
||||
The provided configuration will apply equally to all video tracks of the input. If you want to apply a separate configuration to each video track, check [track-specific options](#track-specific-options).
|
||||
:::
|
||||
|
||||
### Discarding video
|
||||
@@ -143,6 +139,8 @@ The `width`, `height` and `fit` properties control how the video is resized. If
|
||||
|
||||
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
|
||||
|
||||
If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options).
|
||||
|
||||
### Adjusting frame rate
|
||||
|
||||
The `frameRate` property can be used to set the frame rate of the output video in Hz. If not specified, the original input frame rate will be used (which may be variable).
|
||||
@@ -157,19 +155,15 @@ If you want to prevent direct copying of media data and force a transcoding step
|
||||
|
||||
## Audio options
|
||||
|
||||
You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks:
|
||||
You can set the `audio` property in the conversion options to configure the converter's behavior for audio tracks. The options are:
|
||||
```ts
|
||||
type ConversionOptions = {
|
||||
// ...
|
||||
audio?: {
|
||||
discard?: boolean;
|
||||
codec?: AudioCodec;
|
||||
bitrate?: number | Quality;
|
||||
numberOfChannels?: number;
|
||||
sampleRate?: number;
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
// ...
|
||||
type ConversionAudioOptions = {
|
||||
discard?: boolean;
|
||||
codec?: AudioCodec;
|
||||
bitrate?: number | Quality;
|
||||
numberOfChannels?: number;
|
||||
sampleRate?: number;
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -186,7 +180,7 @@ const conversion = await Conversion.init({
|
||||
```
|
||||
|
||||
::: info
|
||||
The provided configuration will apply equally to all audio tracks of the input.
|
||||
The provided configuration will apply equally to all audio tracks of the input. If you want to apply a separate configuration to each audio track, check [track-specific options](#track-specific-options).
|
||||
:::
|
||||
|
||||
### Discarding audio
|
||||
@@ -207,6 +201,46 @@ 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
For this, instead of passing an object for `video` and `audio`, you can instead pass a function:
|
||||
|
||||
```ts
|
||||
const conversion = await Conversion.init({
|
||||
input,
|
||||
output,
|
||||
|
||||
// Function gets invoked for each video track:
|
||||
video: (videoTrack, n) => {
|
||||
if (n > 1) {
|
||||
// Keep only the first video track
|
||||
return { discard: true };
|
||||
}
|
||||
|
||||
return {
|
||||
// Shrink width to 640 only if the track is wider
|
||||
width: Math.min(videoTrack.displayWidth, 640),
|
||||
};
|
||||
},
|
||||
|
||||
// Async functions work too:
|
||||
audio: async (audioTrack, n) => {
|
||||
if (audioTrack.languageCode !== 'rus') {
|
||||
// Keep only Russian audio tracks
|
||||
return { discard: true };
|
||||
}
|
||||
|
||||
return {
|
||||
codec: 'aac',
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For documentation about the properties of video and audio tracks, refer to [Reading track metadata](./reading-media-files#reading-track-metadata).
|
||||
|
||||
## Trimming
|
||||
|
||||
Use the `trim` property in the conversion options to extract only a section of the input file into the output file:
|
||||
|
||||
@@ -42,7 +42,7 @@ while (notDone) {
|
||||
|
||||
### Video encoding config
|
||||
|
||||
All video sources that handle encoding internally require you to specify a `VideoEncodingConfiguration`, specifying the codec configuration to use:
|
||||
All video sources that handle encoding internally require you to specify a `VideoEncodingConfig`, specifying the codec configuration to use:
|
||||
```ts
|
||||
type VideoEncodingConfig = {
|
||||
codec: VideoCodec;
|
||||
@@ -62,7 +62,7 @@ type VideoEncodingConfig = {
|
||||
```
|
||||
- `codec`: The [video codec](./supported-formats-and-codecs#video-codecs) used for encoding.
|
||||
- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
|
||||
- `latencyMode`: The latency mode as specified by the WebCodecs API. Media stream-driven video sources will automatically use the `realtime` setting.
|
||||
- `latencyMode`: The latency mode as specified by the WebCodecs API. Browsers default to `quality`. Media stream-driven video sources will automatically use the `realtime` setting.
|
||||
- `keyFrameInterval`: The maximum interval in seconds between two adjacent key frames. Defaults to 5 seconds. More frequent key frames improve seeking behavior but increase file size. When using multiple video tracks, this value should be set to the same value for all tracks.
|
||||
- `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
|
||||
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
|
||||
@@ -70,7 +70,7 @@ type VideoEncodingConfig = {
|
||||
|
||||
### Audio encoding config
|
||||
|
||||
All audio sources that handle encoding internally require you to specify an `AudioEncodingConfiguration`, specifying the codec configuration to use:
|
||||
All audio sources that handle encoding internally require you to specify an `AudioEncodingConfig`, specifying the codec configuration to use:
|
||||
```ts
|
||||
type AudioEncodingConfig = {
|
||||
codec: AudioCodec;
|
||||
|
||||
@@ -206,11 +206,17 @@ const output = new Output({
|
||||
The following options are available:
|
||||
```ts
|
||||
type Mp3OutputFormatOptions = {
|
||||
xingHeader?: boolean;
|
||||
onXingFrame?: (data: Uint8Array, position: number) => unknown;
|
||||
};
|
||||
```
|
||||
- `xingHeader`\
|
||||
Controls whether the Xing header, which contains additional metadata as well as an index, is written to the start of the MP3 file. Defaults to `true`.
|
||||
::: info
|
||||
When set to `false`, this option ensures [append-only writing](#append-only-writing).
|
||||
:::
|
||||
- `onXingFrame`\
|
||||
Will be called once the Xing metadata frame is finalized, which happens at the end of the writing process.
|
||||
Will be called once the Xing metadata frame is finalized, which happens at the end of the writing process. This callback only fires if `xingHeader` isn't set to `false`.
|
||||
|
||||
::: info
|
||||
Most browsers don't support encoding MP3. Use the official [`@mediabunny/mp3-encoder`](./extensions/mp3-encoder) package to polyfill an encoder.
|
||||
|
||||
@@ -132,6 +132,9 @@ const initMediaPlayer = async (file: File) => {
|
||||
warningElement.textContent = problemMessage;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
|
||||
// We must create the audio context with the matching sample rate for correct acoustic results
|
||||
// (especially for low-sample rate files)
|
||||
audioContext = new AudioContext({ sampleRate: audioTrack?.sampleRate });
|
||||
@@ -166,7 +169,11 @@ const initMediaPlayer = async (file: File) => {
|
||||
fileLoaded = true;
|
||||
|
||||
await startVideoIterator();
|
||||
await play();
|
||||
|
||||
if (audioContext.state === 'running') {
|
||||
// Start playback automatically if the audio context permits
|
||||
await play();
|
||||
}
|
||||
|
||||
playerContainer.style.display = '';
|
||||
|
||||
@@ -330,6 +337,10 @@ const getPlaybackTime = () => {
|
||||
};
|
||||
|
||||
const play = async () => {
|
||||
if (audioContext!.state === 'suspended') {
|
||||
await audioContext!.resume();
|
||||
}
|
||||
|
||||
if (getPlaybackTime() === totalDuration) {
|
||||
// If we're at the end, let's snap back to the start
|
||||
playbackTimeAtStart = 0;
|
||||
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.2",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -5900,9 +5900,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.6.0.tgz",
|
||||
"integrity": "sha512-6qBEk4sxAiZQINUvww/ose/hwqw7MIZXYbnBMezd7Uoh6v+Fkr4JQAkNyimli/fEgOJ0DJ8tglTJ/hyLMCH69w==",
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.7.1.tgz",
|
||||
"integrity": "sha512-y9s+Vf6TLhXeVjvlJFSmHRrwUQi2CJCMhxthm6nfhBO1XHJhcOqNOxu4CjhYyjoWgVZB5pkPKyWgZxpPOZfglQ==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -9017,7 +9017,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.2",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.2",
|
||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
Browsers typically have no support for MP3 encoding in their WebCodecs implementations. Given the ubiquity of the format, this extension package provides an MP3 encoder for use with [Mediabunny](https://github.com/Vanilagy/mediabunny). It is implemented using Mediabunny's [custom coder API](https://mediabunny.dev/guide/supported-formats-and-codecs#custom-coders) and uses a highly-performant WASM build of the [LAME MP3 Encoder](https://lame.sourceforge.io/) under the hood.
|
||||
|
||||
> This package, like the rest of Mediabunny, is enabled by its [sponsors](https://mediabunny.dev/#sponsors) and their donations. If you've derived value from this package, please consider leaving a donation! 💘
|
||||
> This package, like the rest of Mediabunny, is enabled by its [sponsors](https://mediabunny.dev/#sponsors) and their donations. If you've derived value from this package, please consider [leaving a donation](https://github.com/sponsors/Vanilagy)! 💘
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.2",
|
||||
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
|
||||
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
|
||||
@@ -22,12 +22,13 @@
|
||||
"license": "MPL-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Vanilagy/mediabunny.git"
|
||||
"url": "git+https://github.com/Vanilagy/mediabunny.git",
|
||||
"directory": "packages/mp3-encoder"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/Vanilagy/mediabunny/issues"
|
||||
},
|
||||
"homepage": "https://mediabunny.dev/",
|
||||
"homepage": "https://mediabunny.dev/guide/extensions/mp3-encoder",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/Vanilagy"
|
||||
|
||||
@@ -71,13 +71,18 @@ if (cacheDir === undefined) {
|
||||
throw new Error('Cache directory not found.');
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
|
||||
async function buildWorker(workerPath: string, extraConfig: esbuild.BuildOptions) {
|
||||
const scriptNameParts = path.basename(workerPath).split('.');
|
||||
scriptNameParts.pop();
|
||||
scriptNameParts.push(String(i)); // To make sure it doesn't clash with other builds
|
||||
scriptNameParts.push('js');
|
||||
const scriptName = scriptNameParts.join('.');
|
||||
const bundlePath = path.resolve(cacheDir!, scriptName);
|
||||
|
||||
i = (i + 1) % 32;
|
||||
|
||||
if (extraConfig) {
|
||||
delete extraConfig.entryPoints;
|
||||
delete extraConfig.outfile;
|
||||
|
||||
+243
-180
@@ -35,10 +35,131 @@ import {
|
||||
VideoSampleSource,
|
||||
AudioSampleSource,
|
||||
} from './media-source';
|
||||
import { assert, clamp, normalizeRotation, promiseWithResolvers, Rotation } from './misc';
|
||||
import { assert, clamp, MaybePromise, normalizeRotation, promiseWithResolvers, Rotation } from './misc';
|
||||
import { Output, TrackType } from './output';
|
||||
import { AudioSample, VideoSample } from './sample';
|
||||
|
||||
/**
|
||||
* Video-specific options.
|
||||
* @public
|
||||
*/
|
||||
export type ConversionVideoOptions = {
|
||||
/** If true, all video tracks will be discarded and will not be present in the output. */
|
||||
discard?: boolean;
|
||||
/**
|
||||
* The desired width of the output video in pixels, defaulting to the video's natural display width. If height
|
||||
* is not set, it will be deduced automatically based on aspect ratio.
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* The desired height of the output video in pixels, defaulting to the video's natural display height. If width
|
||||
* is not set, it will be deduced automatically based on aspect ratio.
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* The fitting algorithm in case both width and height are set.
|
||||
*
|
||||
* - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
|
||||
* - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
|
||||
* letterboxing.
|
||||
* - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio.
|
||||
*/
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
/**
|
||||
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This
|
||||
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
|
||||
*/
|
||||
rotate?: Rotation;
|
||||
/**
|
||||
* The desired frame rate of the output video, in hertz. If not specified, the original input frame rate will
|
||||
* be used (which may be variable).
|
||||
*/
|
||||
frameRate?: number;
|
||||
/** The desired output video codec. */
|
||||
codec?: VideoCodec;
|
||||
/** The desired bitrate of the output video. */
|
||||
bitrate?: VideoEncodingConfig['bitrate'];
|
||||
/** When true, video will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Audio-specific options.
|
||||
* @public
|
||||
*/
|
||||
export type ConversionAudioOptions = {
|
||||
/** If true, all audio tracks will be discarded and will not be present in the output. */
|
||||
discard?: boolean;
|
||||
/** The desired channel count of the output audio. */
|
||||
numberOfChannels?: number;
|
||||
/** The desired sample rate of the output audio, in hertz. */
|
||||
sampleRate?: number;
|
||||
/** The desired output audio codec. */
|
||||
codec?: AudioCodec;
|
||||
/** The desired bitrate of the output audio. */
|
||||
bitrate?: AudioEncodingConfig['bitrate'];
|
||||
/** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
|
||||
const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined) => {
|
||||
if (videoOptions !== undefined && (!videoOptions || typeof videoOptions !== 'object')) {
|
||||
throw new TypeError('options.video, when provided, must be an object.');
|
||||
}
|
||||
if (videoOptions?.discard !== undefined && typeof videoOptions.discard !== 'boolean') {
|
||||
throw new TypeError('options.video.discard, when provided, must be a boolean.');
|
||||
}
|
||||
if (videoOptions?.forceTranscode !== undefined && typeof videoOptions.forceTranscode !== 'boolean') {
|
||||
throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.');
|
||||
}
|
||||
if (videoOptions?.codec !== undefined && !VIDEO_CODECS.includes(videoOptions.codec)) {
|
||||
throw new TypeError(
|
||||
`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
videoOptions?.bitrate !== undefined
|
||||
&& !(videoOptions.bitrate instanceof Quality)
|
||||
&& (!Number.isInteger(videoOptions.bitrate) || videoOptions.bitrate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.width !== undefined
|
||||
&& (!Number.isInteger(videoOptions.width) || videoOptions.width <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.width, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.height !== undefined
|
||||
&& (!Number.isInteger(videoOptions.height) || videoOptions.height <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.height, when provided, must be a positive integer.');
|
||||
}
|
||||
if (videoOptions?.fit !== undefined && !['fill', 'contain', 'cover'].includes(videoOptions.fit)) {
|
||||
throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".');
|
||||
}
|
||||
if (
|
||||
videoOptions?.width !== undefined
|
||||
&& videoOptions.height !== undefined
|
||||
&& videoOptions.fit === undefined
|
||||
) {
|
||||
throw new TypeError(
|
||||
'When both options.video.width and options.video.height are provided, options.video.fit must also be'
|
||||
+ ' provided.',
|
||||
);
|
||||
}
|
||||
if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
|
||||
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.frameRate !== undefined
|
||||
&& (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The options for media file conversion.
|
||||
* @public
|
||||
@@ -49,62 +170,23 @@ export type ConversionOptions = {
|
||||
/** The output file. */
|
||||
output: Output;
|
||||
|
||||
/** Video-specific options. */
|
||||
video?: {
|
||||
/** If true, all video tracks will be discarded and will not be present in the output. */
|
||||
discard?: boolean;
|
||||
/**
|
||||
* The desired width of the output video in pixels, defaulting to the video's natural display width. If height
|
||||
* is not set, it will be deduced automatically based on aspect ratio.
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* The desired height of the output video in pixels, defaulting to the video's natural display height. If width
|
||||
* is not set, it will be deduced automatically based on aspect ratio.
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* The fitting algorithm in case both width and height are set.
|
||||
*
|
||||
* - 'fill' will stretch the image to fill the entire box, potentially altering aspect ratio.
|
||||
* - 'contain' will contain the entire image within the box while preserving aspect ratio. This may lead to
|
||||
* letterboxing.
|
||||
* - 'cover' will scale the image until the entire box is filled, while preserving aspect ratio.
|
||||
*/
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
/**
|
||||
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This
|
||||
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
|
||||
*/
|
||||
rotate?: Rotation;
|
||||
/**
|
||||
* The desired frame rate of the output video, in hertz. If not specified, the original input frame rate will
|
||||
* be used (which may be variable).
|
||||
*/
|
||||
frameRate?: number;
|
||||
/** The desired output video codec. */
|
||||
codec?: VideoCodec;
|
||||
/** The desired bitrate of the output video. */
|
||||
bitrate?: VideoEncodingConfig['bitrate'];
|
||||
/** When true, video will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
/**
|
||||
* Video-specific options. When passing an object, the same options are applied to all video tracks. When passing a
|
||||
* function, it will be invoked for each video track and is expected to return or resolve to the options
|
||||
* for that specific track. The function is passed an instance of `InputVideoTrack` as well as a number `n`, which
|
||||
* is the 1-based index of the track in the list of all video tracks.
|
||||
*/
|
||||
video?: ConversionVideoOptions
|
||||
| ((track: InputVideoTrack, n: number) => MaybePromise<ConversionVideoOptions | undefined>);
|
||||
|
||||
/** Audio-specific options. */
|
||||
audio?: {
|
||||
/** If true, all audio tracks will be discarded and will not be present in the output. */
|
||||
discard?: boolean;
|
||||
/** The desired channel count of the output audio. */
|
||||
numberOfChannels?: number;
|
||||
/** The desired sample rate of the output audio, in hertz. */
|
||||
sampleRate?: number;
|
||||
/** The desired output audio codec. */
|
||||
codec?: AudioCodec;
|
||||
/** The desired bitrate of the output audio. */
|
||||
bitrate?: AudioEncodingConfig['bitrate'];
|
||||
/** When true, audio will always be re-encoded instead of directly copying over the encoded samples. */
|
||||
forceTranscode?: boolean;
|
||||
};
|
||||
/**
|
||||
* Audio-specific options. When passing an object, the same options are applied to all audio tracks. When passing a
|
||||
* function, it will be invoked for each audio track and is expected to return or resolve to the options
|
||||
* for that specific track. The function is passed an instance of `InputAudioTrack` as well as a number `n`, which
|
||||
* is the 1-based index of the track in the list of all audio tracks.
|
||||
*/
|
||||
audio?: ConversionAudioOptions
|
||||
| ((track: InputAudioTrack, n: number) => MaybePromise<ConversionAudioOptions | undefined>);
|
||||
|
||||
/** Options to trim the input file. */
|
||||
trim?: {
|
||||
@@ -115,6 +197,42 @@ export type ConversionOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
|
||||
if (audioOptions !== undefined && (!audioOptions || typeof audioOptions !== 'object')) {
|
||||
throw new TypeError('options.audio, when provided, must be an object.');
|
||||
}
|
||||
if (audioOptions?.discard !== undefined && typeof audioOptions.discard !== 'boolean') {
|
||||
throw new TypeError('options.audio.discard, when provided, must be a boolean.');
|
||||
}
|
||||
if (audioOptions?.forceTranscode !== undefined && typeof audioOptions.forceTranscode !== 'boolean') {
|
||||
throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.');
|
||||
}
|
||||
if (audioOptions?.codec !== undefined && !AUDIO_CODECS.includes(audioOptions.codec)) {
|
||||
throw new TypeError(
|
||||
`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
audioOptions?.bitrate !== undefined
|
||||
&& !(audioOptions.bitrate instanceof Quality)
|
||||
&& (!Number.isInteger(audioOptions.bitrate) || audioOptions.bitrate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.');
|
||||
}
|
||||
if (
|
||||
audioOptions?.numberOfChannels !== undefined
|
||||
&& (!Number.isInteger(audioOptions.numberOfChannels) || audioOptions.numberOfChannels <= 0)
|
||||
) {
|
||||
throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
audioOptions?.sampleRate !== undefined
|
||||
&& (!Number.isInteger(audioOptions.sampleRate) || audioOptions.sampleRate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
|
||||
}
|
||||
};
|
||||
|
||||
const FALLBACK_NUMBER_OF_CHANNELS = 2;
|
||||
const FALLBACK_SAMPLE_RATE = 48000;
|
||||
|
||||
@@ -217,94 +335,19 @@ export class Conversion {
|
||||
if (options.output._tracks.length > 0 || options.output.state !== 'pending') {
|
||||
throw new TypeError('options.output must be fresh: no tracks added and not started.');
|
||||
}
|
||||
if (options.video !== undefined && (!options.video || typeof options.video !== 'object')) {
|
||||
throw new TypeError('options.video, when provided, must be an object.');
|
||||
|
||||
if (typeof options.video !== 'function') {
|
||||
validateVideoOptions(options.video);
|
||||
} else {
|
||||
// We'll validate the return value later
|
||||
}
|
||||
if (options.video?.discard !== undefined && typeof options.video.discard !== 'boolean') {
|
||||
throw new TypeError('options.video.discard, when provided, must be a boolean.');
|
||||
}
|
||||
if (options.video?.forceTranscode !== undefined && typeof options.video.forceTranscode !== 'boolean') {
|
||||
throw new TypeError('options.video.forceTranscode, when provided, must be a boolean.');
|
||||
}
|
||||
if (options.video?.codec !== undefined && !VIDEO_CODECS.includes(options.video.codec)) {
|
||||
throw new TypeError(
|
||||
`options.video.codec, when provided, must be one of: ${VIDEO_CODECS.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.video?.bitrate !== undefined
|
||||
&& !(options.video.bitrate instanceof Quality)
|
||||
&& (!Number.isInteger(options.video.bitrate) || options.video.bitrate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.bitrate, when provided, must be a positive integer or a quality.');
|
||||
}
|
||||
if (
|
||||
options.video?.width !== undefined
|
||||
&& (!Number.isInteger(options.video.width) || options.video.width <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.width, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
options.video?.height !== undefined
|
||||
&& (!Number.isInteger(options.video.height) || options.video.height <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.height, when provided, must be a positive integer.');
|
||||
}
|
||||
if (options.video?.fit !== undefined && !['fill', 'contain', 'cover'].includes(options.video.fit)) {
|
||||
throw new TypeError('options.video.fit, when provided, must be one of "fill", "contain", or "cover".');
|
||||
}
|
||||
if (
|
||||
options.video?.width !== undefined
|
||||
&& options.video.height !== undefined
|
||||
&& options.video.fit === undefined
|
||||
) {
|
||||
throw new TypeError(
|
||||
'When both options.video.width and options.video.height are provided, options.video.fit must also be'
|
||||
+ ' provided.',
|
||||
);
|
||||
}
|
||||
if (options.video?.rotate !== undefined && ![0, 90, 180, 270].includes(options.video.rotate)) {
|
||||
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
|
||||
}
|
||||
if (
|
||||
options.video?.frameRate !== undefined
|
||||
&& (!Number.isFinite(options.video.frameRate) || options.video.frameRate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.video.frameRate, when provided, must be a finite positive number.');
|
||||
}
|
||||
if (options.audio !== undefined && (!options.audio || typeof options.audio !== 'object')) {
|
||||
throw new TypeError('options.audio, when provided, must be an object.');
|
||||
}
|
||||
if (options.audio?.discard !== undefined && typeof options.audio.discard !== 'boolean') {
|
||||
throw new TypeError('options.audio.discard, when provided, must be a boolean.');
|
||||
}
|
||||
if (options.audio?.forceTranscode !== undefined && typeof options.audio.forceTranscode !== 'boolean') {
|
||||
throw new TypeError('options.audio.forceTranscode, when provided, must be a boolean.');
|
||||
}
|
||||
if (options.audio?.codec !== undefined && !AUDIO_CODECS.includes(options.audio.codec)) {
|
||||
throw new TypeError(
|
||||
`options.audio.codec, when provided, must be one of: ${AUDIO_CODECS.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.audio?.bitrate !== undefined
|
||||
&& !(options.audio.bitrate instanceof Quality)
|
||||
&& (!Number.isInteger(options.audio.bitrate) || options.audio.bitrate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.audio.bitrate, when provided, must be a positive integer or a quality.');
|
||||
}
|
||||
if (
|
||||
options.audio?.numberOfChannels !== undefined
|
||||
&& (!Number.isInteger(options.audio.numberOfChannels) || options.audio.numberOfChannels <= 0)
|
||||
) {
|
||||
throw new TypeError('options.audio.numberOfChannels, when provided, must be a positive integer.');
|
||||
}
|
||||
if (
|
||||
options.audio?.sampleRate !== undefined
|
||||
&& (!Number.isInteger(options.audio.sampleRate) || options.audio.sampleRate <= 0)
|
||||
) {
|
||||
throw new TypeError('options.audio.sampleRate, when provided, must be a positive integer.');
|
||||
|
||||
if (typeof options.audio !== 'function') {
|
||||
validateAudioOptions(options.audio);
|
||||
} else {
|
||||
// We'll validate the return value later
|
||||
}
|
||||
|
||||
if (options.trim !== undefined && (!options.trim || typeof options.trim !== 'object')) {
|
||||
throw new TypeError('options.trim, when provided, must be an object.');
|
||||
}
|
||||
@@ -338,16 +381,36 @@ export class Conversion {
|
||||
const inputTracks = await this.input.getTracks();
|
||||
const outputTrackCounts = this.output.format.getSupportedTrackCounts();
|
||||
|
||||
let nVideo = 1;
|
||||
let nAudio = 1;
|
||||
|
||||
for (const track of inputTracks) {
|
||||
if (track.isVideoTrack() && this._options.video?.discard) {
|
||||
this.discardedTracks.push({
|
||||
track,
|
||||
reason: 'discarded_by_user',
|
||||
});
|
||||
continue;
|
||||
let trackOptions: ConversionVideoOptions | ConversionAudioOptions | undefined = undefined;
|
||||
if (track.isVideoTrack()) {
|
||||
if (this._options.video) {
|
||||
if (typeof this._options.video === 'function') {
|
||||
trackOptions = await this._options.video(track, nVideo);
|
||||
validateVideoOptions(trackOptions);
|
||||
nVideo++;
|
||||
} else {
|
||||
trackOptions = this._options.video;
|
||||
}
|
||||
}
|
||||
} else if (track.isAudioTrack()) {
|
||||
if (this._options.audio) {
|
||||
if (typeof this._options.audio === 'function') {
|
||||
trackOptions = await this._options.audio(track, nAudio);
|
||||
validateAudioOptions(trackOptions);
|
||||
nAudio++;
|
||||
} else {
|
||||
trackOptions = this._options.audio;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert(false);
|
||||
}
|
||||
|
||||
if (track.isAudioTrack() && this._options.audio?.discard) {
|
||||
if (trackOptions?.discard) {
|
||||
this.discardedTracks.push({
|
||||
track,
|
||||
reason: 'discarded_by_user',
|
||||
@@ -372,9 +435,9 @@ export class Conversion {
|
||||
}
|
||||
|
||||
if (track.isVideoTrack()) {
|
||||
await this._processVideoTrack(track);
|
||||
await this._processVideoTrack(track, (trackOptions ?? {}) as ConversionVideoOptions);
|
||||
} else if (track.isAudioTrack()) {
|
||||
await this._processAudioTrack(track);
|
||||
await this._processAudioTrack(track, (trackOptions ?? {}) as ConversionAudioOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,7 +506,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _processVideoTrack(track: InputVideoTrack) {
|
||||
async _processVideoTrack(track: InputVideoTrack, trackOptions: ConversionVideoOptions) {
|
||||
const sourceCodec = track.codec;
|
||||
if (!sourceCodec) {
|
||||
this.discardedTracks.push({
|
||||
@@ -455,7 +518,7 @@ export class Conversion {
|
||||
|
||||
let videoSource: VideoSource;
|
||||
|
||||
const totalRotation = normalizeRotation(track.rotation + (this._options.video?.rotate ?? 0));
|
||||
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
|
||||
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
|
||||
|
||||
const [originalWidth, originalHeight] = totalRotation % 180 === 0
|
||||
@@ -469,22 +532,22 @@ export class Conversion {
|
||||
// A lot of video encoders require that the dimensions be multiples of 2
|
||||
const ceilToMultipleOfTwo = (value: number) => Math.ceil(value / 2) * 2;
|
||||
|
||||
if (this._options.video?.width !== undefined && this._options.video.height === undefined) {
|
||||
width = ceilToMultipleOfTwo(this._options.video.width);
|
||||
if (trackOptions.width !== undefined && trackOptions.height === undefined) {
|
||||
width = ceilToMultipleOfTwo(trackOptions.width);
|
||||
height = ceilToMultipleOfTwo(Math.round(width / aspectRatio));
|
||||
} else if (this._options.video?.width === undefined && this._options.video?.height !== undefined) {
|
||||
height = ceilToMultipleOfTwo(this._options.video.height);
|
||||
} else if (trackOptions.width === undefined && trackOptions.height !== undefined) {
|
||||
height = ceilToMultipleOfTwo(trackOptions.height);
|
||||
width = ceilToMultipleOfTwo(Math.round(height * aspectRatio));
|
||||
} else if (this._options.video?.width !== undefined && this._options.video.height !== undefined) {
|
||||
width = ceilToMultipleOfTwo(this._options.video.width);
|
||||
height = ceilToMultipleOfTwo(this._options.video.height);
|
||||
} else if (trackOptions.width !== undefined && trackOptions.height !== undefined) {
|
||||
width = ceilToMultipleOfTwo(trackOptions.width);
|
||||
height = ceilToMultipleOfTwo(trackOptions.height);
|
||||
}
|
||||
|
||||
const firstTimestamp = await track.getFirstTimestamp();
|
||||
const needsTranscode = !!this._options.video?.forceTranscode
|
||||
const needsTranscode = !!trackOptions.forceTranscode
|
||||
|| this._startTimestamp > 0
|
||||
|| firstTimestamp < 0
|
||||
|| !!this._options.video?.frameRate;
|
||||
|| !!trackOptions.frameRate;
|
||||
const needsRerender = width !== originalWidth
|
||||
|| height !== originalHeight
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation);
|
||||
@@ -492,10 +555,10 @@ export class Conversion {
|
||||
let videoCodecs = this.output.format.getSupportedVideoCodecs();
|
||||
if (
|
||||
!needsTranscode
|
||||
&& !this._options.video?.bitrate
|
||||
&& !trackOptions.bitrate
|
||||
&& !needsRerender
|
||||
&& videoCodecs.includes(sourceCodec)
|
||||
&& (!this._options.video?.codec || this._options.video?.codec === sourceCodec)
|
||||
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
||||
) {
|
||||
// Fast path, we can simply copy over the encoded packets
|
||||
|
||||
@@ -540,11 +603,11 @@ export class Conversion {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._options.video?.codec) {
|
||||
videoCodecs = videoCodecs.filter(codec => codec === this._options.video?.codec);
|
||||
if (trackOptions.codec) {
|
||||
videoCodecs = videoCodecs.filter(codec => codec === trackOptions.codec);
|
||||
}
|
||||
|
||||
const bitrate = this._options.video?.bitrate ?? QUALITY_HIGH;
|
||||
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
||||
|
||||
const encodableCodec = await getFirstEncodableVideoCodec(videoCodecs, { width, height, bitrate });
|
||||
if (!encodableCodec) {
|
||||
@@ -571,12 +634,12 @@ export class Conversion {
|
||||
const sink = new CanvasSink(track, {
|
||||
width,
|
||||
height,
|
||||
fit: this._options.video?.fit ?? 'fill',
|
||||
fit: trackOptions.fit ?? 'fill',
|
||||
rotation: totalRotation, // Bake the rotation into the output
|
||||
poolSize: 1,
|
||||
});
|
||||
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
||||
const frameRate = this._options.video?.frameRate;
|
||||
const frameRate = trackOptions.frameRate;
|
||||
|
||||
let lastCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
|
||||
let lastCanvasTimestamp: number | null = null;
|
||||
@@ -661,7 +724,7 @@ export class Conversion {
|
||||
await this._started;
|
||||
|
||||
const sink = new VideoSampleSink(track);
|
||||
const frameRate = this._options.video?.frameRate;
|
||||
const frameRate = trackOptions.frameRate;
|
||||
|
||||
let lastSample: VideoSample | null = null;
|
||||
let lastSampleTimestamp: number | null = null;
|
||||
@@ -744,7 +807,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
this.output.addVideoTrack(videoSource, {
|
||||
frameRate: this._options.video?.frameRate,
|
||||
frameRate: trackOptions.frameRate,
|
||||
languageCode: track.languageCode,
|
||||
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
|
||||
});
|
||||
@@ -755,7 +818,7 @@ export class Conversion {
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async _processAudioTrack(track: InputAudioTrack) {
|
||||
async _processAudioTrack(track: InputAudioTrack, trackOptions: ConversionAudioOptions) {
|
||||
const sourceCodec = track.codec;
|
||||
if (!sourceCodec) {
|
||||
this.discardedTracks.push({
|
||||
@@ -772,8 +835,8 @@ export class Conversion {
|
||||
|
||||
const firstTimestamp = await track.getFirstTimestamp();
|
||||
|
||||
let numberOfChannels = this._options.audio?.numberOfChannels ?? originalNumberOfChannels;
|
||||
let sampleRate = this._options.audio?.sampleRate ?? originalSampleRate;
|
||||
let numberOfChannels = trackOptions.numberOfChannels ?? originalNumberOfChannels;
|
||||
let sampleRate = trackOptions.sampleRate ?? originalSampleRate;
|
||||
let needsResample = numberOfChannels !== originalNumberOfChannels
|
||||
|| sampleRate !== originalSampleRate
|
||||
|| this._startTimestamp > 0
|
||||
@@ -781,11 +844,11 @@ export class Conversion {
|
||||
|
||||
let audioCodecs = this.output.format.getSupportedAudioCodecs();
|
||||
if (
|
||||
!this._options.audio?.forceTranscode
|
||||
&& !this._options.audio?.bitrate
|
||||
!trackOptions.forceTranscode
|
||||
&& !trackOptions.bitrate
|
||||
&& !needsResample
|
||||
&& audioCodecs.includes(sourceCodec)
|
||||
&& (!this._options.audio?.codec || this._options.audio.codec === sourceCodec)
|
||||
&& (!trackOptions.codec || trackOptions.codec === sourceCodec)
|
||||
) {
|
||||
// Fast path, we can simply copy over the encoded packets
|
||||
|
||||
@@ -832,11 +895,11 @@ export class Conversion {
|
||||
|
||||
let codecOfChoice: AudioCodec | null = null;
|
||||
|
||||
if (this._options.audio?.codec) {
|
||||
audioCodecs = audioCodecs.filter(codec => codec === this._options.audio!.codec);
|
||||
if (trackOptions.codec) {
|
||||
audioCodecs = audioCodecs.filter(codec => codec === trackOptions.codec);
|
||||
}
|
||||
|
||||
const bitrate = this._options.audio?.bitrate ?? QUALITY_HIGH;
|
||||
const bitrate = trackOptions.bitrate ?? QUALITY_HIGH;
|
||||
|
||||
const encodableCodecs = await getEncodableAudioCodecs(audioCodecs, {
|
||||
numberOfChannels,
|
||||
|
||||
+2
-2
@@ -84,7 +84,7 @@ export {
|
||||
getFirstEncodableSubtitleCodec,
|
||||
} from './codec';
|
||||
export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target';
|
||||
export { Rotation, AnyIterable, SetRequired } from './misc';
|
||||
export { Rotation, AnyIterable, SetRequired, MaybePromise } from './misc';
|
||||
export {
|
||||
Source,
|
||||
BufferSource,
|
||||
@@ -135,7 +135,7 @@ export {
|
||||
AudioBufferSink,
|
||||
WrappedAudioBuffer,
|
||||
} from './media-sink';
|
||||
export { ConversionOptions, Conversion } from './conversion';
|
||||
export { Conversion, ConversionOptions, ConversionVideoOptions, ConversionAudioOptions } from './conversion';
|
||||
export {
|
||||
CustomVideoDecoder,
|
||||
CustomAudioDecoder,
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ export class MatroskaInputFormat extends InputFormat {
|
||||
}
|
||||
}; break;
|
||||
case EBMLId.DocType: {
|
||||
const docType = ebmlReader.readString(size);
|
||||
const docType = ebmlReader.readAsciiString(size);
|
||||
if (docType !== desiredDocType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
roundToMultiple,
|
||||
normalizeRotation,
|
||||
Bitstream,
|
||||
insertSorted,
|
||||
} from '../misc';
|
||||
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
|
||||
import { Reader } from '../reader';
|
||||
@@ -1458,6 +1459,12 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
const sampleIndex = this.metadataReader.readU32() - 1; // Convert to 0-indexed
|
||||
track.sampleTable.keySampleIndices.push(sampleIndex);
|
||||
}
|
||||
|
||||
if (track.sampleTable.keySampleIndices[0] !== 0) {
|
||||
// Some files don't mark the first sample a key sample, which is basically almost always incorrect.
|
||||
// Here, we correct for that mistake:
|
||||
track.sampleTable.keySampleIndices.unshift(0);
|
||||
}
|
||||
}; break;
|
||||
|
||||
case 'stsc': {
|
||||
@@ -1624,12 +1631,7 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
|
||||
this.readContiguousBoxes(boxInfo.contentSize);
|
||||
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
this.fragments,
|
||||
this.currentFragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
this.fragments.splice(insertionIndex + 1, 0, this.currentFragment);
|
||||
insertSorted(this.fragments, this.currentFragment, x => x.moofOffset);
|
||||
|
||||
// Compute the byte range of the sample data in this fragment, so we can load the whole fragment at once
|
||||
for (const [, trackData] of this.currentFragment.trackData) {
|
||||
@@ -1661,21 +1663,15 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
if (trackData) {
|
||||
// We know there is sample data for this track in this fragment, so let's add it to the
|
||||
// track's fragments:
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
this.currentTrack.fragments,
|
||||
this.currentFragment.moofOffset,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
this.currentTrack.fragments.splice(insertionIndex + 1, 0, this.currentFragment);
|
||||
insertSorted(this.currentTrack.fragments, this.currentFragment, x => x.moofOffset);
|
||||
|
||||
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
|
||||
if (hasKeyFrame) {
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
insertSorted(
|
||||
this.currentTrack.fragmentsWithKeyFrame,
|
||||
this.currentFragment.moofOffset,
|
||||
this.currentFragment,
|
||||
x => x.moofOffset,
|
||||
);
|
||||
this.currentTrack.fragmentsWithKeyFrame.splice(insertionIndex + 1, 0, this.currentFragment);
|
||||
}
|
||||
|
||||
const { currentFragmentState } = this.currentTrack;
|
||||
|
||||
@@ -312,8 +312,7 @@ export class EBMLWriter {
|
||||
this.writer.write(this.helper.subarray(0, pos));
|
||||
}
|
||||
|
||||
// Assumes the string is ASCII
|
||||
writeString(str: string) {
|
||||
writeAsciiString(str: string) {
|
||||
this.writer.write(new Uint8Array(str.split('').map(x => x.charCodeAt(0))));
|
||||
}
|
||||
|
||||
@@ -359,7 +358,7 @@ export class EBMLWriter {
|
||||
this.writeUnsignedInt(data.data, size);
|
||||
} else if (typeof data.data === 'string') {
|
||||
this.writeVarInt(data.data.length);
|
||||
this.writeString(data.data);
|
||||
this.writeAsciiString(data.data);
|
||||
} else if (data.data instanceof Uint8Array) {
|
||||
this.writeVarInt(data.data.byteLength, data.size);
|
||||
this.writer.write(data.data);
|
||||
@@ -495,7 +494,7 @@ export class EBMLReader {
|
||||
return value;
|
||||
}
|
||||
|
||||
readString(length: number) {
|
||||
readAsciiString(length: number) {
|
||||
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
|
||||
this.pos += length;
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
binarySearchLessOrEqual,
|
||||
COLOR_PRIMARIES_MAP_INVERSE,
|
||||
findLastIndex,
|
||||
insertSorted,
|
||||
isIso639Dash2LanguageCode,
|
||||
last,
|
||||
MATRIX_COEFFICIENTS_MAP_INVERSE,
|
||||
@@ -570,32 +571,16 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
trackData.endTimestamp = lastBlock.timestamp + lastBlock.duration;
|
||||
|
||||
if (track) {
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
track.clusters,
|
||||
cluster.elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
);
|
||||
track.clusters.splice(insertionIndex + 1, 0, cluster);
|
||||
insertSorted(track.clusters, cluster, x => x.elementStartPos);
|
||||
|
||||
const hasKeyFrame = trackData.firstKeyFrameTimestamp !== null;
|
||||
if (hasKeyFrame) {
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
track.clustersWithKeyFrame,
|
||||
cluster.elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
);
|
||||
track.clustersWithKeyFrame.splice(insertionIndex + 1, 0, cluster);
|
||||
insertSorted(track.clustersWithKeyFrame, cluster, x => x.elementStartPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
segment.clusters,
|
||||
elementStartPos,
|
||||
x => x.elementStartPos,
|
||||
);
|
||||
segment.clusters.splice(insertionIndex + 1, 0, cluster);
|
||||
|
||||
insertSorted(segment.clusters, cluster, x => x.elementStartPos);
|
||||
this.currentCluster = null;
|
||||
|
||||
return cluster;
|
||||
@@ -746,7 +731,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
|
||||
switch (id) {
|
||||
case EBMLId.DocType: {
|
||||
this.isWebM = reader.readString(size) === 'webm';
|
||||
this.isWebM = reader.readAsciiString(size) === 'webm';
|
||||
}; break;
|
||||
|
||||
case EBMLId.Seek: {
|
||||
@@ -955,7 +940,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
case EBMLId.CodecID: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.codecId = reader.readString(size);
|
||||
this.currentTrack.codecId = reader.readAsciiString(size);
|
||||
}; break;
|
||||
|
||||
case EBMLId.CodecPrivate: {
|
||||
@@ -974,7 +959,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
case EBMLId.Language: {
|
||||
if (!this.currentTrack) break;
|
||||
|
||||
this.currentTrack.languageCode = reader.readString(size);
|
||||
this.currentTrack.languageCode = reader.readAsciiString(size);
|
||||
|
||||
if (!isIso639Dash2LanguageCode(this.currentTrack.languageCode)) {
|
||||
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
|
||||
|
||||
+57
-29
@@ -13,10 +13,11 @@ import {
|
||||
AnyIterable,
|
||||
assert,
|
||||
assertNever,
|
||||
binarySearchLessOrEqual,
|
||||
CallSerializer,
|
||||
getInt24,
|
||||
getUint24,
|
||||
insertSorted,
|
||||
isSafari,
|
||||
last,
|
||||
mapAsyncGenerator,
|
||||
promiseWithResolvers,
|
||||
@@ -750,7 +751,8 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
customDecoderCallSerializer = new CallSerializer();
|
||||
customDecoderQueueSize = 0;
|
||||
|
||||
sampleQueue: VideoSample[] = [];
|
||||
inputTimestamps: number[] = []; // Timestamps input into the decoder, sorted.
|
||||
sampleQueue: VideoSample[] = []; // Safari-specific thing, check usage.
|
||||
|
||||
constructor(
|
||||
onSample: (sample: VideoSample) => unknown,
|
||||
@@ -762,28 +764,6 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
) {
|
||||
super(onSample, onError);
|
||||
|
||||
const sampleHandler = (sample: VideoSample) => {
|
||||
// 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 can sure that is
|
||||
// not a B-frame. Typically, WebCodecs automatically guarantees that frames are emitted in presentation
|
||||
// order, but some browsers (Safari) don't always follow this rule.
|
||||
if (this.sampleQueue.length > 0 && (sample.timestamp >= last(this.sampleQueue)!.timestamp)) {
|
||||
for (const sample of this.sampleQueue) {
|
||||
this.finalizeAndEmitSample(sample);
|
||||
}
|
||||
|
||||
this.sampleQueue.length = 0;
|
||||
}
|
||||
|
||||
const insertionIndex = binarySearchLessOrEqual(
|
||||
this.sampleQueue,
|
||||
sample.timestamp,
|
||||
x => x.timestamp,
|
||||
);
|
||||
this.sampleQueue.splice(insertionIndex + 1, 0, sample);
|
||||
};
|
||||
|
||||
const MatchingCustomDecoder = customVideoDecoders.find(x => x.supports(codec, decoderConfig));
|
||||
if (MatchingCustomDecoder) {
|
||||
// @ts-expect-error "Can't create instance of abstract class 🤓"
|
||||
@@ -798,11 +778,45 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
throw new TypeError('The argument passed to onSample must be a VideoSample.');
|
||||
}
|
||||
|
||||
sampleHandler(sample);
|
||||
this.finalizeAndEmitSample(sample);
|
||||
};
|
||||
|
||||
void this.customDecoderCallSerializer.call(() => this.customDecoder!.init());
|
||||
} else {
|
||||
// Specific handler for the WebCodecs VideoDecoder to iron out browser differences
|
||||
const sampleHandler = (sample: VideoSample) => {
|
||||
if (isSafari()) {
|
||||
// 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
|
||||
// can sure that is not a B-frame. Typically, WebCodecs automatically guarantees that frames are
|
||||
// emitted in presentation order, but Safari doesn't always follow this rule.
|
||||
if (this.sampleQueue.length > 0 && (sample.timestamp >= last(this.sampleQueue)!.timestamp)) {
|
||||
for (const sample of this.sampleQueue) {
|
||||
this.finalizeAndEmitSample(sample);
|
||||
}
|
||||
|
||||
this.sampleQueue.length = 0;
|
||||
}
|
||||
|
||||
insertSorted(this.sampleQueue, sample, x => x.timestamp);
|
||||
} else {
|
||||
// Assign it the next earliest timestamp from the input. We do this because browsers, by spec, are
|
||||
// required to emit decoded frames in presentation order *while* retaining the timestamp of their
|
||||
// originating EncodedVideoChunk. For files with B-frames but no out-of-order timestamps (like a
|
||||
// missing ctts box, for example), this causes a mismatch. We therefore fix the timestamps and
|
||||
// ensure they are sorted by doing this.
|
||||
const timestamp = this.inputTimestamps.shift();
|
||||
|
||||
// There's no way we'd have more decoded frames than encoded packets we passed in. Actually, the
|
||||
// correspondence should be 1:1.
|
||||
assert(timestamp !== undefined);
|
||||
|
||||
sample.setTimestamp(timestamp);
|
||||
this.finalizeAndEmitSample(sample);
|
||||
}
|
||||
};
|
||||
|
||||
this.decoder = new VideoDecoder({
|
||||
output: frame => sampleHandler(new VideoSample(frame)),
|
||||
error: onError,
|
||||
@@ -837,6 +851,11 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
.then(() => this.customDecoderQueueSize--);
|
||||
} else {
|
||||
assert(this.decoder);
|
||||
|
||||
if (!isSafari()) {
|
||||
insertSorted(this.inputTimestamps, packet.timestamp, x => x);
|
||||
}
|
||||
|
||||
this.decoder.decode(packet.toEncodedVideoChunk());
|
||||
}
|
||||
}
|
||||
@@ -849,10 +868,13 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
|
||||
await this.decoder.flush();
|
||||
}
|
||||
|
||||
for (const sample of this.sampleQueue) {
|
||||
this.finalizeAndEmitSample(sample);
|
||||
if (isSafari()) {
|
||||
for (const sample of this.sampleQueue) {
|
||||
this.finalizeAndEmitSample(sample);
|
||||
}
|
||||
|
||||
this.sampleQueue.length = 0;
|
||||
}
|
||||
this.sampleQueue.length = 0;
|
||||
}
|
||||
|
||||
close() {
|
||||
@@ -1226,9 +1248,15 @@ class AudioDecoderWrapper extends DecoderWrapper<AudioSample> {
|
||||
super(onSample, onError);
|
||||
|
||||
const sampleHandler = (sample: AudioSample) => {
|
||||
const sampleRate = decoderConfig.sampleRate;
|
||||
if (sample.numberOfFrames === 0) {
|
||||
// We skip zero-data (empty) AudioSamples. These are sometimes emitted, for example, by Firefox when it
|
||||
// decodes Vorbis (at the start).
|
||||
sample.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Round the timestamp to the sample rate
|
||||
const sampleRate = decoderConfig.sampleRate;
|
||||
sample.setTimestamp(Math.round(sample.timestamp * sampleRate) / sampleRate);
|
||||
|
||||
onSample(sample);
|
||||
|
||||
+7
-2
@@ -158,7 +158,8 @@ export class EncodedVideoPacketSource extends VideoSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an encoded packet to the output video track.
|
||||
* Adds an encoded packet to the output video track. Packets must be added in *decode order*, while a packet's
|
||||
* timestamp must be its *presentation timestamp*. B-frames are handled automatically.
|
||||
*
|
||||
* @param meta - Additional metadata from the encoder. You should pass this for the first call, including a valid
|
||||
* decoder config.
|
||||
@@ -788,7 +789,7 @@ export class EncodedAudioPacketSource extends AudioSource {
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an encoded packet to the output audio track.
|
||||
* Adds an encoded packet to the output audio track. Packets must be added in *decode order*.
|
||||
*
|
||||
* @param meta - Additional metadata from the encoder. You should pass this for the first call, including a valid
|
||||
* decoder config.
|
||||
@@ -1446,6 +1447,10 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
||||
});
|
||||
} else {
|
||||
// Let's fall back to an AudioContext approach
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
|
||||
this._audioContext = new AudioContext({ sampleRate: this._track.getSettings().sampleRate });
|
||||
const sourceNode = this._audioContext.createMediaStreamSource(new MediaStream([this._track]));
|
||||
this._scriptProcessorNode = this._audioContext.createScriptProcessor(4096);
|
||||
|
||||
+30
@@ -292,6 +292,12 @@ export const binarySearchLessOrEqual = <T>(arr: T[], key: number, valueGetter: (
|
||||
return ans;
|
||||
};
|
||||
|
||||
/** Assumes the array is already sorted. */
|
||||
export const insertSorted = <T>(arr: T[], item: T, valueGetter: (x: T) => number) => {
|
||||
const insertionIndex = binarySearchLessOrEqual(arr, valueGetter(item), valueGetter);
|
||||
arr.splice(insertionIndex + 1, 0, item); // This even behaves correctly for the -1 case
|
||||
};
|
||||
|
||||
export const promiseWithResolvers = <T = void>() => {
|
||||
let resolve: (value: T) => void;
|
||||
let reject: (reason: unknown) => void;
|
||||
@@ -586,3 +592,27 @@ export class CallSerializer {
|
||||
return this.currentPromise = this.currentPromise.then(fn);
|
||||
}
|
||||
}
|
||||
|
||||
let isSafariCache: boolean | null = null;
|
||||
export const isSafari = () => {
|
||||
if (isSafariCache !== null) {
|
||||
return isSafariCache;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* T or a promise that resolves to T.
|
||||
* @public
|
||||
*/
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
+131
-62
@@ -11,7 +11,7 @@ import { Demuxer } from '../demuxer';
|
||||
import { Input } from '../input';
|
||||
import { InputAudioTrack, InputAudioTrackBacking } from '../input-track';
|
||||
import { PacketRetrievalOptions } from '../media-sink';
|
||||
import { assert, binarySearchExact, binarySearchLessOrEqual, last, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { assert, AsyncMutex, binarySearchExact, binarySearchLessOrEqual, UNDETERMINED_LANGUAGE } from '../misc';
|
||||
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
|
||||
import { FrameHeader, getXingOffset, INFO, XING } from '../../shared/mp3-misc';
|
||||
import { Mp3Reader } from './mp3-reader';
|
||||
@@ -28,10 +28,15 @@ export class Mp3Demuxer extends Demuxer {
|
||||
|
||||
metadataPromise: Promise<void> | null = null;
|
||||
firstFrameHeader: FrameHeader | null = null;
|
||||
allSamples: Sample[] = [];
|
||||
loadedSamples: Sample[] = []; // All samples from the start of the file to lastLoadedPos
|
||||
|
||||
tracks: InputAudioTrack[] = [];
|
||||
|
||||
loadingMutex = new AsyncMutex();
|
||||
lastLoadedPos = 0;
|
||||
fileSize = 0;
|
||||
nextTimestampInSamples = 0;
|
||||
|
||||
constructor(input: Input) {
|
||||
super(input);
|
||||
|
||||
@@ -40,53 +45,12 @@ export class Mp3Demuxer extends Demuxer {
|
||||
|
||||
async readMetadata() {
|
||||
return this.metadataPromise ??= (async () => {
|
||||
const fileSize = await this.input.source.getSize();
|
||||
this.reader.fileSize = fileSize;
|
||||
this.fileSize = await this.input.source.getSize();
|
||||
this.reader.fileSize = this.fileSize;
|
||||
|
||||
// Just load the entire file. Primitive, but the only way to actually ensure 100% correct timestamps.
|
||||
// Random access in MP3 can be flaky and unreliable.
|
||||
await this.reader.reader.loadRange(0, fileSize);
|
||||
|
||||
const id3Tag = this.reader.readId3();
|
||||
if (id3Tag) {
|
||||
this.reader.pos += id3Tag.size;
|
||||
}
|
||||
|
||||
let nextTimestampInSamples = 0;
|
||||
|
||||
// Let's read all samples
|
||||
while (true) {
|
||||
const header = this.reader.readNextFrameHeader();
|
||||
if (!header) {
|
||||
break;
|
||||
}
|
||||
|
||||
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
|
||||
this.reader.pos = header.startPos + xingOffset;
|
||||
const word = this.reader.readU32();
|
||||
const isXing = word === XING || word === INFO;
|
||||
|
||||
this.reader.pos = header.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
|
||||
|
||||
if (isXing) {
|
||||
// There's no actual audio data in this frame, so let's skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.firstFrameHeader) {
|
||||
this.firstFrameHeader = header;
|
||||
}
|
||||
|
||||
const sampleDuration = header.audioSamplesInFrame / header.sampleRate;
|
||||
const sample: Sample = {
|
||||
timestamp: nextTimestampInSamples / header.sampleRate,
|
||||
duration: sampleDuration,
|
||||
dataStart: header.startPos,
|
||||
dataSize: header.totalSize,
|
||||
};
|
||||
|
||||
this.allSamples.push(sample);
|
||||
nextTimestampInSamples += header.audioSamplesInFrame;
|
||||
// Keep loading until we find the first frame header
|
||||
while (!this.firstFrameHeader && this.lastLoadedPos < this.fileSize) {
|
||||
await this.loadNextChunk();
|
||||
}
|
||||
|
||||
if (!this.firstFrameHeader) {
|
||||
@@ -97,6 +61,80 @@ export class Mp3Demuxer extends Demuxer {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Loads the next 0.5 MiB of frames. */
|
||||
async loadNextChunk() {
|
||||
const release = await this.loadingMutex.acquire();
|
||||
|
||||
try {
|
||||
assert(this.lastLoadedPos < this.fileSize);
|
||||
|
||||
const chunkSize = 0.5 * 1024 * 1024; // 0.5 MiB
|
||||
const endPos = Math.min(this.lastLoadedPos + chunkSize, this.fileSize);
|
||||
await this.reader.reader.loadRange(this.lastLoadedPos, endPos);
|
||||
|
||||
this.lastLoadedPos = endPos;
|
||||
assert(this.lastLoadedPos <= this.fileSize);
|
||||
|
||||
if (this.reader.pos === 0) {
|
||||
// First time, let's see if there's an ID3 tag
|
||||
const id3Tag = this.reader.readId3();
|
||||
if (id3Tag) {
|
||||
this.reader.pos += id3Tag.size;
|
||||
}
|
||||
}
|
||||
|
||||
this.parseFramesFromLoadedData();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
private parseFramesFromLoadedData() {
|
||||
while (true) {
|
||||
const startPos = this.reader.pos;
|
||||
const header = this.reader.readNextFrameHeader();
|
||||
if (!header) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if the entire frame fits in the loaded data
|
||||
if (header.startPos + header.totalSize > this.lastLoadedPos) {
|
||||
// Frame doesn't fit, reset positions and stop
|
||||
this.reader.pos = startPos;
|
||||
this.lastLoadedPos = startPos; // Snap this back too so that the next read is frame-aligned
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const xingOffset = getXingOffset(header.mpegVersionId, header.channel);
|
||||
this.reader.pos = header.startPos + xingOffset;
|
||||
const word = this.reader.readU32();
|
||||
const isXing = word === XING || word === INFO;
|
||||
|
||||
this.reader.pos = header.startPos + header.totalSize - 1; // -1 in case the frame is 1 byte too short
|
||||
|
||||
if (isXing) {
|
||||
// There's no actual audio data in this frame, so let's skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.firstFrameHeader) {
|
||||
this.firstFrameHeader = header;
|
||||
}
|
||||
|
||||
const sampleDuration = header.audioSamplesInFrame / header.sampleRate;
|
||||
const sample: Sample = {
|
||||
timestamp: this.nextTimestampInSamples / header.sampleRate,
|
||||
duration: sampleDuration,
|
||||
dataStart: header.startPos,
|
||||
dataSize: header.totalSize,
|
||||
};
|
||||
|
||||
this.loadedSamples.push(sample);
|
||||
this.nextTimestampInSamples += header.audioSamplesInFrame;
|
||||
}
|
||||
}
|
||||
|
||||
async getMimeType() {
|
||||
return 'audio/mpeg';
|
||||
}
|
||||
@@ -109,10 +147,10 @@ export class Mp3Demuxer extends Demuxer {
|
||||
async computeDuration() {
|
||||
await this.readMetadata();
|
||||
|
||||
const lastSample = last(this.allSamples);
|
||||
assert(lastSample);
|
||||
const track = this.tracks[0];
|
||||
assert(track);
|
||||
|
||||
return lastSample.timestamp + lastSample.duration;
|
||||
return track.computeDuration();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,8 +170,9 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
return this.demuxer.firstFrameHeader.sampleRate / this.demuxer.firstFrameHeader.audioSamplesInFrame;
|
||||
}
|
||||
|
||||
computeDuration() {
|
||||
return this.demuxer.computeDuration();
|
||||
async computeDuration() {
|
||||
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
|
||||
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
|
||||
}
|
||||
|
||||
getLanguageCode() {
|
||||
@@ -169,7 +208,7 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawSample = this.demuxer.allSamples[sampleIndex];
|
||||
const rawSample = this.demuxer.loadedSamples[sampleIndex];
|
||||
if (!rawSample) {
|
||||
return null;
|
||||
}
|
||||
@@ -193,12 +232,17 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
}
|
||||
|
||||
async getFirstPacket(options: PacketRetrievalOptions) {
|
||||
// Ensure we have at least one frame loaded
|
||||
while (this.demuxer.loadedSamples.length === 0 && this.demuxer.lastLoadedPos < this.demuxer.fileSize) {
|
||||
await this.demuxer.loadNextChunk();
|
||||
}
|
||||
|
||||
return this.getPacketAtIndex(0, options);
|
||||
}
|
||||
|
||||
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
||||
const sampleIndex = binarySearchExact(
|
||||
this.demuxer.allSamples,
|
||||
this.demuxer.loadedSamples,
|
||||
packet.timestamp,
|
||||
x => x.timestamp,
|
||||
);
|
||||
@@ -206,16 +250,41 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
|
||||
throw new Error('Packet was not created from this track.');
|
||||
}
|
||||
|
||||
return this.getPacketAtIndex(sampleIndex + 1, options);
|
||||
const nextIndex = sampleIndex + 1;
|
||||
// Ensure the next sample exists
|
||||
while (nextIndex >= this.demuxer.loadedSamples.length && this.demuxer.lastLoadedPos < this.demuxer.fileSize) {
|
||||
await this.demuxer.loadNextChunk();
|
||||
}
|
||||
|
||||
return this.getPacketAtIndex(nextIndex, options);
|
||||
}
|
||||
|
||||
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
|
||||
const index = binarySearchLessOrEqual(
|
||||
this.demuxer.allSamples,
|
||||
timestamp,
|
||||
x => x.timestamp,
|
||||
);
|
||||
return this.getPacketAtIndex(index, options);
|
||||
while (true) {
|
||||
const index = binarySearchLessOrEqual(
|
||||
this.demuxer.loadedSamples,
|
||||
timestamp,
|
||||
x => x.timestamp,
|
||||
);
|
||||
|
||||
if (index === -1 && this.demuxer.loadedSamples.length > 0) {
|
||||
// We're before the first sample
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.demuxer.lastLoadedPos === this.demuxer.fileSize) {
|
||||
// All data is loaded, return what we found
|
||||
return this.getPacketAtIndex(index, options);
|
||||
}
|
||||
|
||||
if (index >= 0 && index + 1 < this.demuxer.loadedSamples.length) {
|
||||
// The next packet also exists, we're done
|
||||
return this.getPacketAtIndex(index, options);
|
||||
}
|
||||
|
||||
// Otherwise, keep loading data
|
||||
await this.demuxer.loadNextChunk();
|
||||
}
|
||||
}
|
||||
|
||||
getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
|
||||
|
||||
@@ -50,7 +50,9 @@ export class Mp3Muxer extends Muxer {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
try {
|
||||
if (!this.xingFrameData) {
|
||||
const writeXingHeader = this.format._options.xingHeader !== false;
|
||||
|
||||
if (!this.xingFrameData && writeXingHeader) {
|
||||
const view = toDataView(packet.data);
|
||||
if (view.byteLength < 4) {
|
||||
throw new Error('Invalid MP3 header in sample.');
|
||||
@@ -97,11 +99,14 @@ export class Mp3Muxer extends Muxer {
|
||||
|
||||
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
|
||||
|
||||
this.framePositions.push(this.writer.getPos());
|
||||
this.writer.write(packet.data);
|
||||
this.frameCount++;
|
||||
|
||||
await this.writer.flush();
|
||||
|
||||
if (writeXingHeader) {
|
||||
this.framePositions.push(this.writer.getPos());
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
@@ -475,6 +475,12 @@ export class WebMOutputFormat extends MkvOutputFormat {
|
||||
* @public
|
||||
*/
|
||||
export type Mp3OutputFormatOptions = {
|
||||
/**
|
||||
* Controls whether the Xing header, which contains additional metadata as well as an index, is written to the start
|
||||
* of the MP3 file. When disabled, the writing process becomes append-only. Defaults to true.
|
||||
*/
|
||||
xingHeader?: boolean;
|
||||
|
||||
/**
|
||||
* Will be called once the Xing metadata frame is finalized.
|
||||
*
|
||||
@@ -496,6 +502,9 @@ export class Mp3OutputFormat extends OutputFormat {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('options must be an object.');
|
||||
}
|
||||
if (options.xingHeader !== undefined && typeof options.xingHeader !== 'boolean') {
|
||||
throw new TypeError('options.xingHeader, when provided, must be a boolean.');
|
||||
}
|
||||
if (options.onXingFrame !== undefined && typeof options.onXingFrame !== 'function') {
|
||||
throw new TypeError('options.onXingFrame, when provided, must be a function.');
|
||||
}
|
||||
|
||||
+22
-2
@@ -164,11 +164,13 @@ export type UrlSourceOptions = {
|
||||
*/
|
||||
export class UrlSource extends Source {
|
||||
/** @internal */
|
||||
private _url: string | URL;
|
||||
private _url: URL;
|
||||
/** @internal */
|
||||
private _options: UrlSourceOptions;
|
||||
/** @internal */
|
||||
private _fullData: ArrayBuffer | null = null;
|
||||
/** @internal */
|
||||
private _nextUrlVersion: number | null = null;
|
||||
|
||||
constructor(
|
||||
url: string | URL,
|
||||
@@ -189,7 +191,7 @@ export class UrlSource extends Source {
|
||||
|
||||
super();
|
||||
|
||||
this._url = url;
|
||||
this._url = url instanceof URL ? url : new URL(url);
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
@@ -203,6 +205,11 @@ export class UrlSource extends Source {
|
||||
headers['Range'] = `bytes=${range.start}-${range.end - 1}`;
|
||||
}
|
||||
|
||||
if (this._nextUrlVersion !== null) {
|
||||
this._url.searchParams.set('mediabunny_version', this._nextUrlVersion.toString());
|
||||
this._nextUrlVersion++;
|
||||
}
|
||||
|
||||
const response = await retriedFetch(
|
||||
this._url,
|
||||
mergeObjectsDeeply(this._options.requestInit ?? {}, {
|
||||
@@ -218,6 +225,19 @@ export class UrlSource extends Source {
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
if (
|
||||
response.status === 206
|
||||
&& range
|
||||
&& buffer.byteLength !== range.end - range.start
|
||||
&& this._nextUrlVersion === null
|
||||
) {
|
||||
// We did a range request but it resolved with the wrong range; in Chromium, this can be due to a caching
|
||||
// bug (https://issues.chromium.org/issues/436025873). Let's circumvent the cache for the rest of the
|
||||
// session by appending a version to the URL.
|
||||
this._nextUrlVersion = 1;
|
||||
return this._makeRequest(range);
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
// The server didn't return 206 Partial Content, so it's not a range response
|
||||
this._fullData = buffer;
|
||||
|
||||
Reference in New Issue
Block a user