Compare commits

...
38 Commits
Author SHA1 Message Date
Vanilagy f13a81bee3 Bump minor 2025-09-17 23:09:53 +02:00
Vanilagy 6d5bd58ae8 Add support for header removal content compression in Matroska 2025-09-17 23:09:04 +02:00
Vanilagy 6907e25c6d Fix invalid Vorbis description for Ogg demuxer 2025-09-17 18:06:41 +02:00
Vanilagy 1a1664683b run -> GOP 2025-09-17 16:39:35 +02:00
Vanilagy 13224d2998 Add HADB sponsor 2025-09-17 14:15:45 +02:00
Vanilagy c5523516c7 Add fastStart: 'reserve' option (closes #119) 2025-09-17 12:26:24 +02:00
David P.andGitHub ec910bdd2c Merge pull request #124 from studnitz/main
Pin github actions to commit SHAs
2025-09-17 12:00:26 +02:00
Alexander von Studnitz 32b3007a3f Pin github actions to commit SHAs
This reduces the attack surface of supply-chain attacks and is generally a good practice.
2025-09-17 11:55:41 +02:00
Vanilagy f13941d350 Add Mux sponsor 2025-09-17 09:11:56 +02:00
Vanilagy 7d725d068b Bump patch 2025-09-16 23:12:51 +02:00
Vanilagy 7a0051bec5 Tag spam 2025-09-16 21:27:54 +02:00
Vanilagy 555006b9c3 Add improved logic for loading Node.js module (fixes #118) 2025-09-16 21:13:32 +02:00
Vanilagy 1bb3c0e262 Avoid opaque canvases in case of Firefox (fixes #117) 2025-09-16 17:42:23 +02:00
Vanilagy 530ccd7523 Drag 0-duration VideoFrame duration to null (fixes #116) 2025-09-16 15:50:14 +02:00
Vanilagy fcec67645a Allow object for tags field in conversion 2025-09-16 15:10:16 +02:00
Vanilagy 7efb092ff5 Add parsing of 'track' metadata key for ISOBMFF 2025-09-16 14:46:27 +02:00
Vanilagy d313a85053 Document cropping in more places 2025-09-12 17:30:31 +02:00
Vanilagy 1534eb4c40 Lint 2025-09-12 17:26:52 +02:00
Vanilagy 8fb08f1c8e Fix missing @group tag, add doc generation validation to build script 2025-09-12 17:16:12 +02:00
Vanilagy bc90a6dc16 Bump minor 2025-09-12 16:58:46 +02:00
David P.andGitHub ea8027bacb Merge pull request #97 from pablocubico/crop-feature
Add Crop feature
2025-09-12 16:56:56 +02:00
Vanilagy 3cfb9f27cb Prefix crop validation error messages 2025-09-12 16:52:55 +02:00
Vanilagy 684b7b7391 Fix order 2025-09-12 16:50:14 +02:00
Vanilagy c9433771e9 Fix description in docs 2025-09-12 16:49:46 +02:00
Vanilagy eba1f360d5 Clean up cropping logic 2025-09-12 16:48:35 +02:00
Vanilagy 4aca172d14 Merge branch 'main' into fork-branch 2025-09-12 15:46:12 +02:00
Vanilagy 6f06631355 Use decoder information for resampler sample rate, fix Opus sample rate to 48 kHz everywhere 2025-09-10 23:03:20 +02:00
Vanilagy f314f04037 Add wcw 2025-09-10 16:52:45 +02:00
Vanilagy b9f7ab2fa2 Skip HEVC RASL slices following a decoder reset 2025-09-09 13:09:41 +02:00
Vanilagy a295cd76c6 Oops 2025-09-08 12:54:50 +02:00
github-actions[bot] b9efcc867d Merge main into release for tag v1.14.2 2025-09-07 20:37:38 +00:00
github-actions[bot] d63d7f6ff9 Merge main into release for tag v1.14.1 2025-09-06 10:01:23 +00:00
Vanilagy b3396727f0 Merge branch 'main' into release 2025-09-06 02:29:45 +02:00
Vanilagy e672a51dc6 Merge branch 'main' into release 2025-09-06 02:16:26 +02:00
github-actions[bot] 9f9809fbd1 Merge main into release for tag v1.14.0 2025-09-05 21:48:34 +00:00
Pablo Cúbico 904fd8f95e feat: add video crop option 2025-09-05 07:07:43 -03:00
github-actions[bot] dcda90fddb Merge main into release for tag v1.13.3 2025-09-04 09:14:52 +00:00
David P.andGitHub d7e8273185 Merge pull request #93 from Vanilagy/main
Release
2025-09-04 09:40:23 +02:00
44 changed files with 1030 additions and 267 deletions
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: 'npm'
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
permissions: write-all
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
with:
fetch-depth: 0
@@ -27,7 +27,7 @@ jobs:
git push origin release
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: 'npm'
@@ -39,7 +39,7 @@ jobs:
- name: Check package.json version against tag name
if: steps.package-json-version.outputs.version != github.event.release.tag_name
uses: actions/github-script@v3
uses: actions/github-script@ffc2c79a5b2490bd33e0a41c1de74b877714d736 # v3.2.0
with:
script: core.setFailed('Release tag does not match package.json version!')
+2 -2
View File
@@ -10,10 +10,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: 'npm'
+5 -1
View File
@@ -39,6 +39,10 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<a href="https://www.reactvideoeditor.com/" target="_blank">
<img src="./docs/public/sponsors/rve.svg" width="40" height="40" alt="React Video Editor">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mux.com/" target="_blank">
<img src="./docs/public/sponsors/mux.jpg" width="40" height="40" alt="Mux">
</a>
</div>
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
@@ -50,7 +54,7 @@ Core features include:
- **Wide format support**: Read and write MP4, MOV, WebM, MKV, WAVE, MP3, Ogg, ADTS
- **Built-in encoding & decoding**: Supports 25+ video, audio, and subtitle codecs, hardware-accelerated using the WebCodecs API
- **High precision**: Fine-grained, microsecond-accurate reading and writing operations
- **Conversion API**: Easy-to-use API with features such as transmuxing, transcoding, resizing, rotation, resampling, trimming, and more
- **Conversion API**: Easy-to-use API with features such as transmuxing, transcoding, resizing, rotation, cropping, resampling, trimming, and more
- **Streaming I/O**: Handle reading & writing files of any size with memory-efficient streaming
- **Tree-shakable**: Only bundle what you use (as small as 5 kB gzipped)
- **Zero dependencies**: Implemented in highly performant TypeScript
+3
View File
@@ -29,6 +29,9 @@ api-extractor run -c packages/mp3-encoder/api-extractor.json
tsx scripts/check-docblocks.ts dist/mediabunny.d.ts
tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
# Checks that API docs are generatable
npm run docs:generate -- --dry
# Appends stuff to the declaration files to register the global variables these libraries expose
echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts
echo 'export as namespace MediabunnyMp3Encoder;' >> packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
+33 -20
View File
@@ -47,23 +47,6 @@
format: outputFormat,
target
});
output.setMetadataTags({
title: 'Bigggy',
artist: 'Buck Bunny',
images: [{
data: blobData,
kind: 'coverFront',
mimeType: 'image/jpeg'
}],
trackNumber: 4,
tracksTotal: 10,
discNumber: 5,
discNumberMax: 8,
lyrics: "There's no way\nThat it's not going there",
raw: {
'ENCODER': 'Mediabunny epic own encoder'
}
});
const conversion = await Mediabunny.Conversion.init({
input: new Mediabunny.Input({
@@ -72,6 +55,9 @@
}),
output,
audio: {
//codec: 'pcm-s16',
//sampleRate: 16000,
//numberOfChannels: 1,
//discard: true,
//codec: 'opus',
//bitrate: 128000,
@@ -106,7 +92,17 @@
*/
video: () => ({
//discard: true,
forceTranscode: true,
//crop: {
// left: 0,
// top: 0,
// width: 500,
// height: 500,
//},
//rotate: 90,
//width: 200,
//height: 500,
//fit: 'contain',
//forceTranscode: true,
//codec: 'avc',
//fit: 'contain',
//frameRate: 27.123,
@@ -128,9 +124,26 @@
//width: 200,
//height: 100,
}),
tags: {
title: 'Bigggy',
artist: 'Buck Bunny',
images: [{
data: blobData,
kind: 'coverFront',
mimeType: 'image/jpeg'
}],
trackNumber: 4,
tracksTotal: 10,
discNumber: 5,
discsTotal: 8,
lyrics: "There's no way\nThat it's not going there",
raw: {
'AIGC': 'Mediabunny epic own encoder'
}
},
trim: {
start: 1,
end: 10
start: 10,
end: 20
},
});
console.log(conversion);
+28 -1
View File
@@ -16,8 +16,35 @@
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.VideoSampleSink(videoTrack);
const sink2 = new Mediabunny.EncodedPacketSink(videoTrack);
console.log(await sink.getSample(await videoTrack.getFirstTimestamp()))
for await (const packet of sink2.packets()) {
console.log(packet)//, await videoTrack.determinePacketType(packet));
if (packet.timestamp >= 1) {
break;
}
}
console.log(await sink.getSample(0))
return;
for await (const sample of sink.samples()) {
console.log(sample);
if (sample.timestamp >= 1) {
break;
}
}
/*
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
console.log(packet)
}
*/
/*
for await (const sample of sink.samples(0.99)) {
+7 -5
View File
@@ -46,6 +46,7 @@
format = new Mediabunny.WavOutputFormat();
format = new Mediabunny.MkvOutputFormat();
format = new Mediabunny.MovOutputFormat();
format = new Mediabunny.Mp4OutputFormat({ fastStart: 'reserve' });
let target = new Mediabunny.BufferTarget();
/*
@@ -116,9 +117,10 @@
*/
let videoSource = new Mediabunny.CanvasSource(canvas, {
codec: 'avc',
codec: 'vp9',
//fullCodecString: 'avc1.42001f',
bitrate: 1e6
bitrate: 1e6,
onEncoderConfig: console.log,
});
let audioSource = new Mediabunny.AudioBufferSource({
codec: 'opus',
@@ -126,8 +128,8 @@
});
let subtitleSource = new Mediabunny.TextSubtitleSource('webvtt');
output.addVideoTrack(videoSource, { languageCode: 'eng', name: 'Mononoké' });
output.addAudioTrack(audioSource, { name: 'Yooo' });
output.addVideoTrack(videoSource, { languageCode: 'eng', name: 'Mononoké', maximumPacketCount: 100 });
output.addAudioTrack(audioSource, { name: 'Yooo', maximumPacketCount: 1000 });
//output.addSubtitleTrack(subtitleSource);
output.start();
@@ -207,5 +209,5 @@ Testing... <00:17.350>One... <00:18.125>Two...
await output.finalize();
console.log(target);
download(new Blob([target.buffer]), 'test' + format.fileExtension);
//download(new Blob([target.buffer]), 'test' + format.fileExtension);
</script>
+17 -9
View File
@@ -11,6 +11,7 @@ It has the following features:
- Trimming
- Video resizing & fitting
- Video rotation
- Video cropping
- Video frame rate adjustment
- Audio resampling
- Audio up/downmixing
@@ -48,7 +49,7 @@ await conversion.execute();
That's it! A `Conversion` simply takes an instance of `Input` and `Output`, then reads the data from the input and writes it to the output. If you're unfamiliar with [`Input`](./reading-media-files) and [`Output`](./writing-media-files), check out their respective guides.
::: info
The `Output` passed to the `Conversion` must be *fresh*; that is, it must have no added tracks and be in the `'pending'` state (not started yet).
The `Output` passed to the `Conversion` must be *fresh*; that is, it must have no added tracks or metadata tags and be in the `'pending'` state (not started yet).
:::
Unconfigured, the conversion process handles all the details automatically, such as:
@@ -109,6 +110,7 @@ type ConversionVideoOptions = {
height?: number;
fit?: 'fill' | 'contain' | 'cover';
rotate?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
frameRate?: number;
codec?: VideoCodec;
bitrate?: number | Quality;
@@ -137,21 +139,27 @@ The provided configuration will apply equally to all video tracks of the input.
If you want to get rid of the video track, use `discard: true`.
### Resizing/rotating video
### Resizing video
The `width`, `height` and `fit` properties control how the video is resized. If only `width` or `height` is provided, the other value is deduced automatically to preserve the video's original aspect ratio. If both are used, `fit` must be set to control the fitting algorithm:
- `'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.
`rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file.
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
If `width` or `height` is used in conjunction with `rotation` or `crop`, they control the post-rotation, post-crop dimensions.
If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options).
In the rare case that the input video changes size over time, the `fit` field can be used to control the size change behavior (see [`VideoEncodingConfig`](./media-sources#video-encoding-config)). When unset, the behavior is `'passThrough'`.
### Rotating video
`rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file and happens before cropping and resizing.
### Cropping video
`crop` can be used to extract a rectangular region from the original video. The rectangle is specified using `left`, `top`, `width` and `height` and is clamped to the dimensions of the video. Cropping is applied after rotation but before resizing.
### 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).
@@ -291,17 +299,17 @@ By default, any [descriptive metadata tags](../api/MetadataTags.md) of the input
// Set your own metadata:
const conversion = await Conversion.init({
// ...
tags: () => ({
tags: {
title: 're:Turning',
artist: 'Alexander Panos',
}),
},
// ...
});
// Or, augment the input's metadata:
const conversion = await Conversion.init({
// ...
tags: inputTags => ({
tags: (inputTags) => ({
...inputTags, // Keep the existing metadata
images: [{ // And add cover art
data: new Uint8Array(...),
@@ -316,7 +324,7 @@ const conversion = await Conversion.init({
// Or, remove all metadata
const conversion = await Conversion.init({
// ...
tags: () => ({}),
tags: {},
// ...
});
```
+1 -1
View File
@@ -17,7 +17,7 @@ Here's a long list of stuff this library does:
- Lazy, optimized, on-demand file reading
- Input and output streaming, arbitrary file size support
- File location independence (memory, disk, network, ...)
- Utilities for compression, resizing, rotation, resampling, trimming
- Utilities for compression, resizing, rotation, cropping, resampling, trimming
- Transmuxing and transcoding
- Microsecond-accurate reading and writing precision
- Efficient seeking through time
+5 -2
View File
@@ -314,7 +314,7 @@ for await (const sample of keyFrameSamples) {
### `CanvasSink`
While `VideoSampleSink` extracts raw decoded video samples, you can use `CanvasSink` to extract these samples as canvases instead. In doing so, certain operations such as scaling and rotating can also be handled by the sink. The downside is the additional VRAM requirements for the canvases' framebuffers.
While `VideoSampleSink` extracts raw decoded video samples, you can use `CanvasSink` to extract these samples as canvases instead. In doing so, certain operations such as scaling, rotating, and cropping can also be handled by the sink. The downside is the additional VRAM requirements for the canvases' framebuffers.
::: info
This sink yields `HTMLCanvasElement` whenever possible, and falls back to `OffscreenCanvas` otherwise (in Worker contexts, for example).
@@ -334,6 +334,7 @@ type CanvasSinkOptions = {
height?: number;
fit?: 'fill' | 'contain' | 'cover';
rotation?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
poolSize?: number;
};
```
@@ -347,7 +348,9 @@ type CanvasSinkOptions = {
- `'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.
- `rotation`\
The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata. Rotation is applied before resizing.
The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata. Rotation is applied before cropping and resizing.
- `crop`\
Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to the dimensions of the input video track. Cropping is performed after rotation but before resizing.
- `poolSize`\
See [Canvas pool](#canvas-pool).
+3 -1
View File
@@ -62,7 +62,7 @@ const output = new Output({
The following options are available:
```ts
type IsobmffOutputFormatOptions = {
fastStart?: false | 'in-memory' | 'fragmented';
fastStart?: false | 'in-memory' | 'reserve' | 'fragmented';
minimumFragmentDuration?: number;
onFtyp?: (data: Uint8Array, position: number) => unknown;
@@ -80,6 +80,8 @@ type IsobmffOutputFormatOptions = {
::: info
This option ensures [append-only writing](#append-only-writing), although all the writing happens in bulk, at the end.
:::
- `'reserve'`\
Produces a file with Fast Start by reserving space at the start of the file into which the metadata will be written later. This requires knowledge about the expected length of the file beforehand. When using this option, you must set the [`maximumPacketCount`](../api/BaseTrackMetadata#maximumpacketcount) field in the track metadata for all tracks.
- `'fragmented'`\
Produces a _fragmented MP4 (fMP4)_ file, evenly placing sample metadata throughout the file by grouping it into "fragments" (short sections of media), while placing general metadata at the beginning of the file. Fragmented files are ideal in streaming contexts, as each fragment can be played individually without requiring knowledge of the other fragments. Furthermore, they remain lightweight to create no matter how large the file becomes, as they don't require media to be kept in memory for very long. However, fragmented files are not as widely and wholly supported as regular MP4 files, and some players don't provide seeking functionality for them.
::: info
+1
View File
@@ -350,6 +350,7 @@ drawWithFit(
options: {
fit: 'fill' | 'contain' | 'cover';
rotation?: Rotation; // Overrides the sample's rotation
crop?: CropRectangle;
},
): void;
```
+6 -6
View File
@@ -60,6 +60,12 @@ await input.computeDuration(); // => 1905.4615
```
More specifically, the duration is defined as the maximum end timestamp across all tracks.
Mediabunny also lets you read descriptive metadata tags from media files, such as title, artist, or cover art:
```ts
await input.getMetadataTags(); // => MetadataTags
```
For more info, see [`MetadataTags`](../api/MetadataTags).
## Reading track metadata
You can extract the list of all media tracks in the file like so:
@@ -76,12 +82,6 @@ await input.getPrimaryVideoTrack(); // => InputVideoTrack | null
await input.getPrimaryAudioTrack(); // => InputAudioTrack | null
```
Mediabunny also lets you read descriptive metadata tags from media files, such as title, artist, or cover art:
```ts
await input.getMetadataTags(); // => MetadataTags
```
For more info, see [`MetadataTags`](../api/MetadataTags).
::: info
Subtitle tracks are currently not supported for reading.
:::
+3
View File
@@ -98,6 +98,7 @@ const sponsors = {
],
bronze: [
{ image: '/sponsors/rve.svg', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
{ image: '/sponsors/mux.jpg', name: 'Mux', url: 'https://www.mux.com/' },
],
individual: [
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
@@ -105,6 +106,8 @@ const sponsors = {
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ image: 'https://avatars.githubusercontent.com/u/38181164', name: 'wcw', url: 'https://github.com/asd55667' },
{ image: 'https://avatars.githubusercontent.com/u/1836701', name: 'Bean Deng', url: 'https://github.com/HADB' },
{ image: 'https://avatars.githubusercontent.com/u/63088713', name: 'taf2000', url: 'https://github.com/taf2000' },
{ image: 'https://avatars.githubusercontent.com/u/58149663', name: 'H7GhosT', url: 'https://github.com/H7GhosT' },
{ image: 'https://avatars.githubusercontent.com/u/91711202', name: 'ihasq', url: 'https://github.com/ihasq' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

@@ -96,6 +96,13 @@ const generateThumbnails = async (resource: File | string) => {
timestampElement.className
= 'absolute bottom-0 right-0 bg-black/30 text-white px-1 py-0.5 text-[11px] rounded-tl-lg';
container.append(timestampElement);
} else {
// Add something to indicate that the thumbnail is missing
const p = document.createElement('p');
p.textContent = '?';
p.className = 'absolute inset-0 flex items-center justify-center text-3xl opacity-50';
container.append(p);
}
i++;
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.14.2",
"version": "1.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.14.2",
"version": "1.16.0",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7749,9 +7749,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.14.1.tgz",
"integrity": "sha512-TjLg8GQGGsnGePcA6i0NItGe9a5G8WaleCGaXpb5V5okwuK5KpNKfcmTZXItnjfPLo7FvfEZI0NFp1lIR8Os7Q==",
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.15.2.tgz",
"integrity": "sha512-OvIuSK10wNdz+7Dd4bmeq/IWkYG0bkm33FQb29vmyDztpr47ru8XSxYansa3EHLy6lLEm4rUADUENOcmrnN0jg==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -12242,7 +12242,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.14.2",
"version": "1.16.0",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+9 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.14.2",
"version": "1.16.0",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
@@ -22,6 +22,10 @@
"dist",
"src"
],
"browser": {
"./dist/modules/src/node.js": false,
"./src/node.ts": false
},
"sideEffects": false,
"scripts": {
"build": "./build.sh",
@@ -102,11 +106,15 @@
"decoder",
"convert",
"mp4",
"mov",
"fmp4",
"webm",
"mkv",
"wav",
"mp3",
"ogg",
"adts",
"flac",
"video",
"audio",
"subtitles",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.14.2",
"version": "1.16.0",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+1 -1
View File
@@ -24,7 +24,7 @@ const walkDir = (dir: string) => {
const fixFile = (filePath: string) => {
const content = fs.readFileSync(filePath, 'utf8');
const fixed = content.replace(
/(\s+from\s+['"])([^'"]*)(['"])/g,
/(\s+from\s+['"])(\.[^'"]*)(['"])/g, // This only matches relative imports
'$1$2.js$3',
);
+3
View File
@@ -16,6 +16,9 @@ const createVariants = async (
entryPoints: [entryPoint],
bundle: true,
logLevel: 'info',
logOverride: {
'import-is-undefined': 'silent', // Warning caused by the disabled "node.ts" import
},
banner: {
js: `/*!
* Copyright (c) 2025-present, Vanilagy and contributors
+135 -34
View File
@@ -16,7 +16,7 @@ import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) => {
const program = ts.createProgram(entryFiles, {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.ESNext,
@@ -55,11 +55,13 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
delete groupConfig['heading'];
delete groupConfig['intro'];
// Clear and recreate output directory
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true });
// Clear and recreate output directory (skip if dry run)
if (!dry) {
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true });
}
fs.mkdirSync(outputDir, { recursive: true });
}
fs.mkdirSync(outputDir, { recursive: true });
// Collect all exported types for cross-referencing
const exportedTypes = new Set<string>();
@@ -167,29 +169,72 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
// Helper to process {@link} tags in JSDoc comments
const processLinkTags = (text: string, currentTypeName?: string): string => {
// Replace {@link TypeName} with [TypeName](./TypeName.md) if TypeName is exported
// or just TypeName if not exported
// If TypeName is the current type, just use code formatting without link
return text.replace(/\{@link\s+([^}]+)\}/g, (_, typeName) => {
const cleanTypeName = typeName.trim();
if (cleanTypeName === currentTypeName) {
return `\`${cleanTypeName}\``;
// Updated regex to handle member links and optional link text, e.g., {@link Type.member | text}
return text.replace(/\{@link\s+([^}|]+)(?:\s*\|\s*([^}]+))?\}/g, (_, target, linkText) => {
const cleanTarget = target.trim();
// Split into type and member parts
const parts = cleanTarget.split('.');
const typeName = parts[0];
const memberName = parts.length > 1 ? parts[1] : undefined;
let displayText: string;
if (linkText) {
// If custom link text is provided, always use it.
displayText = linkText.trim();
} else if (memberName) {
// If it's a member link, default the text to just the member name.
displayText = `\`${memberName}\``;
} else {
// Otherwise, it's a type link, so use the full type name.
displayText = `\`${cleanTarget}\``;
}
if (exportedTypes.has(cleanTypeName)) {
return `[\`${cleanTypeName}\`](./${cleanTypeName}.md)`;
// Check if the base type is a known exported type
if (exportedTypes.has(typeName)) {
let linkUrl = '';
if (memberName) {
// It's a link to a member (property or method)
const anchor = memberName.toLowerCase();
if (typeName === currentTypeName) {
// Link to an anchor on the same page
linkUrl = `#${anchor}`;
} else {
// Link to another page's anchor
linkUrl = `./${typeName}.md#${anchor}`;
}
} else {
// It's a link to a type
if (typeName === currentTypeName) {
// Don't link to the current page, just format it
return `\`${cleanTarget}\``;
}
linkUrl = `./${typeName}.md`;
}
return `[${displayText}](${linkUrl})`;
}
return `\`${cleanTypeName}\``;
// Fallback for unknown types: just format as code
return `\`${cleanTarget}\``;
});
};
// Helper to extract linked types from {@link} tags in text
const extractLinkedTypes = (text: string): string[] => {
if (!text) return [];
const linkMatches = text.match(/\{@link\s+([^}]+)\}/g) || [];
return linkMatches.map((match) => {
const typeName = match.replace(/\{@link\s+([^}]+)\}/, '$1').trim();
return typeName;
});
const linkedTypes: string[] = [];
// Use a regex to find all link targets
const regex = /\{@link\s+([^}|]+)/g;
let match;
while ((match = regex.exec(text)) !== null) {
const target = match[1]!.trim();
// Return only the base type name (the part before the first dot)
const typeName = target.split('.')[0];
linkedTypes.push(typeName!);
}
return linkedTypes;
};
// Helper to format references with proper "and" and period
@@ -1204,9 +1249,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
let desc = '';
const propDeclaration = prop.valueDeclaration || prop.declarations?.[0];
if (propDeclaration) {
const jsDoc = ts.getJSDocCommentsAndTags(propDeclaration)[0];
if (jsDoc && ts.isJSDoc(jsDoc) && typeof jsDoc.comment === 'string') {
desc = processLinkTags(jsDoc.comment.trim(), className);
const rawDesc = getFullJSDocDescription(propDeclaration);
if (rawDesc) {
desc = processLinkTags(rawDesc, className);
}
}
@@ -1539,9 +1584,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
}
const finalMarkdown = markdown.replace('<!-- USED_BY_SECTION -->', usedByMarkdown);
const outputPath = path.join(outputDir, `${symbolName}.md`);
fs.writeFileSync(outputPath, finalMarkdown);
console.log(`Generated: ${outputPath}`);
if (!dry) {
const outputPath = path.join(outputDir, `${symbolName}.md`);
fs.writeFileSync(outputPath, finalMarkdown);
console.log(`Generated: ${outputPath}`);
}
});
// Generate index.md with all exported symbols grouped by group
@@ -1587,9 +1634,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
indexMarkdown += '\n';
});
const indexPath = path.join(outputDir, 'index.md');
fs.writeFileSync(indexPath, indexMarkdown);
console.log(`Generated: ${indexPath}`);
if (!dry) {
const indexPath = path.join(outputDir, 'index.md');
fs.writeFileSync(indexPath, indexMarkdown);
console.log(`Generated: ${indexPath}`);
}
// Generate index.json with sidebar config structure
const sidebarConfig = sortedGroups.map((group) => {
@@ -1607,15 +1656,67 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
};
});
const jsonPath = path.join(outputDir, 'index.json');
fs.writeFileSync(jsonPath, JSON.stringify(sidebarConfig, null, 2));
console.log(`Generated: ${jsonPath}`);
if (!dry) {
const jsonPath = path.join(outputDir, 'index.json');
fs.writeFileSync(jsonPath, JSON.stringify(sidebarConfig, null, 2));
console.log(`Generated: ${jsonPath}`);
}
};
// Helper to get the full description text from a JSDoc comment, handling inline tags.
const getFullJSDocDescription = (node: ts.Node): string => {
const jsDoc = ts.getJSDocCommentsAndTags(node)[0];
if (!jsDoc || !ts.isJSDoc(jsDoc)) return '';
// If it's a simple string, just return it.
if (typeof jsDoc.comment === 'string') {
return jsDoc.comment.trim();
}
// If it's a structured comment (with inline tags), get the raw text.
const sourceFile = node.getSourceFile();
const sourceText = sourceFile.getFullText();
const start = jsDoc.getStart();
const end = jsDoc.getEnd();
const rawJsDoc = sourceText.substring(start, end);
// Extract the content between /** and */
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
if (match && match[1]) {
const content = match[1]
.split('\n')
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
.join('\n')
.trim();
// Filter out @-tags (like @param, @returns) to keep only the main description
const lines = content.split('\n');
const descLines = [];
for (const line of lines) {
if (line.trim().startsWith('@')) break; // Stop at the first @-tag
descLines.push(line);
}
return descLines.join('\n').trim();
}
return '';
};
const main = () => {
const args = process.argv.slice(2);
// Check for --dry flag
const dryIndex = args.indexOf('--dry');
const dry = dryIndex !== -1;
// Remove --dry flag from args
if (dry) {
args.splice(dryIndex, 1);
}
if (args.length < 2) {
console.error('Usage: npm run generate-docs <entry-file1> [entry-file2 ...] <api-config-file>');
console.error('Usage: npm run generate-docs [--dry] <entry-file1> [entry-file2 ...] <api-config-file>');
console.error(' --dry: Check if docs are generatable without writing files');
console.error(' entry-files: One or more TypeScript entry files');
console.error(' api-config-file: JSON config file defining groups');
process.exit(1);
@@ -1625,7 +1726,7 @@ const main = () => {
const apiConfigFile = args[args.length - 1]!;
const entryFiles = args.slice(0, -1);
generateDocs(entryFiles, apiConfigFile);
generateDocs(entryFiles, apiConfigFile, dry);
};
main();
+67 -50
View File
@@ -26,8 +26,27 @@ import { EncodedPacket, PacketType } from './packet';
// Rec. ITU-T H.265
// https://stackoverflow.com/questions/24884827
export enum AvcNalUnitType {
IDR = 5,
SPS = 7,
PPS = 8,
SPS_EXT = 13,
}
export enum HevcNalUnitType {
RASL_N = 8,
RASL_R = 9,
BLA_W_LP = 16,
RSV_IRAP_VCL23 = 23,
VPS_NUT = 32,
SPS_NUT = 33,
PPS_NUT = 34,
PREFIX_SEI_NUT = 39,
SUFFIX_SEI_NUT = 40,
}
/** Finds all NAL units in an AVC packet in Annex B format. */
const findNalUnitsInAnnexB = (packetData: Uint8Array) => {
export const findNalUnitsInAnnexB = (packetData: Uint8Array) => {
const nalUnits: Uint8Array[] = [];
let i = 0;
@@ -184,6 +203,21 @@ export type AvcDecoderConfigurationRecord = {
sequenceParameterSetExt: Uint8Array[] | null;
};
export const extractAvcNalUnits = (packetData: Uint8Array, decoderConfig: VideoDecoderConfig) => {
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[4]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
return findNalUnitsInLengthPrefixed(packetData, lengthSize);
} else {
// Stream is in Annex B format
return findNalUnitsInAnnexB(packetData);
}
};
const extractNalUnitTypeForAvc = (data: Uint8Array) => {
return data[0]! & 0x1F;
};
@@ -193,9 +227,9 @@ export const extractAvcDecoderConfigurationRecord = (packetData: Uint8Array) =>
try {
const nalUnits = findNalUnitsInAnnexB(packetData);
const spsUnits = nalUnits.filter(unit => extractNalUnitTypeForAvc(unit) === 7);
const ppsUnits = nalUnits.filter(unit => extractNalUnitTypeForAvc(unit) === 8);
const spsExtUnits = nalUnits.filter(unit => extractNalUnitTypeForAvc(unit) === 13);
const spsUnits = nalUnits.filter(unit => extractNalUnitTypeForAvc(unit) === AvcNalUnitType.SPS);
const ppsUnits = nalUnits.filter(unit => extractNalUnitTypeForAvc(unit) === AvcNalUnitType.PPS);
const spsExtUnits = nalUnits.filter(unit => extractNalUnitTypeForAvc(unit) === AvcNalUnitType.SPS_EXT);
if (spsUnits.length === 0) {
return null;
@@ -337,12 +371,6 @@ export const serializeAvcDecoderConfigurationRecord = (record: AvcDecoderConfigu
return new Uint8Array(bytes);
};
const NALU_TYPE_VPS = 32;
const NALU_TYPE_SPS = 33;
const NALU_TYPE_PPS = 34;
const NALU_TYPE_SEI_PREFIX = 39;
const NALU_TYPE_SEI_SUFFIX = 40;
// Data specified in ISO 14496-15
export type HevcDecoderConfigurationRecord = {
configurationVersion: number;
@@ -369,7 +397,22 @@ export type HevcDecoderConfigurationRecord = {
}[];
};
const extractNalUnitTypeForHevc = (data: Uint8Array) => {
export const extractHevcNalUnits = (packetData: Uint8Array, decoderConfig: VideoDecoderConfig) => {
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[21]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
return findNalUnitsInLengthPrefixed(packetData, lengthSize);
} else {
// Stream is in Annex B format
return findNalUnitsInAnnexB(packetData);
}
};
export const extractNalUnitTypeForHevc = (data: Uint8Array) => {
return (data[0]! >> 1) & 0x3F;
};
@@ -380,12 +423,12 @@ export const extractHevcDecoderConfigurationRecord = (
try {
const nalUnits = findNalUnitsInAnnexB(packetData);
const vpsUnits = nalUnits.filter(unit => extractNalUnitTypeForHevc(unit) === NALU_TYPE_VPS);
const spsUnits = nalUnits.filter(unit => extractNalUnitTypeForHevc(unit) === NALU_TYPE_SPS);
const ppsUnits = nalUnits.filter(unit => extractNalUnitTypeForHevc(unit) === NALU_TYPE_PPS);
const vpsUnits = nalUnits.filter(unit => extractNalUnitTypeForHevc(unit) === HevcNalUnitType.VPS_NUT);
const spsUnits = nalUnits.filter(unit => extractNalUnitTypeForHevc(unit) === HevcNalUnitType.SPS_NUT);
const ppsUnits = nalUnits.filter(unit => extractNalUnitTypeForHevc(unit) === HevcNalUnitType.PPS_NUT);
const seiUnits = nalUnits.filter(
unit => extractNalUnitTypeForHevc(unit) === NALU_TYPE_SEI_PREFIX
|| extractNalUnitTypeForHevc(unit) === NALU_TYPE_SEI_SUFFIX,
unit => extractNalUnitTypeForHevc(unit) === HevcNalUnitType.PREFIX_SEI_NUT
|| extractNalUnitTypeForHevc(unit) === HevcNalUnitType.SUFFIX_SEI_NUT,
);
if (spsUnits.length === 0 || ppsUnits.length === 0) return null;
@@ -521,7 +564,7 @@ export const extractHevcDecoderConfigurationRecord = (
? [
{
arrayCompleteness: 1,
nalUnitType: NALU_TYPE_VPS,
nalUnitType: HevcNalUnitType.VPS_NUT,
nalUnits: vpsUnits,
},
]
@@ -530,7 +573,7 @@ export const extractHevcDecoderConfigurationRecord = (
? [
{
arrayCompleteness: 1,
nalUnitType: NALU_TYPE_SPS,
nalUnitType: HevcNalUnitType.SPS_NUT,
nalUnits: spsUnits,
},
]
@@ -539,7 +582,7 @@ export const extractHevcDecoderConfigurationRecord = (
? [
{
arrayCompleteness: 1,
nalUnitType: NALU_TYPE_PPS,
nalUnitType: HevcNalUnitType.PPS_NUT,
nalUnits: ppsUnits,
},
]
@@ -1440,22 +1483,9 @@ export const determineVideoPacketType = async (
const decoderConfig = await videoTrack.getDecoderConfig();
assert(decoderConfig);
let nalUnits: Uint8Array[];
const nalUnits = extractAvcNalUnits(packet.data, decoderConfig);
const isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === AvcNalUnitType.IDR);
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[4]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
nalUnits = findNalUnitsInLengthPrefixed(packet.data, lengthSize);
} else {
// Stream is in Annex B format
nalUnits = findNalUnitsInAnnexB(packet.data);
}
const isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === 5);
return isKeyframe ? 'key' : 'delta';
};
@@ -1463,25 +1493,12 @@ export const determineVideoPacketType = async (
const decoderConfig = await videoTrack.getDecoderConfig();
assert(decoderConfig);
let nalUnits: Uint8Array[];
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[21]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
nalUnits = findNalUnitsInLengthPrefixed(packet.data, lengthSize);
} else {
// Stream is in Annex B format
nalUnits = findNalUnitsInAnnexB(packet.data);
}
const nalUnits = extractHevcNalUnits(packet.data, decoderConfig);
const isKeyframe = nalUnits.some((x) => {
const type = extractNalUnitTypeForHevc(x);
return 16 <= type && type <= 23;
return HevcNalUnitType.BLA_W_LP <= type && type <= HevcNalUnitType.RSV_IRAP_VCL23;
});
return isKeyframe ? 'key' : 'delta';
};
+1 -1
View File
@@ -621,7 +621,7 @@ export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null): AacAudioS
};
};
export const OPUS_INTERNAL_SAMPLE_RATE = 48000;
export const OPUS_SAMPLE_RATE = 48_000;
const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
+72 -31
View File
@@ -47,7 +47,7 @@ import {
} from './misc';
import { Output, TrackType } from './output';
import { Mp4OutputFormat } from './output-format';
import { AudioSample, VideoSample } from './sample';
import { AudioSample, clampCropRectangle, validateCropRectangle, VideoSample } from './sample';
import { MetadataTags, validateMetadataTags } from './tags';
import { NullTarget } from './target';
@@ -89,13 +89,13 @@ export type ConversionOptions = {
};
/**
* A callback that returns or resolves to the descriptive metadata tags that should be written to the output file.
* As input, this function will be passed the tags of the input file, allowing you to modify, augment or extend
* them.
* An object or a callback that returns or resolves to an object containing the descriptive metadata tags that
* should be written to the output file. If a function is passed, it will be passed the tags of the input file as
* its first argument, allowing you to modify, augment or extend them.
*
* If no function is set, the input's metadata tags will be copied to the output.
*/
tags?: (inputTags: MetadataTags) => MaybePromise<MetadataTags>;
tags?: MetadataTags | ((inputTags: MetadataTags) => MaybePromise<MetadataTags>);
};
/**
@@ -126,10 +126,24 @@ export type ConversionVideoOptions = {
*/
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.
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before cropping and resizing.
* This rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
*/
rotate?: Rotation;
/**
* Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
* the dimensions of the input video track. Cropping is performed after rotation but before resizing.
*/
crop?: {
/** The distance in pixels from the left edge of the source frame to the left edge of the crop rectangle. */
left: number;
/** The distance in pixels from the top edge of the source frame to the top edge of the crop rectangle. */
top: number;
/** The width in pixels of the crop rectangle. */
width: number;
/** The height in pixels of the crop rectangle. */
height: number;
};
/**
* 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).
@@ -213,6 +227,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
}
if (videoOptions?.crop !== undefined) {
validateCropRectangle(videoOptions.crop, 'options.video.');
}
if (
videoOptions?.frameRate !== undefined
&& (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)
@@ -378,8 +395,12 @@ export class Conversion {
if (!(options.output instanceof Output)) {
throw new TypeError('options.output must be an Output.');
}
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.output._tracks.length > 0
|| Object.keys(options.output._metadataTags).length > 0
|| options.output.state !== 'pending'
) {
throw new TypeError('options.output must be fresh: no tracks or metadata tags added and not started.');
}
if (typeof options.video !== 'function') {
@@ -409,8 +430,15 @@ export class Conversion {
&& options.trim.start >= options.trim.end) {
throw new TypeError('options.trim.start must be less than options.trim.end.');
}
if (options.tags !== undefined && typeof options.tags !== 'function') {
throw new TypeError('options.tags, when provided, must be a function.');
if (
options.tags !== undefined
&& (typeof options.tags !== 'object' || !options.tags)
&& typeof options.tags !== 'function'
) {
throw new TypeError('options.tags, when provided, must be an object or a function.');
}
if (typeof options.tags === 'object') {
validateMetadataTags(options.tags);
}
this._options = options;
@@ -502,7 +530,9 @@ export class Conversion {
let outputTags: MetadataTags;
if (this._options.tags) {
const result = await this._options.tags(inputTags);
const result = typeof this._options.tags === 'function'
? await this._options.tags(inputTags)
: this._options.tags;
validateMetadataTags(result);
outputTags = result;
@@ -601,10 +631,19 @@ export class Conversion {
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
const [originalWidth, originalHeight] = totalRotation % 180 === 0
const [rotatedWidth, rotatedHeight] = totalRotation % 180 === 0
? [track.codedWidth, track.codedHeight]
: [track.codedHeight, track.codedWidth];
const crop = trackOptions.crop;
if (crop) {
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
}
const [originalWidth, originalHeight] = crop
? [crop.width, crop.height]
: [rotatedWidth, rotatedHeight];
let width = originalWidth;
let height = originalHeight;
const aspectRatio = width / height;
@@ -630,7 +669,8 @@ export class Conversion {
|| !!trackOptions.frameRate;
let needsRerender = width !== originalWidth
|| height !== originalHeight
|| (totalRotation !== 0 && !outputSupportsRotation);
|| (totalRotation !== 0 && !outputSupportsRotation)
|| !!crop;
let videoCodecs = this.output.format.getSupportedVideoCodecs();
if (
@@ -754,6 +794,7 @@ export class Conversion {
height,
fit: trackOptions.fit ?? 'fill',
rotation: totalRotation, // Bake the rotation into the output
crop: trackOptions.crop,
poolSize: 1,
});
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
@@ -1125,8 +1166,6 @@ export class Conversion {
await this._started;
const resampler = new AudioResampler({
sourceNumberOfChannels: track.numberOfChannels,
sourceSampleRate: track.sampleRate,
targetNumberOfChannels,
targetSampleRate,
startTime: this._startTimestamp,
@@ -1246,9 +1285,9 @@ class TrackSynchronizer {
* OfflineAudioContext.
*/
export class AudioResampler {
sourceSampleRate: number;
sourceSampleRate: number | null = null;
targetSampleRate: number;
sourceNumberOfChannels: number;
sourceNumberOfChannels: number | null = null;
targetNumberOfChannels: number;
startTime: number;
endTime: number;
@@ -1262,20 +1301,16 @@ export class AudioResampler {
/** The highest index written to in the current buffer */
maxWrittenFrame: number;
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
tempSourceBuffer: Float32Array;
tempSourceBuffer!: Float32Array;
constructor(options: {
sourceSampleRate: number;
targetSampleRate: number;
sourceNumberOfChannels: number;
targetNumberOfChannels: number;
startTime: number;
endTime: number;
onSample: (sample: AudioSample) => Promise<void>;
}) {
this.sourceSampleRate = options.sourceSampleRate;
this.targetSampleRate = options.targetSampleRate;
this.sourceNumberOfChannels = options.sourceNumberOfChannels;
this.targetNumberOfChannels = options.targetNumberOfChannels;
this.startTime = options.startTime;
this.endTime = options.endTime;
@@ -1287,17 +1322,14 @@ export class AudioResampler {
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
this.bufferStartFrame = 0;
this.maxWrittenFrame = -1;
this.setupChannelMixer();
// Pre-allocate temporary buffer for source data
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
}
/**
* Sets up the channel mixer to handle up/downmixing in the case where input and output channel counts don't match.
*/
setupChannelMixer(): void {
doChannelMixerSetup(): void {
assert(this.sourceNumberOfChannels !== null);
const sourceNum = this.sourceNumberOfChannels;
const targetNum = this.targetNumberOfChannels;
@@ -1415,8 +1447,17 @@ export class AudioResampler {
}
async add(audioSample: AudioSample) {
if (!audioSample || audioSample._closed) {
return;
if (this.sourceSampleRate === null) {
// This is the first sample, so let's init the missing data. Initting the sample rate from the decoded
// sample is more reliable than using the file's metadata, because decoders are free to emit any sample rate
// they see fit.
this.sourceSampleRate = audioSample.sampleRate;
this.sourceNumberOfChannels = audioSample.numberOfChannels;
// Pre-allocate temporary buffer for source data
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
this.doChannelMixerSetup();
}
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
+1
View File
@@ -160,6 +160,7 @@ export {
AudioSampleCopyToOptions,
VideoSample,
VideoSampleInit,
CropRectangle,
} from './sample';
export {
AudioBufferSink,
+5 -5
View File
@@ -330,17 +330,17 @@ export const ftyp = (details: {
/** Movie Sample Data Box. Contains the actual frames/samples of the media. */
export const mdat = (reserveLargeSize: boolean): Box => ({ type: 'mdat', largeSize: reserveLargeSize });
/** Free Space Box: A box that designates unused space in the movie data file. */
export const free = (size: number): Box => ({ type: 'free', size });
/**
* Movie Box: Used to specify the information that defines a movie - that is, the information that allows
* an application to interpret the sample data that is stored elsewhere.
*/
export const moov = (
muxer: IsobmffMuxer,
fragmented = false,
) => box('moov', undefined, [
export const moov = (muxer: IsobmffMuxer) => box('moov', undefined, [
mvhd(muxer.creationTime, muxer.trackDatas),
...muxer.trackDatas.map(x => trak(x, muxer.creationTime)),
fragmented ? mvex(muxer.trackDatas) : null,
muxer.isFragmented ? mvex(muxer.trackDatas) : null,
udta(muxer),
]);
+21 -1
View File
@@ -12,6 +12,7 @@ import {
extractAudioCodecString,
extractVideoCodecString,
MediaCodec,
OPUS_SAMPLE_RATE,
parseAacAudioSpecificConfig,
parsePcmCodec,
PCM_AUDIO_CODECS,
@@ -1049,6 +1050,10 @@ export class IsobmffDemuxer extends Demuxer {
}
}
if (track.info.codec === 'opus') {
sampleRate = OPUS_SAMPLE_RATE; // Always the same
}
track.info.numberOfChannels = channelCount;
track.info.sampleRate = sampleRate;
@@ -1421,7 +1426,7 @@ export class IsobmffDemuxer extends Demuxer {
track.info.codecDescription = description;
track.info.numberOfChannels = outputChannelCount;
track.info.sampleRate = inputSampleRate;
// Don't copy the input sample rate, irrelevant, and output sample rate is fixed
}; break;
case 'dfLa': { // Used for FLAC audio
@@ -2301,6 +2306,21 @@ export class IsobmffDemuxer extends Demuxer {
}
}; break;
case 'track': {
if (typeof data === 'string') {
const parts = data.split('/');
const trackNum = Number.parseInt(parts[0]!, 10);
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
if (Number.isInteger(trackNum) && trackNum > 0) {
this.metadataTags.trackNumber ??= trackNum;
}
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
this.metadataTags.tracksTotal ??= tracksTotal;
}
}
}; break;
case 'trkn': {
if (data instanceof Uint8Array) {
const view = toDataView(data);
+123 -14
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Box, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes';
import { Box, free, ftyp, IsobmffBoxWriter, mdat, mfra, moof, moov, vtta, vttc, vtte } from './isobmff-boxes';
import { Muxer } from '../muxer';
import { Output, OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from '../output';
import { BufferTargetWriter, Writer } from '../writer';
@@ -142,7 +142,7 @@ export class IsobmffMuxer extends Muxer {
private writer: Writer;
private boxWriter: IsobmffBoxWriter;
private fastStart: NonNullable<IsobmffOutputFormatOptions['fastStart']>;
private isFragmented: boolean;
isFragmented: boolean;
isQuickTime: boolean;
@@ -151,6 +151,7 @@ export class IsobmffMuxer extends Muxer {
private auxBoxWriter = new IsobmffBoxWriter(this.auxWriter);
private mdat: Box | null = null;
private ftypSize: number | null = null;
trackDatas: IsobmffTrackData[] = [];
private allTracksKnown = promiseWithResolvers();
@@ -208,8 +209,22 @@ export class IsobmffMuxer extends Muxer {
}
}
this.ftypSize = this.writer.getPos();
if (this.fastStart === 'in-memory') {
this.mdat = mdat(false);
// We're write at finalization
} else if (this.fastStart === 'reserve') {
// Validate that all tracks have set maximumPacketCount
for (const track of this.output._tracks) {
if (track.metadata.maximumPacketCount === undefined) {
throw new Error(
'All tracks must specify maximumPacketCount in their metadata when using'
+ ' fastStart: \'reserve\'.',
);
}
}
// We'll start writing once we know all tracks
} else if (this.isFragmented) {
// We write the moov box once we write out the first fragment to make sure we get the decoder configs
} else {
@@ -830,6 +845,8 @@ export class IsobmffMuxer extends Muxer {
if (this.isFragmented) {
trackData.sampleQueue.push(sample);
await this.interleaveSamples();
} else if (this.fastStart === 'reserve') {
await this.registerSampleFastStartReserve(trackData, sample);
} else {
await this.addSampleToTrack(trackData, sample);
}
@@ -838,6 +855,18 @@ export class IsobmffMuxer extends Muxer {
private async addSampleToTrack(trackData: IsobmffTrackData, sample: Sample) {
if (!this.isFragmented) {
trackData.samples.push(sample);
if (this.fastStart === 'reserve') {
const maximumPacketCount = trackData.track.metadata.maximumPacketCount;
assert(maximumPacketCount !== undefined);
if (trackData.samples.length > maximumPacketCount) {
throw new Error(
`Track #${trackData.track.id} has already reached the maximum packet count`
+ ` (${maximumPacketCount}). Either add less packets or increase the maximum packet count.`,
);
}
}
}
let beginNewChunk = false;
@@ -946,10 +975,8 @@ export class IsobmffMuxer extends Muxer {
private async interleaveSamples(isFinalCall = false) {
assert(this.isFragmented);
if (!isFinalCall) {
if (!this.allTracksAreKnown()) {
return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
}
if (!isFinalCall && !this.allTracksAreKnown()) {
return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
}
outer:
@@ -988,7 +1015,7 @@ export class IsobmffMuxer extends Muxer {
}
// Write the moov box now that we have all decoder configs
const movieBox = moov(this, true);
const movieBox = moov(this);
this.boxWriter.writeBox(movieBox);
if (this.format._options.onMoov) {
@@ -1077,6 +1104,72 @@ export class IsobmffMuxer extends Muxer {
}
}
private async registerSampleFastStartReserve(trackData: IsobmffTrackData, sample: Sample) {
if (this.allTracksAreKnown()) {
if (!this.mdat) {
// We finally know all tracks, let's reserve space for the moov box
const moovBox = moov(this);
const moovSize = this.boxWriter.measureBox(moovBox);
const reservedSize = moovSize
+ this.computeSampleTableSizeUpperBound()
+ 4096; // Just a little extra headroom
assert(this.ftypSize !== null);
this.writer.seek(this.ftypSize + reservedSize);
if (this.format._options.onMdat) {
this.writer.startTrackingWrites();
}
this.mdat = mdat(true);
this.boxWriter.writeBox(this.mdat);
// Now write everything that was queued
for (const trackData of this.trackDatas) {
for (const sample of trackData.sampleQueue) {
await this.addSampleToTrack(trackData, sample);
}
trackData.sampleQueue.length = 0;
}
}
await this.addSampleToTrack(trackData, sample);
} else {
// Queue it for when we know all tracks
trackData.sampleQueue.push(sample);
}
}
private computeSampleTableSizeUpperBound() {
assert(this.fastStart === 'reserve');
let upperBound = 0;
for (const trackData of this.trackDatas) {
const n = trackData.track.metadata.maximumPacketCount;
assert(n !== undefined); // We validated this earlier
// Given the max allowed packet count, compute the space they'll take up in the Sample Table Box, assuming
// the worst case for each individual box:
// stts box - since it is compactly coded, the maximum length of this table will be 2/3n
upperBound += (4 + 4) * Math.ceil(2 / 3 * n);
// stss box - 1 entry per sample
upperBound += 4 * n;
// ctts box - since it is compactly coded, the maximum length of this table will be 2/3n
upperBound += (4 + 4) * Math.ceil(2 / 3 * n);
// stsc box - since it is compactly coded, the maximum length of this table will be 2/3n
upperBound += (4 + 4 + 4) * Math.ceil(2 / 3 * n);
// stsz box - 1 entry per sample
upperBound += 4 * n;
// co64 box - we assume 1 sample per chunk and 64-bit chunk offsets (co64 instead of stco)
upperBound += 8 * n;
}
return upperBound;
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
override async onTrackClose(track: OutputTrack) {
const release = await this.mutex.acquire();
@@ -1128,7 +1221,7 @@ export class IsobmffMuxer extends Muxer {
}
if (this.fastStart === 'in-memory') {
assert(this.mdat);
this.mdat = mdat(false);
let mdatSize: number;
// We know how many chunks there are, but computing the chunk positions requires an iterative approach:
@@ -1214,12 +1307,28 @@ export class IsobmffMuxer extends Muxer {
this.format._options.onMdat(data, start);
}
if (this.format._options.onMoov) {
this.writer.startTrackingWrites();
}
const movieBox = moov(this);
this.boxWriter.writeBox(movieBox);
if (this.fastStart === 'reserve') {
assert(this.ftypSize !== null);
this.writer.seek(this.ftypSize);
if (this.format._options.onMoov) {
this.writer.startTrackingWrites();
}
this.boxWriter.writeBox(movieBox);
// Fill the remaining space with a free box. If there are less than 8 bytes left, sucks I guess
const remainingSpace = this.boxWriter.offsets.get(this.mdat)! - this.writer.getPos();
this.boxWriter.writeBox(free(remainingSpace));
} else {
if (this.format._options.onMoov) {
this.writer.startTrackingWrites();
}
this.boxWriter.writeBox(movieBox);
}
if (this.format._options.onMoov) {
const { data, start } = this.writer.stopTrackingWrites();
+8
View File
@@ -150,6 +150,14 @@ export enum EBMLId {
TagLanguage = 0x447a,
TagString = 0x4487,
TagBinary = 0x4485,
ContentEncodings = 0x6d80,
ContentEncoding = 0x6240,
ContentEncodingOrder = 0x5031,
ContentEncodingScope = 0x5032,
ContentCompression = 0x5034,
ContentCompAlgo = 0x4254,
ContentCompSettings = 0x4255,
ContentEncryption = 0x5035,
}
export const LEVEL_0_EBML_IDS: EBMLId[] = [
+166 -4
View File
@@ -18,6 +18,7 @@ import {
extractAudioCodecString,
extractVideoCodecString,
MediaCodec,
OPUS_SAMPLE_RATE,
VideoCodec,
} from '../codec';
import { Demuxer } from '../demuxer';
@@ -104,6 +105,7 @@ type SeekEntry = {
};
type Cluster = {
segment: Segment;
elementStartPos: number;
elementEndPos: number;
dataStartPos: number;
@@ -114,6 +116,7 @@ type Cluster = {
};
type ClusterTrackData = {
track: InternalTrack | null;
startTimestamp: number;
endTimestamp: number;
firstKeyFrameTimestamp: number | null;
@@ -146,6 +149,32 @@ type CuePoint = {
clusterPosition: number;
};
enum ContentEncodingScope {
Block = 1,
Private = 2,
Next = 4,
}
enum ContentCompAlgo {
Zlib,
Bzlib,
lzo1x,
HeaderStripping,
}
type DecodingInstruction = {
order: number;
scope: ContentEncodingScope;
data: {
type: 'decompress';
algorithm: ContentCompAlgo | null;
settings: Uint8Array | null;
} | {
type: 'decrypt';
// Don't store more yet since this operation is unsupported
} | null;
};
type InternalTrack = {
id: number;
demuxer: MatroskaDemuxer;
@@ -161,6 +190,8 @@ type InternalTrack = {
defaultDuration: number | null;
name: string | null;
languageCode: string;
decodingInstructions: DecodingInstruction[];
info:
| null
| {
@@ -204,6 +235,7 @@ export class MatroskaDemuxer extends Demuxer {
currentCluster: Cluster | null = null;
currentBlock: ClusterBlock | null = null;
currentCueTime: number | null = null;
currentDecodingInstruction: DecodingInstruction | null = null;
currentTagTargetIsMovie: boolean = true;
currentSimpleTagName: string | null = null;
currentAttachedFile: {
@@ -582,6 +614,7 @@ export class MatroskaDemuxer extends Demuxer {
if (dataSlice instanceof Promise) dataSlice = await dataSlice;
const cluster: Cluster = {
segment,
elementStartPos,
elementEndPos: dataStartPos + size,
dataStartPos,
@@ -596,8 +629,8 @@ export class MatroskaDemuxer extends Demuxer {
this.readContiguousElements(dataSlice);
}
for (const [trackId, trackData] of cluster.trackData) {
const track = segment.tracks.find(x => x.id === trackId) ?? null;
for (const [, trackData] of cluster.trackData) {
const track = trackData.track;
// This must hold, as track datas only get created if a block for that track is encountered
assert(trackData.blocks.length > 0);
@@ -682,6 +715,7 @@ export class MatroskaDemuxer extends Demuxer {
let trackData = cluster.trackData.get(trackNumber);
if (!trackData) {
trackData = {
track: cluster.segment.tracks.find(x => x.id === trackNumber) ?? null,
startTimestamp: 0,
endTimestamp: 0,
firstKeyFrameTimestamp: null,
@@ -933,11 +967,21 @@ export class MatroskaDemuxer extends Demuxer {
defaultDuration: null,
name: null,
languageCode: UNDETERMINED_LANGUAGE,
decodingInstructions: [],
info: null,
};
this.readContiguousElements(slice.slice(dataStartPos, size));
if (this.currentTrack.decodingInstructions.some((instruction) => {
return instruction.data?.type !== 'decompress'
|| instruction.data.algorithm !== ContentCompAlgo.HeaderStripping;
})) {
console.warn(`Track #${this.currentTrack.id} has an unsupported content encoding; dropping.`);
this.currentTrack = null;
}
if (
this.currentTrack
&& this.currentTrack.id !== -1
@@ -988,6 +1032,7 @@ export class MatroskaDemuxer extends Demuxer {
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.opus) {
this.currentTrack.info.codec = 'opus';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
this.currentTrack.info.sampleRate = OPUS_SAMPLE_RATE; // Always the same
} else if (codecIdWithoutSuffix === CODEC_STRING_MAP.vorbis) {
this.currentTrack.info.codec = 'vorbis';
this.currentTrack.info.codecDescription = this.currentTrack.codecPrivate;
@@ -1293,12 +1338,18 @@ export class MatroskaDemuxer extends Demuxer {
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
let blockData = readBytes(slice, size - (slice.filePos - dataStartPos));
if (trackData.track) {
blockData = this.decodeBlockData(trackData.track, blockData);
}
trackData.blocks.push({
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
duration: 0, // Will set later
isKeyFrame,
referencedTimestamps: [],
data: readBytes(slice, size - (slice.filePos - dataStartPos)),
data: blockData,
lacing,
});
}; break;
@@ -1329,12 +1380,18 @@ export class MatroskaDemuxer extends Demuxer {
const lacing = (flags >> 1) & 0x3 as BlockLacing; // If the block is laced, we'll expand it later
const trackData = this.getTrackDataInCluster(this.currentCluster, trackNumber);
let blockData = readBytes(slice, size - (slice.filePos - dataStartPos));
if (trackData.track) {
blockData = this.decodeBlockData(trackData.track, blockData);
}
this.currentBlock = {
timestamp: relativeTimestamp, // We'll add the cluster's timestamp to this later
duration: 0, // Will set later
isKeyFrame: true,
referencedTimestamps: [],
data: readBytes(slice, size - (slice.filePos - dataStartPos)),
data: blockData,
lacing,
};
trackData.blocks.push(this.currentBlock);
@@ -1467,12 +1524,117 @@ export class MatroskaDemuxer extends Demuxer {
this.currentAttachedFile.fileDescription = readUnicodeString(slice, size);
}; break;
case EBMLId.ContentEncodings: {
if (!this.currentTrack) break;
this.readContiguousElements(slice.slice(dataStartPos, size));
// "**MUST** start with the `ContentEncoding` with the highest `ContentEncodingOrder`"
this.currentTrack.decodingInstructions.sort((a, b) => b.order - a.order);
}; break;
case EBMLId.ContentEncoding: {
this.currentDecodingInstruction = {
order: 0,
scope: ContentEncodingScope.Block,
data: null,
};
this.readContiguousElements(slice.slice(dataStartPos, size));
if (this.currentDecodingInstruction.data) {
this.currentTrack!.decodingInstructions.push(this.currentDecodingInstruction);
}
this.currentDecodingInstruction = null;
}; break;
case EBMLId.ContentEncodingOrder: {
if (!this.currentDecodingInstruction) break;
this.currentDecodingInstruction.order = readUnsignedInt(slice, size);
}; break;
case EBMLId.ContentEncodingScope: {
if (!this.currentDecodingInstruction) break;
this.currentDecodingInstruction.scope = readUnsignedInt(slice, size);
}; break;
case EBMLId.ContentCompression: {
if (!this.currentDecodingInstruction) break;
this.currentDecodingInstruction.data = {
type: 'decompress',
algorithm: ContentCompAlgo.Zlib,
settings: null,
};
this.readContiguousElements(slice.slice(dataStartPos, size));
}; break;
case EBMLId.ContentCompAlgo: {
if (this.currentDecodingInstruction?.data?.type !== 'decompress') break;
this.currentDecodingInstruction.data.algorithm = readUnsignedInt(slice, size);
}; break;
case EBMLId.ContentCompSettings: {
if (this.currentDecodingInstruction?.data?.type !== 'decompress') break;
this.currentDecodingInstruction.data.settings = readBytes(slice, size);
}; break;
case EBMLId.ContentEncryption: {
if (!this.currentDecodingInstruction) break;
this.currentDecodingInstruction.data = {
type: 'decrypt',
};
}; break;
}
slice.filePos = dataStartPos + size;
return true;
}
decodeBlockData(track: InternalTrack, rawData: Uint8Array) {
let currentData = rawData;
// In the vast number of cases there are exactly zero decoding instructions
for (let i = 0; i < track.decodingInstructions.length; i++) {
const instruction = track.decodingInstructions[i]!;
assert(instruction.data);
switch (instruction.data.type) {
case 'decompress': {
switch (instruction.data.algorithm) {
case ContentCompAlgo.HeaderStripping: {
const prefix = instruction.data.settings ?? new Uint8Array(0);
const newData = new Uint8Array(prefix.length + currentData.length);
newData.set(prefix, 0);
newData.set(currentData, prefix.length);
currentData = newData;
}; break;
default: {
// Unhandled
};
}
}; break;
default: {
// Unhandled
};
}
}
return currentData;
}
processTagValue(name: string, value: string | Uint8Array) {
if (!this.currentSegment?.metadataTags) return;
+4 -6
View File
@@ -47,7 +47,7 @@ import {
parseSubtitleTimestamp,
} from '../subtitles';
import {
OPUS_INTERNAL_SAMPLE_RATE,
OPUS_SAMPLE_RATE,
PCM_AUDIO_CODECS,
PcmAudioCodec,
SubtitleCodec,
@@ -293,7 +293,7 @@ export class MatroskaMuxer extends Muxer {
const header = parseOpusIdentificationHeader(bytes);
// Use the preSkip value from the header
seekPreRollNs = Math.round(1e9 * (header.preSkip / OPUS_INTERNAL_SAMPLE_RATE));
seekPreRollNs = Math.round(1e9 * (header.preSkip / OPUS_SAMPLE_RATE));
}
}
@@ -854,10 +854,8 @@ export class MatroskaMuxer extends Muxer {
}
private async interleaveChunks(isFinalCall = false) {
if (!isFinalCall) {
if (!this.allTracksAreKnown()) {
return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
}
if (!isFinalCall && !this.allTracksAreKnown()) {
return; // We can't interleave yet as we don't yet know how many tracks we'll truly have
}
outer:
+63 -8
View File
@@ -7,6 +7,7 @@
*/
import { parsePcmCodec, PCM_AUDIO_CODECS, PcmAudioCodec, VideoCodec, AudioCodec } from './codec';
import { extractHevcNalUnits, extractNalUnitTypeForHevc, HevcNalUnitType } from './codec-data';
import { CustomVideoDecoder, customVideoDecoders, CustomAudioDecoder, customAudioDecoders } from './custom-coder';
import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track';
import {
@@ -17,6 +18,7 @@ import {
getInt24,
getUint24,
insertSorted,
isFirefox,
isSafari,
last,
mapAsyncGenerator,
@@ -28,7 +30,7 @@ import {
} from './misc';
import { EncodedPacket } from './packet';
import { fromAlaw, fromUlaw } from './pcm';
import { AudioSample, VideoSample } from './sample';
import { AudioSample, clampCropRectangle, CropRectangle, validateCropRectangle, VideoSample } from './sample';
/**
* Additional options for controlling packet retrieval.
@@ -624,8 +626,8 @@ export abstract class BaseMediaSampleSink<
const nextPacket = await packetSink.getNextPacket(currentPacket);
assert(nextPacket);
currentPacket = nextPacket;
decoder.decode(nextPacket);
currentPacket = nextPacket;
}
maxSequenceNumber = -1;
@@ -757,12 +759,14 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
inputTimestamps: number[] = []; // Timestamps input into the decoder, sorted.
sampleQueue: VideoSample[] = []; // Safari-specific thing, check usage.
currentPacketIndex = 0;
raslSkipped = false; // For HEVC stuff
constructor(
onSample: (sample: VideoSample) => unknown,
onError: (error: DOMException) => unknown,
codec: VideoCodec,
decoderConfig: VideoDecoderConfig,
public codec: VideoCodec,
public decoderConfig: VideoDecoderConfig,
public rotation: Rotation,
public timeResolution: number,
) {
@@ -848,6 +852,26 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
}
decode(packet: EncodedPacket) {
if (this.codec === 'hevc' && this.currentPacketIndex > 0 && !this.raslSkipped) {
// If we're using HEVC, we need to make sure to skip any RASL slices that follow a non-IDR key frame such as
// CRA_NUT. This is because RASL slices cannot be decoded without data before the CRA_NUT. Browsers behave
// differently here: Chromium drops the packets, Safari throws a decoder error. Either way, it's not good
// and causes bugs upstream. So, let's take the dropping into our own hands.
const nalUnits = extractHevcNalUnits(packet.data, this.decoderConfig);
const hasRaslPicture = nalUnits.some((x) => {
const type = extractNalUnitTypeForHevc(x);
return type === HevcNalUnitType.RASL_N || type === HevcNalUnitType.RASL_R;
});
if (hasRaslPicture) {
return; // Drop
}
this.raslSkipped = true;
}
this.currentPacketIndex++;
if (this.customDecoder) {
this.customDecoderQueueSize++;
void this.customDecoderCallSerializer
@@ -879,6 +903,9 @@ class VideoDecoderWrapper extends DecoderWrapper<VideoSample> {
this.sampleQueue.length = 0;
}
this.currentPacketIndex = 0;
this.raslSkipped = false;
}
close() {
@@ -1026,6 +1053,11 @@ export type CanvasSinkOptions = {
* Rotation is applied before resizing.
*/
rotation?: Rotation;
/**
* Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
* the dimensions of the input video track. Cropping is performed after rotation but before resizing.
*/
crop?: CropRectangle;
/**
* When set, specifies the number of canvases in the pool. These canvases will be reused in a ring buffer /
* round-robin type fashion. This keeps the amount of allocated VRAM constant and relieves the browser from
@@ -1057,6 +1089,8 @@ export class CanvasSink {
/** @internal */
_rotation: Rotation;
/** @internal */
_crop?: { left: number; top: number; width: number; height: number };
/** @internal */
_videoSampleSink: VideoSampleSink;
/** @internal */
_canvasPool: (HTMLCanvasElement | OffscreenCanvas | null)[];
@@ -1092,6 +1126,9 @@ export class CanvasSink {
if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {
throw new TypeError('options.rotation, when provided, must be 0, 90, 180 or 270.');
}
if (options.crop !== undefined) {
validateCropRectangle(options.crop, 'options.');
}
if (
options.poolSize !== undefined
&& (typeof options.poolSize !== 'number' || !Number.isInteger(options.poolSize) || options.poolSize < 0)
@@ -1100,9 +1137,19 @@ export class CanvasSink {
}
const rotation = options.rotation ?? videoTrack.rotation;
let [width, height] = rotation % 180 === 0
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
? [videoTrack.codedWidth, videoTrack.codedHeight]
: [videoTrack.codedHeight, videoTrack.codedWidth];
const crop = options.crop;
if (crop) {
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
}
let [width, height] = crop
? [crop.width, crop.height]
: [rotatedWidth, rotatedHeight];
const originalAspectRatio = width / height;
// If width and height aren't defined together, deduce the missing value using the aspect ratio
@@ -1121,6 +1168,7 @@ export class CanvasSink {
this._width = width;
this._height = height;
this._rotation = rotation;
this._crop = crop;
this._fit = options.fit ?? 'fill';
this._videoSampleSink = new VideoSampleSink(videoTrack);
this._canvasPool = Array.from({ length: options.poolSize ?? 0 }, () => null);
@@ -1152,19 +1200,26 @@ export class CanvasSink {
this._nextCanvasIndex = (this._nextCanvasIndex + 1) % this._canvasPool.length;
}
const context
= canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
const context = canvas.getContext('2d', {
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
assert(context);
context.resetTransform();
if (!canvasIsNew) {
context.clearRect(0, 0, this._width, this._height);
if (isFirefox()) {
context.fillStyle = 'black';
context.fillRect(0, 0, this._width, this._height);
} else {
context.clearRect(0, 0, this._width, this._height);
}
}
sample.drawWithFit(context, {
fit: this._fit,
rotation: this._rotation,
crop: this._crop,
});
const result = {
+19 -4
View File
@@ -18,7 +18,16 @@ import {
VideoCodec,
} from './codec';
import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from './output';
import { assert, assertNever, CallSerializer, clamp, promiseWithResolvers, setInt24, setUint24 } from './misc';
import {
assert,
assertNever,
CallSerializer,
clamp,
isFirefox,
promiseWithResolvers,
setInt24,
setUint24,
} from './misc';
import { Muxer } from './muxer';
import { SubtitleParser } from './subtitles';
import { toAlaw, toUlaw } from './pcm';
@@ -248,12 +257,18 @@ class VideoEncoderWrapper {
canvasIsNew = true;
}
const context = this.resizeCanvas.getContext('2d', { alpha: false }) as
CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
const context = this.resizeCanvas.getContext('2d', {
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
assert(context);
if (!canvasIsNew) {
context.clearRect(0, 0, this.codedWidth, this.codedHeight);
if (isFirefox()) {
context.fillStyle = 'black';
context.fillRect(0, 0, this.codedWidth, this.codedHeight);
} else {
context.clearRect(0, 0, this.codedWidth, this.codedHeight);
}
}
videoSample.drawWithFit(context, { fit: sizeChangeBehavior });
+9
View File
@@ -642,6 +642,15 @@ export const isSafari = () => {
return result;
};
let isFirefoxCache: boolean | null = null;
export const isFirefox = () => {
if (isFirefoxCache !== null) {
return isFirefoxCache;
}
return isFirefoxCache = typeof navigator !== 'undefined' && navigator.userAgent?.includes('Firefox');
};
/**
* T or a promise that resolves to T.
* @group Miscellaneous
+3 -3
View File
@@ -75,9 +75,9 @@ export abstract class Muxer {
if (timestampInSeconds < timestampInfo.maxTimestampBeforeLastKeyFrame) {
throw new Error(
`Timestamps cannot be smaller than the highest timestamp of the previous run (a run begins with a`
+ ` key frame and ends right before the next key frame). Got ${timestampInSeconds}s, but highest`
+ ` timestamp is ${timestampInfo.maxTimestampBeforeLastKeyFrame}s.`,
`Timestamps cannot be smaller than the highest timestamp of the previous GOP (a GOP begins with a key`
+ ` frame and ends right before the next key frame). Got ${timestampInSeconds}s, but highest timestamp`
+ ` is ${timestampInfo.maxTimestampBeforeLastKeyFrame}s.`,
);
}
+11
View File
@@ -0,0 +1,11 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
// This file contains Node.js-specific code that does not run in a browser.
export * as fs from 'node:fs/promises';
+4 -4
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { OPUS_INTERNAL_SAMPLE_RATE } from '../codec';
import { OPUS_SAMPLE_RATE } from '../codec';
import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data';
import { Demuxer } from '../demuxer';
import { Input } from '../input';
@@ -192,7 +192,7 @@ export class OggDemuxer extends Demuxer {
1 + lacingValues.length
+ firstPacket.data.length + secondPacket.data.length + thirdPacket.data.length,
);
description[0] = lacingValues.length;
description[0] = 2; // Num entries in the segment table
description.set(
lacingValues, 1,
);
@@ -249,7 +249,7 @@ export class OggDemuxer extends Demuxer {
const header = parseOpusIdentificationHeader(firstPacket.data);
bitstream.numberOfChannels = header.outputChannelCount;
bitstream.sampleRate = header.inputSampleRate;
bitstream.sampleRate = OPUS_SAMPLE_RATE; // Always the same
bitstream.codecInfo.opusInfo = {
preSkip: header.preSkip,
@@ -574,7 +574,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
constructor(public bitstream: LogicalBitstream, public demuxer: OggDemuxer) {
// Opus always uses a fixed sample rate for its internal calculations, even if the actual rate is different
this.internalSampleRate = bitstream.codecInfo.codec === 'opus'
? OPUS_INTERNAL_SAMPLE_RATE
? OPUS_SAMPLE_RATE
: bitstream.sampleRate;
}
+2 -2
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { OPUS_INTERNAL_SAMPLE_RATE, validateAudioChunkMetadata } from '../codec';
import { OPUS_SAMPLE_RATE, validateAudioChunkMetadata } from '../codec';
import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data';
import {
assert,
@@ -119,7 +119,7 @@ export class OggMuxer extends Muxer {
track,
serialNumber,
internalSampleRate: track.source._codec === 'opus'
? OPUS_INTERNAL_SAMPLE_RATE
? OPUS_SAMPLE_RATE
: meta.decoderConfig.sampleRate,
codecInfo: {
codec: track.source._codec,
+13 -3
View File
@@ -115,6 +115,11 @@ export type IsobmffOutputFormatOptions = {
* finalized. This produces a high-quality and compact output at the cost of a more expensive finalization step and
* higher memory requirements. Data will be written monotonically (in order) when this option is set.
*
* Use `'reserve'` to reserve space at the start of the file into which the metadata will be written later. This
* produces a file with Fast Start but requires knowledge about the expected length of the file beforehand. When
* using this option, you must set the {@link BaseTrackMetadata.maximumPacketCount} field in the track metadata
* for all tracks.
*
* Use `'fragmented'` to place metadata at the start of the file by creating a fragmented file (fMP4). In a
* fragmented file, chunks of media and their metadata are written to the file in "fragments", eliminating the need
* to put all metadata in one place. Fragmented files are useful for streaming contexts, as each fragment can be
@@ -126,7 +131,7 @@ export type IsobmffOutputFormatOptions = {
* When this field is not defined, either `false` or `'in-memory'` will be used, automatically determined based on
* the type of output target used.
*/
fastStart?: false | 'in-memory' | 'fragmented';
fastStart?: false | 'in-memory' | 'reserve' | 'fragmented';
/**
* When using `fastStart: 'fragmented'`, this field controls the minimum duration of each fragment, in seconds.
@@ -184,8 +189,13 @@ export abstract class IsobmffOutputFormat extends OutputFormat {
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (options.fastStart !== undefined && ![false, 'in-memory', 'fragmented'].includes(options.fastStart)) {
throw new TypeError('options.fastStart, when provided, must be false, "in-memory", or "fragmented".');
if (
options.fastStart !== undefined
&& ![false, 'in-memory', 'reserve', 'fragmented'].includes(options.fastStart)
) {
throw new TypeError(
'options.fastStart, when provided, must be false, \'in-memory\', \'reserve\', or \'fragmented\'.',
);
}
if (
options.minimumFragmentDuration !== undefined
+21
View File
@@ -74,6 +74,21 @@ export type BaseTrackMetadata = {
languageCode?: string;
/** A user-defined name for this track, like "English" or "Director Commentary". */
name?: string;
/**
* The maximum amount of encoded packets that will be added to this track. Setting this field provides the muxer
* with an additional signal that it can use to preallocate space in the file.
*
* When this field is set, it is an error to provide more packets than whatever this field specifies.
*
* Predicting the maximum packet count requires considering both the maximum duration as well as the codec.
* - For video codecs, you can assume one packet per frame.
* - For audio codecs, there is one packet for each "audio chunk", the duration of which depends on the codec. For
* simplicity, you can assume each packet is roughly 10 ms or 512 samples long, whichever is shorter.
* - For subtitles, assume each cue and each gap in the subtitles adds a packet.
*
* If you're not fully sure, make sure to add a buffer of around 33% to make sure you stay below the maximum.
*/
maximumPacketCount?: number;
};
/**
@@ -114,6 +129,12 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => {
if (metadata.name !== undefined && typeof metadata.name !== 'string') {
throw new TypeError('metadata.name, when provided, must be a string.');
}
if (
metadata.maximumPacketCount !== undefined
&& (!Number.isInteger(metadata.maximumPacketCount) || metadata.maximumPacketCount < 0)
) {
throw new TypeError('metadata.maximumPacketCount, when provided, must be a non-negative integer.');
}
};
/**
+130 -31
View File
@@ -15,6 +15,7 @@ import {
toDataView,
toUint8Array,
SetRequired,
isFirefox,
} from './misc';
/**
@@ -201,7 +202,8 @@ export class VideoSample {
return new VideoSample(
new VideoFrame(data, {
timestamp: Math.trunc(init.timestamp! * SECOND_TO_MICROSECOND_FACTOR),
duration: Math.trunc((init.duration ?? 0) * SECOND_TO_MICROSECOND_FACTOR),
// Drag 0 to undefined
duration: Math.trunc((init.duration ?? 0) * SECOND_TO_MICROSECOND_FACTOR) || undefined,
}),
init,
);
@@ -227,7 +229,10 @@ export class VideoSample {
}
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d', { alpha: false, willReadFrequently: true });
const context = canvas.getContext('2d', {
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
willReadFrequently: true,
});
assert(context);
// Draw it to a canvas
@@ -342,7 +347,7 @@ export class VideoSample {
dest.set(this._data);
} else {
const canvas = this._data;
const context = canvas.getContext('2d', { alpha: false });
const context = canvas.getContext('2d');
assert(context);
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
@@ -373,13 +378,13 @@ export class VideoSample {
codedWidth: this.codedWidth,
codedHeight: this.codedHeight,
timestamp: this.microsecondTimestamp,
duration: this.microsecondDuration,
duration: this.microsecondDuration || undefined,
colorSpace: this.colorSpace,
});
} else {
return new VideoFrame(this._data, {
timestamp: this.microsecondTimestamp,
duration: this.microsecondDuration,
duration: this.microsecondDuration || undefined,
});
}
}
@@ -507,28 +512,7 @@ export class VideoSample {
throw new Error('VideoSample is closed.');
}
// The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is
// stored. Therefore, we must map these back onto the original, pre-rotation image.
if (this.rotation === 90) {
[sx, sy, sWidth, sHeight] = [
sy,
this.codedHeight - sx - sWidth,
sHeight,
sWidth,
];
} else if (this.rotation === 180) {
[sx, sy] = [
this.codedWidth - sx - sWidth,
this.codedHeight - sy - sHeight,
];
} else if (this.rotation === 270) {
[sx, sy, sWidth, sHeight] = [
this.codedWidth - sy - sHeight,
sx,
sHeight,
sWidth,
];
}
({ sx, sy, sWidth, sHeight } = this._rotateSourceRegion(sx, sy, sWidth, sHeight, this.rotation));
const source = this.toCanvasImageSource();
@@ -576,26 +560,69 @@ export class VideoSample {
fit: 'fill' | 'contain' | 'cover';
/** A way to override rotation. Defaults to the rotation of the sample. */
rotation?: Rotation;
/**
* Specifies the rectangular region of the video sample to crop to. The crop region will automatically be
* clamped to the dimensions of the video sample. Cropping is performed after rotation but before resizing.
*/
crop?: CropRectangle;
}) {
if (!(
(typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)
|| (
typeof OffscreenCanvasRenderingContext2D !== 'undefined'
&& context instanceof OffscreenCanvasRenderingContext2D
)
)) {
throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.');
}
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (!['fill', 'contain', 'cover'].includes(options.fit)) {
throw new TypeError('options.fit must be \'fill\', \'contain\', or \'cover\'.');
}
if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {
throw new TypeError('options.rotation, when provided, must be 0, 90, 180, or 270.');
}
if (options.crop !== undefined) {
validateCropRectangle(options.crop, 'options.');
}
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
const rotation = options.rotation ?? this.rotation;
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
? [this.codedWidth, this.codedHeight]
: [this.codedHeight, this.codedWidth];
if (options.crop) {
clampCropRectangle(options.crop, rotatedWidth, rotatedHeight);
}
// These variables specify where the final sample will be drawn on the canvas
let dx: number;
let dy: number;
let newWidth: number;
let newHeight: number;
const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(
options.crop?.left ?? 0,
options.crop?.top ?? 0,
options.crop?.width ?? this.codedWidth,
options.crop?.height ?? this.codedHeight,
rotation,
);
if (options.fit === 'fill') {
dx = 0;
dy = 0;
newWidth = canvasWidth;
newHeight = canvasHeight;
} else {
const [sampleWidth, sampleHeight] = rotation % 180 === 0
? [this.codedWidth, this.codedHeight]
: [this.codedHeight, this.codedWidth];
const [sampleWidth, sampleHeight] = options.crop
? [options.crop.width, options.crop.height]
: [rotatedWidth, rotatedHeight];
const scale = options.fit === 'contain'
? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight)
@@ -616,7 +643,35 @@ export class VideoSample {
// Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it
// ourselves here
context.drawImage(this.toCanvasImageSource(), dx, dy, newWidth, newHeight);
context.drawImage(this.toCanvasImageSource(), sx, sy, sWidth, sHeight, dx, dy, newWidth, newHeight);
}
/** @internal */
_rotateSourceRegion(sx: number, sy: number, sWidth: number, sHeight: number, rotation: number) {
// The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is
// stored. Therefore, we must map these back onto the original, pre-rotation image.
if (rotation === 90) {
[sx, sy, sWidth, sHeight] = [
sy,
this.codedHeight - sx - sWidth,
sHeight,
sWidth,
];
} else if (rotation === 180) {
[sx, sy] = [
this.codedWidth - sx - sWidth,
this.codedHeight - sy - sHeight,
];
} else if (rotation === 270) {
[sx, sy, sWidth, sHeight] = [
this.codedWidth - sy - sHeight,
sx,
sHeight,
sWidth,
];
}
return { sx, sy, sWidth, sHeight };
}
/**
@@ -679,6 +734,50 @@ const isVideoFrame = (x: unknown): x is VideoFrame => {
return typeof VideoFrame !== 'undefined' && x instanceof VideoFrame;
};
/**
* Specifies the rectangular cropping region.
* @group Miscellaneous
* @public
*/
export type CropRectangle = {
/** The distance in pixels from the left edge of the source frame to the left edge of the crop rectangle. */
left: number;
/** The distance in pixels from the top edge of the source frame to the top edge of the crop rectangle. */
top: number;
/** The width in pixels of the crop rectangle. */
width: number;
/** The height in pixels of the crop rectangle. */
height: number;
};
export const clampCropRectangle = (crop: CropRectangle, outerWidth: number, outerHeight: number) => {
crop.left = Math.min(crop.left, outerWidth);
crop.top = Math.min(crop.top, outerHeight);
crop.width = Math.min(crop.width, outerWidth - crop.left);
crop.height = Math.min(crop.height, outerHeight - crop.top);
assert(crop.width >= 0);
assert(crop.height >= 0);
};
export const validateCropRectangle = (crop: CropRectangle, prefix: string) => {
if (!crop || typeof crop !== 'object') {
throw new TypeError(prefix + 'crop, when provided, must be an object.');
}
if (!Number.isInteger(crop.left) || crop.left < 0) {
throw new TypeError(prefix + 'crop.left must be a non-negative integer.');
}
if (!Number.isInteger(crop.top) || crop.top < 0) {
throw new TypeError(prefix + 'crop.top must be a non-negative integer.');
}
if (!Number.isInteger(crop.width) || crop.width < 0) {
throw new TypeError(prefix + 'crop.width must be a non-negative integer.');
}
if (!Number.isInteger(crop.height) || crop.height < 0) {
throw new TypeError(prefix + 'crop.height must be a non-negative integer.');
}
};
const AUDIO_SAMPLE_FORMATS = new Set(
['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'],
);
+6 -4
View File
@@ -18,6 +18,9 @@ import {
toDataView,
toUint8Array,
} from './misc';
import * as nodeAlias from './node';
const node = nodeAlias; // Aliasing it prevents some bundler warnings
export type ReadResult = {
bytes: Uint8Array;
@@ -548,9 +551,7 @@ export class FilePathSource extends Source {
// Let's back this source with a StreamSource, makes the implementation very simple
this._streamSource = new StreamSource({
getSize: async () => {
const FS_MODULE_NAME = 'node:fs/promises';
const fs = await import(/* @vite-ignore */ FS_MODULE_NAME) as typeof import('node:fs/promises');
fileHandle = await fs.open(filePath, 'r');
fileHandle = await node.fs.open(filePath, 'r');
const stats = await fileHandle.stat();
return stats.size;
@@ -558,8 +559,9 @@ export class FilePathSource extends Source {
read: async (start, end) => {
assert(fileHandle);
const buffer = Buffer.alloc(end - start);
const buffer = new Uint8Array(end - start);
await fileHandle.read(buffer, 0, end - start, start);
return buffer;
},
maxCacheSize: options.maxCacheSize,