mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 06:13:46 +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 | ||
|
|
904fd8f95e |
@@ -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
|
||||
+33
-20
@@ -47,23 +47,6 @@
|
||||
format: outputFormat,
|
||||
target
|
||||
});
|
||||
output.setMetadataTags({
|
||||
title: 'Bigggy',
|
||||
artist: 'Buck Bunny',
|
||||
images: [{
|
||||
data: blobData,
|
||||
kind: 'coverFront',
|
||||
mimeType: 'image/jpeg'
|
||||
}],
|
||||
trackNumber: 4,
|
||||
tracksTotal: 10,
|
||||
discNumber: 5,
|
||||
discNumberMax: 8,
|
||||
lyrics: "There's no way\nThat it's not going there",
|
||||
raw: {
|
||||
'ENCODER': 'Mediabunny epic own encoder'
|
||||
}
|
||||
});
|
||||
|
||||
const conversion = await Mediabunny.Conversion.init({
|
||||
input: new Mediabunny.Input({
|
||||
@@ -72,6 +55,9 @@
|
||||
}),
|
||||
output,
|
||||
audio: {
|
||||
//codec: 'pcm-s16',
|
||||
//sampleRate: 16000,
|
||||
//numberOfChannels: 1,
|
||||
//discard: true,
|
||||
//codec: 'opus',
|
||||
//bitrate: 128000,
|
||||
@@ -106,7 +92,17 @@
|
||||
*/
|
||||
video: () => ({
|
||||
//discard: true,
|
||||
forceTranscode: true,
|
||||
//crop: {
|
||||
// left: 0,
|
||||
// top: 0,
|
||||
// width: 500,
|
||||
// height: 500,
|
||||
//},
|
||||
//rotate: 90,
|
||||
//width: 200,
|
||||
//height: 500,
|
||||
//fit: 'contain',
|
||||
//forceTranscode: true,
|
||||
//codec: 'avc',
|
||||
//fit: 'contain',
|
||||
//frameRate: 27.123,
|
||||
@@ -128,9 +124,26 @@
|
||||
//width: 200,
|
||||
//height: 100,
|
||||
}),
|
||||
tags: {
|
||||
title: 'Bigggy',
|
||||
artist: 'Buck Bunny',
|
||||
images: [{
|
||||
data: blobData,
|
||||
kind: 'coverFront',
|
||||
mimeType: 'image/jpeg'
|
||||
}],
|
||||
trackNumber: 4,
|
||||
tracksTotal: 10,
|
||||
discNumber: 5,
|
||||
discsTotal: 8,
|
||||
lyrics: "There's no way\nThat it's not going there",
|
||||
raw: {
|
||||
'AIGC': 'Mediabunny epic own encoder'
|
||||
}
|
||||
},
|
||||
trim: {
|
||||
start: 1,
|
||||
end: 10
|
||||
start: 10,
|
||||
end: 20
|
||||
},
|
||||
});
|
||||
console.log(conversion);
|
||||
|
||||
+1
-4
@@ -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
@@ -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;
|
||||
```
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -621,7 +621,7 @@ export const parseAacAudioSpecificConfig = (bytes: Uint8Array | null): AacAudioS
|
||||
};
|
||||
};
|
||||
|
||||
export const OPUS_INTERNAL_SAMPLE_RATE = 48000;
|
||||
export const OPUS_SAMPLE_RATE = 48_000;
|
||||
|
||||
const PCM_CODEC_REGEX = /^pcm-([usf])(\d+)+(be)?$/;
|
||||
|
||||
|
||||
+72
-31
@@ -47,7 +47,7 @@ import {
|
||||
} from './misc';
|
||||
import { Output, TrackType } from './output';
|
||||
import { Mp4OutputFormat } from './output-format';
|
||||
import { AudioSample, VideoSample } from './sample';
|
||||
import { AudioSample, clampCropRectangle, validateCropRectangle, VideoSample } from './sample';
|
||||
import { MetadataTags, validateMetadataTags } from './tags';
|
||||
import { NullTarget } from './target';
|
||||
|
||||
@@ -89,13 +89,13 @@ export type ConversionOptions = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A callback that returns or resolves to the descriptive metadata tags that should be written to the output file.
|
||||
* As input, this function will be passed the tags of the input file, allowing you to modify, augment or extend
|
||||
* them.
|
||||
* An object or a callback that returns or resolves to an object containing the descriptive metadata tags that
|
||||
* should be written to the output file. If a function is passed, it will be passed the tags of the input file as
|
||||
* its first argument, allowing you to modify, augment or extend them.
|
||||
*
|
||||
* If no function is set, the input's metadata tags will be copied to the output.
|
||||
*/
|
||||
tags?: (inputTags: MetadataTags) => MaybePromise<MetadataTags>;
|
||||
tags?: MetadataTags | ((inputTags: MetadataTags) => MaybePromise<MetadataTags>);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -126,10 +126,24 @@ export type ConversionVideoOptions = {
|
||||
*/
|
||||
fit?: 'fill' | 'contain' | 'cover';
|
||||
/**
|
||||
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before resizing. This
|
||||
* rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
|
||||
* The angle in degrees to rotate the input video by, clockwise. Rotation is applied before cropping and resizing.
|
||||
* This rotation is _in addition to_ the natural rotation of the input video as specified in input file's metadata.
|
||||
*/
|
||||
rotate?: Rotation;
|
||||
/**
|
||||
* Specifies the rectangular region of the input video to crop to. The crop region will automatically be clamped to
|
||||
* the dimensions of the input video track. Cropping is performed after rotation but before resizing.
|
||||
*/
|
||||
crop?: {
|
||||
/** The distance in pixels from the left edge of the source frame to the left edge of the crop rectangle. */
|
||||
left: number;
|
||||
/** The distance in pixels from the top edge of the source frame to the top edge of the crop rectangle. */
|
||||
top: number;
|
||||
/** The width in pixels of the crop rectangle. */
|
||||
width: number;
|
||||
/** The height in pixels of the crop rectangle. */
|
||||
height: number;
|
||||
};
|
||||
/**
|
||||
* The desired frame rate of the output video, in hertz. If not specified, the original input frame rate will
|
||||
* be used (which may be variable).
|
||||
@@ -213,6 +227,9 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
|
||||
if (videoOptions?.rotate !== undefined && ![0, 90, 180, 270].includes(videoOptions.rotate)) {
|
||||
throw new TypeError('options.video.rotate, when provided, must be 0, 90, 180 or 270.');
|
||||
}
|
||||
if (videoOptions?.crop !== undefined) {
|
||||
validateCropRectangle(videoOptions.crop, 'options.video.');
|
||||
}
|
||||
if (
|
||||
videoOptions?.frameRate !== undefined
|
||||
&& (!Number.isFinite(videoOptions.frameRate) || videoOptions.frameRate <= 0)
|
||||
@@ -378,8 +395,12 @@ export class Conversion {
|
||||
if (!(options.output instanceof Output)) {
|
||||
throw new TypeError('options.output must be an Output.');
|
||||
}
|
||||
if (options.output._tracks.length > 0 || options.output.state !== 'pending') {
|
||||
throw new TypeError('options.output must be fresh: no tracks added and not started.');
|
||||
if (
|
||||
options.output._tracks.length > 0
|
||||
|| Object.keys(options.output._metadataTags).length > 0
|
||||
|| options.output.state !== 'pending'
|
||||
) {
|
||||
throw new TypeError('options.output must be fresh: no tracks or metadata tags added and not started.');
|
||||
}
|
||||
|
||||
if (typeof options.video !== 'function') {
|
||||
@@ -409,8 +430,15 @@ export class Conversion {
|
||||
&& options.trim.start >= options.trim.end) {
|
||||
throw new TypeError('options.trim.start must be less than options.trim.end.');
|
||||
}
|
||||
if (options.tags !== undefined && typeof options.tags !== 'function') {
|
||||
throw new TypeError('options.tags, when provided, must be a function.');
|
||||
if (
|
||||
options.tags !== undefined
|
||||
&& (typeof options.tags !== 'object' || !options.tags)
|
||||
&& typeof options.tags !== 'function'
|
||||
) {
|
||||
throw new TypeError('options.tags, when provided, must be an object or a function.');
|
||||
}
|
||||
if (typeof options.tags === 'object') {
|
||||
validateMetadataTags(options.tags);
|
||||
}
|
||||
|
||||
this._options = options;
|
||||
@@ -502,7 +530,9 @@ export class Conversion {
|
||||
let outputTags: MetadataTags;
|
||||
|
||||
if (this._options.tags) {
|
||||
const result = await this._options.tags(inputTags);
|
||||
const result = typeof this._options.tags === 'function'
|
||||
? await this._options.tags(inputTags)
|
||||
: this._options.tags;
|
||||
validateMetadataTags(result);
|
||||
|
||||
outputTags = result;
|
||||
@@ -601,10 +631,19 @@ export class Conversion {
|
||||
const totalRotation = normalizeRotation(track.rotation + (trackOptions.rotate ?? 0));
|
||||
const outputSupportsRotation = this.output.format.supportsVideoRotationMetadata;
|
||||
|
||||
const [originalWidth, originalHeight] = totalRotation % 180 === 0
|
||||
const [rotatedWidth, rotatedHeight] = totalRotation % 180 === 0
|
||||
? [track.codedWidth, track.codedHeight]
|
||||
: [track.codedHeight, track.codedWidth];
|
||||
|
||||
const crop = trackOptions.crop;
|
||||
if (crop) {
|
||||
clampCropRectangle(crop, rotatedWidth, rotatedHeight);
|
||||
}
|
||||
|
||||
const [originalWidth, originalHeight] = crop
|
||||
? [crop.width, crop.height]
|
||||
: [rotatedWidth, rotatedHeight];
|
||||
|
||||
let width = originalWidth;
|
||||
let height = originalHeight;
|
||||
const aspectRatio = width / height;
|
||||
@@ -630,7 +669,8 @@ export class Conversion {
|
||||
|| !!trackOptions.frameRate;
|
||||
let needsRerender = width !== originalWidth
|
||||
|| height !== originalHeight
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation);
|
||||
|| (totalRotation !== 0 && !outputSupportsRotation)
|
||||
|| !!crop;
|
||||
|
||||
let videoCodecs = this.output.format.getSupportedVideoCodecs();
|
||||
if (
|
||||
@@ -754,6 +794,7 @@ export class Conversion {
|
||||
height,
|
||||
fit: trackOptions.fit ?? 'fill',
|
||||
rotation: totalRotation, // Bake the rotation into the output
|
||||
crop: trackOptions.crop,
|
||||
poolSize: 1,
|
||||
});
|
||||
const iterator = sink.canvases(this._startTimestamp, this._endTimestamp);
|
||||
@@ -1125,8 +1166,6 @@ export class Conversion {
|
||||
await this._started;
|
||||
|
||||
const resampler = new AudioResampler({
|
||||
sourceNumberOfChannels: track.numberOfChannels,
|
||||
sourceSampleRate: track.sampleRate,
|
||||
targetNumberOfChannels,
|
||||
targetSampleRate,
|
||||
startTime: this._startTimestamp,
|
||||
@@ -1246,9 +1285,9 @@ class TrackSynchronizer {
|
||||
* OfflineAudioContext.
|
||||
*/
|
||||
export class AudioResampler {
|
||||
sourceSampleRate: number;
|
||||
sourceSampleRate: number | null = null;
|
||||
targetSampleRate: number;
|
||||
sourceNumberOfChannels: number;
|
||||
sourceNumberOfChannels: number | null = null;
|
||||
targetNumberOfChannels: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
@@ -1262,20 +1301,16 @@ export class AudioResampler {
|
||||
/** The highest index written to in the current buffer */
|
||||
maxWrittenFrame: number;
|
||||
channelMixer!: (sourceData: Float32Array, sourceFrameIndex: number, targetChannelIndex: number) => number;
|
||||
tempSourceBuffer: Float32Array;
|
||||
tempSourceBuffer!: Float32Array;
|
||||
|
||||
constructor(options: {
|
||||
sourceSampleRate: number;
|
||||
targetSampleRate: number;
|
||||
sourceNumberOfChannels: number;
|
||||
targetNumberOfChannels: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
onSample: (sample: AudioSample) => Promise<void>;
|
||||
}) {
|
||||
this.sourceSampleRate = options.sourceSampleRate;
|
||||
this.targetSampleRate = options.targetSampleRate;
|
||||
this.sourceNumberOfChannels = options.sourceNumberOfChannels;
|
||||
this.targetNumberOfChannels = options.targetNumberOfChannels;
|
||||
this.startTime = options.startTime;
|
||||
this.endTime = options.endTime;
|
||||
@@ -1287,17 +1322,14 @@ export class AudioResampler {
|
||||
this.outputBuffer = new Float32Array(this.bufferSizeInSamples);
|
||||
this.bufferStartFrame = 0;
|
||||
this.maxWrittenFrame = -1;
|
||||
|
||||
this.setupChannelMixer();
|
||||
|
||||
// Pre-allocate temporary buffer for source data
|
||||
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the channel mixer to handle up/downmixing in the case where input and output channel counts don't match.
|
||||
*/
|
||||
setupChannelMixer(): void {
|
||||
doChannelMixerSetup(): void {
|
||||
assert(this.sourceNumberOfChannels !== null);
|
||||
|
||||
const sourceNum = this.sourceNumberOfChannels;
|
||||
const targetNum = this.targetNumberOfChannels;
|
||||
|
||||
@@ -1415,8 +1447,17 @@ export class AudioResampler {
|
||||
}
|
||||
|
||||
async add(audioSample: AudioSample) {
|
||||
if (!audioSample || audioSample._closed) {
|
||||
return;
|
||||
if (this.sourceSampleRate === null) {
|
||||
// This is the first sample, so let's init the missing data. Initting the sample rate from the decoded
|
||||
// sample is more reliable than using the file's metadata, because decoders are free to emit any sample rate
|
||||
// they see fit.
|
||||
this.sourceSampleRate = audioSample.sampleRate;
|
||||
this.sourceNumberOfChannels = audioSample.numberOfChannels;
|
||||
|
||||
// Pre-allocate temporary buffer for source data
|
||||
this.tempSourceBuffer = new Float32Array(this.sourceSampleRate * this.sourceNumberOfChannels);
|
||||
|
||||
this.doChannelMixerSetup();
|
||||
}
|
||||
|
||||
const requiredSamples = audioSample.numberOfFrames * audioSample.numberOfChannels;
|
||||
|
||||
@@ -160,6 +160,7 @@ export {
|
||||
AudioSampleCopyToOptions,
|
||||
VideoSample,
|
||||
VideoSampleInit,
|
||||
CropRectangle,
|
||||
} from './sample';
|
||||
export {
|
||||
AudioBufferSink,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-5
@@ -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
@@ -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