mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-04 22:33:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8770de99 | ||
|
|
c498b87b3e | ||
|
|
ffdaf2dacb | ||
|
|
448e43cbfc | ||
|
|
ef2094b0db | ||
|
|
daa6bdbe1d | ||
|
|
2d11f53192 | ||
|
|
1b4628cbb0 | ||
|
|
72eb54391e | ||
|
|
ca1305da93 | ||
|
|
63b8190185 | ||
|
|
ad26edc6b5 | ||
|
|
945df51944 | ||
|
|
652ca69417 | ||
|
|
803e58a0d2 | ||
|
|
e6420caea4 | ||
|
|
bb1b20afa7 | ||
|
|
0e03779169 | ||
|
|
5a6528d8f5 | ||
|
|
134a6f8f4e | ||
|
|
a56dca10d9 | ||
|
|
c81600bf17 | ||
|
|
2d62395fc0 |
@@ -1,4 +1,3 @@
|
||||
.vscode
|
||||
node_modules
|
||||
/dist
|
||||
/dist-docs
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
},
|
||||
*/
|
||||
video: () => ({
|
||||
codec: 'avc',
|
||||
//fit: 'contain',
|
||||
//frameRate: 27.123,
|
||||
//width: 320,
|
||||
//forceTranscode: true,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user