mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-06 23:33:45 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d725d068b | ||
|
|
7a0051bec5 | ||
|
|
555006b9c3 | ||
|
|
1bb3c0e262 | ||
|
|
530ccd7523 | ||
|
|
fcec67645a | ||
|
|
7efb092ff5 | ||
|
|
d313a85053 | ||
|
|
1534eb4c40 | ||
|
|
8fb08f1c8e | ||
|
|
bc90a6dc16 | ||
|
|
ea8027bacb | ||
|
|
3cfb9f27cb | ||
|
|
684b7b7391 | ||
|
|
c9433771e9 | ||
|
|
eba1f360d5 | ||
|
|
4aca172d14 | ||
|
|
6f06631355 | ||
|
|
f314f04037 | ||
|
|
b9f7ab2fa2 | ||
|
|
a295cd76c6 | ||
|
|
b9efcc867d | ||
|
|
cb9f4e0fdf | ||
|
|
d63d7f6ff9 | ||
|
|
f3613b7260 | ||
|
|
b3396727f0 | ||
|
|
af25bb41ab | ||
|
|
e672a51dc6 | ||
|
|
2586e0e6f8 | ||
|
|
9f9809fbd1 | ||
|
|
904fd8f95e | ||
|
|
dcda90fddb | ||
|
|
d7e8273185 |
@@ -50,7 +50,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
|
||||
+35
-21
@@ -24,7 +24,7 @@
|
||||
chunked: true,
|
||||
chunkSize: 2**20
|
||||
});
|
||||
const outputFormat = new Mediabunny.WavOutputFormat({});
|
||||
const outputFormat = new Mediabunny.Mp4OutputFormat({});
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Cancel';
|
||||
@@ -47,23 +47,6 @@
|
||||
format: outputFormat,
|
||||
target
|
||||
});
|
||||
output.setMetadata({
|
||||
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,
|
||||
@@ -105,7 +91,18 @@
|
||||
},
|
||||
*/
|
||||
video: () => ({
|
||||
discard: true,
|
||||
//discard: 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,
|
||||
@@ -127,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: 0,
|
||||
end: 10
|
||||
start: 10,
|
||||
end: 20
|
||||
},
|
||||
});
|
||||
console.log(conversion);
|
||||
|
||||
+19
-4
@@ -14,12 +14,27 @@
|
||||
source: new Mediabunny.BlobSource(file),
|
||||
});
|
||||
|
||||
const audioTrack = await input.getPrimaryAudioTrack();
|
||||
const sink = new Mediabunny.EncodedPacketSink(audioTrack);
|
||||
console.log(await input.getMetadataTags())
|
||||
|
||||
console.log(await audioTrack.getDecoderConfig())
|
||||
console.log(await sink.getFirstPacket());
|
||||
/*
|
||||
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)) {
|
||||
console.log(sample);
|
||||
|
||||
if (sample.timestamp >= 2) {
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
*/
|
||||
//console.log(await sink.getSample(1))
|
||||
|
||||
/*
|
||||
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
||||
|
||||
+4
-3
@@ -116,9 +116,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',
|
||||
@@ -207,5 +208,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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
:::
|
||||
|
||||
@@ -105,6 +105,7 @@ const sponsors = {
|
||||
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/504909', name: 'Hirbod', url: 'https://github.com/hirbod' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/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/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' },
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
@reference "../base.css";
|
||||
|
||||
ul {
|
||||
@apply py-1 px-4 bg-gray-500/10 rounded-lg;
|
||||
@apply py-1 px-4 bg-gray-500/10 rounded-lg max-w-lg;
|
||||
}
|
||||
|
||||
b {
|
||||
|
||||
@@ -80,6 +80,37 @@ const extractMetadata = (resource: File | string) => {
|
||||
: {}
|
||||
),
|
||||
}))),
|
||||
'Metadata tags': input.getMetadataTags().then((tags) => {
|
||||
const result = {
|
||||
'Title': tags.title,
|
||||
'Description': tags.description,
|
||||
'Artist': tags.artist,
|
||||
'Album': tags.album,
|
||||
'Album artist': tags.albumArtist,
|
||||
'Track number': tags.trackNumber,
|
||||
'Tracks total': tags.tracksTotal,
|
||||
'Disc number': tags.discNumber,
|
||||
'Discs total': tags.discsTotal,
|
||||
'Genre': tags.genre,
|
||||
'Date': tags.date?.toISOString().slice(0, 10),
|
||||
'Lyrics': tags.lyrics,
|
||||
'Comment': tags.comment,
|
||||
'Images': tags.images?.map((image) => {
|
||||
const blob = new Blob([image.data], { type: image.mimeType });
|
||||
const element = new Image();
|
||||
element.src = URL.createObjectURL(blob);
|
||||
|
||||
return element;
|
||||
}),
|
||||
'Raw tag count': tags.raw && Object.keys(tags.raw).length,
|
||||
};
|
||||
|
||||
if (Object.values(result).some(x => x !== undefined)) {
|
||||
return result;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
fileNameElement.textContent = resource instanceof File ? resource.name : resource;
|
||||
@@ -93,7 +124,9 @@ const extractMetadata = (resource: File | string) => {
|
||||
|
||||
// Creates an HTML element to display any given value
|
||||
const renderValue = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
if (value instanceof HTMLElement) {
|
||||
return value;
|
||||
} else if (Array.isArray(value)) {
|
||||
const arrayAsObject: Record<string, unknown> = Object.fromEntries(
|
||||
value.map((item, index) => [(index + 1).toString(), item]),
|
||||
);
|
||||
@@ -116,6 +149,7 @@ const renderObject = (object: Record<string, unknown>) => {
|
||||
for (const key of keys) {
|
||||
const value = object[key];
|
||||
const listItem = document.createElement('li');
|
||||
listItem.style.wordBreak = 'break-word';
|
||||
const keySpan = document.createElement('b');
|
||||
keySpan.textContent = `${key}: `;
|
||||
|
||||
@@ -132,6 +166,10 @@ const renderObject = (object: Record<string, unknown>) => {
|
||||
// Replace the loading text with the resolved value
|
||||
listItem.removeChild(loadingSpan);
|
||||
listItem.appendChild(renderValue(resolvedValue));
|
||||
|
||||
if (resolvedValue === undefined) {
|
||||
listElement.removeChild(listItem);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
@@ -146,7 +184,9 @@ const renderObject = (object: Record<string, unknown>) => {
|
||||
listItem.appendChild(renderValue(value));
|
||||
}
|
||||
|
||||
listElement.appendChild(listItem);
|
||||
if (value !== undefined) {
|
||||
listElement.appendChild(listItem);
|
||||
}
|
||||
}
|
||||
|
||||
return listElement;
|
||||
|
||||
@@ -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.0",
|
||||
"version": "1.15.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.2",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -7749,9 +7749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.13.3.tgz",
|
||||
"integrity": "sha512-kV12CVwMdhMQILGrVcyDWOgnZHwAytStQYKZeEaUj32XamYhlDX0Od4SkXVr0Yms8iapJsiCoEQdSK4UKUfooA==",
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.15.1.tgz",
|
||||
"integrity": "sha512-+eRTVzd3E4LuGYZzPSQcPzuGdAIljohSlzYTX358XsfLM2qH1lQIBYa+erx7wzVcGQLRNjdV7x7ZS0EpK04DfA==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -12242,7 +12242,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.2",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
|
||||
+9
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.2",
|
||||
"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.0",
|
||||
"version": "1.15.2",
|
||||
"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
|
||||
|
||||
+223
-38
@@ -1,6 +1,6 @@
|
||||
// This script has been 100% vibe-coded with Claude, meaning I literally haven't looked at any of the code. It's
|
||||
// probably a mess, but it solves a one-off problem where only the output matters, and the output is indeed good, which
|
||||
// is the point of a custom script for this: full, precise control.
|
||||
// This script has been 100% vibe-coded with Claude (and Gemini!), meaning I literally haven't looked at any of the
|
||||
// code. It's probably a mess, but it solves a one-off problem where only the output matters, and the output is indeed
|
||||
// good, which is the point of a custom script for this: full, precise control.
|
||||
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
@@ -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>();
|
||||
@@ -386,7 +388,34 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 2: Generate documentation for each symbol
|
||||
// Data structures for "Used by" feature
|
||||
const usedByReferences = new Map<string, Set<{ user: string; context: string; type: 'constructor' | 'method' | 'property' | 'extends' | 'type_param' | 'type_alias' | 'variable' | 'function' }>>();
|
||||
const generatedDocs = new Map<string, string>();
|
||||
|
||||
const addUsage = (
|
||||
used: string,
|
||||
user: string,
|
||||
context: string,
|
||||
type: 'constructor' | 'method' | 'property' | 'extends' | 'type_param' | 'type_alias' | 'variable' | 'function',
|
||||
) => {
|
||||
// No self-references
|
||||
if (used === user) return;
|
||||
|
||||
if (!usedByReferences.has(used)) {
|
||||
usedByReferences.set(used, new Set());
|
||||
}
|
||||
const usageSet = usedByReferences.get(used)!;
|
||||
|
||||
// Check for duplicates before adding
|
||||
for (const item of usageSet) {
|
||||
if (item.user === user && item.context === context && item.type === type) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
usageSet.add({ user, context, type });
|
||||
};
|
||||
|
||||
// Phase 2: Generate documentation for each symbol (and collect usage data)
|
||||
allSymbols.forEach((exportSymbol) => {
|
||||
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
|
||||
if (!declaration) return;
|
||||
@@ -492,9 +521,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const allReferences = filterToExportedTypes([...new Set(allTypeStrings.flatMap(findAllTypeReferences))], variableName);
|
||||
markdown += formatReferences(allReferences);
|
||||
|
||||
const outputPath = path.join(outputDir, `${variableName}.md`);
|
||||
fs.writeFileSync(outputPath, markdown);
|
||||
console.log(`Generated: ${outputPath}`);
|
||||
// In tandem: update usage map
|
||||
allReferences.forEach(ref => addUsage(ref, variableName, variableName, 'function'));
|
||||
|
||||
generatedDocs.set(variableName, markdown);
|
||||
}
|
||||
} else {
|
||||
// Handle regular variables
|
||||
@@ -507,9 +537,10 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const references = filterToExportedTypes(findAllTypeReferences(variableValue), variableName);
|
||||
markdown += formatReferences(references);
|
||||
|
||||
const outputPath = path.join(outputDir, `${variableName}.md`);
|
||||
fs.writeFileSync(outputPath, markdown);
|
||||
console.log(`Generated: ${outputPath}`);
|
||||
// In tandem: update usage map
|
||||
references.forEach(ref => addUsage(ref, variableName, variableName, 'variable'));
|
||||
|
||||
generatedDocs.set(variableName, markdown);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -643,6 +674,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
if (typeParamReferencesText) {
|
||||
typeParameters += typeParamReferencesText;
|
||||
}
|
||||
|
||||
// In tandem: update usage map
|
||||
typeParamReferences.forEach(ref => addUsage(ref, className, className, 'type_param'));
|
||||
}
|
||||
|
||||
// Check for extends clause (only for classes/interfaces)
|
||||
@@ -653,6 +687,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
if (extendsClauseNode && extendsClauseNode.types[0]) {
|
||||
const superClassName = extendsClauseNode.types[0].expression.getText();
|
||||
extendsClause = `\n\n**Extends:** [\`${superClassName}\`](./${superClassName}.md)\n`;
|
||||
|
||||
// In tandem: update usage map
|
||||
if (exportedTypes.has(superClassName)) {
|
||||
addUsage(superClassName, className, className, 'extends');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,6 +874,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const references = filterToExportedTypes(findAllTypeReferences(paramType), className);
|
||||
const referencesText = formatReferences(references);
|
||||
|
||||
// In tandem: update usage map
|
||||
references.forEach(ref => addUsage(ref, className, paramName, 'property'));
|
||||
|
||||
const propertyContent = `### \`${paramName}\`\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`;
|
||||
properties.push(propertyContent);
|
||||
});
|
||||
@@ -905,6 +947,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const overloadReferences = filterToExportedTypes([...new Set(ctorTypeStrings.flatMap(findAllTypeReferences))], className);
|
||||
constructorBlock += formatReferences(overloadReferences, linkedTypes);
|
||||
|
||||
// In tandem: update usage map
|
||||
overloadReferences.forEach(ref => addUsage(ref, className, className, 'constructor'));
|
||||
|
||||
constructorBlocks.push(constructorBlock);
|
||||
});
|
||||
|
||||
@@ -950,6 +995,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const references = filterToExportedTypes(findAllTypeReferences(type), className);
|
||||
const referencesText = formatReferences(references, linkedTypes);
|
||||
|
||||
// In tandem: update usage map
|
||||
references.forEach(ref => addUsage(ref, className, name, 'property'));
|
||||
|
||||
// Check if this is an event handler (starts with "on" and can be a function)
|
||||
const isEventHandler = name.startsWith('on') && (
|
||||
type.includes('=>')
|
||||
@@ -993,6 +1041,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const references = filterToExportedTypes(findAllTypeReferences(type), className);
|
||||
const referencesText = formatReferences(references, linkedTypes);
|
||||
|
||||
// In tandem: update usage map
|
||||
references.forEach(ref => addUsage(ref, className, name, 'property'));
|
||||
|
||||
const inheritedBadge = '';
|
||||
properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
|
||||
} else if (ts.isSetAccessorDeclaration(member) && member.name) {
|
||||
@@ -1013,6 +1064,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const references = filterToExportedTypes(findAllTypeReferences(paramType), className);
|
||||
const referencesText = formatReferences(references, linkedTypes);
|
||||
|
||||
// In tandem: update usage map
|
||||
references.forEach(ref => addUsage(ref, className, name, 'property'));
|
||||
|
||||
const inheritedBadge = '';
|
||||
properties.push(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
|
||||
} else if ((ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && member.name) {
|
||||
@@ -1106,6 +1160,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const allReferences = filterToExportedTypes([...new Set(allTypeStrings.flatMap(findAllTypeReferences))], className);
|
||||
methodContent += formatReferences(allReferences, linkedTypes);
|
||||
|
||||
// In tandem: update usage map
|
||||
allReferences.forEach(ref => addUsage(ref, className, name, 'method'));
|
||||
|
||||
if (isStatic) {
|
||||
staticMethods.push(methodContent);
|
||||
} else {
|
||||
@@ -1174,6 +1231,9 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const references = filterToExportedTypes(findAllTypeReferences(cleanedType), className);
|
||||
const referencesText = formatReferences(references);
|
||||
|
||||
// In tandem: update usage map
|
||||
references.forEach(ref => addUsage(ref, className, propName, 'property'));
|
||||
|
||||
// Check if this is an event handler (starts with "on" and can be a function)
|
||||
const isEventHandler = propName.startsWith('on') && (
|
||||
cleanedType.includes('=>')
|
||||
@@ -1252,19 +1312,39 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
|
||||
// Add subclasses section for classes that have subclasses
|
||||
if (ts.isClassDeclaration(declaration) && classHierarchy.has(className)) {
|
||||
const subclasses = classHierarchy.get(className)!;
|
||||
// Sort by definition order instead of alphabetically
|
||||
subclasses.sort((a, b) => {
|
||||
const orderA = symbolOrderMap.get(a);
|
||||
const orderB = symbolOrderMap.get(b);
|
||||
if (orderA === undefined) throw new Error(`Symbol '${a}' not found in entry files export order`);
|
||||
if (orderB === undefined) throw new Error(`Symbol '${b}' not found in entry files export order`);
|
||||
return orderA - orderB;
|
||||
});
|
||||
markdown += `\n## Subclasses\n\n`;
|
||||
subclasses.forEach((sub) => {
|
||||
markdown += `- [\`${sub}\`](./${sub}.md)\n`;
|
||||
});
|
||||
// Recursively build hierarchical list
|
||||
const buildHierarchicalList = (parentClass: string, depth = 0, visited = new Set<string>()): string => {
|
||||
if (visited.has(parentClass)) return ''; // Prevent infinite loops
|
||||
visited.add(parentClass);
|
||||
|
||||
const directChildren = classHierarchy.get(parentClass) || [];
|
||||
if (directChildren.length === 0) return '';
|
||||
|
||||
// Sort children by definition order
|
||||
const sortedChildren = [...directChildren].sort((a, b) => {
|
||||
const orderA = symbolOrderMap.get(a);
|
||||
const orderB = symbolOrderMap.get(b);
|
||||
if (orderA === undefined) throw new Error(`Symbol '${a}' not found in entry files export order`);
|
||||
if (orderB === undefined) throw new Error(`Symbol '${b}' not found in entry files export order`);
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
let result = '';
|
||||
const indent = ' '.repeat(depth);
|
||||
|
||||
for (const child of sortedChildren) {
|
||||
result += `${indent}- [\`${child}\`](./${child}.md)\n`;
|
||||
// Recursively add children of this child
|
||||
result += buildHierarchicalList(child, depth + 1, visited);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const hierarchicalList = buildHierarchicalList(className);
|
||||
if (hierarchicalList) {
|
||||
markdown += `\n## Subclasses\n\n${hierarchicalList}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add instances section for classes that have instances
|
||||
@@ -1284,6 +1364,11 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Add placeholder for "Used by" section for classes and interfaces
|
||||
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) {
|
||||
markdown += '\n<!-- USED_BY_SECTION -->\n';
|
||||
}
|
||||
|
||||
// Add type definition for type aliases
|
||||
if (ts.isTypeAliasDeclaration(declaration) && declaration.type) {
|
||||
const resolvedType = typeChecker.getTypeAtLocation(declaration);
|
||||
@@ -1346,7 +1431,13 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
const typeReferences = filterToExportedTypes([...new Set(allTypeRefs)], className);
|
||||
const typeReferencesText = formatReferences(typeReferences);
|
||||
|
||||
// In tandem: update usage map
|
||||
typeReferences.forEach(ref => addUsage(ref, className, className, 'type_alias'));
|
||||
|
||||
markdown += `\n\`\`\`ts\n${typeDefinition}\n\`\`\`${typeReferencesText}`;
|
||||
|
||||
// Add placeholder for "Used by" section for type aliases
|
||||
markdown += '\n<!-- USED_BY_SECTION -->\n';
|
||||
}
|
||||
|
||||
if (typeParameters) {
|
||||
@@ -1372,8 +1463,87 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
|
||||
if (methods.length > 0) {
|
||||
markdown += `\n## Methods\n\n${methods.join('\n\n')}\n`;
|
||||
}
|
||||
const outputPath = path.join(outputDir, `${className}.md`);
|
||||
fs.writeFileSync(outputPath, markdown);
|
||||
|
||||
generatedDocs.set(className, markdown);
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 3: Assemble final docs with "Used by" sections and write files
|
||||
generatedDocs.forEach((markdown, symbolName) => {
|
||||
const usages = usedByReferences.get(symbolName);
|
||||
let usedByMarkdown = '';
|
||||
|
||||
if (usages && usages.size > 0) {
|
||||
const symbolSubclasses = classHierarchy.get(symbolName) || [];
|
||||
|
||||
const usedByLines = [...usages]
|
||||
.filter((usage) => {
|
||||
// Filter out subclasses from "Used by" since they already appear in "Subclasses" section
|
||||
if (usage.type === 'extends' && symbolSubclasses.includes(usage.user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out top-level type references when there are more specific contexts available
|
||||
// This prevents redundancy where both "TypeName" and "TypeName.property" appear
|
||||
if (usage.type === 'type_alias' || usage.type === 'type_param') {
|
||||
// Check if there are more specific usages from the same user (property, method, constructor, etc.)
|
||||
const hasMoreSpecificUsage = [...usages].some(otherUsage =>
|
||||
otherUsage.user === usage.user
|
||||
&& otherUsage.type !== 'type_alias'
|
||||
&& otherUsage.type !== 'type_param'
|
||||
&& otherUsage.type !== 'extends',
|
||||
);
|
||||
if (hasMoreSpecificUsage) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map((usage) => {
|
||||
let displayText = '';
|
||||
let link = '';
|
||||
|
||||
switch (usage.type) {
|
||||
case 'constructor':
|
||||
displayText = `new ${usage.user}()`;
|
||||
link = `./${usage.user}.md#constructor`;
|
||||
break;
|
||||
case 'method':
|
||||
displayText = `${usage.user}.${usage.context}()`;
|
||||
link = `./${usage.user}.md#${usage.context.toLowerCase()}`;
|
||||
break;
|
||||
case 'property':
|
||||
case 'variable':
|
||||
displayText = `${usage.user}.${usage.context}`;
|
||||
link = `./${usage.user}.md#${usage.context.toLowerCase()}`;
|
||||
break;
|
||||
case 'function':
|
||||
displayText = `${usage.user}()`;
|
||||
link = `./${usage.user}.md`;
|
||||
break;
|
||||
case 'extends':
|
||||
case 'type_param':
|
||||
case 'type_alias':
|
||||
displayText = usage.user;
|
||||
link = `./${usage.user}.md`;
|
||||
break;
|
||||
}
|
||||
return { text: `[\`${displayText}\`](${link})`, sortKey: displayText.toLowerCase() };
|
||||
});
|
||||
|
||||
if (usedByLines.length > 0) {
|
||||
// Sort alphabetically by display text
|
||||
usedByLines.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
|
||||
const listItems = usedByLines.map(item => `- ${item.text}`).join('\n');
|
||||
usedByMarkdown = `\n## Used by\n\n${listItems}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
const finalMarkdown = markdown.replace('<!-- USED_BY_SECTION -->', usedByMarkdown);
|
||||
if (!dry) {
|
||||
const outputPath = path.join(outputDir, `${symbolName}.md`);
|
||||
fs.writeFileSync(outputPath, finalMarkdown);
|
||||
console.log(`Generated: ${outputPath}`);
|
||||
}
|
||||
});
|
||||
@@ -1421,9 +1591,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) => {
|
||||
@@ -1441,15 +1613,28 @@ 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}`);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -1459,7 +1644,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)?$/;
|
||||
|
||||
|
||||
+83
-34
@@ -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;
|
||||
@@ -534,9 +564,14 @@ export class Conversion {
|
||||
if (this.onProgress) {
|
||||
this._computeProgress = true;
|
||||
this._totalDuration = Math.min(
|
||||
await this.input.computeDuration() - this._startTimestamp,
|
||||
(await this.input.computeDuration()) - this._startTimestamp,
|
||||
this._endTimestamp - this._startTimestamp,
|
||||
);
|
||||
|
||||
for (const track of this.utilizedTracks) {
|
||||
this._maxTimestamps.set(track.id, 0);
|
||||
}
|
||||
|
||||
this.onProgress?.(0);
|
||||
}
|
||||
|
||||
@@ -596,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;
|
||||
@@ -625,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 (
|
||||
@@ -723,7 +768,7 @@ export class Conversion {
|
||||
await tempOutput.start();
|
||||
|
||||
const sink = new VideoSampleSink(track);
|
||||
const firstSample = await sink.getSample(this._startTimestamp);
|
||||
const firstSample = await sink.getSample(firstTimestamp); // Let's just use the first sample
|
||||
|
||||
if (firstSample) {
|
||||
try {
|
||||
@@ -749,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);
|
||||
@@ -1120,8 +1166,6 @@ export class Conversion {
|
||||
await this._started;
|
||||
|
||||
const resampler = new AudioResampler({
|
||||
sourceNumberOfChannels: track.numberOfChannels,
|
||||
sourceSampleRate: track.sampleRate,
|
||||
targetNumberOfChannels,
|
||||
targetSampleRate,
|
||||
startTime: this._startTimestamp,
|
||||
@@ -1160,7 +1204,10 @@ export class Conversion {
|
||||
}
|
||||
assert(this._totalDuration !== null);
|
||||
|
||||
this._maxTimestamps.set(trackId, Math.max(endTimestamp, this._maxTimestamps.get(trackId) ?? -Infinity));
|
||||
this._maxTimestamps.set(
|
||||
trackId,
|
||||
Math.max(endTimestamp, this._maxTimestamps.get(trackId)!),
|
||||
);
|
||||
|
||||
const minTimestamp = Math.min(...this._maxTimestamps.values());
|
||||
const newProgress = clamp(minTimestamp / this._totalDuration, 0, 1);
|
||||
@@ -1238,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;
|
||||
@@ -1254,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;
|
||||
@@ -1279,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;
|
||||
|
||||
@@ -1407,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
extractAudioCodecString,
|
||||
extractVideoCodecString,
|
||||
MediaCodec,
|
||||
OPUS_SAMPLE_RATE,
|
||||
VideoCodec,
|
||||
} from '../codec';
|
||||
import { Demuxer } from '../demuxer';
|
||||
@@ -988,6 +989,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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+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';
|
||||
@@ -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,
|
||||
|
||||
+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