Compare commits

...
20 Commits
Author SHA1 Message Date
Vanilagy 7d725d068b Bump patch 2025-09-16 23:12:51 +02:00
Vanilagy 7a0051bec5 Tag spam 2025-09-16 21:27:54 +02:00
Vanilagy 555006b9c3 Add improved logic for loading Node.js module (fixes #118) 2025-09-16 21:13:32 +02:00
Vanilagy 1bb3c0e262 Avoid opaque canvases in case of Firefox (fixes #117) 2025-09-16 17:42:23 +02:00
Vanilagy 530ccd7523 Drag 0-duration VideoFrame duration to null (fixes #116) 2025-09-16 15:50:14 +02:00
Vanilagy fcec67645a Allow object for tags field in conversion 2025-09-16 15:10:16 +02:00
Vanilagy 7efb092ff5 Add parsing of 'track' metadata key for ISOBMFF 2025-09-16 14:46:27 +02:00
Vanilagy d313a85053 Document cropping in more places 2025-09-12 17:30:31 +02:00
Vanilagy 1534eb4c40 Lint 2025-09-12 17:26:52 +02:00
Vanilagy 8fb08f1c8e Fix missing @group tag, add doc generation validation to build script 2025-09-12 17:16:12 +02:00
Vanilagy bc90a6dc16 Bump minor 2025-09-12 16:58:46 +02:00
David P.andGitHub ea8027bacb Merge pull request #97 from pablocubico/crop-feature
Add Crop feature
2025-09-12 16:56:56 +02:00
Vanilagy 3cfb9f27cb Prefix crop validation error messages 2025-09-12 16:52:55 +02:00
Vanilagy 684b7b7391 Fix order 2025-09-12 16:50:14 +02:00
Vanilagy c9433771e9 Fix description in docs 2025-09-12 16:49:46 +02:00
Vanilagy eba1f360d5 Clean up cropping logic 2025-09-12 16:48:35 +02:00
Vanilagy 4aca172d14 Merge branch 'main' into fork-branch 2025-09-12 15:46:12 +02:00
Vanilagy 6f06631355 Use decoder information for resampler sample rate, fix Opus sample rate to 48 kHz everywhere 2025-09-10 23:03:20 +02:00
Vanilagy f314f04037 Add wcw 2025-09-10 16:52:45 +02:00
Pablo Cúbico 904fd8f95e feat: add video crop option 2025-09-05 07:07:43 -03:00
30 changed files with 435 additions and 149 deletions
+1 -1
View File
@@ -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
+3
View File
@@ -29,6 +29,9 @@ api-extractor run -c packages/mp3-encoder/api-extractor.json
tsx scripts/check-docblocks.ts dist/mediabunny.d.ts
tsx scripts/check-docblocks.ts packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
# Checks that API docs are generatable
npm run docs:generate -- --dry
# Appends stuff to the declaration files to register the global variables these libraries expose
echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts
echo 'export as namespace MediabunnyMp3Encoder;' >> packages/mp3-encoder/dist/mediabunny-mp3-encoder.d.ts
+33 -20
View File
@@ -47,23 +47,6 @@
format: outputFormat,
target
});
output.setMetadataTags({
title: 'Bigggy',
artist: 'Buck Bunny',
images: [{
data: blobData,
kind: 'coverFront',
mimeType: 'image/jpeg'
}],
trackNumber: 4,
tracksTotal: 10,
discNumber: 5,
discNumberMax: 8,
lyrics: "There's no way\nThat it's not going there",
raw: {
'ENCODER': 'Mediabunny epic own encoder'
}
});
const conversion = await Mediabunny.Conversion.init({
input: new Mediabunny.Input({
@@ -72,6 +55,9 @@
}),
output,
audio: {
//codec: 'pcm-s16',
//sampleRate: 16000,
//numberOfChannels: 1,
//discard: true,
//codec: 'opus',
//bitrate: 128000,
@@ -106,7 +92,17 @@
*/
video: () => ({
//discard: true,
forceTranscode: true,
//crop: {
// left: 0,
// top: 0,
// width: 500,
// height: 500,
//},
//rotate: 90,
//width: 200,
//height: 500,
//fit: 'contain',
//forceTranscode: true,
//codec: 'avc',
//fit: 'contain',
//frameRate: 27.123,
@@ -128,9 +124,26 @@
//width: 200,
//height: 100,
}),
tags: {
title: 'Bigggy',
artist: 'Buck Bunny',
images: [{
data: blobData,
kind: 'coverFront',
mimeType: 'image/jpeg'
}],
trackNumber: 4,
tracksTotal: 10,
discNumber: 5,
discsTotal: 8,
lyrics: "There's no way\nThat it's not going there",
raw: {
'AIGC': 'Mediabunny epic own encoder'
}
},
trim: {
start: 1,
end: 10
start: 10,
end: 20
},
});
console.log(conversion);
+1 -4
View File
@@ -14,10 +14,7 @@
source: new Mediabunny.BlobSource(file),
});
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new Mediabunny.VideoSampleSink(videoTrack);
console.log(await sink.getSample(2.131875));
console.log(await input.getMetadataTags())
/*
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
+4 -3
View File
@@ -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>
+17 -9
View File
@@ -11,6 +11,7 @@ It has the following features:
- Trimming
- Video resizing & fitting
- Video rotation
- Video cropping
- Video frame rate adjustment
- Audio resampling
- Audio up/downmixing
@@ -48,7 +49,7 @@ await conversion.execute();
That's it! A `Conversion` simply takes an instance of `Input` and `Output`, then reads the data from the input and writes it to the output. If you're unfamiliar with [`Input`](./reading-media-files) and [`Output`](./writing-media-files), check out their respective guides.
::: info
The `Output` passed to the `Conversion` must be *fresh*; that is, it must have no added tracks and be in the `'pending'` state (not started yet).
The `Output` passed to the `Conversion` must be *fresh*; that is, it must have no added tracks or metadata tags and be in the `'pending'` state (not started yet).
:::
Unconfigured, the conversion process handles all the details automatically, such as:
@@ -109,6 +110,7 @@ type ConversionVideoOptions = {
height?: number;
fit?: 'fill' | 'contain' | 'cover';
rotate?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
frameRate?: number;
codec?: VideoCodec;
bitrate?: number | Quality;
@@ -137,21 +139,27 @@ The provided configuration will apply equally to all video tracks of the input.
If you want to get rid of the video track, use `discard: true`.
### Resizing/rotating video
### Resizing video
The `width`, `height` and `fit` properties control how the video is resized. If only `width` or `height` is provided, the other value is deduced automatically to preserve the video's original aspect ratio. If both are used, `fit` must be set to control the fitting algorithm:
- `'fill'` will stretch the image to fill the entire box, potentially altering aspect ratio.
- `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to letterboxing.
- `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
`rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file.
If `width` or `height` is used in conjunction with `rotation`, they control the post-rotation dimensions.
If `width` or `height` is used in conjunction with `rotation` or `crop`, they control the post-rotation, post-crop dimensions.
If you want to apply max/min constraints to a video's dimensions, check out [track-specific options](#track-specific-options).
In the rare case that the input video changes size over time, the `fit` field can be used to control the size change behavior (see [`VideoEncodingConfig`](./media-sources#video-encoding-config)). When unset, the behavior is `'passThrough'`.
### Rotating video
`rotation` rotates the video by the specified number of degrees clockwise. This rotation is applied on top of any rotation metadata in the original input file and happens before cropping and resizing.
### Cropping video
`crop` can be used to extract a rectangular region from the original video. The rectangle is specified using `left`, `top`, `width` and `height` and is clamped to the dimensions of the video. Cropping is applied after rotation but before resizing.
### Adjusting frame rate
The `frameRate` property can be used to set the frame rate of the output video in Hz. If not specified, the original input frame rate will be used (which may be variable).
@@ -291,17 +299,17 @@ By default, any [descriptive metadata tags](../api/MetadataTags.md) of the input
// Set your own metadata:
const conversion = await Conversion.init({
// ...
tags: () => ({
tags: {
title: 're:Turning',
artist: 'Alexander Panos',
}),
},
// ...
});
// Or, augment the input's metadata:
const conversion = await Conversion.init({
// ...
tags: inputTags => ({
tags: (inputTags) => ({
...inputTags, // Keep the existing metadata
images: [{ // And add cover art
data: new Uint8Array(...),
@@ -316,7 +324,7 @@ const conversion = await Conversion.init({
// Or, remove all metadata
const conversion = await Conversion.init({
// ...
tags: () => ({}),
tags: {},
// ...
});
```
+1 -1
View File
@@ -17,7 +17,7 @@ Here's a long list of stuff this library does:
- Lazy, optimized, on-demand file reading
- Input and output streaming, arbitrary file size support
- File location independence (memory, disk, network, ...)
- Utilities for compression, resizing, rotation, resampling, trimming
- Utilities for compression, resizing, rotation, cropping, resampling, trimming
- Transmuxing and transcoding
- Microsecond-accurate reading and writing precision
- Efficient seeking through time
+5 -2
View File
@@ -314,7 +314,7 @@ for await (const sample of keyFrameSamples) {
### `CanvasSink`
While `VideoSampleSink` extracts raw decoded video samples, you can use `CanvasSink` to extract these samples as canvases instead. In doing so, certain operations such as scaling and rotating can also be handled by the sink. The downside is the additional VRAM requirements for the canvases' framebuffers.
While `VideoSampleSink` extracts raw decoded video samples, you can use `CanvasSink` to extract these samples as canvases instead. In doing so, certain operations such as scaling, rotating, and cropping can also be handled by the sink. The downside is the additional VRAM requirements for the canvases' framebuffers.
::: info
This sink yields `HTMLCanvasElement` whenever possible, and falls back to `OffscreenCanvas` otherwise (in Worker contexts, for example).
@@ -334,6 +334,7 @@ type CanvasSinkOptions = {
height?: number;
fit?: 'fill' | 'contain' | 'cover';
rotation?: 0 | 90 | 180 | 270;
crop?: { left: number; top: number; width: number; height: number };
poolSize?: number;
};
```
@@ -347,7 +348,9 @@ type CanvasSinkOptions = {
- `'contain'` will contain the entire image within the box while preserving aspect ratio. This may lead to letterboxing.
- `'cover'` will scale the image until the entire box is filled, while preserving aspect ratio.
- `rotation`\
The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata. Rotation is applied before resizing.
The clockwise rotation by which to rotate the raw video frame. Defaults to the rotation set in the file metadata. Rotation is applied before cropping and resizing.
- `crop`\
Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to the dimensions of the input video track. Cropping is performed after rotation but before resizing.
- `poolSize`\
See [Canvas pool](#canvas-pool).
+1
View File
@@ -350,6 +350,7 @@ drawWithFit(
options: {
fit: 'fill' | 'contain' | 'cover';
rotation?: Rotation; // Overrides the sample's rotation
crop?: CropRectangle;
},
): void;
```
+1
View File
@@ -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' },
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.14.3",
"version": "1.15.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.14.3",
"version": "1.15.2",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -7749,9 +7749,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.14.2.tgz",
"integrity": "sha512-JCup3rpsJGIw3I7E7bZSmVEANCr/OwfwkH3g/Wq1TcHEJ7ymLLw+jC3+/Nnn/JLXzFnw8/I6fV3xECLnFedfHg==",
"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.3",
"version": "1.15.2",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+9 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.14.3",
"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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.14.3",
"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",
+1 -1
View File
@@ -24,7 +24,7 @@ const walkDir = (dir: string) => {
const fixFile = (filePath: string) => {
const content = fs.readFileSync(filePath, 'utf8');
const fixed = content.replace(
/(\s+from\s+['"])([^'"]*)(['"])/g,
/(\s+from\s+['"])(\.[^'"]*)(['"])/g, // This only matches relative imports
'$1$2.js$3',
);
+3
View File
@@ -16,6 +16,9 @@ const createVariants = async (
entryPoints: [entryPoint],
bundle: true,
logLevel: 'info',
logOverride: {
'import-is-undefined': 'silent', // Warning caused by the disabled "node.ts" import
},
banner: {
js: `/*!
* Copyright (c) 2025-present, Vanilagy and contributors
+35 -16
View File
@@ -16,7 +16,7 @@ import * as ts from 'typescript';
import * as fs from 'fs';
import * as path from 'path';
const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) => {
const program = ts.createProgram(entryFiles, {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.ESNext,
@@ -55,11 +55,13 @@ const generateDocs = (entryFiles: string[], apiConfigFile: string) => {
delete groupConfig['heading'];
delete groupConfig['intro'];
// Clear and recreate output directory
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true });
// Clear and recreate output directory (skip if dry run)
if (!dry) {
if (fs.existsSync(outputDir)) {
fs.rmSync(outputDir, { recursive: true });
}
fs.mkdirSync(outputDir, { recursive: true });
}
fs.mkdirSync(outputDir, { recursive: true });
// Collect all exported types for cross-referencing
const exportedTypes = new Set<string>();
@@ -1539,9 +1541,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 +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) => {
@@ -1607,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);
@@ -1625,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();
+1 -1
View File
@@ -621,7 +621,7 @@ export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null): AacAudioS
};
};
export const OPUS_INTERNAL_SAMPLE_RATE = 48000;
export const OPUS_SAMPLE_RATE = 48_000;
const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
+72 -31
View File
@@ -47,7 +47,7 @@ import {
} from './misc';
import { Output, TrackType } from './output';
import { Mp4OutputFormat } from './output-format';
import { AudioSample, VideoSample } from './sample';
import { AudioSample, clampCropRectangle, validateCropRectangle, VideoSample } from './sample';
import { MetadataTags, validateMetadataTags } from './tags';
import { NullTarget } from './target';
@@ -89,13 +89,13 @@ export type ConversionOptions = {
};
/**
* A callback that returns or resolves to the descriptive metadata tags that should be written to the output file.
* As input, this function will be passed the tags of the input file, allowing you to modify, augment or extend
* them.
* An object or a callback that returns or resolves to an object containing the descriptive metadata tags that
* should be written to the output file. If a function is passed, it will be passed the tags of the input file as
* its first argument, allowing you to modify, augment or extend them.
*
* If no function is set, the input's metadata tags will be copied to the output.
*/
tags?: (inputTags: MetadataTags) => MaybePromise<MetadataTags>;
tags?: MetadataTags | ((inputTags: MetadataTags) => MaybePromise<MetadataTags>);
};
/**
@@ -126,10 +126,24 @@ export type ConversionVideoOptions = {
*/
fit?: 'fill' | 'contain' | 'cover';
/**
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before cropping and resizing.
* This rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
*/
rotate?: Rotation;
/**
* Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
* the dimensions of the input video track. Cropping is performed after rotation but before resizing.
*/
crop?: {
/** The distance in pixels from the left edge of the source frame to the left edge of the crop rectangle. */
left: number;
/** The distance in pixels from the top edge of the source frame to the top edge of the crop rectangle. */
top: number;
/** The width in pixels of the crop rectangle. */
width: number;
/** The height in pixels of the crop rectangle. */
height: number;
};
/**
* The desired frame rate of the output video, in hertz. If not specified, the original input frame rate will
* be used (which may be variable).
@@ -213,6 +227,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
}
if (videoOptions?.crop !== undefined) {
validateCropRectangle(videoOptions.crop, 'options.video.');
}
if (
videoOptions?.frameRate !== undefined
&& (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)
@@ -378,8 +395,12 @@ export class Conversion {
if (!(options.output instanceof Output)) {
throw new TypeError('options.output must be an Output.');
}
if (options.output._tracks.length > 0 || options.output.state !== 'pending') {
throw new TypeError('options.output must be fresh: no tracks added and not started.');
if (
options.output._tracks.length > 0
|| Object.keys(options.output._metadataTags).length > 0
|| options.output.state !== 'pending'
) {
throw new TypeError('options.output must be fresh: no tracks or metadata tags added and not started.');
}
if (typeof options.video !== 'function') {
@@ -409,8 +430,15 @@ export class Conversion {
&& options.trim.start >= options.trim.end) {
throw new TypeError('options.trim.start must be less than options.trim.end.');
}
if (options.tags !== undefined && typeof options.tags !== 'function') {
throw new TypeError('options.tags, when provided, must be a function.');
if (
options.tags !== undefined
&& (typeof options.tags !== 'object' || !options.tags)
&& typeof options.tags !== 'function'
) {
throw new TypeError('options.tags, when provided, must be an object or a function.');
}
if (typeof options.tags === 'object') {
validateMetadataTags(options.tags);
}
this._options = options;
@@ -502,7 +530,9 @@ export class Conversion {
let outputTags: MetadataTags;
if (this._options.tags) {
const result = await this._options.tags(inputTags);
const result = typeof this._options.tags === 'function'
? await this._options.tags(inputTags)
: this._options.tags;
validateMetadataTags(result);
outputTags = result;
@@ -601,10 +631,19 @@ export class Conversion {
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
const [originalWidth, originalHeight] = totalRotation % 180 === 0
const [rotatedWidth, rotatedHeight] = totalRotation % 180 === 0
? [track.codedWidth, track.codedHeight]
: [track.codedHeight, track.codedWidth];
const crop = trackOptions.crop;
if (crop) {
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
}
const [originalWidth, originalHeight] = crop
? [crop.width, crop.height]
: [rotatedWidth, rotatedHeight];
let width = originalWidth;
let height = originalHeight;
const aspectRatio = width / height;
@@ -630,7 +669,8 @@ export class Conversion {
|| !!trackOptions.frameRate;
let needsRerender = width !== originalWidth
|| height !== originalHeight
|| (totalRotation !== 0 && !outputSupportsRotation);
|| (totalRotation !== 0 && !outputSupportsRotation)
|| !!crop;
let videoCodecs = this.output.format.getSupportedVideoCodecs();
if (
@@ -754,6 +794,7 @@ export class Conversion {
height,
fit: trackOptions.fit ?? 'fill',
rotation: totalRotation, // Bake the rotation into the output
crop: trackOptions.crop,
poolSize: 1,
});
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
@@ -1125,8 +1166,6 @@ export class Conversion {
await this._started;
const resampler = new AudioResampler({
sourceNumberOfChannels: track.numberOfChannels,
sourceSampleRate: track.sampleRate,
targetNumberOfChannels,
targetSampleRate,
startTime: this._startTimestamp,
@@ -1246,9 +1285,9 @@ class TrackSynchronizer {
* OfflineAudioContext.
*/
export class AudioResampler {
sourceSampleRate: number;
sourceSampleRate: number | null = null;
targetSampleRate: number;
sourceNumberOfChannels: number;
sourceNumberOfChannels: number | null = null;
targetNumberOfChannels: number;
startTime: number;
endTime: number;
@@ -1262,20 +1301,16 @@ export class AudioResampler {
/** The highest index written to in the current buffer */
maxWrittenFrame: number;
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
tempSourceBuffer: Float32Array;
tempSourceBuffer!: Float32Array;
constructor(options: {
sourceSampleRate: number;
targetSampleRate: number;
sourceNumberOfChannels: number;
targetNumberOfChannels: number;
startTime: number;
endTime: number;
onSample: (sample: AudioSample) => Promise<void>;
}) {
this.sourceSampleRate = options.sourceSampleRate;
this.targetSampleRate = options.targetSampleRate;
this.sourceNumberOfChannels = options.sourceNumberOfChannels;
this.targetNumberOfChannels = options.targetNumberOfChannels;
this.startTime = options.startTime;
this.endTime = options.endTime;
@@ -1287,17 +1322,14 @@ export class AudioResampler {
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
this.bufferStartFrame = 0;
this.maxWrittenFrame = -1;
this.setupChannelMixer();
// Pre-allocate temporary buffer for source data
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
}
/**
* Sets up the channel mixer to handle up/downmixing in the case where input and output channel counts don't match.
*/
setupChannelMixer(): void {
doChannelMixerSetup(): void {
assert(this.sourceNumberOfChannels !== null);
const sourceNum = this.sourceNumberOfChannels;
const targetNum = this.targetNumberOfChannels;
@@ -1415,8 +1447,17 @@ export class AudioResampler {
}
async add(audioSample: AudioSample) {
if (!audioSample || audioSample._closed) {
return;
if (this.sourceSampleRate === null) {
// This is the first sample, so let's init the missing data. Initting the sample rate from the decoded
// sample is more reliable than using the file's metadata, because decoders are free to emit any sample rate
// they see fit.
this.sourceSampleRate = audioSample.sampleRate;
this.sourceNumberOfChannels = audioSample.numberOfChannels;
// Pre-allocate temporary buffer for source data
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
this.doChannelMixerSetup();
}
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
+1
View File
@@ -160,6 +160,7 @@ export {
AudioSampleCopyToOptions,
VideoSample,
VideoSampleInit,
CropRectangle,
} from './sample';
export {
AudioBufferSink,
+21 -1
View File
@@ -12,6 +12,7 @@ import {
extractAudioCodecString,
extractVideoCodecString,
MediaCodec,
OPUS_SAMPLE_RATE,
parseAacAudioSpecificConfig,
parsePcmCodec,
PCM_AUDIO_CODECS,
@@ -1049,6 +1050,10 @@ export class IsobmffDemuxer extends Demuxer {
}
}
if (track.info.codec === 'opus') {
sampleRate = OPUS_SAMPLE_RATE; // Always the same
}
track.info.numberOfChannels = channelCount;
track.info.sampleRate = sampleRate;
@@ -1421,7 +1426,7 @@ export class IsobmffDemuxer extends Demuxer {
track.info.codecDescription = description;
track.info.numberOfChannels = outputChannelCount;
track.info.sampleRate = inputSampleRate;
// Don't copy the input sample rate, irrelevant, and output sample rate is fixed
}; break;
case 'dfLa': { // Used for FLAC audio
@@ -2301,6 +2306,21 @@ export class IsobmffDemuxer extends Demuxer {
}
}; break;
case 'track': {
if (typeof data === 'string') {
const parts = data.split('/');
const trackNum = Number.parseInt(parts[0]!, 10);
const tracksTotal = parts[1] && Number.parseInt(parts[1], 10);
if (Number.isInteger(trackNum) && trackNum > 0) {
this.metadataTags.trackNumber ??= trackNum;
}
if (tracksTotal && Number.isInteger(tracksTotal) && tracksTotal > 0) {
this.metadataTags.tracksTotal ??= tracksTotal;
}
}
}; break;
case 'trkn': {
if (data instanceof Uint8Array) {
const view = toDataView(data);
+2
View File
@@ -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;
+2 -2
View File
@@ -47,7 +47,7 @@ import {
parseSubtitleTimestamp,
} from '../subtitles';
import {
OPUS_INTERNAL_SAMPLE_RATE,
OPUS_SAMPLE_RATE,
PCM_AUDIO_CODECS,
PcmAudioCodec,
SubtitleCodec,
@@ -293,7 +293,7 @@ export class MatroskaMuxer extends Muxer {
const header = parseOpusIdentificationHeader(bytes);
// Use the preSkip value from the header
seekPreRollNs = Math.round(1e9 * (header.preSkip / OPUS_INTERNAL_SAMPLE_RATE));
seekPreRollNs = Math.round(1e9 * (header.preSkip / OPUS_SAMPLE_RATE));
}
}
+34 -5
View File
@@ -18,6 +18,7 @@ import {
getInt24,
getUint24,
insertSorted,
isFirefox,
isSafari,
last,
mapAsyncGenerator,
@@ -29,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.
@@ -1052,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
@@ -1083,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)[];
@@ -1118,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)
@@ -1126,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
@@ -1147,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);
@@ -1178,19 +1200,26 @@ export class CanvasSink {
this._nextCanvasIndex = (this._nextCanvasIndex + 1) % this._canvasPool.length;
}
const context
= canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
const context = canvas.getContext('2d', {
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
assert(context);
context.resetTransform();
if (!canvasIsNew) {
context.clearRect(0, 0, this._width, this._height);
if (isFirefox()) {
context.fillStyle = 'black';
context.fillRect(0, 0, this._width, this._height);
} else {
context.clearRect(0, 0, this._width, this._height);
}
}
sample.drawWithFit(context, {
fit: this._fit,
rotation: this._rotation,
crop: this._crop,
});
const result = {
+19 -4
View File
@@ -18,7 +18,16 @@ import {
VideoCodec,
} from './codec';
import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from './output';
import { assert, assertNever, CallSerializer, clamp, promiseWithResolvers, setInt24, setUint24 } from './misc';
import {
assert,
assertNever,
CallSerializer,
clamp,
isFirefox,
promiseWithResolvers,
setInt24,
setUint24,
} from './misc';
import { Muxer } from './muxer';
import { SubtitleParser } from './subtitles';
import { toAlaw, toUlaw } from './pcm';
@@ -248,12 +257,18 @@ class VideoEncoderWrapper {
canvasIsNew = true;
}
const context = this.resizeCanvas.getContext('2d', { alpha: false }) as
CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
const context = this.resizeCanvas.getContext('2d', {
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
}) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
assert(context);
if (!canvasIsNew) {
context.clearRect(0, 0, this.codedWidth, this.codedHeight);
if (isFirefox()) {
context.fillStyle = 'black';
context.fillRect(0, 0, this.codedWidth, this.codedHeight);
} else {
context.clearRect(0, 0, this.codedWidth, this.codedHeight);
}
}
videoSample.drawWithFit(context, { fit: sizeChangeBehavior });
+9
View File
@@ -642,6 +642,15 @@ export const isSafari = () => {
return result;
};
let isFirefoxCache: boolean | null = null;
export const isFirefox = () => {
if (isFirefoxCache !== null) {
return isFirefoxCache;
}
return isFirefoxCache = typeof navigator !== 'undefined' && navigator.userAgent?.includes('Firefox');
};
/**
* T or a promise that resolves to T.
* @group Miscellaneous
+11
View File
@@ -0,0 +1,11 @@
/*!
* Copyright (c) 2025-present, Vanilagy and contributors
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
// This file contains Node.js-specific code that does not run in a browser.
export * as fs from 'node:fs/promises';
+3 -3
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { OPUS_INTERNAL_SAMPLE_RATE } from '../codec';
import { OPUS_SAMPLE_RATE } from '../codec';
import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data';
import { Demuxer } from '../demuxer';
import { Input } from '../input';
@@ -249,7 +249,7 @@ export class OggDemuxer extends Demuxer {
const header = parseOpusIdentificationHeader(firstPacket.data);
bitstream.numberOfChannels = header.outputChannelCount;
bitstream.sampleRate = header.inputSampleRate;
bitstream.sampleRate = OPUS_SAMPLE_RATE; // Always the same
bitstream.codecInfo.opusInfo = {
preSkip: header.preSkip,
@@ -574,7 +574,7 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
constructor(public bitstream: LogicalBitstream, public demuxer: OggDemuxer) {
// Opus always uses a fixed sample rate for its internal calculations, even if the actual rate is different
this.internalSampleRate = bitstream.codecInfo.codec === 'opus'
? OPUS_INTERNAL_SAMPLE_RATE
? OPUS_SAMPLE_RATE
: bitstream.sampleRate;
}
+2 -2
View File
@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { OPUS_INTERNAL_SAMPLE_RATE, validateAudioChunkMetadata } from '../codec';
import { OPUS_SAMPLE_RATE, validateAudioChunkMetadata } from '../codec';
import { parseModesFromVorbisSetupPacket, parseOpusIdentificationHeader } from '../codec-data';
import {
assert,
@@ -119,7 +119,7 @@ export class OggMuxer extends Muxer {
track,
serialNumber,
internalSampleRate: track.source._codec === 'opus'
? OPUS_INTERNAL_SAMPLE_RATE
? OPUS_SAMPLE_RATE
: meta.decoderConfig.sampleRate,
codecInfo: {
codec: track.source._codec,
+130 -31
View File
@@ -15,6 +15,7 @@ import {
toDataView,
toUint8Array,
SetRequired,
isFirefox,
} from './misc';
/**
@@ -201,7 +202,8 @@ export class VideoSample {
return new VideoSample(
new VideoFrame(data, {
timestamp: Math.trunc(init.timestamp! * SECOND_TO_MICROSECOND_FACTOR),
duration: Math.trunc((init.duration ?? 0) * SECOND_TO_MICROSECOND_FACTOR),
// Drag 0 to undefined
duration: Math.trunc((init.duration ?? 0) * SECOND_TO_MICROSECOND_FACTOR) || undefined,
}),
init,
);
@@ -227,7 +229,10 @@ export class VideoSample {
}
const canvas = new OffscreenCanvas(width, height);
const context = canvas.getContext('2d', { alpha: false, willReadFrequently: true });
const context = canvas.getContext('2d', {
alpha: isFirefox(), // Firefox has VideoFrame glitches with opaque canvases
willReadFrequently: true,
});
assert(context);
// Draw it to a canvas
@@ -342,7 +347,7 @@ export class VideoSample {
dest.set(this._data);
} else {
const canvas = this._data;
const context = canvas.getContext('2d', { alpha: false });
const context = canvas.getContext('2d');
assert(context);
const imageData = context.getImageData(0, 0, this.codedWidth, this.codedHeight);
@@ -373,13 +378,13 @@ export class VideoSample {
codedWidth: this.codedWidth,
codedHeight: this.codedHeight,
timestamp: this.microsecondTimestamp,
duration: this.microsecondDuration,
duration: this.microsecondDuration || undefined,
colorSpace: this.colorSpace,
});
} else {
return new VideoFrame(this._data, {
timestamp: this.microsecondTimestamp,
duration: this.microsecondDuration,
duration: this.microsecondDuration || undefined,
});
}
}
@@ -507,28 +512,7 @@ export class VideoSample {
throw new Error('VideoSample is closed.');
}
// The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is
// stored. Therefore, we must map these back onto the original, pre-rotation image.
if (this.rotation === 90) {
[sx, sy, sWidth, sHeight] = [
sy,
this.codedHeight - sx - sWidth,
sHeight,
sWidth,
];
} else if (this.rotation === 180) {
[sx, sy] = [
this.codedWidth - sx - sWidth,
this.codedHeight - sy - sHeight,
];
} else if (this.rotation === 270) {
[sx, sy, sWidth, sHeight] = [
this.codedWidth - sy - sHeight,
sx,
sHeight,
sWidth,
];
}
({ sx, sy, sWidth, sHeight } = this._rotateSourceRegion(sx, sy, sWidth, sHeight, this.rotation));
const source = this.toCanvasImageSource();
@@ -576,26 +560,69 @@ export class VideoSample {
fit: 'fill' | 'contain' | 'cover';
/** A way to override rotation. Defaults to the rotation of the sample. */
rotation?: Rotation;
/**
* Specifies the rectangular region of the video sample to crop to. The crop region will automatically be
* clamped to the dimensions of the video sample. Cropping is performed after rotation but before resizing.
*/
crop?: CropRectangle;
}) {
if (!(
(typeof CanvasRenderingContext2D !== 'undefined' && context instanceof CanvasRenderingContext2D)
|| (
typeof OffscreenCanvasRenderingContext2D !== 'undefined'
&& context instanceof OffscreenCanvasRenderingContext2D
)
)) {
throw new TypeError('context must be a CanvasRenderingContext2D or OffscreenCanvasRenderingContext2D.');
}
if (!options || typeof options !== 'object') {
throw new TypeError('options must be an object.');
}
if (!['fill', 'contain', 'cover'].includes(options.fit)) {
throw new TypeError('options.fit must be \'fill\', \'contain\', or \'cover\'.');
}
if (options.rotation !== undefined && ![0, 90, 180, 270].includes(options.rotation)) {
throw new TypeError('options.rotation, when provided, must be 0, 90, 180, or 270.');
}
if (options.crop !== undefined) {
validateCropRectangle(options.crop, 'options.');
}
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
const rotation = options.rotation ?? this.rotation;
const [rotatedWidth, rotatedHeight] = rotation % 180 === 0
? [this.codedWidth, this.codedHeight]
: [this.codedHeight, this.codedWidth];
if (options.crop) {
clampCropRectangle(options.crop, rotatedWidth, rotatedHeight);
}
// These variables specify where the final sample will be drawn on the canvas
let dx: number;
let dy: number;
let newWidth: number;
let newHeight: number;
const { sx, sy, sWidth, sHeight } = this._rotateSourceRegion(
options.crop?.left ?? 0,
options.crop?.top ?? 0,
options.crop?.width ?? this.codedWidth,
options.crop?.height ?? this.codedHeight,
rotation,
);
if (options.fit === 'fill') {
dx = 0;
dy = 0;
newWidth = canvasWidth;
newHeight = canvasHeight;
} else {
const [sampleWidth, sampleHeight] = rotation % 180 === 0
? [this.codedWidth, this.codedHeight]
: [this.codedHeight, this.codedWidth];
const [sampleWidth, sampleHeight] = options.crop
? [options.crop.width, options.crop.height]
: [rotatedWidth, rotatedHeight];
const scale = options.fit === 'contain'
? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight)
@@ -616,7 +643,35 @@ export class VideoSample {
// Important that we don't use .draw() here since that would take rotation into account, but we wanna handle it
// ourselves here
context.drawImage(this.toCanvasImageSource(), dx, dy, newWidth, newHeight);
context.drawImage(this.toCanvasImageSource(), sx, sy, sWidth, sHeight, dx, dy, newWidth, newHeight);
}
/** @internal */
_rotateSourceRegion(sx: number, sy: number, sWidth: number, sHeight: number, rotation: number) {
// The provided sx,sy,sWidth,sHeight refer to the final rotated image, but that's not actually how the image is
// stored. Therefore, we must map these back onto the original, pre-rotation image.
if (rotation === 90) {
[sx, sy, sWidth, sHeight] = [
sy,
this.codedHeight - sx - sWidth,
sHeight,
sWidth,
];
} else if (rotation === 180) {
[sx, sy] = [
this.codedWidth - sx - sWidth,
this.codedHeight - sy - sHeight,
];
} else if (rotation === 270) {
[sx, sy, sWidth, sHeight] = [
this.codedWidth - sy - sHeight,
sx,
sHeight,
sWidth,
];
}
return { sx, sy, sWidth, sHeight };
}
/**
@@ -679,6 +734,50 @@ const isVideoFrame = (x: unknown): x is VideoFrame => {
return typeof VideoFrame !== 'undefined' && x instanceof VideoFrame;
};
/**
* Specifies the rectangular cropping region.
* @group Miscellaneous
* @public
*/
export type CropRectangle = {
/** The distance in pixels from the left edge of the source frame to the left edge of the crop rectangle. */
left: number;
/** The distance in pixels from the top edge of the source frame to the top edge of the crop rectangle. */
top: number;
/** The width in pixels of the crop rectangle. */
width: number;
/** The height in pixels of the crop rectangle. */
height: number;
};
export const clampCropRectangle = (crop: CropRectangle, outerWidth: number, outerHeight: number) => {
crop.left = Math.min(crop.left, outerWidth);
crop.top = Math.min(crop.top, outerHeight);
crop.width = Math.min(crop.width, outerWidth - crop.left);
crop.height = Math.min(crop.height, outerHeight - crop.top);
assert(crop.width >= 0);
assert(crop.height >= 0);
};
export const validateCropRectangle = (crop: CropRectangle, prefix: string) => {
if (!crop || typeof crop !== 'object') {
throw new TypeError(prefix + 'crop, when provided, must be an object.');
}
if (!Number.isInteger(crop.left) || crop.left < 0) {
throw new TypeError(prefix + 'crop.left must be a non-negative integer.');
}
if (!Number.isInteger(crop.top) || crop.top < 0) {
throw new TypeError(prefix + 'crop.top must be a non-negative integer.');
}
if (!Number.isInteger(crop.width) || crop.width < 0) {
throw new TypeError(prefix + 'crop.width must be a non-negative integer.');
}
if (!Number.isInteger(crop.height) || crop.height < 0) {
throw new TypeError(prefix + 'crop.height must be a non-negative integer.');
}
};
const AUDIO_SAMPLE_FORMATS = new Set(
['f32', 'f32-planar', 's16', 's16-planar', 's32', 's32-planar', 'u8', 'u8-planar'],
);
+6 -4
View File
@@ -18,6 +18,9 @@ import {
toDataView,
toUint8Array,
} from './misc';
import * as nodeAlias from './node';
const node = nodeAlias; // Aliasing it prevents some bundler warnings
export type ReadResult = {
bytes: Uint8Array;
@@ -548,9 +551,7 @@ export class FilePathSource extends Source {
// Let's back this source with a StreamSource, makes the implementation very simple
this._streamSource = new StreamSource({
getSize: async () => {
const FS_MODULE_NAME = 'node:fs/promises';
const fs = await import(/* @vite-ignore */ FS_MODULE_NAME) as typeof import('node:fs/promises');
fileHandle = await fs.open(filePath, 'r');
fileHandle = await node.fs.open(filePath, 'r');
const stats = await fileHandle.stat();
return stats.size;
@@ -558,8 +559,9 @@ export class FilePathSource extends Source {
read: async (start, end) => {
assert(fileHandle);
const buffer = Buffer.alloc(end - start);
const buffer = new Uint8Array(end - start);
await fileHandle.read(buffer, 0, end - start, start);
return buffer;
},
maxCacheSize: options.maxCacheSize,