Compare commits

...
32 Commits
Author SHA1 Message Date
Vanilagy 716b864fbf Bump patch 2025-08-14 10:56:23 +02:00
David P.andGitHub bea53d9a2b Merge pull request #60 from devPablo/main
Fix UrlSource single-byte buffer on Range Request HTTP 200
2025-08-14 03:12:21 +02:00
Pablo Bonilla 4521a65587 Add descriptive comment on why range request response must be skipped 2025-08-13 19:05:03 -06:00
Pablo Bonilla 5c598e35c2 Skip single-byte buffer when reading through UrlSource for range request 2025-08-13 18:22:59 -06:00
Vanilagy c2292c8304 Bump patch 2025-08-13 17:14:19 +02:00
David P.andGitHub 84219b3ed4 Merge pull request #59 from Yukiniro/e_q6qy_i
fix(conversion): fixed using wrong timestamp
2025-08-13 17:13:38 +02:00
Yukiniro 88b0b5f46f fix(conversion): fix sample end timestamp calculation error 2025-08-13 23:06:11 +08:00
Yukiniro 0348eda8a9 fix(conversion): fixed using wrong timestamp when calculating lastCanvasEndTimestamp 2025-08-13 22:48:08 +08:00
Vanilagy 93cbe69abc Fix remaining type errors, bump version 2025-08-13 09:58:52 +02:00
Vanilagy 16c8a6c255 Gracefully handle invalid EBML headers 2025-08-13 09:46:27 +02:00
Vanilagy 7e546c2cc7 Fix sample closing before it reaches the custom encoder 2025-08-12 15:36:17 +02:00
Vanilagy 7e89511ca6 Bump patch 2025-08-12 10:09:34 +02:00
Vanilagy e0a4169bf8 Handle missing starting key sample in ISOBMFF 2025-08-12 09:29:57 +02:00
Vanilagy c9dcebed6f Skip zero-length AudioSamples 2025-08-12 08:53:25 +02:00
Vanilagy ae0266df52 Clarify decode order thing for encoded packet sources 2025-08-11 18:41:15 +02:00
Vanilagy 1492fd5c6f Add workaround for Chromium range request caching bug 2025-08-11 18:14:02 +02:00
Vanilagy 6d447660c2 Clarify EBML strings are ASCII 2025-08-11 16:10:43 +02:00
Vanilagy b224ac0dc6 Bump minor version 2025-08-11 03:02:25 +02:00
Vanilagy 47c955524f Rework MP3 reading pattern 2025-08-11 02:50:13 +02:00
Vanilagy 80c6fa3201 Fall back to webkitAudioContext 2025-08-11 01:51:20 +02:00
Vanilagy 11c15c2392 Add track-specific conversion options, fix esbuild race condition 2025-08-11 00:00:04 +02:00
Vanilagy 975c1c42cb First linting error yay 2025-08-10 20:06:18 +02:00
Vanilagy a9cec387d4 Extract insertSorted function 2025-08-10 19:55:21 +02:00
Vanilagy 12df436e15 Add more graceful handling of suspended AudioContext to media player example 2025-08-10 19:26:58 +02:00
Vanilagy 56ae97f994 Fix decoded frame timestamps (#49) 2025-08-10 19:26:41 +02:00
Vanilagy 5608f519a7 Add xingHeader option to Mp3OutputFormat 2025-08-10 16:04:18 +02:00
Vanilagy 230f02fffc Clarify latencyMode 2025-08-10 14:36:47 +02:00
Vanilagy 51059c4b82 Add donation link 2025-08-10 13:48:34 +02:00
Vanilagy db437bba01 Fix links in mp3-encoder's package.json 2025-08-10 04:08:22 +02:00
Vanilagy 224da29440 Adjust release script, again 2025-08-10 03:50:58 +02:00
Vanilagy c53b9a6fa1 Oops 2025-08-10 03:47:31 +02:00
Vanilagy 835143c2e2 Adjust release script, bump patch 2025-08-10 03:46:12 +02:00
25 changed files with 795 additions and 414 deletions
+8 -3
View File
@@ -52,9 +52,14 @@ jobs:
- name: Upload build artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts
run: gh release upload ${{ github.event.release.tag_name }} dist/bundles/mediabunny.cjs dist/bundles/mediabunny.min.cjs dist/bundles/mediabunny.mjs dist/bundles/mediabunny.min.mjs dist/mediabunny.d.ts packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.js packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.mjs packages/mp3-encoder/dist/bundles/mediabunny-mp3-encoder.min.mjs packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
- name: Create Publish to npm
run: npm publish --provenance
- name: Publish Mediabunny to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish workspace packages to npm
run: npm publish --provenance --access public --workspaces
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+5 -4
View File
@@ -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,10 +92,10 @@
//height: 512,
//width: 200,
//height: 100,
},
}),
trim: {
start: 0,
end: 20
//start: 0,
//end: 20
},
});
console.log(conversion);
+32
View File
@@ -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);
+63 -29
View File
@@ -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:
+5 -3
View 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;
@@ -123,6 +123,7 @@ const sampleSource = new VideoSampleSource({
});
await sampleSource.add(videoSample);
videoSample.close(); // If it's not needed anymore
// You may optionally force samples to be encoded as key frames:
await sampleSource.add(videoSample, { keyFrame: true });
@@ -285,6 +286,7 @@ const sampleSource = new AudioSampleSource({
});
await sampleSource.add(audioSample);
audioSample.close(); // If it's not needed anymore
```
### `AudioBufferSource`
+7 -1
View File
@@ -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.
+12 -1
View File
@@ -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;
+14 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.5.0",
"version": "1.7.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.5.0",
"version": "1.7.6",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -5900,11 +5900,14 @@
}
},
"node_modules/mediabunny": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.5.0.tgz",
"integrity": "sha512-59gA8jMk8fjU1XdIwCDOI1v3M3nUE1r2Hl4jLtGZNLdxFv7sbDiliuneOPdhacCv/THheCOA6y5IdiIsF0250g==",
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.7.5.tgz",
"integrity": "sha512-H7dH2KCOzP/QDHaaNmgo52pDOAuHfC0aa9w9XKPOzcY6x923vGE2CTOqO9W/WEmf8DCRKReGwupSlWuq9NMLYg==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
"packages/*"
],
"dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/dom-webcodecs": "0.1.13"
@@ -9014,10 +9017,15 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "0.0.1",
"version": "1.7.6",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/Vanilagy"
},
"peerDependencies": {
"mediabunny": "^1.0.0"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.6.0",
"version": "1.7.6",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -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
+4 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.6.0",
"version": "1.7.6",
"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"
+5
View File
@@ -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;
+245 -182
View File
@@ -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;
@@ -608,7 +671,7 @@ export class Conversion {
}
let adjustedSampleTimestamp = Math.max(timestamp - this._startTimestamp, 0);
lastCanvasEndTimestamp = timestamp + duration;
lastCanvasEndTimestamp = adjustedSampleTimestamp + duration;
if (frameRate !== undefined) {
// Logic for skipping/repeating frames when a frame rate is set
@@ -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;
@@ -694,7 +757,7 @@ export class Conversion {
}
let adjustedSampleTimestamp = Math.max(sample.timestamp - this._startTimestamp, 0);
lastSampleEndTimestamp = sample.timestamp + sample.duration;
lastSampleEndTimestamp = adjustedSampleTimestamp + sample.duration;
if (frameRate !== undefined) {
// Logic for skipping/repeating frames when a frame rate is set
@@ -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
View File
@@ -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,
+17 -4
View File
@@ -10,7 +10,7 @@ import { Demuxer } from './demuxer';
import { Input } from './input';
import { IsobmffDemuxer } from './isobmff/isobmff-demuxer';
import { IsobmffReader } from './isobmff/isobmff-reader';
import { EBMLId, EBMLReader } from './matroska/ebml';
import { EBMLId, EBMLReader, MIN_HEADER_SIZE } from './matroska/ebml';
import { MatroskaDemuxer } from './matroska/matroska-demuxer';
import { Mp3Demuxer } from './mp3/mp3-demuxer';
import { FRAME_HEADER_SIZE } from '../shared/mp3-misc';
@@ -106,6 +106,10 @@ export class QuickTimeInputFormat extends IsobmffInputFormat {
}
}
function foo() {
return 5;
}
/**
* Matroska file format.
* @public
@@ -120,6 +124,12 @@ export class MatroskaInputFormat extends InputFormat {
const ebmlReader = new EBMLReader(input._mainReader);
const varIntSize = ebmlReader.readVarIntSize();
if (varIntSize === null) {
return false;
}
foo();
if (varIntSize < 1 || varIntSize > 8) {
return false;
}
@@ -135,8 +145,11 @@ export class MatroskaInputFormat extends InputFormat {
}
const startPos = ebmlReader.pos;
while (ebmlReader.pos < startPos + dataSize) {
const { id, size } = ebmlReader.readElementHeader();
while (ebmlReader.pos <= startPos + dataSize - MIN_HEADER_SIZE) {
const header = ebmlReader.readElementHeader();
if (!header) break;
const { id, size } = header;
const dataStartPos = ebmlReader.pos;
if (size === null) return false;
@@ -154,7 +167,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;
}
+11 -15
View File
@@ -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;
+28 -10
View File
@@ -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);
@@ -380,7 +379,7 @@ export class EBMLWriter {
const MAX_VAR_INT_SIZE = 8;
export const MIN_HEADER_SIZE = 2; // 1-byte ID and 1-byte size
export const MAX_HEADER_SIZE = 4 + MAX_VAR_INT_SIZE; // 4-byte ID and 8-byte size
export const MAX_HEADER_SIZE = 2 * MAX_VAR_INT_SIZE; // 8-byte ID and 8-byte size
export class EBMLReader {
pos = 0;
@@ -412,9 +411,13 @@ export class EBMLReader {
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
const firstByte = view.getUint8(offset);
if (firstByte === 0) {
return null; // Invalid VINT
}
let width = 1;
let mask = 0x80;
while ((firstByte & mask) === 0 && width < 8) {
while ((firstByte & mask) === 0) {
width++;
mask >>= 1;
}
@@ -427,10 +430,14 @@ export class EBMLReader {
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + 1);
const firstByte = view.getUint8(offset);
if (firstByte === 0) {
return null; // Invalid VINT
}
// Find the position of VINT_MARKER, which determines the width
let width = 1;
let mask = 1 << 7;
while ((firstByte & mask) === 0 && width < MAX_VAR_INT_SIZE) {
while ((firstByte & mask) === 0) {
width++;
mask >>= 1;
}
@@ -495,7 +502,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;
@@ -510,8 +517,11 @@ export class EBMLReader {
readElementId() {
const size = this.readVarIntSize();
const id = this.readUnsignedInt(size);
if (size === null) {
return null;
}
const id = this.readUnsignedInt(size);
return id;
}
@@ -539,6 +549,10 @@ export class EBMLReader {
readElementHeader() {
const id = this.readElementId();
if (id === null) {
return null;
}
const size = this.readElementSize();
return { id, size };
@@ -549,13 +563,17 @@ export class EBMLReader {
const loadChunkSize = 2 ** 20; // 1 MiB
const idsSet = new Set(ids);
while (this.pos < until - MAX_HEADER_SIZE) {
if (!this.reader.rangeIsLoaded(this.pos, this.pos + MAX_HEADER_SIZE)) {
while (this.pos <= until - MIN_HEADER_SIZE) {
if (!this.reader.rangeIsLoaded(this.pos, Math.min(this.pos + MAX_HEADER_SIZE, until))) {
await this.reader.loadRange(this.pos, Math.min(this.pos + loadChunkSize, until));
}
const elementStartPos = this.pos;
const elementHeader = this.readElementHeader();
if (!elementHeader) {
break;
}
if (idsSet.has(elementHeader.id)) {
return elementStartPos;
}
+45 -29
View File
@@ -38,6 +38,7 @@ import {
binarySearchLessOrEqual,
COLOR_PRIMARIES_MAP_INVERSE,
findLastIndex,
insertSorted,
isIso639Dash2LanguageCode,
last,
MATRIX_COEFFICIENTS_MAP_INVERSE,
@@ -238,6 +239,10 @@ export class MatroskaDemuxer extends Demuxer {
);
const header = this.metadataReader.readElementHeader();
if (!header) {
break; // Zero padding at the end of the file triggers this, for example
}
const id = header.id;
let size = header.size;
const startPos = this.metadataReader.pos;
@@ -317,14 +322,19 @@ export class MatroskaDemuxer extends Demuxer {
);
let clusterEncountered = false;
while (this.metadataReader.pos < this.currentSegment.elementEndPos) {
while (this.metadataReader.pos <= this.currentSegment.elementEndPos - MIN_HEADER_SIZE) {
await this.metadataReader.reader.loadRange(
this.metadataReader.pos,
this.metadataReader.pos + MAX_HEADER_SIZE,
);
const elementStartPos = this.metadataReader.pos;
const { id, size } = this.metadataReader.readElementHeader();
const header = this.metadataReader.readElementHeader();
if (!header) {
break;
}
const { id, size } = header;
const dataStartPos = this.metadataReader.pos;
const metadataElementIndex = METADATA_ELEMENTS.findIndex(x => x.id === id);
@@ -391,7 +401,10 @@ export class MatroskaDemuxer extends Demuxer {
this.metadataReader.pos,
this.metadataReader.pos + 2 ** 12, // Load a larger range, assuming the correct element will be there
);
const { id, size } = this.metadataReader.readElementHeader();
const header = this.metadataReader.readElementHeader();
if (!header) continue;
const { id, size } = header;
if (id !== target.id) continue;
assertDefinedSize(size);
@@ -468,6 +481,8 @@ export class MatroskaDemuxer extends Demuxer {
const elementStartPos = this.metadataReader.pos;
const elementHeader = this.metadataReader.readElementHeader();
assert(elementHeader);
const id = elementHeader.id;
let size = elementHeader.size;
const dataStartPos = this.metadataReader.pos;
@@ -570,32 +585,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;
@@ -735,18 +734,26 @@ export class MatroskaDemuxer extends Demuxer {
const startIndex = reader.pos;
while (reader.pos - startIndex <= totalSize - MIN_HEADER_SIZE) {
this.traverseElement(reader);
const foundElement = this.traverseElement(reader);
if (!foundElement) {
break;
}
}
}
traverseElement(reader: EBMLReader) {
const { id, size } = reader.readElementHeader();
traverseElement(reader: EBMLReader): boolean {
const header = reader.readElementHeader();
if (!header) {
return false;
}
const { id, size } = header;
const dataStartPos = reader.pos;
assertDefinedSize(size);
switch (id) {
case EBMLId.DocType: {
this.isWebM = reader.readString(size) === 'webm';
this.isWebM = reader.readAsciiString(size) === 'webm';
}; break;
case EBMLId.Seek: {
@@ -955,7 +962,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 +981,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;
@@ -1128,6 +1135,8 @@ export class MatroskaDemuxer extends Demuxer {
if (!this.currentCluster) break;
const trackNumber = reader.readVarInt();
if (trackNumber === null) break;
const relativeTimestamp = reader.readS16();
const flags = reader.readU8();
@@ -1163,6 +1172,8 @@ export class MatroskaDemuxer extends Demuxer {
if (!this.currentCluster) break;
const trackNumber = reader.readVarInt();
if (trackNumber === null) break;
const relativeTimestamp = reader.readS16();
const flags = reader.readU8();
@@ -1199,6 +1210,7 @@ export class MatroskaDemuxer extends Demuxer {
}
reader.pos = dataStartPos + size;
return true;
}
}
@@ -1577,7 +1589,7 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
}
}
while (metadataReader.pos < segment.elementEndPos) {
while (metadataReader.pos <= segment.elementEndPos - MIN_HEADER_SIZE) {
if (prevCluster) {
const trackData = prevCluster.trackData.get(this.internalTrack.id);
if (trackData && trackData.startTimestamp > latestTimestamp) {
@@ -1597,6 +1609,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
await metadataReader.reader.loadRange(metadataReader.pos, metadataReader.pos + MAX_HEADER_SIZE);
const elementStartPos = metadataReader.pos;
const elementHeader = metadataReader.readElementHeader();
if (!elementHeader) {
break;
}
const id = elementHeader.id;
let size = elementHeader.size;
const dataStartPos = metadataReader.pos;
+57 -29
View File
@@ -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);
+29 -24
View File
@@ -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.
@@ -322,17 +323,17 @@ class VideoEncoderWrapper {
if (this.customEncoder) {
this.customEncoderQueueSize++;
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(videoSample, finalEncodeOptions))
.then(() => {
this.customEncoderQueueSize--;
if (shouldClose) {
videoSample.close();
}
})
.catch((error: Error) => {
this.encoderError ??= error;
// We clone the sample so it cannot be closed on us from the outside before it reaches the encoder
const clonedSample = videoSample.clone();
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(clonedSample, finalEncodeOptions))
.then(() => this.customEncoderQueueSize--)
.catch((error: Error) => this.encoderError ??= error)
.finally(() => {
clonedSample.close();
// `videoSample` gets closed in the finally block at the end of the method
});
if (this.customEncoderQueueSize >= 4) {
@@ -439,6 +440,7 @@ class VideoEncoderWrapper {
void this.muxer!.addEncodedVideoPacket(this.source._connectedTrack!, packet, meta);
},
error: (error) => {
error.stack = new Error().stack; // Provide a more useful stack trace
this.encoderError ??= error;
},
});
@@ -482,7 +484,6 @@ class VideoEncoderWrapper {
checkForEncoderError() {
if (this.encoderError) {
this.encoderError.stack = new Error().stack; // Provide a more useful stack trace
throw this.encoderError;
}
}
@@ -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.
@@ -938,17 +939,17 @@ class AudioEncoderWrapper {
if (this.customEncoder) {
this.customEncoderQueueSize++;
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(audioSample))
.then(() => {
this.customEncoderQueueSize--;
if (shouldClose) {
audioSample.close();
}
})
.catch((error: Error) => {
this.encoderError ??= error;
// We clone the sample so it cannot be closed on us from the outside before it reaches the encoder
const clonedSample = audioSample.clone();
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(clonedSample))
.then(() => this.customEncoderQueueSize--)
.catch((error: Error) => this.encoderError ??= error)
.finally(() => {
clonedSample.close();
// `audioSample` gets closed in the finally block at the end of the method
});
if (this.customEncoderQueueSize >= 4) {
@@ -1127,6 +1128,7 @@ class AudioEncoderWrapper {
void this.muxer!.addEncodedAudioPacket(this.source._connectedTrack!, packet, meta);
},
error: (error) => {
error.stack = new Error().stack; // Provide a more useful stack trace
this.encoderError ??= error;
},
});
@@ -1265,7 +1267,6 @@ class AudioEncoderWrapper {
checkForEncoderError() {
if (this.encoderError) {
this.encoderError.stack = new Error().stack; // Provide a more useful stack trace
throw this.encoderError;
}
}
@@ -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
View File
@@ -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
View File
@@ -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) {
+7 -2
View File
@@ -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();
}
+9
View File
@@ -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.');
}
+27 -3
View File
@@ -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;
@@ -294,7 +314,11 @@ export class UrlSource extends Source {
} else if (rangeResponse.status === 200) {
// The server just returned the whole thing
this._fullData = await rangeResponse.arrayBuffer();
return this._fullData.byteLength;
if (this._fullData.byteLength !== 1) {
return this._fullData.byteLength;
} else {
// The server responded with 200, but returned only the requested range, so skip the response
}
}
// If the range request didn't provide the size, make a full GET request