mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 14:23:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f13a81bee3 | ||
|
|
6d5bd58ae8 | ||
|
|
6907e25c6d | ||
|
|
1a1664683b | ||
|
|
13224d2998 | ||
|
|
c5523516c7 | ||
|
|
ec910bdd2c | ||
|
|
32b3007a3f | ||
|
|
f13941d350 | ||
|
|
7d725d068b | ||
|
|
7a0051bec5 | ||
|
|
555006b9c3 | ||
|
|
1bb3c0e262 | ||
|
|
530ccd7523 | ||
|
|
fcec67645a | ||
|
|
7efb092ff5 | ||
|
|
d313a85053 | ||
|
|
1534eb4c40 | ||
|
|
8fb08f1c8e | ||
|
|
bc90a6dc16 | ||
|
|
ea8027bacb | ||
|
|
3cfb9f27cb | ||
|
|
684b7b7391 | ||
|
|
c9433771e9 | ||
|
|
eba1f360d5 | ||
|
|
4aca172d14 | ||
|
|
6f06631355 | ||
|
|
f314f04037 | ||
|
|
b9f7ab2fa2 | ||
|
|
a295cd76c6 | ||
|
|
b9efcc867d | ||
|
|
d63d7f6ff9 | ||
|
|
b3396727f0 | ||
|
|
e672a51dc6 | ||
|
|
9f9809fbd1 | ||
|
|
904fd8f95e | ||
|
|
dcda90fddb | ||
|
|
d7e8273185 |
@@ -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'
|
||||
|
||||
@@ -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!')
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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: {},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -350,6 +350,7 @@ drawWithFit(
|
||||
options: {
|
||||
fit: 'fill' | 'contain' | 'cover';
|
||||
rotation?: Rotation; // Overrides the sample's rotation
|
||||
crop?: CropRectangle;
|
||||
},
|
||||
): void;
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
:::
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -160,6 +160,7 @@ export {
|
||||
AudioSampleCopyToOptions,
|
||||
VideoSample,
|
||||
VideoSampleInit,
|
||||
CropRectangle,
|
||||
} from './sample';
|
||||
export {
|
||||
AudioBufferSink,
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user