mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-05 14:53:49 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652ca69417 | ||
|
|
803e58a0d2 | ||
|
|
e6420caea4 | ||
|
|
bb1b20afa7 | ||
|
|
0e03779169 | ||
|
|
5a6528d8f5 | ||
|
|
134a6f8f4e | ||
|
|
a56dca10d9 | ||
|
|
c81600bf17 | ||
|
|
2d62395fc0 | ||
|
|
bb9554df60 | ||
|
|
f4b97c1731 | ||
|
|
5782219941 | ||
|
|
b265826b7e |
@@ -28,13 +28,21 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Bronze sponsors
|
||||
|
||||
<div align="center">
|
||||
<a href="https://www.reactvideoeditor.com/" target="_blank">
|
||||
<img src="./docs/public/sponsors/rve.svg" width="40" height="40" alt="React Video Editor">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
|
||||
|
||||
## Features
|
||||
|
||||
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
|
||||
|
||||
+1
-3
@@ -24,9 +24,7 @@
|
||||
chunked: true,
|
||||
chunkSize: 2**20
|
||||
});
|
||||
const outputFormat = new Mediabunny.AdtsOutputFormat({
|
||||
onFrame: console.log
|
||||
});
|
||||
const outputFormat = new Mediabunny.Mp4OutputFormat({});
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Cancel';
|
||||
|
||||
+10
-2
@@ -17,12 +17,20 @@
|
||||
});
|
||||
|
||||
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(undefined, undefined, { verifyKeyPackets: false })) {
|
||||
for await (const packet of sink.packets()) {
|
||||
console.log(packet);
|
||||
if (packet.timestamp >= 2.4) break;
|
||||
|
||||
//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>
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 121 KiB |
@@ -47,9 +47,13 @@ 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;
|
||||
|
||||
onEncodedPacket?: (
|
||||
packet: EncodedPacket,
|
||||
@@ -62,9 +66,13 @@ 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).
|
||||
- `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 +83,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 +97,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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
+14
-1
@@ -92,6 +92,9 @@ const sponsors = {
|
||||
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
|
||||
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
|
||||
],
|
||||
bronze: [
|
||||
{ image: '/sponsors/rve.svg', name: 'React Video Editor', url: 'https://www.reactvideoeditor.com/' },
|
||||
],
|
||||
individual: [
|
||||
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
|
||||
{ image: 'https://avatars.githubusercontent.com/u/5913254', name: 'Brandon McConnell', url: 'https://github.com/brandonmcconnell' },
|
||||
@@ -101,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>
|
||||
@@ -336,8 +340,17 @@ await conversion.execute();
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="sponsors.bronze.length > 0">
|
||||
<h3 class="!text-xl">Bronze sponsors</h3>
|
||||
<div class="flex flex-wrap mt-1 justify-center gap-1">
|
||||
<a v-for="sponsor in sponsors.bronze" :href="sponsor.url" target="_blank" class="flex items-center p-2 rounded-full hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<img :src="sponsor.image" class="size-10 rounded-full">
|
||||
<p class="!my-0 !font-medium px-3 text-sm">{{ sponsor.name }}</p>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="sponsors.individual.length > 0">
|
||||
<h4>Individual sponsors</h4>
|
||||
<h4 class="!text-base">Individual sponsors</h4>
|
||||
<div class="flex flex-wrap mt-1 justify-center">
|
||||
<a v-for="sponsor in sponsors.individual" :href="sponsor.url" target="_blank" class="flex gap-1 w-24 flex-col items-center p-2 rounded-xl hover:bg-(--vp-c-gray-3) !text-(--vp-c-text-1) !no-underline">
|
||||
<img :src="sponsor.image" class="size-8 rounded-full">
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="52" height="51" viewBox="0 0 52 51" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.1186 1.50109C23.3805 0.147773 21.1954 -0.253724 19.1203 0.397437L5.08373 4.80289C2.19204 5.71105 0.248047 8.44236 0.248047 11.6022V42.9894C0.248047 45.2558 1.24465 47.3313 2.98266 48.6825C4.72099 50.036 6.9084 50.4373 8.98111 49.7863L19.4595 46.4967C19.1318 45.366 18.9658 44.1862 18.9658 42.9894V28.735C18.9658 25.7417 20.0093 22.8829 21.9088 20.6899C23.303 19.0744 25.1116 17.8826 27.1329 17.2463L27.8534 17.0309V7.19654C27.8534 4.93006 26.8568 2.85458 25.1185 1.50109H25.1186Z" fill="#1C2AC5"/>
|
||||
<path d="M51.2479 24.3293V38.5836C51.2479 41.7411 49.3061 44.4749 46.4122 45.3806L32.3756 49.7862C31.7253 49.9917 31.0632 50.0922 30.4081 50.0922C28.9739 50.0922 27.5703 49.6125 26.3771 48.6824C25.832 48.2565 25.3595 47.7645 24.9711 47.2139C24.4986 46.553 24.1453 45.8164 23.923 45.0257C23.9206 45.0257 23.9184 45.0257 23.916 45.0281L23.9206 45.0232C23.7406 44.3746 23.6423 43.6917 23.6423 42.9893V28.7349C23.6423 27.2712 24.0588 25.8983 24.7935 24.7576C25.528 23.6169 26.5807 22.7066 27.8534 22.1656C28.057 22.08 28.2652 22.0015 28.4782 21.9356L30.1953 21.3971L42.515 17.53C44.5899 16.8788 46.7751 17.2803 48.5132 18.6338C50.2516 19.9873 51.248 22.0628 51.248 24.3293H51.2479Z" fill="#3E8AF5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mediabunny",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"license": "MPL-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -5900,9 +5900,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mediabunny": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.8.0.tgz",
|
||||
"integrity": "sha512-+JjE+A2NxtjxnBsEy4yCw9CYx8yDyJzivAx28xU5vWqEy4Xu9gE8qiIklKWfLEAoiyudJ+wn0gn3rl1HtK8vcw==",
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.9.3.tgz",
|
||||
"integrity": "sha512-uVspdzrpwvW8NxtGspHPOJvdNSY0dNFEmOQngSheX9PYX9oXijZENBjfYWOgm9c9IgSSlN2bdJF9iyPyY3f+fQ==",
|
||||
"license": "MPL-2.0",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
@@ -9017,7 +9017,7 @@
|
||||
},
|
||||
"packages/mp3-encoder": {
|
||||
"name": "@mediabunny/mp3-encoder",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"license": "MPL-2.0",
|
||||
"devDependencies": {
|
||||
"@types/emscripten": "^1.40.1"
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mediabunny",
|
||||
"author": "Vanilagy",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"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.0",
|
||||
"version": "1.10.0",
|
||||
"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);
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ export class AdtsMuxer extends Muxer {
|
||||
const release = await this.mutex.acquire();
|
||||
|
||||
try {
|
||||
this.validateAndNormalizeTimestamp(track, packet.timestamp, packet.type === 'key');
|
||||
|
||||
if (!this.audioSpecificConfig) {
|
||||
validateAudioChunkMetadata(meta);
|
||||
|
||||
|
||||
-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;
|
||||
};
|
||||
|
||||
+6
-4
@@ -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,11 +30,9 @@ import {
|
||||
VideoSampleSink,
|
||||
} from './media-sink';
|
||||
import {
|
||||
AudioEncodingConfig,
|
||||
AudioSource,
|
||||
EncodedVideoPacketSource,
|
||||
EncodedAudioPacketSource,
|
||||
VideoEncodingConfig,
|
||||
VideoSource,
|
||||
VideoSampleSource,
|
||||
AudioSampleSource,
|
||||
|
||||
+546
@@ -0,0 +1,546 @@
|
||||
/*!
|
||||
* 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;
|
||||
|
||||
/** 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.');
|
||||
}
|
||||
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;
|
||||
@@ -257,6 +260,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
);
|
||||
const startPos = this.metadataReader.pos;
|
||||
const boxInfo = this.metadataReader.readBoxHeader();
|
||||
if (!boxInfo) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (boxInfo.name === 'ftyp') {
|
||||
const majorBrand = this.metadataReader.readAscii(4);
|
||||
@@ -291,14 +297,20 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
const lastWord = this.metadataReader.readU32();
|
||||
const potentialMfraPos = sourceSize - lastWord;
|
||||
|
||||
if (potentialMfraPos >= 0 && potentialMfraPos < sourceSize) {
|
||||
await this.metadataReader.reader.loadRange(potentialMfraPos, sourceSize);
|
||||
if (potentialMfraPos >= 0 && potentialMfraPos <= sourceSize - MAX_BOX_HEADER_SIZE) {
|
||||
// Load the header and a bit more, likely covering the entire box
|
||||
await this.metadataReader.reader.loadRange(potentialMfraPos, potentialMfraPos + 2 ** 16);
|
||||
|
||||
this.metadataReader.pos = potentialMfraPos;
|
||||
const boxInfo = this.metadataReader.readBoxHeader();
|
||||
|
||||
if (boxInfo.name === 'mfra') {
|
||||
// We found the mfra box, allowing for much better random access. Let's parse it:
|
||||
if (boxInfo && boxInfo.name === 'mfra') {
|
||||
// We found the mfra box, allowing for much better random access. Let's parse it.
|
||||
|
||||
await this.metadataReader.reader.loadRange(
|
||||
potentialMfraPos,
|
||||
potentialMfraPos + boxInfo.totalSize,
|
||||
);
|
||||
this.readContiguousBoxes(boxInfo.contentSize);
|
||||
}
|
||||
}
|
||||
@@ -456,7 +468,7 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
);
|
||||
|
||||
const moofBoxInfo = this.metadataReader.readBoxHeader();
|
||||
assert(moofBoxInfo.name === 'moof');
|
||||
assert(moofBoxInfo?.name === 'moof');
|
||||
|
||||
const contentStart = this.metadataReader.pos;
|
||||
await this.metadataReader.reader.loadRange(contentStart, contentStart + moofBoxInfo.contentSize);
|
||||
@@ -502,7 +514,7 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
|
||||
let nextFragmentIsFirstFragment = this.metadataReader.pos === 0;
|
||||
|
||||
while (this.metadataReader.pos < startPos) {
|
||||
while (this.metadataReader.pos <= startPos - MIN_BOX_HEADER_SIZE) {
|
||||
if (currentFragment?.nextFragment) {
|
||||
currentFragment = currentFragment.nextFragment;
|
||||
this.metadataReader.pos = currentFragment.moofOffset + currentFragment.moofSize;
|
||||
@@ -513,6 +525,9 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
);
|
||||
const startPos = this.metadataReader.pos;
|
||||
const boxInfo = this.metadataReader.readBoxHeader();
|
||||
if (!boxInfo) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (boxInfo.name === 'moof') {
|
||||
const index = binarySearchExact(this.fragments, startPos, x => x.moofOffset);
|
||||
@@ -562,13 +577,21 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
const startIndex = this.metadataReader.pos;
|
||||
|
||||
while (this.metadataReader.pos - startIndex <= totalSize - MIN_BOX_HEADER_SIZE) {
|
||||
this.traverseBox();
|
||||
const foundBox = this.traverseBox();
|
||||
|
||||
if (!foundBox) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverseBox() {
|
||||
const startPos = this.metadataReader.pos;
|
||||
const boxInfo = this.metadataReader.readBoxHeader();
|
||||
if (!boxInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boxEndPos = startPos + boxInfo.totalSize;
|
||||
|
||||
switch (boxInfo.name) {
|
||||
@@ -576,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;
|
||||
|
||||
@@ -605,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,
|
||||
@@ -819,6 +845,11 @@ export class IsobmffDemuxer extends Demuxer {
|
||||
for (let i = 0; i < entries; i++) {
|
||||
const startPos = this.metadataReader.pos;
|
||||
const sampleBoxInfo = this.metadataReader.readBoxHeader();
|
||||
if (!sampleBoxInfo) {
|
||||
break;
|
||||
}
|
||||
|
||||
track.internalCodecId = sampleBoxInfo.name;
|
||||
const lowercaseBoxName = sampleBoxInfo.name.toLowerCase();
|
||||
|
||||
if (track.info.type === 'video') {
|
||||
@@ -1895,9 +1926,20 @@ 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;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1918,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;
|
||||
}
|
||||
@@ -2318,8 +2368,12 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
|
||||
/** Looks for a packet in the fragments while trying to load as few fragments as possible to retrieve it. */
|
||||
private async performFragmentedLookup(
|
||||
// This function returns the best-matching sample that is currently loaded. Based on this information, we know
|
||||
// which fragments we need to load to find the actual match.
|
||||
getBestMatch: () => { fragmentIndex: number; sampleIndex: number; correctSampleFound: boolean },
|
||||
// The timestamp with which we can search the lookup table
|
||||
searchTimestamp: number,
|
||||
// The timestamp for which we know the correct sample will not come after it
|
||||
latestTimestamp: number,
|
||||
options: PacketRetrievalOptions,
|
||||
): Promise<EncodedPacket | null> {
|
||||
@@ -2391,6 +2445,9 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
||||
await metadataReader.reader.loadRange(metadataReader.pos, metadataReader.pos + MAX_BOX_HEADER_SIZE);
|
||||
const startPos = metadataReader.pos;
|
||||
const boxInfo = metadataReader.readBoxHeader();
|
||||
if (!boxInfo) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (boxInfo.name === 'moof') {
|
||||
const index = binarySearchExact(demuxer.fragments, startPos, x => x.moofOffset);
|
||||
@@ -2428,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];
|
||||
@@ -2444,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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -132,6 +132,11 @@ export class IsobmffReader {
|
||||
headerSize = 16;
|
||||
}
|
||||
|
||||
return { name, totalSize, headerSize, contentSize: totalSize - headerSize };
|
||||
const contentSize = totalSize - headerSize;
|
||||
if (contentSize < 0) {
|
||||
return null; // Hardly a box is it
|
||||
}
|
||||
|
||||
return { name, totalSize, headerSize, contentSize };
|
||||
}
|
||||
}
|
||||
|
||||
+58
-3
@@ -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,
|
||||
@@ -122,9 +137,8 @@ export const LEVEL_0_EBML_IDS: EBMLId[] = [
|
||||
EBMLId.Segment,
|
||||
];
|
||||
|
||||
// All the stuff that can appear in a segment, basically
|
||||
export const LEVEL_1_EBML_IDS: EBMLId[] = [
|
||||
EBMLId.EBMLMaxIDLength,
|
||||
EBMLId.EBMLMaxSizeLength,
|
||||
EBMLId.SeekHead,
|
||||
EBMLId.Info,
|
||||
EBMLId.Cluster,
|
||||
@@ -372,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,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) {
|
||||
@@ -585,6 +618,28 @@ export class EBMLReader {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Searches for the next occurrence of an element ID using a naive byte-wise search. */
|
||||
async resync(ids: EBMLId[], until: number) {
|
||||
const loadChunkSize = 2 ** 20; // 1 MiB
|
||||
const idsSet = new Set(ids);
|
||||
|
||||
while (this.pos <= until - MIN_HEADER_SIZE) {
|
||||
if (!this.reader.rangeIsLoaded(this.pos, Math.min(this.pos + MAX_HEADER_SIZE, until))) {
|
||||
await this.reader.loadRange(this.pos, Math.min(this.pos + loadChunkSize, until));
|
||||
}
|
||||
|
||||
const elementStartPos = this.pos;
|
||||
const elementId = this.readElementId();
|
||||
if (elementId !== null && idsSet.has(elementId)) {
|
||||
return elementStartPos;
|
||||
}
|
||||
|
||||
this.pos = elementStartPos + 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const CODEC_STRING_MAP: Partial<Record<MediaCodec, string>> = {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
EBMLId,
|
||||
EBMLReader,
|
||||
LEVEL_0_AND_1_EBML_IDS,
|
||||
LEVEL_1_EBML_IDS,
|
||||
MAX_HEADER_SIZE,
|
||||
MIN_HEADER_SIZE,
|
||||
readVarInt,
|
||||
@@ -144,6 +145,7 @@ type InternalTrack = {
|
||||
codecId: string | null;
|
||||
codecPrivate: Uint8Array | null;
|
||||
defaultDuration: number | null;
|
||||
name: string | null;
|
||||
languageCode: string;
|
||||
info:
|
||||
| null
|
||||
@@ -175,6 +177,7 @@ const METADATA_ELEMENTS = [
|
||||
{ id: EBMLId.Tracks, flag: 'tracksSeen' },
|
||||
{ id: EBMLId.Cues, flag: 'cuesSeen' },
|
||||
] as const;
|
||||
const MAX_RESYNC_LENGTH = 10 * 2 ** 20; // 10 MiB
|
||||
|
||||
export class MatroskaDemuxer extends Demuxer {
|
||||
metadataReader: EBMLReader;
|
||||
@@ -330,8 +333,22 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
|
||||
const elementStartPos = this.metadataReader.pos;
|
||||
const header = this.metadataReader.readElementHeader();
|
||||
if (!header) {
|
||||
break;
|
||||
|
||||
if (!header || !LEVEL_1_EBML_IDS.includes(header.id)) {
|
||||
// Potential junk. Let's try to resync
|
||||
|
||||
this.metadataReader.pos = elementStartPos;
|
||||
const nextPos = await this.metadataReader.resync(
|
||||
LEVEL_1_EBML_IDS,
|
||||
Math.min(this.currentSegment.elementEndPos, this.metadataReader.pos + MAX_RESYNC_LENGTH),
|
||||
);
|
||||
|
||||
if (nextPos) {
|
||||
this.metadataReader.pos = nextPos;
|
||||
continue;
|
||||
} else {
|
||||
break; // Resync failed
|
||||
}
|
||||
}
|
||||
|
||||
const { id, size } = header;
|
||||
@@ -352,6 +369,10 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
}
|
||||
}
|
||||
|
||||
if (size !== null) {
|
||||
this.metadataReader.pos = dataStartPos + size;
|
||||
}
|
||||
|
||||
if (this.currentSegment.infoSeen && this.currentSegment.tracksSeen && this.currentSegment.cuesSeen) {
|
||||
// No need to search anymore, we have everything
|
||||
break;
|
||||
@@ -381,10 +402,15 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
if (size === null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.metadataReader.pos = dataStartPos + size;
|
||||
if (!clusterEncountered) {
|
||||
const seekEntry = this.currentSegment.seekEntries.find(entry => entry.id === EBMLId.Cluster);
|
||||
|
||||
if (!clusterEncountered) {
|
||||
if (seekEntry) {
|
||||
// The seek head points us to the first cluster, nice
|
||||
this.currentSegment.clusterSeekStartPos = segmentDataStart + seekEntry.segmentPosition;
|
||||
} else {
|
||||
this.currentSegment.clusterSeekStartPos = this.metadataReader.pos;
|
||||
}
|
||||
}
|
||||
@@ -735,6 +761,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
|
||||
while (reader.pos - startIndex <= totalSize - MIN_HEADER_SIZE) {
|
||||
const foundElement = this.traverseElement(reader);
|
||||
|
||||
if (!foundElement) {
|
||||
break;
|
||||
}
|
||||
@@ -810,6 +837,7 @@ export class MatroskaDemuxer extends Demuxer {
|
||||
codecId: null,
|
||||
codecPrivate: null,
|
||||
defaultDuration: null,
|
||||
name: null,
|
||||
languageCode: UNDETERMINED_LANGUAGE,
|
||||
info: null,
|
||||
};
|
||||
@@ -978,8 +1006,15 @@ 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) break; // LanguageBCP47 was present, which takes precedence
|
||||
|
||||
this.currentTrack.languageCode = reader.readAsciiString(size);
|
||||
|
||||
@@ -988,6 +1023,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;
|
||||
|
||||
@@ -1230,11 +1282,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;
|
||||
}
|
||||
@@ -1609,8 +1669,24 @@ abstract class MatroskaTrackBacking implements InputTrackBacking {
|
||||
await metadataReader.reader.loadRange(metadataReader.pos, metadataReader.pos + MAX_HEADER_SIZE);
|
||||
const elementStartPos = metadataReader.pos;
|
||||
const elementHeader = metadataReader.readElementHeader();
|
||||
if (!elementHeader) {
|
||||
break;
|
||||
|
||||
if (!elementHeader || !LEVEL_1_EBML_IDS.includes(elementHeader.id)) {
|
||||
// There's an element here that shouldn't be here (or Void). Might be garbage. In this case, let's
|
||||
// try and resync to the next valid element.
|
||||
|
||||
metadataReader.pos = elementStartPos;
|
||||
|
||||
const nextPos = await metadataReader.resync(
|
||||
LEVEL_1_EBML_IDS,
|
||||
Math.min(segment.elementEndPos, metadataReader.pos + MAX_RESYNC_LENGTH),
|
||||
);
|
||||
|
||||
if (nextPos) {
|
||||
metadataReader.pos = nextPos;
|
||||
continue;
|
||||
} else {
|
||||
break; // Resync failed
|
||||
}
|
||||
}
|
||||
|
||||
const id = elementHeader.id;
|
||||
@@ -1689,15 +1765,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];
|
||||
@@ -1705,7 +1776,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: [
|
||||
|
||||
+19
-168
@@ -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,74 +185,6 @@ 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;
|
||||
@@ -370,26 +304,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 +347,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 +734,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 +921,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.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+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