Compare commits

..
23 Commits
Author SHA1 Message Date
Vanilagy 7f8770de99 Fix regular Language element in Matroska not having an effect anymore 2025-08-27 08:56:41 +02:00
David P.andGitHub c498b87b3e Merge pull request #76 from JonnyBurger/align-prettier-style
Instruct VSCode to use ESLint as default formatter
2025-08-26 16:30:21 +02:00
Jonny BurgerandGitHub ffdaf2dacb Discard changes to package.json 2025-08-26 15:10:27 +02:00
JonnyBurger 448e43cbfc Instruct .vscode to use ESLint as a default formatter 2025-08-26 15:10:13 +02:00
JonnyBurger ef2094b0db Add trailing-comma option 2025-08-26 15:07:12 +02:00
JonnyBurger daa6bdbe1d Match Prettier style
This way, people who have Prettier as their default formatter in their Editor (probably the majority of people) will not have the code re-formatted to double quotes if they are saving
2025-08-26 15:03:54 +02:00
Vanilagy 2d11f53192 Oops 2025-08-25 19:57:25 +02:00
Vanilagy 1b4628cbb0 Add temp conversion fix regarding languageCode 2025-08-25 19:45:02 +02:00
Vanilagy 72eb54391e Reorder 2025-08-24 18:10:35 +02:00
Vanilagy ca1305da93 Bump minor 2025-08-24 16:37:41 +02:00
Vanilagy 63b8190185 Add logic for encoding video frames that change size over time (#63) 2025-08-24 16:37:03 +02:00
Vanilagy ad26edc6b5 Catch degenerate ISOBMFF matrices 2025-08-24 14:49:33 +02:00
Vanilagy 945df51944 Copy track name in Conversion API 2025-08-24 11:15:08 +02:00
Vanilagy 652ca69417 Bump minor 2025-08-23 16:27:48 +02:00
Vanilagy 803e58a0d2 Add internalCodecId field 2025-08-23 16:24:36 +02:00
Vanilagy e6420caea4 Add support for LanguageBCP47 2025-08-23 15:58:58 +02:00
Vanilagy bb1b20afa7 Add track name support 2025-08-23 15:33:44 +02:00
Vanilagy 0e03779169 Add 808vita 2025-08-23 12:36:05 +02:00
Vanilagy 5a6528d8f5 Add HW acceleration to unsupported message 2025-08-22 19:38:36 +02:00
Vanilagy 134a6f8f4e Add more options to Video/AudioEncodingConfig, restructure code (#61) 2025-08-22 17:24:02 +02:00
Vanilagy a56dca10d9 Use SimpleBlock in almost all cases now (cries a little inside) (fixes #62) 2025-08-22 15:32:05 +02:00
Vanilagy c81600bf17 Add base to URL constructor 2025-08-22 14:22:56 +02:00
Vanilagy 2d62395fc0 Fix faulty faulty lookup table logic (addresses #70) 2025-08-22 14:20:57 +02:00
38 changed files with 1111 additions and 573 deletions
-1
View File
@@ -1,4 +1,3 @@
.vscode
node_modules
/dist
/dist-docs
+3
View File
@@ -0,0 +1,3 @@
{
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
}
+1 -1
View File
@@ -42,7 +42,7 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
Core features include:
- **Wide format support**: Read and write MP4, WebM, WAVE, MP3, Ogg, and more
- **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
+2
View File
@@ -74,6 +74,8 @@
},
*/
video: () => ({
codec: 'avc',
//fit: 'contain',
//frameRate: 27.123,
//width: 320,
//forceTranscode: true,
+7
View File
@@ -17,6 +17,12 @@
});
const videoTrack = await input.getPrimaryVideoTrack();
console.log(await videoTrack.getFirstTimestamp(), await videoTrack.computeDuration());
console.log(videoTrack.name);
console.log(videoTrack.internalCodecId);
/*
const sink = new Mediabunny.EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
@@ -24,6 +30,7 @@
//if (packet.timestamp > 135) break;
}
*/
/*
const audioTrack = await input.getPrimaryAudioTrack();
+4 -3
View File
@@ -45,6 +45,7 @@
format = new Mediabunny.MkvOutputFormat({ minimumClusterDuration: 2 });
format = new Mediabunny.WavOutputFormat();
format = new Mediabunny.MkvOutputFormat();
format = new Mediabunny.MovOutputFormat();
let target = new Mediabunny.BufferTarget();
/*
@@ -125,8 +126,8 @@
});
let subtitleSource = new Mediabunny.TextSubtitleSource('webvtt');
output.addVideoTrack(videoSource, { languageCode: 'eng' });
output.addAudioTrack(audioSource);
output.addVideoTrack(videoSource, { languageCode: 'eng', name: 'Mononoké' });
output.addAudioTrack(audioSource, { name: 'Yooo' });
//output.addSubtitleTrack(subtitleSource);
output.start();
@@ -206,5 +207,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>
+2
View File
@@ -141,6 +141,8 @@ If `width` or `height` is used in conjunction with `rotation`, they control the
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'`.
### 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).
+12
View File
@@ -47,9 +47,14 @@ All video sources that handle encoding internally require you to specify a `Vide
type VideoEncodingConfig = {
codec: VideoCodec;
bitrate: number | Quality;
bitrateMode?: 'constant' | 'variable';
latencyMode?: 'quality' | 'realtime';
keyFrameInterval?: number;
fullCodecString?: string;
hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
scalabilityMode?: string;
contentHint?: string;
sizeChangeBehavior?: 'deny' | 'passThrough' | 'fill' | 'contain' | 'cover';
onEncodedPacket?: (
packet: EncodedPacket,
@@ -62,9 +67,14 @@ type VideoEncodingConfig = {
```
- `codec`: The [video codec](./supported-formats-and-codecs#video-codecs) used for encoding.
- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
- `bitrateMode`: Can be used to control constant vs. variable bitrate.
- `latencyMode`: The latency mode as specified by the WebCodecs API. Browsers default to `quality`. Media stream-driven video sources will automatically use the `realtime` setting.
- `keyFrameInterval`: The maximum interval in seconds between two adjacent key frames. Defaults to 5 seconds. More frequent key frames improve seeking behavior but increase file size. When using multiple video tracks, this value should be set to the same value for all tracks.
- `fullCodecString`: Allows you to optionally specify the full codec string used by the video encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'avc1.42001f'` when using AVC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `hardwareAcceleration`: A hint that configures the hardware acceleration method of this codec. This is best left on `'no-preference'`.
- `scalabilityMode`: An encoding scalability mode identifier as defined by [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*).
- `contentHint`: An encoding video content hint as defined by [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints).
- `sizeChangeBehavior`: Video frames may change size overtime. This field controls the behavior in case this happens. Defaults to `'deny'`.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
@@ -75,6 +85,7 @@ All audio sources that handle encoding internally require you to specify an `Aud
type AudioEncodingConfig = {
codec: AudioCodec;
bitrate?: number | Quality;
bitrateMode?: 'constant' | 'variable';
fullCodecString?: string;
onEncodedPacket?: (
@@ -88,6 +99,7 @@ type AudioEncodingConfig = {
```
- `codec`: The [audio codec](./supported-formats-and-codecs#audio-codecs) used for encoding. Can be omitted for uncompressed PCM codecs.
- `bitrate`: The target number of bits per second. Alternatively, this can be a [subjective quality](#subjective-qualities).
- `bitrateMode`: Can be used to control constant vs. variable bitrate.
- `fullCodecString`: Allows you to optionally specify the full codec string used by the audio encoder, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/). For example, you may set it to `'mp4a.40.2'` when using AAC. Keep in mind that the codec string must still match the codec specified in `codec`. If you don't set this field, a codec string will be generated automatically.
- `onEncodedPacket`: Called for each successfully encoded packet. Useful for determining encoding progress.
- `onEncoderConfig`: Called when the internal encoder config, as used by the WebCodecs API, is created. You can use this to introspect the full codec string.
+11
View File
@@ -343,6 +343,17 @@ draw(
```
These methods behave like [drawImage](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage) and paint the video frame at the given position with the given dimensions. This method will automatically draw the frame with the correct rotation based on its `rotation` property.
The `drawWithFit` method can be used to draw the video sample to fill an entire canvas with a specified fitting algorithm:
```ts
drawWithFit(
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
options: {
fit: 'fill' | 'contain' | 'cover';
rotation?: Rotation; // Overrides the sample's rotation
},
): void;
```
If you want to draw the raw underlying image to a canvas directly (without respecting the rotation metadata), then you can use the following method:
```ts
videoSample.toCanvasImageSource(); // => VideoFrame | OffscreenCanvas;
+4 -1
View File
@@ -97,6 +97,9 @@ track.isAudioTrack(); // => boolean
// Retrieve the track's language as an ISO 639-2/T language code.
// Resolves to 'und' (undetermined) if the language isn't known.
track.languageCode; // => string
// A user-defined name for this track.
track.name; // => string
```
#### Codec information
@@ -105,7 +108,7 @@ You can query metadata related to the track's codec:
```ts
track.codec; // => MediaCodec | null
```
This field is `null` when the track's codec couldn't be recognized or is not supported by Mediabunny. See [Codecs](./supported-formats-and-codecs#codecs) for the full list of supported codecs.
This field is `null` when the track's codec couldn't be recognized or is not supported by Mediabunny. See [Codecs](./supported-formats-and-codecs#codecs) for the full list of supported codecs. When Mediabunny doesn't recognize the format, you can still use the `internalCodecId` field to figure out the codec of the track, although its format depends on the container format used and is not homogenized by Mediabunny.
You can also extract the full codec parameter string from the track, as specified in the [WebCodecs Codec Registry](https://www.w3.org/TR/webcodecs-codec-registry/):
```ts
@@ -120,6 +120,8 @@ canEncodeAudio('aac', {
}); // => Promise<boolean>
```
Additionally, most properties of [`VideoEncodingConfig`](./media-sources#video-encoding-config) and [`AudioEncodingConfig`](./media-sources#audio-encoding-config) can be used here as well.
---
In addition, you can use the following functions to check encodability for multiple codecs at once, getting back a list of supported codecs:
+1
View File
@@ -55,6 +55,7 @@ output.addVideoTrack(videoSource, {
// This adds two audio tracks; one in English and one in German.
output.addAudioTrack(audioSourceEng, {
language: 'eng', // ISO 639-2/T language code
name: 'Developer Commentary', // Sets a user-defined track name
});
output.addAudioTrack(audioSourceGer, {
language: 'ger',
+1
View File
@@ -104,6 +104,7 @@ const sponsors = {
{ 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' },
{ image: 'https://avatars.githubusercontent.com/u/61233224', name: 'Allwhy', url: 'https://github.com/Allwhy' },
{ image: 'https://avatars.githubusercontent.com/u/97225946', name: '808vita', url: 'https://github.com/808vita' },
],
};
</script>
+4 -1
View File
@@ -144,7 +144,10 @@ const initMediaPlayer = async (file: File) => {
// For video, let's use a CanvasSink as it handles rotation and closing video samples for us.
// Pool size of 2: We'll only ever have the current and the next frame around, so we only need two canvases.
videoSink = videoTrack && new CanvasSink(videoTrack, { poolSize: 2 });
videoSink = videoTrack && new CanvasSink(videoTrack, {
poolSize: 2,
fit: 'contain', // In case the video changes dimensions over time
});
// For audio, we'll use an AudioBufferSink to directly retrieve AudioBuffers compatible with the Web Audio API
audioSink = audioTrack && new AudioBufferSink(audioTrack);
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "mediabunny",
"version": "1.9.3",
"version": "1.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.9.3",
"version": "1.11.2",
"license": "MPL-2.0",
"workspaces": [
"packages/*"
@@ -5900,9 +5900,9 @@
}
},
"node_modules/mediabunny": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.9.2.tgz",
"integrity": "sha512-U3RKZVvU2O7vXVtPf6Q+4XNulkV6fdVOVYMm7V2YTr6PqyqrvUl1eUCTtcrmQESgRi9sFZeXkUR8ln5TS48M2g==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.11.1.tgz",
"integrity": "sha512-UsoLZi9aHcQWMHsQF0Y4xSREy2eByFvHRjK3kmvfuaYDHPiqsj0tN6HE+daSzr+WHUh98pFlu2XKXwzBp2AHcQ==",
"license": "MPL-2.0",
"peer": true,
"workspaces": [
@@ -9017,7 +9017,7 @@
},
"packages/mp3-encoder": {
"name": "@mediabunny/mp3-encoder",
"version": "1.9.3",
"version": "1.11.2",
"license": "MPL-2.0",
"devDependencies": {
"@types/emscripten": "^1.40.1"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.9.3",
"version": "1.11.2",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"workspaces": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@mediabunny/mp3-encoder",
"author": "Vanilagy",
"version": "1.9.3",
"version": "1.11.2",
"description": "MP3 encoder extension for Mediabunny, based on LAME.",
"main": "./dist/bundles/mediabunny-mp3-encoder.mjs",
"module": "./dist/bundles/mediabunny-mp3-encoder.mjs",
+10
View File
@@ -156,6 +156,10 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
getName() {
return null;
}
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
@@ -164,6 +168,12 @@ class AdtsAudioTrackBacking implements InputAudioTrackBacking {
return 'aac';
}
getInternalCodecId() {
assert(this.demuxer.firstFrameHeader);
return this.demuxer.firstFrameHeader.objectType;
}
getNumberOfChannels() {
assert(this.demuxer.firstFrameHeader);
-269
View File
@@ -12,7 +12,6 @@ import {
HevcDecoderConfigurationRecord,
Vp9CodecInfo,
} from './codec-data';
import { customAudioEncoders, customVideoEncoders } from './custom-coder';
import {
Bitstream,
COLOR_PRIMARIES_MAP,
@@ -1124,271 +1123,3 @@ export const validateSubtitleMetadata = (metadata: SubtitleMetadata | undefined)
throw new TypeError('Subtitle metadata config description must be a string.');
}
};
/**
* Checks if the browser is able to encode the given codec.
* @public
*/
export const canEncode = (codec: MediaCodec) => {
if ((VIDEO_CODECS as readonly string[]).includes(codec)) {
return canEncodeVideo(codec as VideoCodec);
} else if ((AUDIO_CODECS as readonly string[]).includes(codec)) {
return canEncodeAudio(codec as AudioCodec);
} else if ((SUBTITLE_CODECS as readonly string[]).includes(codec)) {
return canEncodeSubtitles(codec as SubtitleCodec);
}
throw new TypeError(`Unknown codec '${codec}'.`);
};
/**
* Checks if the browser is able to encode the given video codec with the given parameters.
* @public
*/
export const canEncodeVideo = async (codec: VideoCodec, { width = 1280, height = 720, bitrate = 1e6 }: {
width?: number;
height?: number;
bitrate?: number | Quality;
} = {}) => {
if (!VIDEO_CODECS.includes(codec)) {
return false;
}
if (!Number.isInteger(width) || width <= 0) {
throw new TypeError('width must be a positive integer.');
}
if (!Number.isInteger(height) || height <= 0) {
throw new TypeError('height must be a positive integer.');
}
if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('bitrate must be a positive integer or a quality.');
}
const resolvedBitrate = bitrate instanceof Quality
? bitrate._toVideoBitrate(codec, width, height)
: bitrate;
if (customVideoEncoders.length > 0) {
const encoderConfig: VideoEncoderConfig = {
codec: buildVideoCodecString(
codec,
width,
height,
resolvedBitrate,
),
width,
height,
bitrate: resolvedBitrate,
...getVideoEncoderConfigExtension(codec),
};
if (customVideoEncoders.some(x => x.supports(codec, encoderConfig))) {
// There's a custom encoder
return true;
}
}
if (typeof VideoEncoder === 'undefined') {
return false;
}
const support = await VideoEncoder.isConfigSupported({
codec: buildVideoCodecString(codec, width, height, resolvedBitrate),
width,
height,
bitrate: resolvedBitrate,
...getVideoEncoderConfigExtension(codec),
});
return support.supported === true;
};
/**
* Checks if the browser is able to encode the given audio codec with the given parameters.
* @public
*/
export const canEncodeAudio = async (codec: AudioCodec, { numberOfChannels = 2, sampleRate = 48000, bitrate = 128e3 }: {
numberOfChannels?: number;
sampleRate?: number;
bitrate?: number | Quality;
} = {}) => {
if (!AUDIO_CODECS.includes(codec)) {
return false;
}
if (!Number.isInteger(numberOfChannels) || numberOfChannels <= 0) {
throw new TypeError('numberOfChannels must be a positive integer.');
}
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
throw new TypeError('sampleRate must be a positive integer.');
}
if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('bitrate must be a positive integer.');
}
const resolvedBitrate = bitrate instanceof Quality
? bitrate._toAudioBitrate(codec)
: bitrate;
if (customAudioEncoders.length > 0) {
const encoderConfig: AudioEncoderConfig = {
codec: buildAudioCodecString(
codec,
numberOfChannels,
sampleRate,
),
numberOfChannels,
sampleRate,
bitrate: resolvedBitrate,
...getAudioEncoderConfigExtension(codec),
};
if (customAudioEncoders.some(x => x.supports(codec, encoderConfig))) {
// There's a custom encoder
return true;
}
}
if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) {
return true; // Because we encode these ourselves
}
if (typeof AudioEncoder === 'undefined') {
return false;
}
const support = await AudioEncoder.isConfigSupported({
codec: buildAudioCodecString(codec, numberOfChannels, sampleRate),
numberOfChannels,
sampleRate,
bitrate: resolvedBitrate,
...getAudioEncoderConfigExtension(codec),
});
return support.supported === true;
};
/**
* Checks if the browser is able to encode the given subtitle codec.
* @public
*/
export const canEncodeSubtitles = async (codec: SubtitleCodec) => {
if (!SUBTITLE_CODECS.includes(codec)) {
return false;
}
return true;
};
/**
* Returns the list of all media codecs that can be encoded by the browser.
* @public
*/
export const getEncodableCodecs = async (): Promise<MediaCodec[]> => {
const [videoCodecs, audioCodecs, subtitleCodecs] = await Promise.all([
getEncodableVideoCodecs(),
getEncodableAudioCodecs(),
getEncodableSubtitleCodecs(),
]);
return [...videoCodecs, ...audioCodecs, ...subtitleCodecs];
};
/**
* Returns the list of all video codecs that can be encoded by the browser.
* @public
*/
export const getEncodableVideoCodecs = async (
checkedCodecs = VIDEO_CODECS as unknown as VideoCodec[],
options?: {
width?: number;
height?: number;
bitrate?: number | Quality;
},
): Promise<VideoCodec[]> => {
const bools = await Promise.all(checkedCodecs.map(codec => canEncodeVideo(codec, options)));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the list of all audio codecs that can be encoded by the browser.
* @public
*/
export const getEncodableAudioCodecs = async (
checkedCodecs = AUDIO_CODECS as unknown as AudioCodec[],
options?: {
numberOfChannels?: number;
sampleRate?: number;
bitrate?: number | Quality;
},
): Promise<AudioCodec[]> => {
const bools = await Promise.all(checkedCodecs.map(codec => canEncodeAudio(codec, options)));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the list of all subtitle codecs that can be encoded by the browser.
* @public
*/
export const getEncodableSubtitleCodecs = async (
checkedCodecs = SUBTITLE_CODECS as unknown as SubtitleCodec[],
): Promise<SubtitleCodec[]> => {
const bools = await Promise.all(checkedCodecs.map(canEncodeSubtitles));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the first video codec from the given list that can be encoded by the browser.
* @public
*/
export const getFirstEncodableVideoCodec = async (
checkedCodecs: VideoCodec[],
options?: {
width?: number;
height?: number;
bitrate?: number | Quality;
},
): Promise<VideoCodec | null> => {
for (const codec of checkedCodecs) {
if (await canEncodeVideo(codec, options)) {
return codec;
}
}
return null;
};
/**
* Returns the first audio codec from the given list that can be encoded by the browser.
* @public
*/
export const getFirstEncodableAudioCodec = async (
checkedCodecs: AudioCodec[],
options?: {
numberOfChannels?: number;
sampleRate?: number;
bitrate?: number | Quality;
},
): Promise<AudioCodec | null> => {
for (const codec of checkedCodecs) {
if (await canEncodeAudio(codec, options)) {
return codec;
}
}
return null;
};
/**
* Returns the first subtitle codec from the given list that can be encoded by the browser.
* @public
*/
export const getFirstEncodableSubtitleCodec = async (
checkedCodecs: SubtitleCodec[],
): Promise<SubtitleCodec | null> => {
for (const codec of checkedCodecs) {
if (await canEncodeSubtitles(codec)) {
return codec;
}
}
return null;
};
+60 -45
View File
@@ -9,14 +9,18 @@
import {
AUDIO_CODECS,
AudioCodec,
getFirstEncodableVideoCodec,
getEncodableAudioCodecs,
NON_PCM_AUDIO_CODECS,
Quality,
QUALITY_HIGH,
VIDEO_CODECS,
VideoCodec,
} from './codec';
import {
AudioEncodingConfig,
getEncodableAudioCodecs,
getFirstEncodableVideoCodec,
VideoEncodingConfig,
} from './encode';
import { Input } from './input';
import { InputAudioTrack, InputTrack, InputVideoTrack } from './input-track';
import {
@@ -26,19 +30,62 @@ import {
VideoSampleSink,
} from './media-sink';
import {
AudioEncodingConfig,
AudioSource,
EncodedVideoPacketSource,
EncodedAudioPacketSource,
VideoEncodingConfig,
VideoSource,
VideoSampleSource,
AudioSampleSource,
} from './media-source';
import { assert, clamp, MaybePromise, normalizeRotation, promiseWithResolvers, Rotation } from './misc';
import {
assert,
clamp,
isIso639Dash2LanguageCode,
MaybePromise,
normalizeRotation,
promiseWithResolvers,
Rotation,
} from './misc';
import { Output, TrackType } from './output';
import { AudioSample, VideoSample } from './sample';
/**
* The options for media file conversion.
* @public
*/
export type ConversionOptions = {
/** The input file. */
input: Input;
/** The output file. */
output: Output;
/**
* Video-specific options. When passing an object, the same options are applied to all video tracks. When passing a
* function, it will be invoked for each video track and is expected to return or resolve to the options
* for that specific track. The function is passed an instance of `InputVideoTrack` as well as a number `n`, which
* is the 1-based index of the track in the list of all video tracks.
*/
video?: ConversionVideoOptions
| ((track: InputVideoTrack, n: number) => MaybePromise<ConversionVideoOptions | undefined>);
/**
* Audio-specific options. When passing an object, the same options are applied to all audio tracks. When passing a
* function, it will be invoked for each audio track and is expected to return or resolve to the options
* for that specific track. The function is passed an instance of `InputAudioTrack` as well as a number `n`, which
* is the 1-based index of the track in the list of all audio tracks.
*/
audio?: ConversionAudioOptions
| ((track: InputAudioTrack, n: number) => MaybePromise<ConversionAudioOptions | undefined>);
/** Options to trim the input file. */
trim?: {
/** The time in the input file in seconds at which the output file should start. Must be less than `end`. */
start: number;
/** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */
end: number;
};
};
/**
* Video-specific options.
* @public
@@ -57,7 +104,7 @@ export type ConversionVideoOptions = {
*/
height?: number;
/**
* The fitting algorithm in case both width and height are set.
* The fitting algorithm in case both width and height are set, or if the input video changes its size over time.
*
* - '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
@@ -160,43 +207,6 @@ const validateVideoOptions = (videoOptions: ConversionVideoOptions | undefined)
}
};
/**
* The options for media file conversion.
* @public
*/
export type ConversionOptions = {
/** The input file. */
input: Input;
/** The output file. */
output: Output;
/**
* Video-specific options. When passing an object, the same options are applied to all video tracks. When passing a
* function, it will be invoked for each video track and is expected to return or resolve to the options
* for that specific track. The function is passed an instance of `InputVideoTrack` as well as a number `n`, which
* is the 1-based index of the track in the list of all video tracks.
*/
video?: ConversionVideoOptions
| ((track: InputVideoTrack, n: number) => MaybePromise<ConversionVideoOptions | undefined>);
/**
* Audio-specific options. When passing an object, the same options are applied to all audio tracks. When passing a
* function, it will be invoked for each audio track and is expected to return or resolve to the options
* for that specific track. The function is passed an instance of `InputAudioTrack` as well as a number `n`, which
* is the 1-based index of the track in the list of all audio tracks.
*/
audio?: ConversionAudioOptions
| ((track: InputAudioTrack, n: number) => MaybePromise<ConversionAudioOptions | undefined>);
/** Options to trim the input file. */
trim?: {
/** The time in the input file in seconds at which the output file should start. Must be less than `end`. */
start: number;
/** The time in the input file in seconds at which the output file should end. Must be greater than `start`. */
end: number;
};
};
const validateAudioOptions = (audioOptions: ConversionAudioOptions | undefined) => {
if (audioOptions !== undefined && (!audioOptions || typeof audioOptions !== 'object')) {
throw new TypeError('options.audio, when provided, must be an object.');
@@ -621,6 +631,7 @@ export class Conversion {
const encodingConfig: VideoEncodingConfig = {
codec: encodableCodec,
bitrate,
sizeChangeBehavior: trackOptions.fit ?? 'passThrough',
onEncodedPacket: sample => this._reportProgress(track.id, sample.timestamp + sample.duration),
};
@@ -808,7 +819,9 @@ export class Conversion {
this.output.addVideoTrack(videoSource, {
frameRate: trackOptions.frameRate,
languageCode: track.languageCode,
// TEMP: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
name: track.name ?? undefined,
rotation: needsRerender ? 0 : totalRotation, // Rerendering will bake the rotation into the output
});
this._addedCounts.video++;
@@ -977,7 +990,9 @@ export class Conversion {
}
this.output.addAudioTrack(audioSource, {
languageCode: track.languageCode,
// TEMP: This condition can be removed when all demuxers properly homogenize to BCP47 in v2
languageCode: isIso639Dash2LanguageCode(track.languageCode) ? track.languageCode : undefined,
name: track.name ?? undefined,
});
this._addedCounts.audio++;
this._totalTrackCount++;
+560
View File
@@ -0,0 +1,560 @@
/*!
* 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/.
*/
import {
AUDIO_CODECS,
AudioCodec,
buildAudioCodecString,
buildVideoCodecString,
getAudioEncoderConfigExtension,
getVideoEncoderConfigExtension,
inferCodecFromCodecString,
MediaCodec,
PCM_AUDIO_CODECS,
Quality,
SUBTITLE_CODECS,
SubtitleCodec,
VIDEO_CODECS,
VideoCodec,
} from './codec';
import { customAudioEncoders, customVideoEncoders } from './custom-coder';
import { EncodedPacket } from './packet';
/**
* Configuration object that controls video encoding. Can be used to set codec, quality, and more.
* @public
*/
export type VideoEncodingConfig = {
/** The video codec that should be used for encoding the video samples (frames). */
codec: VideoCodec;
/**
* The target bitrate for the encoded video, in bits per second. Alternatively, a subjective Quality can
* be provided.
*/
bitrate: number | Quality;
/**
* The interval, in seconds, of how often frames are encoded as a key frame. The default is 5 seconds. Frequent key
* frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them
* all the same key frame interval.
*/
keyFrameInterval?: number;
/**
* Video frames may change size overtime. This field controls the behavior in case this happens.
*
* - 'deny' (default) will throw an error, requiring all frames to have the exact same dimensions.
* - 'passThrough' will allow the change and directly pass the frame to the encoder.
* - 'fill' will stretch the image to fill the entire original box, potentially altering aspect ratio.
* - 'contain' will contain the entire image within the originalbox while preserving aspect ratio. This may lead to
* letterboxing.
* - 'cover' will scale the image until the entire original box is filled, while preserving aspect ratio.
*
* The "original box" refers to the dimensions of the first encoded frame.
*/
sizeChangeBehavior?: 'deny' | 'passThrough' | 'fill' | 'contain' | 'cover';
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown;
/** Called when the internal encoder config, as used by the WebCodecs API, is created. */
onEncoderConfig?: (config: VideoEncoderConfig) => unknown;
} & VideoEncodingAdditionalOptions;
export const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
if (!config || typeof config !== 'object') {
throw new TypeError('Encoding config must be an object.');
}
if (!VIDEO_CODECS.includes(config.codec)) {
throw new TypeError(`Invalid video codec '${config.codec}'. Must be one of: ${VIDEO_CODECS.join(', ')}.`);
}
if (!(config.bitrate instanceof Quality) && (!Number.isInteger(config.bitrate) || config.bitrate <= 0)) {
throw new TypeError('config.bitrate must be a positive integer or a quality.');
}
if (
config.keyFrameInterval !== undefined
&& (!Number.isFinite(config.keyFrameInterval) || config.keyFrameInterval < 0)
) {
throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.');
}
// todo here
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedChunk, when provided, must be a function.');
}
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
}
validateVideoEncodingAdditionalOptions(config.codec, config);
};
/**
* Additional options that control audio encoding.
* @public
*/
export type VideoEncodingAdditionalOptions = {
/** Configures the bitrate mode. */
bitrateMode?: 'constant' | 'variable';
/** The latency mode used by the encoder; controls the performance-quality tradeoff. */
latencyMode?: VideoEncoderConfig['latencyMode'];
/**
* The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
* specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library.
*/
fullCodecString?: string;
/** A hint that configures the hardware acceleration method of this codec. This is best left on 'no-preference'. */
hardwareAcceleration?: VideoEncoderConfig['hardwareAcceleration'];
/**
* An encoding scalability mode identifier as defined by
* [WebRTC-SVC](https://w3c.github.io/webrtc-svc/#scalabilitymodes*).
*/
scalabilityMode?: VideoEncoderConfig['scalabilityMode'];
/**
* An encoding video content hint as defined by
* [mst-content-hint](https://w3c.github.io/mst-content-hint/#video-content-hints).
*/
contentHint?: VideoEncoderConfig['contentHint'];
};
export const validateVideoEncodingAdditionalOptions = (codec: VideoCodec, options: VideoEncodingAdditionalOptions) => {
if (!options || typeof options !== 'object') {
throw new TypeError('Encoding options must be an object.');
}
if (options.bitrateMode !== undefined && !['constant', 'variable'].includes(options.bitrateMode)) {
throw new TypeError('bitrateMode, when provided, must be \'constant\' or \'variable\'.');
}
if (options.latencyMode !== undefined && !['quality', 'realtime'].includes(options.latencyMode)) {
throw new TypeError('latencyMode, when provided, must be \'quality\' or \'realtime\'.');
}
if (options.fullCodecString !== undefined && typeof options.fullCodecString !== 'string') {
throw new TypeError('fullCodecString, when provided, must be a string.');
}
if (options.fullCodecString !== undefined && inferCodecFromCodecString(options.fullCodecString) !== codec) {
throw new TypeError(
`fullCodecString, when provided, must be a string that matches the specified codec (${codec}).`,
);
}
if (
options.hardwareAcceleration !== undefined
&& !['no-preference', 'prefer-hardware', 'prefer-software'].includes(options.hardwareAcceleration)
) {
throw new TypeError(
'hardwareAcceleration, when provided, must be \'no-preference\', \'prefer-hardware\' or'
+ ' \'prefer-software\'.',
);
}
if (options.scalabilityMode !== undefined && typeof options.scalabilityMode !== 'string') {
throw new TypeError('scalabilityMode, when provided, must be a string.');
}
if (options.contentHint !== undefined && typeof options.contentHint !== 'string') {
throw new TypeError('contentHint, when provided, must be a string.');
}
};
export const buildVideoEncoderConfig = (options: {
codec: VideoCodec;
width: number;
height: number;
bitrate: number | Quality;
framerate: number | undefined;
} & VideoEncodingAdditionalOptions): VideoEncoderConfig => {
const resolvedBitrate = options.bitrate instanceof Quality
? options.bitrate._toVideoBitrate(options.codec, options.width, options.height)
: options.bitrate;
return {
codec: options.fullCodecString ?? buildVideoCodecString(
options.codec,
options.width,
options.height,
resolvedBitrate,
),
width: options.width,
height: options.height,
bitrate: resolvedBitrate,
bitrateMode: options.bitrateMode,
framerate: options.framerate, // this.source._connectedTrack?.metadata.frameRate,
latencyMode: options.latencyMode,
hardwareAcceleration: options.hardwareAcceleration,
scalabilityMode: options.scalabilityMode,
contentHint: options.contentHint,
...getVideoEncoderConfigExtension(options.codec),
};
};
/**
* Configuration object that controls audio encoding. Can be used to set codec, quality, and more.
* @public
*/
export type AudioEncodingConfig = {
/** The audio codec that should be used for encoding the audio samples. */
codec: AudioCodec;
/**
* The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective Quality can
* be provided. Required for compressed audio codecs, unused for PCM codecs.
*/
bitrate?: number | Quality;
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedAudioChunkMetadata | undefined) => unknown;
/** Called when the internal encoder config, as used by the WebCodecs API, is created. */
onEncoderConfig?: (config: AudioEncoderConfig) => unknown;
} & AudioEncodingAdditionalOptions;
export const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
if (!config || typeof config !== 'object') {
throw new TypeError('Encoding config must be an object.');
}
if (!AUDIO_CODECS.includes(config.codec)) {
throw new TypeError(`Invalid audio codec '${config.codec}'. Must be one of: ${AUDIO_CODECS.join(', ')}.`);
}
if (
config.bitrate === undefined
&& (!(PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
) {
throw new TypeError('config.bitrate must be provided for compressed audio codecs.');
}
if (
config.bitrate !== undefined
&& !(config.bitrate instanceof Quality)
&& (!Number.isInteger(config.bitrate) || config.bitrate <= 0)
) {
throw new TypeError('config.bitrate, when provided, must be a positive integer or a quality.');
}
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedChunk, when provided, must be a function.');
}
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
}
validateAudioEncodingAdditionalOptions(config.codec, config);
};
/**
* Additional options that control audio encoding.
* @public
*/
export type AudioEncodingAdditionalOptions = {
/** Configures the bitrate mode. */
bitrateMode?: 'constant' | 'variable';
/**
* The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
* specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library.
*/
fullCodecString?: string;
};
export const validateAudioEncodingAdditionalOptions = (codec: AudioCodec, options: AudioEncodingAdditionalOptions) => {
if (!options || typeof options !== 'object') {
throw new TypeError('Encoding options must be an object.');
}
if (options.bitrateMode !== undefined && !['constant', 'variable'].includes(options.bitrateMode)) {
throw new TypeError('bitrateMode, when provided, must be \'constant\' or \'variable\'.');
}
if (options.fullCodecString !== undefined && typeof options.fullCodecString !== 'string') {
throw new TypeError('fullCodecString, when provided, must be a string.');
}
if (options.fullCodecString !== undefined && inferCodecFromCodecString(options.fullCodecString) !== codec) {
throw new TypeError(
`fullCodecString, when provided, must be a string that matches the specified codec (${codec}).`,
);
}
};
export const buildAudioEncoderConfig = (options: {
codec: AudioCodec;
numberOfChannels: number;
sampleRate: number;
bitrate?: number | Quality;
} & AudioEncodingAdditionalOptions): AudioEncoderConfig => {
const resolvedBitrate = options.bitrate instanceof Quality
? options.bitrate._toAudioBitrate(options.codec)
: options.bitrate;
return {
codec: options.fullCodecString ?? buildAudioCodecString(
options.codec,
options.numberOfChannels,
options.sampleRate,
),
numberOfChannels: options.numberOfChannels,
sampleRate: options.sampleRate,
bitrate: resolvedBitrate,
bitrateMode: options.bitrateMode,
...getAudioEncoderConfigExtension(options.codec),
};
};
/**
* Checks if the browser is able to encode the given codec.
* @public
*/
export const canEncode = (codec: MediaCodec) => {
if ((VIDEO_CODECS as readonly string[]).includes(codec)) {
return canEncodeVideo(codec as VideoCodec);
} else if ((AUDIO_CODECS as readonly string[]).includes(codec)) {
return canEncodeAudio(codec as AudioCodec);
} else if ((SUBTITLE_CODECS as readonly string[]).includes(codec)) {
return canEncodeSubtitles(codec as SubtitleCodec);
}
throw new TypeError(`Unknown codec '${codec}'.`);
};
/**
* Checks if the browser is able to encode the given video codec with the given parameters.
* @public
*/
export const canEncodeVideo = async (codec: VideoCodec, {
width = 1280,
height = 720,
bitrate = 1e6,
...restOptions
}: {
width?: number;
height?: number;
bitrate?: number | Quality;
} & VideoEncodingAdditionalOptions = {}) => {
if (!VIDEO_CODECS.includes(codec)) {
return false;
}
if (!Number.isInteger(width) || width <= 0) {
throw new TypeError('width must be a positive integer.');
}
if (!Number.isInteger(height) || height <= 0) {
throw new TypeError('height must be a positive integer.');
}
if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('bitrate must be a positive integer or a quality.');
}
validateVideoEncodingAdditionalOptions(codec, restOptions);
let encoderConfig: VideoEncoderConfig | null = null;
if (customVideoEncoders.length > 0) {
encoderConfig ??= buildVideoEncoderConfig({
codec,
width,
height,
bitrate,
framerate: undefined,
...restOptions,
});
if (customVideoEncoders.some(x => x.supports(codec, encoderConfig!))) {
// There's a custom encoder
return true;
}
}
if (typeof VideoEncoder === 'undefined') {
return false;
}
encoderConfig ??= buildVideoEncoderConfig({
codec,
width,
height,
bitrate,
framerate: undefined,
...restOptions,
});
const support = await VideoEncoder.isConfigSupported(encoderConfig);
return support.supported === true;
};
/**
* Checks if the browser is able to encode the given audio codec with the given parameters.
* @public
*/
export const canEncodeAudio = async (codec: AudioCodec, {
numberOfChannels = 2,
sampleRate = 48000,
bitrate = 128e3,
...restOptions
}: {
numberOfChannels?: number;
sampleRate?: number;
bitrate?: number | Quality;
} & AudioEncodingAdditionalOptions = {}) => {
if (!AUDIO_CODECS.includes(codec)) {
return false;
}
if (!Number.isInteger(numberOfChannels) || numberOfChannels <= 0) {
throw new TypeError('numberOfChannels must be a positive integer.');
}
if (!Number.isInteger(sampleRate) || sampleRate <= 0) {
throw new TypeError('sampleRate must be a positive integer.');
}
if (!(bitrate instanceof Quality) && (!Number.isInteger(bitrate) || bitrate <= 0)) {
throw new TypeError('bitrate must be a positive integer.');
}
validateAudioEncodingAdditionalOptions(codec, restOptions);
let encoderConfig: AudioEncoderConfig | null = null;
if (customAudioEncoders.length > 0) {
encoderConfig ??= buildAudioEncoderConfig({
codec,
numberOfChannels,
sampleRate,
bitrate,
...restOptions,
});
if (customAudioEncoders.some(x => x.supports(codec, encoderConfig!))) {
// There's a custom encoder
return true;
}
}
if ((PCM_AUDIO_CODECS as readonly string[]).includes(codec)) {
return true; // Because we encode these ourselves
}
if (typeof AudioEncoder === 'undefined') {
return false;
}
encoderConfig ??= buildAudioEncoderConfig({
codec,
numberOfChannels,
sampleRate,
bitrate,
...restOptions,
});
const support = await AudioEncoder.isConfigSupported(encoderConfig);
return support.supported === true;
};
/**
* Checks if the browser is able to encode the given subtitle codec.
* @public
*/
export const canEncodeSubtitles = async (codec: SubtitleCodec) => {
if (!SUBTITLE_CODECS.includes(codec)) {
return false;
}
return true;
};
/**
* Returns the list of all media codecs that can be encoded by the browser.
* @public
*/
export const getEncodableCodecs = async (): Promise<MediaCodec[]> => {
const [videoCodecs, audioCodecs, subtitleCodecs] = await Promise.all([
getEncodableVideoCodecs(),
getEncodableAudioCodecs(),
getEncodableSubtitleCodecs(),
]);
return [...videoCodecs, ...audioCodecs, ...subtitleCodecs];
};
/**
* Returns the list of all video codecs that can be encoded by the browser.
* @public
*/
export const getEncodableVideoCodecs = async (
checkedCodecs = VIDEO_CODECS as unknown as VideoCodec[],
options?: {
width?: number;
height?: number;
bitrate?: number | Quality;
},
): Promise<VideoCodec[]> => {
const bools = await Promise.all(checkedCodecs.map(codec => canEncodeVideo(codec, options)));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the list of all audio codecs that can be encoded by the browser.
* @public
*/
export const getEncodableAudioCodecs = async (
checkedCodecs = AUDIO_CODECS as unknown as AudioCodec[],
options?: {
numberOfChannels?: number;
sampleRate?: number;
bitrate?: number | Quality;
},
): Promise<AudioCodec[]> => {
const bools = await Promise.all(checkedCodecs.map(codec => canEncodeAudio(codec, options)));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the list of all subtitle codecs that can be encoded by the browser.
* @public
*/
export const getEncodableSubtitleCodecs = async (
checkedCodecs = SUBTITLE_CODECS as unknown as SubtitleCodec[],
): Promise<SubtitleCodec[]> => {
const bools = await Promise.all(checkedCodecs.map(canEncodeSubtitles));
return checkedCodecs.filter((_, i) => bools[i]);
};
/**
* Returns the first video codec from the given list that can be encoded by the browser.
* @public
*/
export const getFirstEncodableVideoCodec = async (
checkedCodecs: VideoCodec[],
options?: {
width?: number;
height?: number;
bitrate?: number | Quality;
},
): Promise<VideoCodec | null> => {
for (const codec of checkedCodecs) {
if (await canEncodeVideo(codec, options)) {
return codec;
}
}
return null;
};
/**
* Returns the first audio codec from the given list that can be encoded by the browser.
* @public
*/
export const getFirstEncodableAudioCodec = async (
checkedCodecs: AudioCodec[],
options?: {
numberOfChannels?: number;
sampleRate?: number;
bitrate?: number | Quality;
},
): Promise<AudioCodec | null> => {
for (const codec of checkedCodecs) {
if (await canEncodeAudio(codec, options)) {
return codec;
}
}
return null;
};
/**
* Returns the first subtitle codec from the given list that can be encoded by the browser.
* @public
*/
export const getFirstEncodableSubtitleCodec = async (
checkedCodecs: SubtitleCodec[],
): Promise<SubtitleCodec | null> => {
for (const codec of checkedCodecs) {
if (await canEncodeSubtitles(codec)) {
return codec;
}
}
return null;
};
+7 -3
View File
@@ -41,8 +41,6 @@ export {
InclusiveIntegerRange,
} from './output-format';
export {
VideoEncodingConfig,
AudioEncodingConfig,
MediaSource,
VideoSource,
EncodedVideoPacketSource,
@@ -73,6 +71,12 @@ export {
QUALITY_MEDIUM,
QUALITY_HIGH,
QUALITY_VERY_HIGH,
} from './codec';
export {
VideoEncodingConfig,
VideoEncodingAdditionalOptions,
AudioEncodingConfig,
AudioEncodingAdditionalOptions,
canEncode,
canEncodeVideo,
canEncodeAudio,
@@ -84,7 +88,7 @@ export {
getFirstEncodableVideoCodec,
getFirstEncodableAudioCodec,
getFirstEncodableSubtitleCodec,
} from './codec';
} from './encode';
export { Target, BufferTarget, StreamTarget, StreamTargetChunk, StreamTargetOptions } from './target';
export { Rotation, AnyIterable, SetRequired, MaybePromise } from './misc';
export {
+23
View File
@@ -30,6 +30,8 @@ export type PacketStats = {
export interface InputTrackBacking {
getId(): number;
getCodec(): MediaCodec | null;
getInternalCodecId(): string | number | Uint8Array | null;
getName(): string | null;
getLanguageCode(): string;
getTimeResolution(): number;
getFirstTimestamp(): Promise<number>;
@@ -84,11 +86,32 @@ export abstract class InputTrack {
return this._backing.getId();
}
/**
* The identifier of the codec used internally by the container. It is not homogenized by Mediabunny
* and depends entirely on the container format.
*
* This field can be used to determine the codec of a track in case Mediabunny doesn't know that codec.
*
* - For ISOBMFF files, this field returns the name of the Sample Description Box (e.g. 'avc1').
* - For Matroska files, this field returns the value of the CodecID element.
* - For WAVE files, this field returns the value of the format tag in the 'fmt ' chunk.
* - For ADTS files, this field contains the MPEG-4 Audio Object Type.
* - In all other cases, this field is `null`.
*/
get internalCodecId() {
return this._backing.getInternalCodecId();
}
/** The ISO 639-2/T language code for this track. If the language is unknown, this field is 'und' (undetermined). */
get languageCode() {
return this._backing.getLanguageCode();
}
/** A user-defined name for this track. */
get name() {
return this._backing.getName();
}
/**
* A positive number x such that all timestamps and durations of all packets of this track are
* integer multiples of 1/x.
+73 -10
View File
@@ -31,6 +31,7 @@ import {
import { formatSubtitleTimestamp } from '../subtitles';
import { Writer } from '../writer';
import {
getTrackMetadata,
GLOBAL_TIMESCALE,
intoTimescale,
IsobmffAudioTrackData,
@@ -378,10 +379,72 @@ export const mvhd = (
* independent of the other tracks in the movie and carries its own temporal and spatial information. Each Track Box
* contains its associated Media Box.
*/
export const trak = (trackData: IsobmffTrackData, creationTime: number) => box('trak', undefined, [
tkhd(trackData, creationTime),
mdia(trackData, creationTime),
]);
export const trak = (trackData: IsobmffTrackData, creationTime: number) => {
const trackMetadata = getTrackMetadata(trackData);
return box('trak', undefined, [
tkhd(trackData, creationTime),
mdia(trackData, creationTime),
trackMetadata.name !== undefined
? box('udta', undefined, [
box('©nam', [
...textEncoder.encode(trackMetadata.name),
]),
])
: null,
]);
};
/*
const meta = (trackData: IsobmffTrackData) => {
const trackMetadata = getTrackMetadata(trackData);
if (trackData.muxer.isQuickTime) {
const keyMap: Record<keyof IsobmffMetadata, string> = {
name: 'com.apple.quicktime.title',
};
return box('meta', undefined, [
hdlr(false, 'mdta', ''),
fullBox('keys', 0, 0, [
u32(Object.keys(trackMetadata).length),
], Object.keys(trackMetadata).map(key =>
box('mdta', [
ascii(keyMap[key as keyof IsobmffMetadata]), // Key name
]),
)),
box('ilst', undefined, Object.values(trackMetadata)
.map((value, i) => box(u32(i + 1).map(x => String.fromCharCode(x)).join(''), undefined, [
data(value),
]))),
]);
} else {
const keyMap: Record<keyof IsobmffMetadata, string> = {
name: '©nam',
};
return fullBox('meta', 0, 0, undefined, [
hdlr(false, 'mdir', ''),
box('ilst', undefined, Object.entries(trackMetadata)
.map(([key, value]) => box(keyMap[key as keyof IsobmffMetadata], undefined, [
data(value),
]))),
]);
}
};
const data = (value: unknown) => {
if (typeof value === 'string') {
return box('data', [
u32(1), // Type indicator (UTF-8)
u32(0), // Locale indicator
...textEncoder.encode(value),
]);
}
throw new Error('Unhandled data type.');
};
*/
/** Track Header Box: Specifies the characteristics of a single track within a movie. */
export const tkhd = (
@@ -425,7 +488,7 @@ export const tkhd = (
/** Media Box: Describes and define a track's media type and sample data. */
export const mdia = (trackData: IsobmffTrackData, creationTime: number) => box('mdia', undefined, [
mdhd(trackData, creationTime),
hdlr(trackData),
hdlr(true, TRACK_TYPE_TO_COMPONENT_SUBTYPE[trackData.type], TRACK_TYPE_TO_HANDLER_NAME[trackData.type]),
minf(trackData),
]);
@@ -471,14 +534,14 @@ const TRACK_TYPE_TO_HANDLER_NAME: Record<IsobmffTrackData['type'], string> = {
subtitle: 'MediabunnyTextHandler',
};
/** Handler Reference Box: Specifies the media handler component that is to be used to interpret the media's data. */
export const hdlr = (trackData: IsobmffTrackData) => fullBox('hdlr', 0, 0, [
ascii('mhlr'), // Component type
ascii(TRACK_TYPE_TO_COMPONENT_SUBTYPE[trackData.type]), // Component subtype
/** Handler Reference Box. */
export const hdlr = (hasComponentType: boolean, handlerType: string, name: string) => fullBox('hdlr', 0, 0, [
hasComponentType ? ascii('mhlr') : u32(0), // Component type
ascii(handlerType), // Component subtype
u32(0), // Component manufacturer
u32(0), // Component flags
u32(0), // Component flags mask
ascii(TRACK_TYPE_TO_HANDLER_NAME[trackData.type], true), // Component name
ascii(name, true), // Component name
]);
/**
+41 -9
View File
@@ -56,6 +56,7 @@ import {
normalizeRotation,
Bitstream,
insertSorted,
textDecoder,
} from '../misc';
import { EncodedPacket, PLACEHOLDER_DATA } from '../packet';
import { Reader } from '../reader';
@@ -70,6 +71,8 @@ type InternalTrack = {
durationInMovieTimescale: number;
durationInMediaTimescale: number;
rotation: Rotation;
internalCodecId: string | null;
name: string | null;
languageCode: string;
sampleTableByteOffset: number;
sampleTable: SampleTable | null;
@@ -596,7 +599,8 @@ export class IsobmffDemuxer extends Demuxer {
case 'minf':
case 'dinf':
case 'mfra':
case 'edts': {
case 'edts':
case 'udta': {
this.readContiguousBoxes(boxInfo.contentSize);
}; break;
@@ -625,6 +629,8 @@ export class IsobmffDemuxer extends Demuxer {
durationInMovieTimescale: -1,
durationInMediaTimescale: -1,
rotation: 0,
internalCodecId: null,
name: null,
languageCode: UNDETERMINED_LANGUAGE,
sampleTableByteOffset: -1,
sampleTable: null,
@@ -843,6 +849,7 @@ export class IsobmffDemuxer extends Demuxer {
break;
}
track.internalCodecId = sampleBoxInfo.name;
const lowercaseBoxName = sampleBoxInfo.name.toLowerCase();
if (track.info.type === 'video') {
@@ -1919,6 +1926,16 @@ export class IsobmffDemuxer extends Demuxer {
this.currentFragment.implicitBaseDataOffset = currentOffset;
}; break;
// These appear in udta:
case '©nam':
case 'name': {
if (!this.currentTrack) {
break;
}
this.currentTrack.name = textDecoder.decode(this.metadataReader.readBytes(boxInfo.contentSize));
}; break;
}
this.metadataReader.pos = boxEndPos;
@@ -1943,6 +1960,14 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
throw new Error('Not implemented on base class.');
}
getInternalCodecId() {
return this.internalTrack.internalCodecId;
}
getName() {
return this.internalTrack.name;
}
getLanguageCode() {
return this.internalTrack.languageCode;
}
@@ -2460,15 +2485,10 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
metadataReader.pos = startPos + boxInfo.totalSize;
}
let result: EncodedPacket | null = null;
const bestFragment = bestFragmentIndex !== -1 ? this.internalTrack.fragments[bestFragmentIndex]! : null;
if (bestFragment) {
// If we finished looping but didn't find a perfect match, still return the best match we found
result = await this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);
}
// Catch faulty lookup table entries
if (!result && lookupEntry && (!bestFragment || bestFragment.moofOffset < lookupEntry.moofOffset)) {
if (lookupEntry && (!bestFragment || bestFragment.moofOffset < lookupEntry.moofOffset)) {
// The lookup table entry lied to us! We found a lookup entry but no fragment there that satisfied
// the match. In this case, let's search again but using the lookup entry before that.
const previousLookupEntry = this.internalTrack.fragmentLookupTable![lookupEntryIndex - 1];
@@ -2476,7 +2496,12 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
return this.performFragmentedLookup(getBestMatch, newSearchTimestamp, latestTimestamp, options);
}
return result;
if (bestFragment) {
// If we finished looping but didn't find a perfect match, still return the best match we found
return this.fetchPacketInFragment(bestFragment, bestSampleIndex, options);
}
return null;
} finally {
release();
}
@@ -2730,7 +2755,14 @@ const extractRotationFromMatrix = (matrix: TransformationMatrix) => {
const sinTheta = m21 / scaleX;
// Invert the rotation because matrices are post-multiplied in ISOBMFF
return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
const result = -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
if (!Number.isFinite(result)) {
// Can happen if the entire matrix is 0, for example
return 0;
}
return result;
};
const sampleTableIsEmpty = (sampleTable: SampleTable) => {
+15
View File
@@ -117,6 +117,21 @@ export type IsobmffVideoTrackData = IsobmffTrackData & { type: 'video' };
export type IsobmffAudioTrackData = IsobmffTrackData & { type: 'audio' };
export type IsobmffSubtitleTrackData = IsobmffTrackData & { type: 'subtitle' };
export type IsobmffMetadata = {
name?: string;
};
export const getTrackMetadata = (trackData: IsobmffTrackData) => {
const metadata: IsobmffMetadata = {};
const track = trackData.track as OutputTrack;
if (track.metadata.name !== undefined) {
metadata.name = track.metadata.name;
}
return metadata;
};
export const intoTimescale = (timeInSeconds: number, timescale: number, round = true) => {
const value = timeInSeconds * timescale;
return round ? Math.round(value) : value;
+35 -1
View File
@@ -7,13 +7,22 @@
*/
import { MediaCodec } from '../codec';
import { assertNever, textDecoder, textEncoder } from '../misc';
import { Reader } from '../reader';
import { Writer } from '../writer';
export interface EBMLElement {
id: number;
size?: number;
data: number | string | Uint8Array | EBMLFloat32 | EBMLFloat64 | EBMLSignedInt | (EBML | null)[];
data:
| number
| string
| Uint8Array
| EBMLFloat32
| EBMLFloat64
| EBMLSignedInt
| EBMLUnicodeString
| (EBML | null)[];
}
export type EBML = EBMLElement | Uint8Array | (EBML | null)[];
@@ -45,6 +54,10 @@ export class EBMLSignedInt {
}
}
export class EBMLUnicodeString {
constructor(public value: string) {}
}
/** Defines some of the EBML IDs used by Matroska files. */
export enum EBMLId {
EBML = 0x1a45dfa3,
@@ -73,7 +86,9 @@ export enum EBMLId {
FlagDefault = 0x88,
FlagForced = 0x55aa,
FlagLacing = 0x9c,
Name = 0x536e,
Language = 0x22b59c,
LanguageBCP47 = 0x22b59d,
CodecID = 0x86,
CodecPrivate = 0x63a2,
CodecDelay = 0x56aa,
@@ -371,6 +386,12 @@ export class EBMLWriter {
const size = data.size ?? measureSignedInt(data.data.value);
this.writeVarInt(size);
this.writeSignedInt(data.data.value, size);
} else if (data.data instanceof EBMLUnicodeString) {
const bytes = textEncoder.encode(data.data.value);
this.writeVarInt(bytes.length);
this.writer.write(bytes);
} else {
assertNever(data.data);
}
}
}
@@ -514,6 +535,19 @@ export class EBMLReader {
return String.fromCharCode(...new Uint8Array(view.buffer, offset, strLength));
}
readUnicodeString(length: number) {
const { view, offset } = this.reader.getViewAndOffset(this.pos, this.pos + length);
this.pos += length;
// Actual string length might be shorter due to null terminators
let strLength = 0;
while (strLength < length && view.getUint8(offset + strLength) !== 0) {
strLength += 1;
}
return textDecoder.decode(new Uint8Array(view.buffer, offset, strLength));
}
readElementId() {
const size = this.readVarIntSize();
if (size === null) {
+44 -7
View File
@@ -145,6 +145,7 @@ type InternalTrack = {
codecId: string | null;
codecPrivate: Uint8Array | null;
defaultDuration: number | null;
name: string | null;
languageCode: string;
info:
| null
@@ -836,6 +837,7 @@ export class MatroskaDemuxer extends Demuxer {
codecId: null,
codecPrivate: null,
defaultDuration: null,
name: null,
languageCode: UNDETERMINED_LANGUAGE,
info: null,
};
@@ -1004,8 +1006,18 @@ export class MatroskaDemuxer extends Demuxer {
= this.currentTrack.segment.timestampFactor * reader.readUnsignedInt(size) / 1e9;
}; break;
case EBMLId.Name: {
if (!this.currentTrack) break;
this.currentTrack.name = reader.readUnicodeString(size);
}; break;
case EBMLId.Language: {
if (!this.currentTrack) break;
if (this.currentTrack.languageCode !== UNDETERMINED_LANGUAGE) {
// LanguageBCP47 was present, which takes precedence
break;
}
this.currentTrack.languageCode = reader.readAsciiString(size);
@@ -1014,6 +1026,23 @@ export class MatroskaDemuxer extends Demuxer {
}
}; break;
case EBMLId.LanguageBCP47: {
if (!this.currentTrack) break;
const bcp47 = reader.readAsciiString(size);
const languageSubtag = bcp47.split('-')[0];
if (languageSubtag) {
// Technically invalid, for now: The language subtag might be a language code from ISO 639-1,
// ISO 639-2, ISO 639-3, ISO 639-5 or some other thing (source: Wikipedia). But, `languageCode` is
// documented as ISO 639-2. Changing the definition would be a breaking change. This will get
// cleaned up in the future by defining languageCode to be BCP 47 instead.
this.currentTrack.languageCode = languageSubtag;
} else {
this.currentTrack.languageCode = UNDETERMINED_LANGUAGE;
}
}; break;
case EBMLId.Video: {
if (this.currentTrack?.info?.type !== 'video') break;
@@ -1256,11 +1285,19 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
throw new Error('Not implemented on base class.');
}
getInternalCodecId() {
return this.internalTrack.codecId;
}
async computeDuration() {
const lastPacket = await this.getPacket(Infinity, { metadataOnly: true });
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
getName() {
return this.internalTrack.name;
}
getLanguageCode() {
return this.internalTrack.languageCode;
}
@@ -1731,15 +1768,10 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
metadataReader.pos = dataStartPos + size;
}
let result: EncodedPacket | null = null;
const bestCluster = bestClusterIndex !== -1 ? this.internalTrack.clusters[bestClusterIndex]! : null;
if (bestCluster) {
// If we finished looping but didn't find a perfect match, still return the best match we found
result = await this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
}
// Catch faulty cue points
if (!result && cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {
if (cuePoint && (!bestCluster || bestCluster.elementStartPos < cuePoint.clusterPosition)) {
// The cue point lied to us! We found a cue point but no cluster there that satisfied the match. In this
// case, let's search again but using the cue point before that.
const previousCuePoint = this.internalTrack.cuePoints[cuePointIndex - 1];
@@ -1747,7 +1779,12 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
return this.performClusterLookup(getBestMatch, newSearchTimestamp, latestTimestamp, options);
}
return result;
if (bestCluster) {
// If we finished looping but didn't find a perfect match, still return the best match we found
return this.fetchPacketInCluster(bestCluster, bestBlockIndex, options);
}
return null;
} finally {
release();
}
+6 -2
View File
@@ -29,6 +29,7 @@ import {
EBMLFloat64,
EBMLId,
EBMLSignedInt,
EBMLUnicodeString,
EBMLWriter,
} from './ebml';
import { buildMatroskaMimeType } from './matroska-misc';
@@ -272,6 +273,9 @@ export class MatroskaMuxer extends Muxer {
{ id: EBMLId.CodecID, data: codecId },
{ id: EBMLId.CodecDelay, data: 0 },
{ id: EBMLId.SeekPreRoll, data: seekPreRollNs },
trackData.track.metadata.name !== undefined
? { id: EBMLId.Name, data: new EBMLUnicodeString(trackData.track.metadata.name) }
: null,
(trackData.type === 'video' ? this.videoSpecificTrackInfo(trackData) : null),
(trackData.type === 'audio' ? this.audioSpecificTrackInfo(trackData) : null),
(trackData.type === 'subtitle' ? this.subtitleSpecificTrackInfo(trackData) : null),
@@ -812,8 +816,8 @@ export class MatroskaMuxer extends Muxer {
const msDuration = Math.round(1000 * chunk.duration);
if (msDuration === 0 && !chunk.additions) {
// No duration or additions, we can write out a SimpleBlock
if (!chunk.additions) {
// No additions, we can write out a SimpleBlock
view.setUint8(3, Number(chunk.type === 'key') << 7); // Flags (keyframe flag only present for SimpleBlock)
const simpleBlock = { id: EBMLId.SimpleBlock, data: [
+10 -32
View File
@@ -1118,6 +1118,8 @@ export class CanvasSink {
/** @internal */
_videoSampleToWrappedCanvas(sample: VideoSample): WrappedCanvas {
let canvas = this._canvasPool[this._nextCanvasIndex];
let canvasIsNew = false;
if (!canvas) {
if (typeof document !== 'undefined') {
// Prefer an HTMLCanvasElement
@@ -1131,6 +1133,8 @@ export class CanvasSink {
if (this._canvasPool.length > 0) {
this._canvasPool[this._nextCanvasIndex] = canvas;
}
canvasIsNew = true;
}
if (this._canvasPool.length > 0) {
@@ -1143,40 +1147,14 @@ export class CanvasSink {
context.resetTransform();
// 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;
if (this._fit === 'fill') {
dx = 0;
dy = 0;
newWidth = this._width;
newHeight = this._height;
} else {
const [sampleWidth, sampleHeight] = this._rotation % 180 === 0
? [sample.codedWidth, sample.codedHeight]
: [sample.codedHeight, sample.codedWidth];
const scale = this._fit === 'contain'
? Math.min(this._width / sampleWidth, this._height / sampleHeight)
: Math.max(this._width / sampleWidth, this._height / sampleHeight);
newWidth = sampleWidth * scale;
newHeight = sampleHeight * scale;
dx = (this._width - newWidth) / 2;
dy = (this._height - newHeight) / 2;
if (!canvasIsNew) {
context.clearRect(0, 0, this._width, this._height);
}
const aspectRatioChange = this._rotation % 180 === 0 ? 1 : newWidth / newHeight;
context.translate(this._width / 2, this._height / 2);
context.rotate(this._rotation * Math.PI / 180);
// This aspect ratio compensation is done so that we can draw the sample with the intended dimensions and
// don't need to think about how those dimensions change after the rotation
context.scale(1 / aspectRatioChange, aspectRatioChange);
context.translate(-this._width / 2, -this._height / 2);
context.drawImage(sample.toCanvasImageSource(), dx, dy, newWidth, newHeight);
sample.drawWithFit(context, {
fit: this._fit,
rotation: this._rotation,
});
const result = {
canvas,
+74 -178
View File
@@ -9,15 +9,9 @@
import {
AUDIO_CODECS,
AudioCodec,
buildAudioCodecString,
buildVideoCodecString,
getAudioEncoderConfigExtension,
getVideoEncoderConfigExtension,
inferCodecFromCodecString,
parsePcmCodec,
PCM_AUDIO_CODECS,
PcmAudioCodec,
Quality,
SUBTITLE_CODECS,
SubtitleCodec,
VIDEO_CODECS,
@@ -36,6 +30,14 @@ import {
} from './custom-coder';
import { EncodedPacket } from './packet';
import { AudioSample, VideoSample } from './sample';
import {
AudioEncodingConfig,
buildAudioEncoderConfig,
buildVideoEncoderConfig,
validateAudioEncodingConfig,
validateVideoEncodingConfig,
VideoEncodingConfig,
} from './encode';
/**
* Base class for media sources. Media sources are used to add media samples to an output file.
@@ -183,82 +185,15 @@ export class EncodedVideoPacketSource extends VideoSource {
}
}
/**
* Configuration object that controls video encoding. Can be used to set codec, quality, and more.
* @public
*/
export type VideoEncodingConfig = {
/** The video codec that should be used for encoding the video samples (frames). */
codec: VideoCodec;
/**
* The target bitrate for the encoded video, in bits per second. Alternatively, a subjective Quality can
* be provided.
*/
bitrate: number | Quality;
/** The latency mode used by the encoder; controls the performance-quality tradeoff. */
latencyMode?: VideoEncoderConfig['latencyMode'];
/**
* The interval, in seconds, of how often frames are encoded as a key frame. The default is 5 seconds. Frequent key
* frames improve seeking behavior but increase file size. When using multiple video tracks, you should give them
* all the same key frame interval.
*/
keyFrameInterval?: number;
/**
* The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
* specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library.
*/
fullCodecString?: string;
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedVideoChunkMetadata | undefined) => unknown;
/** Called when the internal encoder config, as used by the WebCodecs API, is created. */
onEncoderConfig?: (config: VideoEncoderConfig) => unknown;
};
const validateVideoEncodingConfig = (config: VideoEncodingConfig) => {
if (!config || typeof config !== 'object') {
throw new TypeError('Encoding config must be an object.');
}
if (!VIDEO_CODECS.includes(config.codec)) {
throw new TypeError(`Invalid video codec '${config.codec}'. Must be one of: ${VIDEO_CODECS.join(', ')}.`);
}
if (!(config.bitrate instanceof Quality) && (!Number.isInteger(config.bitrate) || config.bitrate <= 0)) {
throw new TypeError('config.bitrate must be a positive integer or a quality.');
}
if (config.latencyMode !== undefined && !['quality', 'realtime'].includes(config.latencyMode)) {
throw new TypeError('config.latencyMode, when provided, must be \'quality\' or \'realtime\'.');
}
if (
config.keyFrameInterval !== undefined
&& (!Number.isFinite(config.keyFrameInterval) || config.keyFrameInterval < 0)
) {
throw new TypeError('config.keyFrameInterval, when provided, must be a non-negative number.');
}
if (config.fullCodecString !== undefined && typeof config.fullCodecString !== 'string') {
throw new TypeError('config.fullCodecString, when provided, must be a string.');
}
if (config.fullCodecString !== undefined && inferCodecFromCodecString(config.fullCodecString) !== config.codec) {
throw new TypeError(
`config.fullCodecString, when provided, must be a string that matches the specified codec`
+ ` (${config.codec}).`,
);
}
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedChunk, when provided, must be a function.');
}
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
}
};
class VideoEncoderWrapper {
private ensureEncoderPromise: Promise<void> | null = null;
private encoderInitialized = false;
private encoder: VideoEncoder | null = null;
private muxer: Muxer | null = null;
private lastMultipleOfKeyFrameInterval = -1;
private lastWidth: number | null = null;
private lastHeight: number | null = null;
private codedWidth: number | null = null;
private codedHeight: number | null = null;
private resizeCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
private customEncoder: CustomVideoEncoder | null = null;
private customEncoderCallSerializer = new CallSerializer();
@@ -279,16 +214,60 @@ class VideoEncoderWrapper {
this.source._ensureValidAdd();
// Ensure video sample size remains constant
if (this.lastWidth !== null && this.lastHeight !== null) {
if (videoSample.codedWidth !== this.lastWidth || videoSample.codedHeight !== this.lastHeight) {
throw new Error(
`Video sample size must remain constant. Expected ${this.lastWidth}x${this.lastHeight},`
+ ` got ${videoSample.codedWidth}x${videoSample.codedHeight}.`,
);
if (this.codedWidth !== null && this.codedHeight !== null) {
if (videoSample.codedWidth !== this.codedWidth || videoSample.codedHeight !== this.codedHeight) {
const sizeChangeBehavior = this.encodingConfig.sizeChangeBehavior ?? 'deny';
if (sizeChangeBehavior === 'passThrough') {
// Do nada
} else if (sizeChangeBehavior === 'deny') {
throw new Error(
`Video sample size must remain constant. Expected ${this.codedWidth}x${this.codedHeight},`
+ ` got ${videoSample.codedWidth}x${videoSample.codedHeight}. To allow the sample size to`
+ ` change over time, set \`sizeChangeBehavior\` to a value other than 'strict' in the`
+ ` encoding options.`,
);
} else {
let canvasIsNew = false;
if (!this.resizeCanvas) {
if (typeof document !== 'undefined') {
// Prefer an HTMLCanvasElement
this.resizeCanvas = document.createElement('canvas');
this.resizeCanvas.width = this.codedWidth;
this.resizeCanvas.height = this.codedHeight;
} else {
this.resizeCanvas = new OffscreenCanvas(this.codedWidth, this.codedHeight);
}
canvasIsNew = true;
}
const context = this.resizeCanvas.getContext('2d', { alpha: false }) as
CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
assert(context);
if (!canvasIsNew) {
context.clearRect(0, 0, this.codedWidth, this.codedHeight);
}
videoSample.drawWithFit(context, { fit: sizeChangeBehavior });
if (shouldClose) {
videoSample.close();
}
videoSample = new VideoSample(this.resizeCanvas, {
timestamp: videoSample.timestamp,
duration: videoSample.duration,
rotation: videoSample.rotation,
});
shouldClose = true;
}
}
} else {
this.lastWidth = videoSample.codedWidth;
this.lastHeight = videoSample.codedHeight;
this.codedWidth = videoSample.codedWidth;
this.codedHeight = videoSample.codedHeight;
}
if (!this.encoderInitialized) {
@@ -370,26 +349,12 @@ class VideoEncoderWrapper {
}
return this.ensureEncoderPromise = (async () => {
const width = videoSample.codedWidth;
const height = videoSample.codedHeight;
const bitrate = this.encodingConfig.bitrate instanceof Quality
? this.encodingConfig.bitrate._toVideoBitrate(this.encodingConfig.codec, width, height)
: this.encodingConfig.bitrate;
const encoderConfig: VideoEncoderConfig = {
codec: this.encodingConfig.fullCodecString ?? buildVideoCodecString(
this.encodingConfig.codec,
width,
height,
bitrate,
),
width,
height,
bitrate,
const encoderConfig = buildVideoEncoderConfig({
width: videoSample.codedWidth,
height: videoSample.codedHeight,
...this.encodingConfig,
framerate: this.source._connectedTrack?.metadata.frameRate,
latencyMode: this.encodingConfig.latencyMode,
...getVideoEncoderConfigExtension(this.encodingConfig.codec),
};
});
this.encodingConfig.onEncoderConfig?.(encoderConfig);
const MatchingCustomEncoder = customVideoEncoders.find(x => x.supports(
@@ -427,8 +392,9 @@ class VideoEncoderWrapper {
if (!support.supported) {
throw new Error(
`This specific encoder configuration (${encoderConfig.codec}, ${encoderConfig.bitrate} bps,`
+ ` ${encoderConfig.width}x${encoderConfig.height}) is not supported by this browser. Consider`
+ ` using another codec or changing your video parameters.`,
+ ` ${encoderConfig.width}x${encoderConfig.height}, hardware acceleration:`
+ ` ${encoderConfig.hardwareAcceleration ?? 'no-preference'}) is not supported by this browser.`
+ ` Consider using another codec or changing your video parameters.`,
);
}
@@ -813,67 +779,6 @@ export class EncodedAudioPacketSource extends AudioSource {
}
}
/**
* Configuration object that controls audio encoding. Can be used to set codec, quality, and more.
* @public
*/
export type AudioEncodingConfig = {
/** The audio codec that should be used for encoding the audio samples. */
codec: AudioCodec;
/**
* The target bitrate for the encoded audio, in bits per second. Alternatively, a subjective Quality can
* be provided. Required for compressed audio codecs, unused for PCM codecs.
*/
bitrate?: number | Quality;
/**
* The full codec string as specified in the WebCodecs Codec Registry. This string must match the codec
* specified in `codec`. When not set, a fitting codec string will be constructed automatically by the library.
*/
fullCodecString?: string;
/** Called for each successfully encoded packet. Both the packet and the encoding metadata are passed. */
onEncodedPacket?: (packet: EncodedPacket, meta: EncodedAudioChunkMetadata | undefined) => unknown;
/** Called when the internal encoder config, as used by the WebCodecs API, is created. */
onEncoderConfig?: (config: AudioEncoderConfig) => unknown;
};
const validateAudioEncodingConfig = (config: AudioEncodingConfig) => {
if (!config || typeof config !== 'object') {
throw new TypeError('Encoding config must be an object.');
}
if (!AUDIO_CODECS.includes(config.codec)) {
throw new TypeError(`Invalid audio codec '${config.codec}'. Must be one of: ${AUDIO_CODECS.join(', ')}.`);
}
if (
config.bitrate === undefined
&& (!(PCM_AUDIO_CODECS as readonly string[]).includes(config.codec) || config.codec === 'flac')
) {
throw new TypeError('config.bitrate must be provided for compressed audio codecs.');
}
if (
config.bitrate !== undefined
&& !(config.bitrate instanceof Quality)
&& (!Number.isInteger(config.bitrate) || config.bitrate <= 0)
) {
throw new TypeError('config.bitrate, when provided, must be a positive integer or a quality.');
}
if (config.fullCodecString !== undefined && typeof config.fullCodecString !== 'string') {
throw new TypeError('config.fullCodecString, when provided, must be a string.');
}
if (config.fullCodecString !== undefined && inferCodecFromCodecString(config.fullCodecString) !== config.codec) {
throw new TypeError(
`config.fullCodecString, when provided, must be a string that matches the specified codec`
+ ` (${config.codec}).`,
);
}
if (config.onEncodedPacket !== undefined && typeof config.onEncodedPacket !== 'function') {
throw new TypeError('config.onEncodedChunk, when provided, must be a function.');
}
if (config.onEncoderConfig !== undefined && typeof config.onEncoderConfig !== 'function') {
throw new TypeError('config.onEncoderConfig, when provided, must be a function.');
}
};
class AudioEncoderWrapper {
private ensureEncoderPromise: Promise<void> | null = null;
private encoderInitialized = false;
@@ -1061,21 +966,12 @@ class AudioEncoderWrapper {
return this.ensureEncoderPromise = (async () => {
const { numberOfChannels, sampleRate } = audioSample;
const bitrate = this.encodingConfig.bitrate instanceof Quality
? this.encodingConfig.bitrate._toAudioBitrate(this.encodingConfig.codec)
: this.encodingConfig.bitrate;
const encoderConfig: AudioEncoderConfig = {
codec: this.encodingConfig.fullCodecString ?? buildAudioCodecString(
this.encodingConfig.codec,
numberOfChannels,
sampleRate,
),
const encoderConfig = buildAudioEncoderConfig({
numberOfChannels,
sampleRate,
bitrate,
...getAudioEncoderConfigExtension(this.encodingConfig.codec),
};
...this.encodingConfig,
});
this.encodingConfig.onEncoderConfig?.(encoderConfig);
const MatchingCustomEncoder = customAudioEncoders.find(x => x.supports(
+1
View File
@@ -173,6 +173,7 @@ export const toDataView = (source: AllowSharedBufferSource) => {
}
};
export const textDecoder = new TextDecoder();
export const textEncoder = new TextEncoder();
const invertObject = <K extends PropertyKey, V extends PropertyKey>(object: Record<K, V>) => {
+8
View File
@@ -168,6 +168,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return (lastPacket?.timestamp ?? 0) + (lastPacket?.duration ?? 0);
}
getName() {
return null;
}
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
@@ -176,6 +180,10 @@ class Mp3AudioTrackBacking implements InputAudioTrackBacking {
return 'mp3';
}
getInternalCodecId() {
return null;
}
getNumberOfChannels() {
assert(this.demuxer.firstFrameHeader);
return this.demuxer.firstFrameHeader.channel === 3 ? 1 : 2;
+8
View File
@@ -431,6 +431,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
return this.bitstream.codecInfo.codec;
}
getInternalCodecId() {
return null;
}
async getDecoderConfig(): Promise<AudioDecoderConfig | null> {
assert(this.bitstream.codecInfo.codec);
@@ -442,6 +446,10 @@ class OggAudioTrackBacking implements InputAudioTrackBacking {
};
}
getName() {
return null;
}
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}
+6 -1
View File
@@ -67,6 +67,8 @@ export type OutputSubtitleTrack = OutputTrack & { type: 'subtitle' };
export type BaseTrackMetadata = {
/** The three-letter, ISO 639-2/T language code specifying the language of this track. */
languageCode?: string;
/** A user-defined name for this track, like "English" or "Director Commentary". */
name?: string;
};
/**
@@ -99,7 +101,10 @@ const validateBaseTrackMetadata = (metadata: BaseTrackMetadata) => {
throw new TypeError('metadata must be an object.');
}
if (metadata.languageCode !== undefined && !isIso639Dash2LanguageCode(metadata.languageCode)) {
throw new TypeError('metadata.languageCode must be a three-letter, ISO 639-2/T language code.');
throw new TypeError('metadata.languageCode, when provided, must be a three-letter, ISO 639-2/T language code.');
}
if (metadata.name !== undefined && typeof metadata.name !== 'string') {
throw new TypeError('metadata.name, when provided, must be a string.');
}
};
+58
View File
@@ -534,6 +534,64 @@ export class VideoSample {
context.restore();
}
/**
* Draws the sample in the middle of the canvas corresponding to the context with the specified fit behavior.
*/
drawWithFit(context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, options: {
/**
* Controls 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.
*/
fit: 'fill' | 'contain' | 'cover';
/** A way to override rotation. Defaults to the rotation of the sample. */
rotation?: Rotation;
}) {
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
const rotation = options.rotation ?? this.rotation;
// 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;
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 scale = options.fit === 'contain'
? Math.min(canvasWidth / sampleWidth, canvasHeight / sampleHeight)
: Math.max(canvasWidth / sampleWidth, canvasHeight / sampleHeight);
newWidth = sampleWidth * scale;
newHeight = sampleHeight * scale;
dx = (canvasWidth - newWidth) / 2;
dy = (canvasHeight - newHeight) / 2;
}
const aspectRatioChange = rotation % 180 === 0 ? 1 : newWidth / newHeight;
context.translate(canvasWidth / 2, canvasHeight / 2);
context.rotate(rotation * Math.PI / 180);
// This aspect ratio compensation is done so that we can draw the sample with the intended dimensions and
// don't need to think about how those dimensions change after the rotation
context.scale(1 / aspectRatioChange, aspectRatioChange);
context.translate(-canvasWidth / 2, -canvasHeight / 2);
// 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);
}
/**
* Converts this video sample to a CanvasImageSource for drawing to a canvas.
*
+1 -1
View File
@@ -191,7 +191,7 @@ export class UrlSource extends Source {
super();
this._url = url instanceof URL ? url : new URL(url);
this._url = url instanceof URL ? url : new URL(url, location.href);
this._options = options;
}
+9
View File
@@ -222,6 +222,11 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.getCodec();
}
getInternalCodecId() {
assert(this.demuxer.audioInfo);
return this.demuxer.audioInfo.format;
}
async getDecoderConfig(): Promise<AudioDecoderConfig | null> {
const codec = this.demuxer.getCodec();
if (!codec) {
@@ -255,6 +260,10 @@ class WaveAudioTrackBacking implements InputAudioTrackBacking {
return this.demuxer.audioInfo.sampleRate;
}
getName() {
return null;
}
getLanguageCode() {
return UNDETERMINED_LANGUAGE;
}