Compare commits

...
13 Commits
Author SHA1 Message Date
Vanilagy ac8baa4873 Fall back to default timescale when not provided by the Matroska file 2025-07-27 13:03:43 +02:00
Vanilagy 232d1a6cd7 Bump version to 1.4.0 2025-07-27 12:20:11 +02:00
Vanilagy 50fe065852 Document API changes 2025-07-27 12:19:44 +02:00
Vanilagy d38ad22559 Big realtime playback refactor
- Added Worker- or AudioContext-based fallbacks in case MediaStreamTrackProcessor is not available
- Fixed fMP4-streamed files not playing in Safari
- Added better error handling for MediaStream sources
- Improved the live recording example to be more robust
2025-07-27 12:13:03 +02:00
Vanilagy 9cc38329f2 Fix link 2025-07-27 11:46:36 +02:00
Vanilagy 09ed583b78 Smol change 2025-07-27 00:57:57 +02:00
Vanilagy 9de5b24ec5 Add new sponsors 2025-07-27 00:56:22 +02:00
Vanilagy 03b4843c1d Fix incorrectly fixed .js imports 2025-07-26 00:50:47 +02:00
Vanilagy 9bfa855f25 Downgrade @types/web-codecs for better type support, specify requirements in README and docs 2025-07-26 00:34:24 +02:00
Vanilagy d768f3c63b Add section in docs for Jonny 2025-07-25 13:22:18 +02:00
Vanilagy c4d13ed698 Improve UrlSource fetching logic 2025-07-25 12:07:54 +02:00
Vanilagy 5e933d9362 Add packet type derivation logic, new verifyKeyPackets options, new determinePacketType method 2025-07-24 16:09:01 +02:00
Vanilagy 904ac2e366 Improve error messages in examples 2025-07-19 17:28:39 +02:00
35 changed files with 1441 additions and 493 deletions
+7 -1
View File
@@ -22,9 +22,13 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
<a href="https://diffusion.studio/" target="_blank">
<img src="./docs/public/sponsors/diffusionstudio.png" width="60" height="60" alt="Diffusion Studio">
</a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://kino.ai/" target="_blank">
<img src="./docs/public/sponsors/kino.jpg" width="60" height="60" alt="Kino">
</a>
</div>
[Get featured](https://github.com/sponsors/Vanilagy)
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
## Features
@@ -49,6 +53,8 @@ Core features include:
npm install mediabunny
```
Requires any JavaScript environment that can run ECMAScript 2021 or later. Mediabunny is expected to be run in modern browsers. For types, TypeScript 5.7 or later is required.
### Read file metadata
```js
+4 -2
View File
@@ -21,7 +21,7 @@
chunked: true,
chunkSize: 2**20
});
const outputFormat = new Mediabunny.WavOutputFormat({ large: true });
const outputFormat = new Mediabunny.Mp4OutputFormat({});
const button = document.createElement('button');
button.textContent = 'Cancel';
@@ -70,6 +70,8 @@
},
*/
video: {
forceTranscode: true,
codec: 'av1',
//discard: true,
//width: 1280,
//discard: true,
@@ -87,7 +89,7 @@
},
trim: {
start: 0,
end: 10
end: 20
},
});
console.log(conversion);
+57
View File
@@ -16,6 +16,62 @@
source
});
const videoTrack = await input.getPrimaryVideoTrack();
const packetSink = new Mediabunny.EncodedPacketSink(videoTrack);
const sampleSink = new Mediabunny.VideoSampleSink(videoTrack);
for await (const packet of packetSink.packets(undefined, undefined, {verifyType: true})) {
const guess = packet.type;
const real = await videoTrack.determinePacketType(packet);
if (guess !== real) {
console.log(guess, real, packet);
}
}
console.log("don")
/*
console.time()
for await (const packet of packetSink.packets(undefined, undefined, { verifyType: true })) {
//console.log(packet)
}
console.timeEnd()
*/
//console.log(await packetSink.getPacket(6.666666666666667, { verifyType: true }))
/*
for await (const packet of packetSink.packets()) {
const guess = packet.type;
const real = await videoTrack.determinePacketType(packet);
if (guess !== real) {
console.log(guess, real, packet);
}
}
console.log("done")
*/
/*
const timestamp = 6.666666666666667;
const thePacket = await packetSink.getPacket(timestamp);
console.log(videoTrack.codec, thePacket, await videoTrack.determinePacketType(thePacket));
sampleSink.getSample(thePacket.timestamp);
*/
/*
let packet = await packetSink.getFirstPacket();
while (packet) {
console.log(packet)
const sample = await sampleSink.getSample(packet.timestamp);
packet = await packetSink.getNextKeyPacket(packet);
}
*/
/*
const canvas = document.createElement('canvas');
canvas.width = 1920;
canvas.height = 1080;
@@ -30,6 +86,7 @@
console.log(sample);
sample.draw(ctx, 1500, 500, 50, 50, 0, 0);
*/
/*
let timestamps = [];
+13 -5
View File
@@ -23,21 +23,29 @@
format: new Mediabunny.Mp4OutputFormat(),
});
if (videoTrack) {
output.addVideoTrack(new Mediabunny.MediaStreamVideoTrackSource(videoTrack, {
const source = new Mediabunny.MediaStreamVideoTrackSource(videoTrack, {
codec: 'avc',
bitrate: Mediabunny.QUALITY_MEDIUM
}));
});
source.errorPromise.catch((d) => console.log("Hello?????", d));
output.addVideoTrack(source);
}
if (audioTrack) {
output.addAudioTrack(new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
codec: 'aac',
bitrate: Mediabunny.QUALITY_MEDIUM
}));
});
source.errorPromise.catch((d) => console.log("Hello!!???", d));
output.addAudioTrack(source);
}
await output.start();
await new Promise(resolve => setTimeout(resolve, 3000));
await new Promise(resolve => setTimeout(resolve, 5000));
await output.finalize();
+4
View File
@@ -17,6 +17,10 @@ bun add mediabunny
```
:::
::: info
Requires any JavaScript environment that can run ECMAScript 2021 or later. Mediabunny is expected to be run in modern browsers. For types, TypeScript 5.7 or later is required.
:::
Then, simply import it like this:
```ts
import { ... } from 'mediabunny'; // ESM
+26
View File
@@ -144,6 +144,32 @@ for await (const packet of sink.packets(start, end)) {
The `packets` method is more performant than manual iteration as it will intelligently preload future packets before they are needed.
#### Verifying key packets
By default, packet types are determined using the metadata provided by the containing file. Some files can erroneously label some delta packets as key packets, leading to potential decoder errors. To be guaranteed that a key packet is actually a key packet, you can enable the `verifyKeyPackets` option:
```ts
// If the packet returned by this method has type: 'key', it's guaranteed
// to be a key packet.
await sink.getPacket(5, { verifyKeyPackets: true });
// Returned packets are guaranteed to be key packets
await sink.getKeyPacket(10, { verifyKeyPackets: true });
await sink.getNextKeyPacket(packet, { verifyKeyPackets: true });
// Also works for the iterator:
for await (const packet of sink.packets(
undefined,
undefined,
{ verifyKeyPackets: true },
)) {
// ...
}
```
::: info
`verifyKeyPackets` only works when `metadataOnly` is not also enabled.
:::
#### Metadata-only packet retrieval
Sometimes, you're only interested in a packet's metadata (timestamp, duration, type, ...) and not in its encoded media data. All methods on `EncodedPacketSink` accept a final `options` parameter which you can use to retrieve [metadata-only packets](./packets-and-samples#metadata-only-packets):
+14
View File
@@ -163,6 +163,9 @@ const videoTrackSource = new MediaStreamVideoTrackSource(videoTrack, {
codec: 'vp9',
bitrate: 1e7,
});
// Make sure to allow any internal errors to properly bubble up
videoTrackSource.errorPromise.catch((error) => ...);
```
This source requires no additional method calls; data will automatically be captured and piped to the output file as soon as `start()` is called on the `Output`. Make sure to `stop()` on `videoTrack` after finalizing the `Output` if you don't need the user's media anymore.
@@ -171,6 +174,10 @@ This source requires no additional method calls; data will automatically be capt
If this source is the only MediaStreamTrack source in the `Output`, then the first video sample added by it starts at timestamp 0. If there are multiple, then the earliest media sample across all tracks starts at timestamp 0, and all tracks will be perfectly synchronized with each other.
:::
::: warning
`MediaStreamVideoTrackSource`'s internals are detached from the typical code flow but can still throw, so make sure to utilize `errorPromise` to deal with any errors and to stop the `Output`.
:::
### `EncodedVideoPacketSource`
The most barebones of all video sources, this source can be used to directly pipe [encoded packets](./packets-and-samples#encodedpacket) of video data to the output. This source requires that you take care of the encoding process yourself, which enables you to use the WebCodecs API manually or to plug in your own encoding stack. Alternatively, you may retrieve the encoded packets directly by reading them from another media file, allowing you to skip decoding and reencoding video data.
@@ -312,6 +319,9 @@ const audioTrackSource = new MediaStreamAudioTrackSource(audioTrack, {
codec: 'opus',
bitrate: 128e3,
});
// Make sure to allow any internal errors to properly bubble up
audioTrackSource.errorPromise.catch((error) => ...);
```
This source requires no additional method calls; data will automatically be captured and piped to the output file as soon as `start()` is called on the `Output`. Make sure to `stop()` on `audioTrack` after finalizing the `Output` if you don't need the user's media anymore.
@@ -320,6 +330,10 @@ This source requires no additional method calls; data will automatically be capt
If this source is the only MediaStreamTrack source in the `Output`, then the first audio sample added by it starts at timestamp 0. If there are multiple, then the earliest media sample across all tracks starts at timestamp 0, and all tracks will be perfectly synchronized with each other.
:::
::: warning
`MediaStreamAudioTrackSource`'s internals are detached from the typical code flow but can still throw, so make sure to utilize `errorPromise` to deal with any errors and to stop the `Output`.
:::
### `EncodedAudioPacketSource`
The most barebones of all audio sources, this source can be used to directly pipe [encoded packets](./packets-and-samples#encodedpacket) of audio data to the output. This source requires that you take care of the encoding process yourself, which enables you to use the WebCodecs API manually or to plug in your own encoding stack. Alternatively, you may retrieve the encoded packets directly by reading them from another media file, allowing you to skip decoding and reencoding audio data.
+16 -1
View File
@@ -133,6 +133,16 @@ encodedPacket.type; // => PacketType ('key' | 'delta')
For example, in a video track, it is common to have a key frame about every few seconds. When seeking, if the user seeks to a position shortly after a key frame, the decoded data can be shown quickly; if they seek far away from a key frame, the decoder must first crunch through many delta frames before it can show anything.
#### Determining a packet's actual type
The `type` field is derived from metadata in the containing file, which can sometimes (in rare cases) be incorrect. To determine a packet's actual type with certainty, you can do this:
```ts
// `packet` must come from the InputTrack `track`
const type = await track.determinePacketType(packet); // => PacketType | null
```
This determines the packet's type by looking into its bitstream. `null` is returned when the type couldn't be determined.
---
You can query the packet's timing information:
@@ -391,7 +401,7 @@ An audio sample represents a section of audio data. It can be created directly f
### Creating audio samples
Audio samples can be constructed either from an `AudioData` instance or an initialization object:
Audio samples can be constructed either from an `AudioData` instance, an initialization object, or an `AudioBuffer`:
```ts
import { AudioSample } from 'mediabunny';
@@ -407,6 +417,11 @@ const sample = new AudioSample({
sampleRate: 44100, // in Hz
timestamp: 0, // in seconds
});
// From AudioBuffer:
const timestamp = 0; // in seconds
const samples = AudioSample.fromAudioBuffer(audioBuffer, timestamp);
// => Returns multiple AudioSamples if the AudioBuffer is very long
```
The following audio sample formats are supported:
+12
View File
@@ -286,6 +286,18 @@ See [Media sinks](./media-sinks) for a full list of sinks.
### Examples
Loop over all raw encoded packets of a track:
```ts
import { EncodedPacketSink } from 'mediabunny';
const videoTrack = await input.getPrimaryVideoTrack();
const sink = new EncodedPacketSink(videoTrack);
for await (const packet of sink.packets()) {
console.log(packet.timestamp);
}
```
Here we iterate over all samples (frames) of a video track:
```ts
import { VideoSampleSink } from 'mediabunny';
+2
View File
@@ -90,9 +90,11 @@ const sponsors = {
gold: [
{ image: '/sponsors/gling.svg', name: 'Gling AI', url: 'https://www.gling.ai/' },
{ image: '/sponsors/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
],
individual: [
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
{ image: 'https://avatars.githubusercontent.com/u/9549394', name: 'studnitz', url: 'https://github.com/studnitz' },
{ image: 'https://avatars.githubusercontent.com/u/30229596', name: 'Pablo Bonilla', url: 'https://github.com/devPablo' },
{ 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' },
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -98,6 +98,10 @@ const compressFile = async (file: File) => {
compressionFacts.textContent
= `${(output.target.buffer!.byteLength / file.size * 100).toPrecision(3)}% of original size`;
} catch (error) {
console.error(error);
await currentConversion?.cancel();
errorElement.textContent = String(error);
clearInterval(currentIntervalId);
+1
View File
@@ -22,6 +22,7 @@
<hr class="w-full max-w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<p id="warning-element" class="text-amber-500"></p>
<div class="flex gap-4" id="main-container" style="display: none;">
<div class="flex flex-col items-center">
+43 -14
View File
@@ -1,4 +1,5 @@
import {
canEncodeAudio,
CanvasSource,
MediaStreamAudioTrackSource,
Mp4OutputFormat,
@@ -13,6 +14,7 @@ const mainContainer = document.querySelector('#main-container') as HTMLDivElemen
const videoElement = document.querySelector('video') as HTMLVideoElement;
const downloadButton = document.querySelector('#download-button') as HTMLAnchorElement;
const errorElement = document.querySelector('#error-element') as HTMLParagraphElement;
const warningElement = document.querySelector('#warning-element') as HTMLParagraphElement;
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!;
@@ -38,19 +40,30 @@ const startRecording = async () => {
mainContainer.style.display = 'none';
videoElement.src = '';
downloadButton.style.display = 'none';
errorElement.textContent = '';
warningElement.textContent = '';
// Paint a white background to the canvas
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
// Get user microphone
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioIsEncodable = await canEncodeAudio('opus', {
bitrate: QUALITY_MEDIUM,
});
let audioTrack: MediaStreamAudioTrack | null = null;
if (audioIsEncodable) {
// Get user microphone
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
audioTrack = mediaStream.getAudioTracks()[0] ?? null;
} else {
warningElement.textContent
= 'Audio is not yet encodable by your browser, so the audio track has been omitted.';
}
horizontalRule.style.display = '';
mainContainer.style.display = '';
const audioTrack = mediaStream.getAudioTracks()[0];
// Create a new output file
output = new Output({
// We're using fragmented MP4 here; streamable WebM would also work
@@ -98,6 +111,8 @@ const startRecording = async () => {
codec: 'opus',
bitrate: QUALITY_MEDIUM,
});
audioSource.errorPromise.catch(cancelRecording); // Make sure errors are bubbled up
output.addAudioTrack(audioSource);
}
@@ -107,9 +122,9 @@ const startRecording = async () => {
readyForMoreFrames = true;
lastFrameNumber = -1;
// Start the video frame capture loop
void addVideoFrame();
videoCaptureInterval = window.setInterval(() => void addVideoFrame(), 1000 / frameRate);
// Start the video frame capture loop, making sure errors are caught
void addVideoFrame().catch(cancelRecording);
videoCaptureInterval = window.setInterval(() => void addVideoFrame().catch(cancelRecording), 1000 / frameRate);
const mimeType = await output.getMimeType();
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
@@ -120,21 +135,35 @@ const startRecording = async () => {
toggleRecordingButton.textContent = 'Stop recording';
toggleRecordingButton.disabled = false;
} catch (error) {
errorElement.textContent = String(error);
mainContainer.style.display = 'none';
toggleRecordingButton.textContent = 'Start recording';
toggleRecordingButton.disabled = false;
recording = false;
await cancelRecording(error);
}
};
const cancelRecording = async (error: unknown) => {
if (!recording) {
return; // Already canceled
}
console.error(error);
errorElement.textContent = String(error);
clearInterval(videoCaptureInterval);
mainContainer.style.display = 'none';
toggleRecordingButton.textContent = 'Start recording';
toggleRecordingButton.disabled = false;
recording = false;
await output?.cancel();
mediaStream?.getTracks().forEach(track => track.stop());
};
const stopRecording = async () => {
toggleRecordingButton.textContent = 'Stopping...';
toggleRecordingButton.disabled = true;
clearInterval(videoCaptureInterval);
mediaStream.getTracks().forEach(track => track.stop());
mediaStream?.getTracks().forEach(track => track.stop());
await output.finalize();
+2
View File
@@ -29,6 +29,8 @@
<hr class="w-full max-w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<p id="warning-element" class="text-amber-500 mb-1"></p>
<div id="player" class="relative bg-black rounded-xl shrink min-h-14 min-w-0 w-full max-w-5xl overflow-hidden select-none" style="display: none;">
<canvas class="size-full object-contain" width="1280" height="720"></canvas>
+35 -9
View File
@@ -30,6 +30,7 @@ const volumeIconWrapper = document.querySelector('#volume-icon-wrapper') as HTML
const volumeButton = document.querySelector('#volume-button') as HTMLButtonElement;
const fullscreenButton = document.querySelector('#fullscreen-button') as HTMLButtonElement;
const errorElement = document.querySelector('#error-element') as HTMLDivElement;
const warningElement = document.querySelector('#warning-element') as HTMLDivElement;
const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!;
@@ -81,6 +82,8 @@ const initMediaPlayer = async (file: File) => {
fileNameElement.textContent = file.name;
horizontalRule.style.display = '';
playerContainer.style.display = 'none';
errorElement.textContent = '';
warningElement.textContent = '';
// Create an Input from the file
const input = new Input({
@@ -95,17 +98,38 @@ const initMediaPlayer = async (file: File) => {
let videoTrack = await input.getPrimaryVideoTrack();
let audioTrack = await input.getPrimaryAudioTrack();
if (!(await videoTrack?.canDecode())) {
// We can't decode the video track, so treat it like there is no video track
videoTrack = null;
let problemMessage = '';
if (videoTrack) {
if (videoTrack.codec === null) {
problemMessage += 'Unsupported video codec. ';
videoTrack = null;
} else if (!(await videoTrack.canDecode())) {
problemMessage += 'Unable to decode the video track. ';
videoTrack = null;
}
}
if (!(await audioTrack?.canDecode())) {
// We can't decode the audio track, so treat it like there is no audio track
audioTrack = null;
if (audioTrack) {
if (audioTrack.codec === null) {
problemMessage += 'Unsupported audio codec. ';
audioTrack = null;
} else if (!(await audioTrack.canDecode())) {
problemMessage += 'Unable to decode the audio track. ';
audioTrack = null;
}
}
if (!videoTrack && !audioTrack) {
throw new Error('Media file has no playable video or audio track.');
if (!problemMessage) {
problemMessage = 'No audio or video track found.';
}
throw new Error(problemMessage);
}
if (problemMessage) {
warningElement.textContent = problemMessage;
}
// We must create the audio context with the matching sample rate for correct acoustic results
@@ -151,8 +175,10 @@ const initMediaPlayer = async (file: File) => {
controlsElement.style.opacity = '1';
playerContainer.style.cursor = '';
}
} catch (e) {
errorElement.textContent = String(e);
} catch (error) {
console.error(error);
errorElement.textContent = String(error);
playerContainer.style.display = 'none';
}
};
@@ -129,6 +129,8 @@ const renderObject = (object: Record<string, unknown>) => {
listItem.removeChild(loadingSpan);
listItem.appendChild(renderValue(resolvedValue));
}).catch((error) => {
console.error(error);
// Show the promise error
listItem.removeChild(loadingSpan);
const errorSpan = document.createElement('span');
@@ -7,6 +7,7 @@ import {
QUALITY_HIGH,
getFirstEncodableAudioCodec,
getFirstEncodableVideoCodec,
OutputFormat,
} from 'mediabunny';
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
@@ -57,6 +58,8 @@ let currentScaleIndex = 0;
let collisionCount = 0;
let collisionsPerScale = 0;
let output: Output<OutputFormat, BufferTarget>;
/** === MAIN VIDEO FILE GENERATION LOGIC === */
const generateVideo = async () => {
@@ -82,7 +85,7 @@ const generateVideo = async () => {
initScene(duration);
// Create a new output file
const output = new Output({
output = new Output({
target: new BufferTarget(), // Stored in memory
format: new Mp4OutputFormat(),
});
@@ -184,6 +187,10 @@ const generateVideo = async () => {
const fileSizeMiB = (videoBlob.size / (1024 * 1024)).toPrecision(3);
videoInfo.textContent = `File size: ${fileSizeMiB} MiB`;
} catch (error) {
console.error(error);
await output?.cancel();
clearInterval(progressInterval);
errorElement.textContent = String(error);
progressBarContainer.style.display = 'none';
@@ -15,7 +15,7 @@ const THUMBNAIL_SIZE = 200;
const generateThumbnails = async (file: File) => {
fileNameElement.textContent = file.name;
horizontalRule.style.display = '';
errorElement.innerHTML = '';
errorElement.textContent = '';
thumbnailContainer.innerHTML = '';
try {
@@ -30,6 +30,14 @@ const generateThumbnails = async (file: File) => {
throw new Error('File has no video track.');
}
if (videoTrack.codec === null) {
throw new Error('Unsupported video codec.');
}
if (!(await videoTrack.canDecode())) {
throw new Error('Unable to decode the video track.');
}
// Compute width and height of the thumbnails such that the larger dimension is equal to THUMBNAIL_SIZE
const width = videoTrack.displayWidth > videoTrack.displayHeight
? THUMBNAIL_SIZE
@@ -88,8 +96,10 @@ const generateThumbnails = async (file: File) => {
i++;
}
} catch (e) {
errorElement.textContent = String(e);
} catch (error) {
console.error(error);
errorElement.textContent = String(error);
thumbnailContainer.innerHTML = '';
}
};
+6 -6
View File
@@ -1,16 +1,16 @@
{
"name": "mediabunny",
"version": "1.2.0",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mediabunny",
"version": "1.2.0",
"version": "1.4.1",
"license": "MPL-2.0",
"dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/dom-webcodecs": "^0.1.15"
"@types/dom-webcodecs": "0.1.13"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
@@ -2443,9 +2443,9 @@
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz",
"integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==",
"license": "MIT"
},
"node_modules/@types/estree": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "mediabunny",
"author": "Vanilagy",
"version": "1.2.0",
"version": "1.4.1",
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
"type": "module",
"main": "./dist/bundles/mediabunny.cjs",
@@ -31,7 +31,7 @@
"docs:preview": "vitepress preview docs",
"dev": "vite",
"examples:build": "vite build",
"fix-build-import-paths": "find dist -name \"*.js\" -type f -exec sed -i -r \"s/ from '([^']+)';/ from '\\1.js';/g\" {} \\;",
"fix-build-import-paths": "tsx scripts/add-import-extensions.ts",
"append-namespace": "echo 'export as namespace Mediabunny;' >> dist/mediabunny.d.ts"
},
"license": "MPL-2.0",
@@ -49,7 +49,7 @@
},
"dependencies": {
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/dom-webcodecs": "^0.1.15"
"@types/dom-webcodecs": "0.1.13"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
+37
View File
@@ -0,0 +1,37 @@
import * as fs from 'fs';
import * as path from 'path';
// .js extensions are technically required in compliant ECMAScript, and Webpack needs them, so we add them here.
const walkDir = (dir: string) => {
const files: string[] = [];
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
files.push(...walkDir(fullPath));
} else if (item.endsWith('.js')) {
files.push(fullPath);
}
}
return files;
};
const fixFile = (filePath: string) => {
const content = fs.readFileSync(filePath, 'utf8');
const fixed = content.replace(
/(\s+from\s+['"])([^'"]*)(['"])/g,
'$1$2.js$3',
);
if (content !== fixed) {
fs.writeFileSync(filePath, fixed);
}
};
const jsFiles = walkDir('dist');
jsFiles.forEach(fixFile);
+3 -1
View File
@@ -1,5 +1,5 @@
import ts from 'typescript';
import * as fs from 'node:fs';
import * as fs from 'fs';
const checkDocblocks = (filePath: string) => {
const program = ts.createProgram([filePath], {});
@@ -17,6 +17,8 @@ const checkDocblocks = (filePath: string) => {
ts.isInterfaceDeclaration(node)
|| ts.isClassDeclaration(node)
|| ts.isMethodDeclaration(node)
|| ts.isGetAccessorDeclaration(node)
|| ts.isSetAccessorDeclaration(node)
|| ts.isPropertyDeclaration(node)
|| ts.isFunctionDeclaration(node)
|| ts.isTypeAliasDeclaration(node)
+342 -180
View File
@@ -7,7 +7,18 @@
*/
import { VP9_LEVEL_TABLE } from './codec';
import { assert, Bitstream, last, readExpGolomb, readSignedExpGolomb, toDataView } from './misc';
import { InputVideoTrack } from './input-track';
import {
assert,
assertNever,
Bitstream,
last,
readExpGolomb,
readSignedExpGolomb,
toDataView,
toUint8Array,
} from './misc';
import { EncodedPacket, PacketType } from './packet';
// References for AVC/HEVC code:
// ISO 14496-15
@@ -72,6 +83,39 @@ const findNalUnitsInAnnexB = (packetData: Uint8Array) => {
return nalUnits;
};
/** Finds all NAL units in an AVC packet in length-prefixed format. */
const findNalUnitsInLengthPrefixed = (packetData: Uint8Array, lengthSize: 1 | 2 | 3 | 4) => {
const nalUnits: Uint8Array[] = [];
let offset = 0;
const dataView = new DataView(packetData.buffer, packetData.byteOffset, packetData.byteLength);
while (offset + lengthSize <= packetData.length) {
let nalUnitLength: number;
if (lengthSize === 1) {
nalUnitLength = dataView.getUint8(offset);
} else if (lengthSize === 2) {
nalUnitLength = dataView.getUint16(offset, false);
} else if (lengthSize === 3) {
nalUnitLength = (dataView.getUint16(offset, false) << 8) + dataView.getUint8(offset + 2);
} else if (lengthSize === 4) {
nalUnitLength = dataView.getUint32(offset, false);
} else {
assertNever(lengthSize);
assert(false);
}
offset += lengthSize;
const nalUnit = packetData.subarray(offset, offset + nalUnitLength);
nalUnits.push(nalUnit);
offset += nalUnitLength;
}
return nalUnits;
};
const removeEmulationPreventionBytes = (data: Uint8Array) => {
const result: number[] = [];
const len = data.length;
@@ -879,30 +923,6 @@ export const extractVp9CodecInfoFromPacket = (
// https://storage.googleapis.com/downloads.webmproject.org/docs/vp9/vp9-bitstream-specification-v0.7-20170222-draft.pdf
// http://downloads.webmproject.org/docs/vp9/vp9-bitstream_superframe-and-uncompressed-header_v1.0.pdf
// Handle superframe
const lastByte = packet[packet.length - 1];
if (lastByte && (lastByte & 0xe0) === 0xc0) { // Is superframe
const bytesPerFrameSize = ((lastByte & 0x18) >> 3) + 1;
const numFrames = (lastByte & 0x07) + 1;
const indexSize = 2 + numFrames * bytesPerFrameSize;
// Verify matching marker bytes
if (packet[packet.length - indexSize] !== lastByte) {
return null;
}
// Get first frame size
let frameSize = 0;
const offset = packet.length - indexSize + 1;
for (let i = 0; i < bytesPerFrameSize; i++) {
if (!packet[offset + i]) return null;
frameSize |= packet[offset + i]! << (8 * i);
}
packet = packet.subarray(0, frameSize);
}
const bitstream = new Bitstream(packet);
// Frame marker (0b10)
@@ -1048,13 +1068,8 @@ export type Av1CodecInfo = {
chromaSamplePosition: number;
};
/**
* When AV1 codec information is not provided by the container, we can still try to extract the information by digging
* into the AV1 bitstream.
*/
export const extractAv1CodecInfoFromPacket = (
packet: Uint8Array,
): Av1CodecInfo | null => {
/** Iterates over all OBUs in an AV1 packet bistream. */
export function* iterateAv1PacketObus(packet: Uint8Array) {
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
const bitstream = new Bitstream(packet);
@@ -1064,7 +1079,6 @@ export const extractAv1CodecInfoFromPacket = (
for (let i = 0; i < 8; i++) {
const byte = bitstream.readAlignedByte();
if (byte === undefined) return 0;
value |= ((byte & 0x7f) << (i * 7));
@@ -1088,11 +1102,11 @@ export const extractAv1CodecInfoFromPacket = (
while (bitstream.getBitsLeft() >= 8) {
// Parse OBU header
const obuHeader = bitstream.readBits(8);
const obuType = (obuHeader >> 3) & 0xf;
const obuExtension = (obuHeader >> 2) & 0x1;
const obuHasSizeField = (obuHeader >> 1) & 0x1;
bitstream.skipBits(1);
const obuType = bitstream.readBits(4);
const obuExtension = bitstream.readBits(1);
const obuHasSizeField = bitstream.readBits(1);
bitstream.skipBits(1);
// Skip extension header if present
if (obuExtension) {
@@ -1103,159 +1117,180 @@ export const extractAv1CodecInfoFromPacket = (
let obuSize: number;
if (obuHasSizeField) {
const obuSizeValue = readLeb128();
if (obuSizeValue === null) return null; // It was invalid
if (obuSizeValue === null) return; // It was invalid
obuSize = obuSizeValue;
} else {
// Calculate remaining bits and convert to bytes, rounding down
obuSize = Math.floor(bitstream.getBitsLeft() / 8);
}
// We're only interested in Sequence Header OBU (type 1)
if (obuType === 1) {
// Read sequence header fields
const seqProfile = bitstream.readBits(3);
assert(bitstream.pos % 8 === 0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const stillPicture = bitstream.readBits(1);
const reducedStillPictureHeader = bitstream.readBits(1);
let seqLevel = 0;
let seqTier = 0;
let bufferDelayLengthMinus1 = 0;
if (reducedStillPictureHeader) {
seqLevel = bitstream.readBits(5);
} else {
// Parse timing_info_present_flag
const timingInfoPresentFlag = bitstream.readBits(1);
if (timingInfoPresentFlag) {
// Skip timing info (num_units_in_display_tick, time_scale, equal_picture_interval)
bitstream.skipBits(32); // num_units_in_display_tick
bitstream.skipBits(32); // time_scale
const equalPictureInterval = bitstream.readBits(1);
if (equalPictureInterval) {
// Skip num_ticks_per_picture_minus_1 (uvlc)
// Since this is variable length, we'd need to implement uvlc reading
// For now, we'll return null as this is rare
return null;
}
}
// Parse decoder_model_info_present_flag
const decoderModelInfoPresentFlag = bitstream.readBits(1);
if (decoderModelInfoPresentFlag) {
// Store buffer_delay_length_minus_1 instead of just skipping
bufferDelayLengthMinus1 = bitstream.readBits(5);
bitstream.skipBits(32); // num_units_in_decoding_tick
bitstream.skipBits(5); // buffer_removal_time_length_minus_1
bitstream.skipBits(5); // frame_presentation_time_length_minus_1
}
// Parse operating_points_cnt_minus_1
const operatingPointsCntMinus1 = bitstream.readBits(5);
// For each operating point
for (let i = 0; i <= operatingPointsCntMinus1; i++) {
// operating_point_idc[i]
bitstream.skipBits(12);
// seq_level_idx[i]
const seqLevelIdx = bitstream.readBits(5);
if (i === 0) {
seqLevel = seqLevelIdx;
}
if (seqLevelIdx > 7) {
// seq_tier[i]
const seqTierTemp = bitstream.readBits(1);
if (i === 0) {
seqTier = seqTierTemp;
}
}
if (decoderModelInfoPresentFlag) {
// decoder_model_present_for_this_op[i]
const decoderModelPresentForThisOp = bitstream.readBits(1);
if (decoderModelPresentForThisOp) {
const n = bufferDelayLengthMinus1 + 1;
bitstream.skipBits(n); // decoder_buffer_delay[op]
bitstream.skipBits(n); // encoder_buffer_delay[op]
bitstream.skipBits(1); // low_delay_mode_flag[op]
}
}
// initial_display_delay_present_flag
const initialDisplayDelayPresentFlag = bitstream.readBits(1);
if (initialDisplayDelayPresentFlag) {
// initial_display_delay_minus_1[i]
bitstream.skipBits(4);
}
}
}
const highBitdepth = bitstream.readBits(1);
let bitDepth = 8;
if (seqProfile === 2 && highBitdepth) {
const twelveBit = bitstream.readBits(1);
bitDepth = twelveBit ? 12 : 10;
} else if (seqProfile <= 2) {
bitDepth = highBitdepth ? 10 : 8;
}
let monochrome = 0;
if (seqProfile !== 1) {
monochrome = bitstream.readBits(1);
}
let chromaSubsamplingX = 1;
let chromaSubsamplingY = 1;
let chromaSamplePosition = 0;
if (!monochrome) {
if (seqProfile === 0) {
chromaSubsamplingX = 1;
chromaSubsamplingY = 1;
} else if (seqProfile === 1) {
chromaSubsamplingX = 0;
chromaSubsamplingY = 0;
} else {
if (bitDepth === 12) {
chromaSubsamplingX = bitstream.readBits(1);
if (chromaSubsamplingX) {
chromaSubsamplingY = bitstream.readBits(1);
}
}
}
if (chromaSubsamplingX && chromaSubsamplingY) {
chromaSamplePosition = bitstream.readBits(2);
}
}
return {
profile: seqProfile,
level: seqLevel,
tier: seqTier,
bitDepth,
monochrome,
chromaSubsamplingX,
chromaSubsamplingY,
chromaSamplePosition,
};
}
yield {
type: obuType,
data: packet.subarray(bitstream.pos / 8, bitstream.pos / 8 + obuSize),
};
// Move to next OBU
// The OBU size is in bytes, so skip that many bytes.
bitstream.skipBits(obuSize * 8);
}
};
/**
* When AV1 codec information is not provided by the container, we can still try to extract the information by digging
* into the AV1 bitstream.
*/
export const extractAv1CodecInfoFromPacket = (
packet: Uint8Array,
): Av1CodecInfo | null => {
// https://aomediacodec.github.io/av1-spec/av1-spec.pdf
for (const { type, data } of iterateAv1PacketObus(packet)) {
if (type !== 1) {
continue; // 1 == OBU_SEQUENCE_HEADER
}
const bitstream = new Bitstream(data);
// Read sequence header fields
const seqProfile = bitstream.readBits(3);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const stillPicture = bitstream.readBits(1);
const reducedStillPictureHeader = bitstream.readBits(1);
let seqLevel = 0;
let seqTier = 0;
let bufferDelayLengthMinus1 = 0;
if (reducedStillPictureHeader) {
seqLevel = bitstream.readBits(5);
} else {
// Parse timing_info_present_flag
const timingInfoPresentFlag = bitstream.readBits(1);
if (timingInfoPresentFlag) {
// Skip timing info (num_units_in_display_tick, time_scale, equal_picture_interval)
bitstream.skipBits(32); // num_units_in_display_tick
bitstream.skipBits(32); // time_scale
const equalPictureInterval = bitstream.readBits(1);
if (equalPictureInterval) {
// Skip num_ticks_per_picture_minus_1 (uvlc)
// Since this is variable length, we'd need to implement uvlc reading
// For now, we'll return null as this is rare
return null;
}
}
// Parse decoder_model_info_present_flag
const decoderModelInfoPresentFlag = bitstream.readBits(1);
if (decoderModelInfoPresentFlag) {
// Store buffer_delay_length_minus_1 instead of just skipping
bufferDelayLengthMinus1 = bitstream.readBits(5);
bitstream.skipBits(32); // num_units_in_decoding_tick
bitstream.skipBits(5); // buffer_removal_time_length_minus_1
bitstream.skipBits(5); // frame_presentation_time_length_minus_1
}
// Parse operating_points_cnt_minus_1
const operatingPointsCntMinus1 = bitstream.readBits(5);
// For each operating point
for (let i = 0; i <= operatingPointsCntMinus1; i++) {
// operating_point_idc[i]
bitstream.skipBits(12);
// seq_level_idx[i]
const seqLevelIdx = bitstream.readBits(5);
if (i === 0) {
seqLevel = seqLevelIdx;
}
if (seqLevelIdx > 7) {
// seq_tier[i]
const seqTierTemp = bitstream.readBits(1);
if (i === 0) {
seqTier = seqTierTemp;
}
}
if (decoderModelInfoPresentFlag) {
// decoder_model_present_for_this_op[i]
const decoderModelPresentForThisOp = bitstream.readBits(1);
if (decoderModelPresentForThisOp) {
const n = bufferDelayLengthMinus1 + 1;
bitstream.skipBits(n); // decoder_buffer_delay[op]
bitstream.skipBits(n); // encoder_buffer_delay[op]
bitstream.skipBits(1); // low_delay_mode_flag[op]
}
}
// initial_display_delay_present_flag
const initialDisplayDelayPresentFlag = bitstream.readBits(1);
if (initialDisplayDelayPresentFlag) {
// initial_display_delay_minus_1[i]
bitstream.skipBits(4);
}
}
}
const highBitdepth = bitstream.readBits(1);
let bitDepth = 8;
if (seqProfile === 2 && highBitdepth) {
const twelveBit = bitstream.readBits(1);
bitDepth = twelveBit ? 12 : 10;
} else if (seqProfile <= 2) {
bitDepth = highBitdepth ? 10 : 8;
}
let monochrome = 0;
if (seqProfile !== 1) {
monochrome = bitstream.readBits(1);
}
let chromaSubsamplingX = 1;
let chromaSubsamplingY = 1;
let chromaSamplePosition = 0;
if (!monochrome) {
if (seqProfile === 0) {
chromaSubsamplingX = 1;
chromaSubsamplingY = 1;
} else if (seqProfile === 1) {
chromaSubsamplingX = 0;
chromaSubsamplingY = 0;
} else {
if (bitDepth === 12) {
chromaSubsamplingX = bitstream.readBits(1);
if (chromaSubsamplingX) {
chromaSubsamplingY = bitstream.readBits(1);
}
}
}
if (chromaSubsamplingX && chromaSubsamplingY) {
chromaSamplePosition = bitstream.readBits(2);
}
}
return {
profile: seqProfile,
level: seqLevel,
tier: seqTier,
bitDepth,
monochrome,
chromaSubsamplingX,
chromaSubsamplingY,
chromaSamplePosition,
};
}
return null;
};
@@ -1392,3 +1427,130 @@ export const parseModesFromVorbisSetupPacket = (setupHeader: Uint8Array) => {
return { modeBlockflags };
};
/** Determines a packet's type (key or delta) by digging into the packet bitstream. */
export const determineVideoPacketType = async (
videoTrack: InputVideoTrack,
packet: EncodedPacket,
): Promise<PacketType | null> => {
assert(videoTrack.codec);
switch (videoTrack.codec) {
case 'avc': {
const decoderConfig = await videoTrack.getDecoderConfig();
assert(decoderConfig);
let nalUnits: Uint8Array[];
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[4]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
nalUnits = findNalUnitsInLengthPrefixed(packet.data, lengthSize);
} else {
// Stream is in Annex B format
nalUnits = findNalUnitsInAnnexB(packet.data);
}
const isKeyframe = nalUnits.some(x => extractNalUnitTypeForAvc(x) === 5);
return isKeyframe ? 'key' : 'delta';
};
case 'hevc': {
const decoderConfig = await videoTrack.getDecoderConfig();
assert(decoderConfig);
let nalUnits: Uint8Array[];
if (decoderConfig.description) {
// Stream is length-prefixed. Let's extract the size of the length prefix from the decoder config
const bytes = toUint8Array(decoderConfig.description);
const lengthSizeMinusOne = bytes[21]! & 0b11;
const lengthSize = (lengthSizeMinusOne + 1) as 1 | 2 | 3 | 4;
nalUnits = findNalUnitsInLengthPrefixed(packet.data, lengthSize);
} else {
// Stream is in Annex B format
nalUnits = findNalUnitsInAnnexB(packet.data);
}
const isKeyframe = nalUnits.some((x) => {
const type = extractNalUnitTypeForHevc(x);
return 16 <= type && type <= 23;
});
return isKeyframe ? 'key' : 'delta';
};
case 'vp8': {
// VP8, once again, by far the easiest to deal with.
const frameType = packet.data[0]! & 0b1;
return frameType === 0 ? 'key' : 'delta';
};
case 'vp9': {
const bitstream = new Bitstream(packet.data);
if (bitstream.readBits(2) !== 2) {
return null;
};
const profileLowBit = bitstream.readBits(1);
const profileHighBit = bitstream.readBits(1);
const profile = (profileHighBit << 1) + profileLowBit;
// Skip reserved bit for profile 3
if (profile === 3) {
bitstream.skipBits(1);
}
const showExistingFrame = bitstream.readBits(1);
if (showExistingFrame) {
return null;
}
const frameType = bitstream.readBits(1);
return frameType === 0 ? 'key' : 'delta';
};
case 'av1': {
let reducedStillPictureHeader = false;
for (const { type, data } of iterateAv1PacketObus(packet.data)) {
if (type === 1) { // OBU_SEQUENCE_HEADER
const bitstream = new Bitstream(data);
bitstream.skipBits(4);
reducedStillPictureHeader = !!bitstream.readBits(1);
} else if (
type === 3 // OBU_FRAME_HEADER
|| type === 6 // OBU_FRAME
|| type === 7 // OBU_REDUNDANT_FRAME_HEADER
) {
if (reducedStillPictureHeader) {
return 'key';
}
const bitstream = new Bitstream(data);
const showExistingFrame = bitstream.readBits(1);
if (showExistingFrame) {
return null;
}
const frameType = bitstream.readBits(2);
return frameType === 0 ? 'key' : 'delta';
}
}
return null;
};
default: {
assertNever(videoTrack.codec);
assert(false);
};
}
};
+1 -1
View File
@@ -498,7 +498,7 @@ export class Conversion {
? await sink.getPacket(this._endTimestamp, { metadataOnly: true }) ?? undefined
: undefined;
for await (const packet of sink.packets(undefined, endPacket)) {
for await (const packet of sink.packets(undefined, endPacket, { verifyKeyPackets: true })) {
if (this._synchronizer.shouldWait(track.id, packet.timestamp)) {
await this._synchronizer.wait(packet.timestamp);
}
+42 -3
View File
@@ -7,11 +7,12 @@
*/
import { AudioCodec, MediaCodec, VideoCodec } from './codec';
import { determineVideoPacketType } from './codec-data';
import { customAudioDecoders, customVideoDecoders } from './custom-coder';
import { EncodedPacketSink, PacketRetrievalOptions } from './media-sink';
import { assert, Rotation } from './misc';
import { TrackType } from './output';
import { EncodedPacket } from './packet';
import { EncodedPacket, PacketType } from './packet';
/**
* Contains aggregate statistics about the encoded packets of a track.
@@ -62,6 +63,11 @@ export abstract class InputTrack {
abstract getCodecParameterString(): Promise<string | null>;
/** Checks if this track's packets can be decoded by the browser. */
abstract canDecode(): Promise<boolean>;
/**
* For a given packet of this track, this method determines the actual type of this packet (key/delta) by looking
* into its bitstream. Returns null if the type couldn't be determined.
*/
abstract determinePacketType(packet: EncodedPacket): Promise<PacketType | null>;
/** Returns true iff this track is a video track. */
isVideoTrack(): this is InputVideoTrack {
@@ -222,7 +228,10 @@ export class InputVideoTrack extends InputTrack {
|| (colorSpace.matrix as string) === 'bt2020-ncl';
}
/** Returns the decoder configuration for decoding the track's packets using a VideoDecoder. */
/**
* Returns the decoder configuration for decoding the track's packets using a VideoDecoder. Returns null if the
* track's codec is unknown.
*/
getDecoderConfig() {
return this._backing.getDecoderConfig();
}
@@ -257,6 +266,21 @@ export class InputVideoTrack extends InputTrack {
return false;
}
}
async determinePacketType(packet: EncodedPacket): Promise<PacketType | null> {
if (!(packet instanceof EncodedPacket)) {
throw new TypeError('packet must be an EncodedPacket.');
}
if (packet.isMetadataOnly) {
throw new TypeError('packet must not be metadata-only to determine its type.');
}
if (this.codec === null) {
return null;
}
return determineVideoPacketType(this, packet);
}
}
export interface InputAudioTrackBacking extends InputTrackBacking {
@@ -299,7 +323,10 @@ export class InputAudioTrack extends InputTrack {
return this._backing.getSampleRate();
}
/** Returns the decoder configuration for decoding the track's packets using an AudioDecoder. */
/**
* Returns the decoder configuration for decoding the track's packets using an AudioDecoder. Returns null if the
* track's codec is unknown.
*/
getDecoderConfig() {
return this._backing.getDecoderConfig();
}
@@ -338,4 +365,16 @@ export class InputAudioTrack extends InputTrack {
return false;
}
}
async determinePacketType(packet: EncodedPacket): Promise<PacketType | null> {
if (!(packet instanceof EncodedPacket)) {
throw new TypeError('packet must be an EncodedPacket.');
}
if (this.codec === null) {
return null;
}
return 'key'; // No audio codec with delta packets
}
}
+13 -6
View File
@@ -32,6 +32,7 @@ import {
transformAnnexBToLengthPrefixed,
} from '../codec-data';
import { buildIsobmffMimeType } from './isobmff-misc';
import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE } from './isobmff-reader';
export const GLOBAL_TIMESCALE = 1000;
const TIMESTAMP_OFFSET = 2_082_844_800; // Seconds between Jan 1 1904 and Jan 1 1970
@@ -989,10 +990,7 @@ export class IsobmffMuxer extends Muxer {
const moofOffset = this.writer.getPos();
const mdatStartPos = moofOffset + this.boxWriter.measureBox(moofBox);
// Header with large size. We always reserve 16 bytes for it even if we don't end up using the large size.
const mdatHeaderSize = 16;
let currentPos = mdatStartPos + mdatHeaderSize;
let currentPos = mdatStartPos + MIN_BOX_HEADER_SIZE;
let fragmentStartTimestamp = Infinity;
for (const trackData of tracksInFragment) {
trackData.currentChunk!.offset = currentPos;
@@ -1006,6 +1004,15 @@ export class IsobmffMuxer extends Muxer {
}
const mdatSize = currentPos - mdatStartPos;
const needsLargeMdatSize = mdatSize >= 2 ** 32;
if (needsLargeMdatSize) {
// Shift all offsets by 8. Previously, all chunks were shifted assuming the large box size, but due to what
// I suspect is a bug in WebKit, it failed in Safari (when livestreaming with MSE, not for static playback).
for (const trackData of tracksInFragment) {
trackData.currentChunk!.offset! += MAX_BOX_HEADER_SIZE - MIN_BOX_HEADER_SIZE;
}
}
if (this.format._options.onMoof) {
this.writer.startTrackingWrites();
@@ -1025,11 +1032,11 @@ export class IsobmffMuxer extends Muxer {
this.writer.startTrackingWrites();
}
const mdatBox = mdat(mdatSize >= 2 ** 32);
const mdatBox = mdat(needsLargeMdatSize);
mdatBox.size = mdatSize;
this.boxWriter.writeBox(mdatBox);
this.writer.seek(mdatStartPos + mdatHeaderSize);
this.writer.seek(mdatStartPos + (needsLargeMdatSize ? MAX_BOX_HEADER_SIZE : MIN_BOX_HEADER_SIZE));
// Write sample data
for (const trackData of tracksInFragment) {
+7
View File
@@ -368,6 +368,13 @@ export class MatroskaDemuxer extends Demuxer {
this.readContiguousElements(this.metadataReader, size);
}
if (this.currentSegment.timestampScale === -1) {
// TimestampScale element is missing. Technically an invalid file, but let's default to the typical value,
// which is 1e6.
this.currentSegment.timestampScale = 1e6;
this.currentSegment.timestampFactor = 1e9 / 1e6;
}
// Put default tracks first
this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
+1 -2
View File
@@ -694,8 +694,7 @@ export class MatroskaMuxer extends Muxer {
const bitstream = new Bitstream(chunk.data);
// Check if it's a "superframe"
if (bitstream.readBits(2) !== 0b10) return;
bitstream.skipBits(2);
const profileLowBit = bitstream.readBits(1);
const profileHighBit = bitstream.readBits(1);
+88 -10
View File
@@ -39,6 +39,14 @@ export type PacketRetrievalOptions = {
* be loaded.
*/
metadataOnly?: boolean;
/**
* When set to true, key packets will be verified upon retrieval by looking into the packet's bitstream.
* If not enabled, the packet types will be determined solely by what's stored in the containing file and may be
* incorrect, potentially leading to decoder errors. Since determining a packet's actual type requires looking into
* its data, this option cannot be enabled together with `metadataOnly`.
*/
verifyKeyPackets?: boolean;
};
const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
@@ -48,6 +56,12 @@ const validatePacketRetrievalOptions = (options: PacketRetrievalOptions) => {
if (options.metadataOnly !== undefined && typeof options.metadataOnly !== 'boolean') {
throw new TypeError('options.metadataOnly, when defined, must be a boolean.');
}
if (options.verifyKeyPackets !== undefined && typeof options.verifyKeyPackets !== 'boolean') {
throw new TypeError('options.verifyKeyPackets, when defined, must be a boolean.');
}
if (options.verifyKeyPackets && options.metadataOnly) {
throw new TypeError('options.verifyKeyPackets and options.metadataOnly cannot be enabled together.');
}
};
const validateTimestamp = (timestamp: number) => {
@@ -56,6 +70,30 @@ const validateTimestamp = (timestamp: number) => {
}
};
const maybeFixPacketType = (
track: InputTrack,
promise: Promise<EncodedPacket | null>,
options: PacketRetrievalOptions,
) => {
if (options.verifyKeyPackets) {
return promise.then(async (packet) => {
if (!packet || packet.type === 'delta') {
return packet;
}
const determinedType = await track.determinePacketType(packet);
if (determinedType) {
// @ts-expect-error Technically readonly
packet.type = determinedType;
}
return packet;
});
} else {
return promise;
}
};
/**
* Sink for retrieving encoded packets from an input track.
* @public
@@ -78,7 +116,8 @@ export class EncodedPacketSink {
*/
getFirstPacket(options: PacketRetrievalOptions = {}) {
validatePacketRetrievalOptions(options);
return this._track._backing.getFirstPacket(options);
return maybeFixPacketType(this._track, this._track._backing.getFirstPacket(options), options);
}
/**
@@ -92,7 +131,8 @@ export class EncodedPacketSink {
getPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
validateTimestamp(timestamp);
validatePacketRetrievalOptions(options);
return this._track._backing.getPacket(timestamp, options);
return maybeFixPacketType(this._track, this._track._backing.getPacket(timestamp, options), options);
}
/**
@@ -104,7 +144,8 @@ export class EncodedPacketSink {
throw new TypeError('packet must be an EncodedPacket.');
}
validatePacketRetrievalOptions(options);
return this._track._backing.getNextPacket(packet, options);
return maybeFixPacketType(this._track, this._track._backing.getNextPacket(packet, options), options);
}
/**
@@ -114,24 +155,60 @@ export class EncodedPacketSink {
* last key packet using `getKeyPacket(Infinity)`. The method returns null if the timestamp is before the first
* key packet in the track.
*
* To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
*
* @param timestamp - The timestamp used for retrieval, in seconds.
*/
getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}) {
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions = {}): Promise<EncodedPacket | null> {
validateTimestamp(timestamp);
validatePacketRetrievalOptions(options);
return this._track._backing.getKeyPacket(timestamp, options);
if (!options.verifyKeyPackets) {
return this._track._backing.getKeyPacket(timestamp, options);
}
const packet = await this._track._backing.getKeyPacket(timestamp, options);
if (!packet || packet.type === 'delta') {
return packet;
}
const determinedType = await this._track.determinePacketType(packet);
if (determinedType === 'delta') {
// Try returning the previous key packet (in hopes that it's actually a key packet)
return this.getKeyPacket(packet.timestamp - 1 / this._track.timeResolution, options);
}
return packet;
}
/**
* Retrieves the key packet following the given packet (in decode order), or null if the given packet is the last
* key packet.
*
* To ensure that the returned packet is guaranteed to be a real key frame, enable `options.verifyKeyPackets`.
*/
getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}) {
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions = {}): Promise<EncodedPacket | null> {
if (!(packet instanceof EncodedPacket)) {
throw new TypeError('packet must be an EncodedPacket.');
}
validatePacketRetrievalOptions(options);
return this._track._backing.getNextKeyPacket(packet, options);
if (!options.verifyKeyPackets) {
return this._track._backing.getNextKeyPacket(packet, options);
}
const nextPacket = await this._track._backing.getNextKeyPacket(packet, options);
if (!nextPacket || nextPacket.type === 'delta') {
return nextPacket;
}
const determinedType = await this._track.determinePacketType(nextPacket);
if (determinedType === 'delta') {
// Try returning the next key packet (in hopes that it's actually a key packet)
return this.getNextKeyPacket(nextPacket, options);
}
return nextPacket;
}
/**
@@ -346,7 +423,8 @@ export abstract class BaseMediaSampleSink<
});
const packetSink = this._createPacketSink();
const keyPacket = await packetSink.getKeyPacket(startTimestamp) ?? await packetSink.getFirstPacket();
const keyPacket = await packetSink.getKeyPacket(startTimestamp, { verifyKeyPackets: true })
?? await packetSink.getFirstPacket();
if (!keyPacket) {
return;
}
@@ -364,7 +442,7 @@ export abstract class BaseMediaSampleSink<
? null
: packet.type === 'key' && packet.timestamp === endTimestamp
? packet
: await packetSink.getNextKeyPacket(packet);
: await packetSink.getNextKeyPacket(packet, { verifyKeyPackets: true });
if (keyPacket) {
endPacket = keyPacket;
@@ -567,7 +645,7 @@ export abstract class BaseMediaSampleSink<
}
const targetPacket = await packetSink.getPacket(timestamp);
const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp);
const keyPacket = targetPacket && await packetSink.getKeyPacket(timestamp, { verifyKeyPackets: true });
if (!keyPacket) {
if (maxSequenceNumber !== -1) {
+545 -235
View File
@@ -24,7 +24,7 @@ import {
VideoCodec,
} from './codec';
import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from './output';
import { assert, assertNever, CallSerializer, clamp, setInt24, setUint24 } from './misc';
import { assert, assertNever, CallSerializer, clamp, promiseWithResolvers, setInt24, setUint24 } from './misc';
import { Muxer } from './muxer';
import { SubtitleParser } from './subtitles';
import { toAlaw, toUlaw } from './pcm';
@@ -78,7 +78,7 @@ export abstract class MediaSource {
}
/** @internal */
_start() {}
async _start() {}
/** @internal */
async _flushAndClose() {}
@@ -272,86 +272,94 @@ class VideoEncoderWrapper {
constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {}
async add(videoSample: VideoSample, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) {
this.checkForEncoderError();
this.source._ensureValidAdd();
try {
this.checkForEncoderError();
this.source._ensureValidAdd();
// Ensure video sample size remains constant
if (this.lastWidth !== null && this.lastHeight !== null) {
if (videoSample.codedWidth !== this.lastWidth || videoSample.codedHeight !== this.lastHeight) {
throw new Error(
`Video sample size must remain constant. Expected ${this.lastWidth}x${this.lastHeight},`
+ ` got ${videoSample.codedWidth}x${videoSample.codedHeight}.`,
);
}
} else {
this.lastWidth = videoSample.codedWidth;
this.lastHeight = videoSample.codedHeight;
}
if (!this.encoderInitialized) {
if (!this.ensureEncoderPromise) {
void this.ensureEncoder(videoSample);
// Ensure video sample size remains constant
if (this.lastWidth !== null && this.lastHeight !== null) {
if (videoSample.codedWidth !== this.lastWidth || videoSample.codedHeight !== this.lastHeight) {
throw new Error(
`Video sample size must remain constant. Expected ${this.lastWidth}x${this.lastHeight},`
+ ` got ${videoSample.codedWidth}x${videoSample.codedHeight}.`,
);
}
} else {
this.lastWidth = videoSample.codedWidth;
this.lastHeight = videoSample.codedHeight;
}
// No, this "if" statement is not useless. Sometimes, the above call to `ensureEncoder` might have
// synchronously completed and the encoder is already initialized. In this case, we don't need to await the
// promise anymore. This also fixes nasty async race condition bugs when multiple code paths are calling
// this method: It's important that the call that initialized the encoder go through this code first.
if (!this.encoderInitialized) {
await this.ensureEncoderPromise;
if (!this.ensureEncoderPromise) {
void this.ensureEncoder(videoSample);
}
// No, this "if" statement is not useless. Sometimes, the above call to `ensureEncoder` might have
// synchronously completed and the encoder is already initialized. In this case, we don't need to await
// the promise anymore. This also fixes nasty async race condition bugs when multiple code paths are
// calling this method: It's important that the call that initialized the encoder go through this
// code first.
if (!this.encoderInitialized) {
await this.ensureEncoderPromise;
}
}
}
assert(this.encoderInitialized);
assert(this.encoderInitialized);
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 5;
const multipleOfKeyFrameInterval = Math.floor(videoSample.timestamp / keyFrameInterval);
const keyFrameInterval = this.encodingConfig.keyFrameInterval ?? 5;
const multipleOfKeyFrameInterval = Math.floor(videoSample.timestamp / keyFrameInterval);
// Ensure a key frame every keyFrameInterval seconds. It is important that all video tracks follow the same
// "key frame" rhythm, because aligned key frames are required to start new fragments in ISOBMFF or clusters
// in Matroska (or at least desirable).
const finalEncodeOptions = {
...encodeOptions,
keyFrame: encodeOptions?.keyFrame
|| keyFrameInterval === 0
|| multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval,
};
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
// Ensure a key frame every keyFrameInterval seconds. It is important that all video tracks follow the same
// "key frame" rhythm, because aligned key frames are required to start new fragments in ISOBMFF or clusters
// in Matroska (or at least desirable).
const finalEncodeOptions = {
...encodeOptions,
keyFrame: encodeOptions?.keyFrame
|| keyFrameInterval === 0
|| multipleOfKeyFrameInterval !== this.lastMultipleOfKeyFrameInterval,
};
this.lastMultipleOfKeyFrameInterval = multipleOfKeyFrameInterval;
if (this.customEncoder) {
this.customEncoderQueueSize++;
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(videoSample, finalEncodeOptions))
.then(() => {
this.customEncoderQueueSize--;
if (this.customEncoder) {
this.customEncoderQueueSize++;
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(videoSample, finalEncodeOptions))
.then(() => {
this.customEncoderQueueSize--;
if (shouldClose) {
videoSample.close();
}
})
.catch((error: Error) => {
this.encoderError ??= error;
});
if (shouldClose) {
videoSample.close();
}
})
.catch((error: Error) => {
this.encoderError ??= error;
});
if (this.customEncoderQueueSize >= 4) {
await promise;
if (this.customEncoderQueueSize >= 4) {
await promise;
}
} else {
assert(this.encoder);
const videoFrame = videoSample.toVideoFrame();
this.encoder.encode(videoFrame, finalEncodeOptions);
videoFrame.close();
if (shouldClose) {
videoSample.close();
}
// We need to do this after sending the frame to the encoder as the frame otherwise might be closed
if (this.encoder.encodeQueueSize >= 4) {
await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true }));
}
}
} else {
assert(this.encoder);
const videoFrame = videoSample.toVideoFrame();
this.encoder.encode(videoFrame, finalEncodeOptions);
videoFrame.close();
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
} finally {
if (shouldClose) {
// Make sure it's always closed, even if there was an error
videoSample.close();
}
// We need to do this after sending the frame to the encoder as the frame otherwise might be closed
if (this.encoder.encodeQueueSize >= 4) {
await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true }));
}
}
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
}
private async ensureEncoder(videoSample: VideoSample) {
@@ -575,6 +583,20 @@ export class MediaStreamVideoTrackSource extends VideoSource {
private _abortController: AbortController | null = null;
/** @internal */
private _track: MediaStreamVideoTrack;
/** @internal */
private _workerTrackId: number | null = null;
/** @internal */
private _workerListener: ((event: MessageEvent) => void) | null = null;
/** @internal */
private _promiseWithResolvers = promiseWithResolvers();
/** @internal */
private _errorPromiseAccessed = false;
/** A promise that rejects upon any error within this source. This promise never resolves. */
get errorPromise() {
this._errorPromiseAccessed = true;
return this._promiseWithResolvers.promise;
}
constructor(track: MediaStreamVideoTrack, encodingConfig: VideoEncodingConfig) {
if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') {
@@ -593,41 +615,102 @@ export class MediaStreamVideoTrackSource extends VideoSource {
}
/** @internal */
override _start() {
override async _start() {
if (!this._errorPromiseAccessed) {
console.warn(
'Make sure not to ignore the `errorPromise` field on MediaStreamVideoTrackSource, so that any internal'
+ ' errors get bubbled up properly.',
);
}
this._abortController = new AbortController();
let frameReceived = false;
let firstVideoFrameTimestamp: number | null = null;
let errored = false;
const processor = new MediaStreamTrackProcessor({ track: this._track });
const consumer = new WritableStream<VideoFrame>({
write: (videoFrame) => {
if (!frameReceived) {
setMediaStreamTimestampOffset(this, videoFrame);
frameReceived = true;
const onVideoFrame = (videoFrame: VideoFrame) => {
if (errored) {
videoFrame.close();
return;
}
if (firstVideoFrameTimestamp === null) {
firstVideoFrameTimestamp = videoFrame.timestamp / 1e6;
const muxer = this._connectedTrack!.output._muxer;
if (muxer.firstMediaStreamTimestamp === null) {
muxer.firstMediaStreamTimestamp = performance.now() / 1000;
this._timestampOffset = -firstVideoFrameTimestamp;
} else {
this._timestampOffset = (performance.now() / 1000 - muxer.firstMediaStreamTimestamp)
- firstVideoFrameTimestamp;
}
}
if (this._encoder.getQueueSize() >= 4) {
// Drop frames if the encoder is overloaded
videoFrame.close();
return;
}
if (this._encoder.getQueueSize() >= 4) {
// Drop frames if the encoder is overloaded
videoFrame.close();
return;
}
void this._encoder.add(new VideoSample(videoFrame), true)
.catch((error) => {
this._abortController?.abort();
throw error;
});
},
});
void this._encoder.add(new VideoSample(videoFrame), true)
.catch((error) => {
errored = true;
processor.readable.pipeTo(consumer, {
signal: this._abortController.signal,
}).catch((err) => {
// Handle abort error silently
if (err instanceof DOMException && err.name === 'AbortError') return;
// Handle other errors
console.error('Pipe error:', err);
});
this._abortController?.abort();
this._promiseWithResolvers.reject(error);
if (this._workerTrackId !== null) {
// Tell the worker to stop the track
sendMessageToMediaStreamTrackProcessorWorker({
type: 'stopTrack',
trackId: this._workerTrackId,
});
}
});
};
if (typeof MediaStreamTrackProcessor !== 'undefined') {
// We can do it here directly, perfect
const processor = new MediaStreamTrackProcessor({ track: this._track });
const consumer = new WritableStream<VideoFrame>({ write: onVideoFrame });
processor.readable.pipeTo(consumer, {
signal: this._abortController.signal,
}).catch((error) => {
// Handle AbortError silently
if (error instanceof DOMException && error.name === 'AbortError') return;
this._promiseWithResolvers.reject(error);
});
} else {
// It might still be supported in a worker, so let's check that
const supportedInWorker = await mediaStreamTrackProcessorIsSupportedInWorker();
if (supportedInWorker) {
this._workerTrackId = nextMediaStreamTrackProcessorWorkerId++;
sendMessageToMediaStreamTrackProcessorWorker({
type: 'videoTrack',
trackId: this._workerTrackId,
track: this._track,
}, [this._track]);
this._workerListener = (event: MessageEvent) => {
const message = event.data as MediaStreamTrackProcessorWorkerMessage;
if (message.type === 'videoFrame' && message.trackId === this._workerTrackId) {
onVideoFrame(message.videoFrame);
} else if (message.type === 'error' && message.trackId === this._workerTrackId) {
this._promiseWithResolvers.reject(message.error);
}
};
mediaStreamTrackProcessorWorker!.addEventListener('message', this._workerListener);
} else {
throw new Error('MediaStreamTrackProcessor is required but not supported by this browser.');
}
}
}
/** @internal */
@@ -637,6 +720,32 @@ export class MediaStreamVideoTrackSource extends VideoSource {
this._abortController = null;
}
if (this._workerTrackId !== null) {
assert(this._workerListener);
sendMessageToMediaStreamTrackProcessorWorker({
type: 'stopTrack',
trackId: this._workerTrackId,
});
// Wait for the worker to stop the track
await new Promise<void>((resolve) => {
const listener = (event: MessageEvent) => {
const message = event.data as MediaStreamTrackProcessorWorkerMessage;
if (message.type === 'trackStopped' && message.trackId === this._workerTrackId) {
assert(this._workerListener);
mediaStreamTrackProcessorWorker!.removeEventListener('message', this._workerListener);
mediaStreamTrackProcessorWorker!.removeEventListener('message', listener);
resolve();
}
};
mediaStreamTrackProcessorWorker!.addEventListener('message', listener);
});
}
await this._encoder.flushAndClose();
}
}
@@ -783,78 +892,86 @@ class AudioEncoderWrapper {
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
async add(audioSample: AudioSample, shouldClose: boolean) {
this.checkForEncoderError();
this.source._ensureValidAdd();
try {
this.checkForEncoderError();
this.source._ensureValidAdd();
// Ensure audio parameters remain constant
if (this.lastNumberOfChannels !== null && this.lastSampleRate !== null) {
if (
audioSample.numberOfChannels !== this.lastNumberOfChannels
|| audioSample.sampleRate !== this.lastSampleRate
) {
throw new Error(
`Audio parameters must remain constant. Expected ${this.lastNumberOfChannels} channels at`
+ ` ${this.lastSampleRate} Hz, got ${audioSample.numberOfChannels} channels at`
+ ` ${audioSample.sampleRate} Hz.`,
);
}
} else {
this.lastNumberOfChannels = audioSample.numberOfChannels;
this.lastSampleRate = audioSample.sampleRate;
}
if (!this.encoderInitialized) {
if (!this.ensureEncoderPromise) {
void this.ensureEncoder(audioSample);
// Ensure audio parameters remain constant
if (this.lastNumberOfChannels !== null && this.lastSampleRate !== null) {
if (
audioSample.numberOfChannels !== this.lastNumberOfChannels
|| audioSample.sampleRate !== this.lastSampleRate
) {
throw new Error(
`Audio parameters must remain constant. Expected ${this.lastNumberOfChannels} channels at`
+ ` ${this.lastSampleRate} Hz, got ${audioSample.numberOfChannels} channels at`
+ ` ${audioSample.sampleRate} Hz.`,
);
}
} else {
this.lastNumberOfChannels = audioSample.numberOfChannels;
this.lastSampleRate = audioSample.sampleRate;
}
// No, this "if" statement is not useless. Sometimes, the above call to `ensureEncoder` might have
// synchronously completed and the encoder is already initialized. In this case, we don't need to await the
// promise anymore. This also fixes nasty async race condition bugs when multiple code paths are calling
// this method: It's important that the call that initialized the encoder go through this code first.
if (!this.encoderInitialized) {
await this.ensureEncoderPromise;
if (!this.ensureEncoderPromise) {
void this.ensureEncoder(audioSample);
}
// No, this "if" statement is not useless. Sometimes, the above call to `ensureEncoder` might have
// synchronously completed and the encoder is already initialized. In this case, we don't need to await
// the promise anymore. This also fixes nasty async race condition bugs when multiple code paths are
// calling this method: It's important that the call that initialized the encoder go through this
// code first.
if (!this.encoderInitialized) {
await this.ensureEncoderPromise;
}
}
}
assert(this.encoderInitialized);
assert(this.encoderInitialized);
if (this.customEncoder) {
this.customEncoderQueueSize++;
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(audioSample))
.then(() => {
this.customEncoderQueueSize--;
if (this.customEncoder) {
this.customEncoderQueueSize++;
const promise = this.customEncoderCallSerializer
.call(() => this.customEncoder!.encode(audioSample))
.then(() => {
this.customEncoderQueueSize--;
if (shouldClose) {
audioSample.close();
}
})
.catch((error: Error) => {
this.encoderError ??= error;
});
if (shouldClose) {
audioSample.close();
}
})
.catch((error: Error) => {
this.encoderError ??= error;
});
if (this.customEncoderQueueSize >= 4) {
await promise;
if (this.customEncoderQueueSize >= 4) {
await promise;
}
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
} else if (this.isPcmEncoder) {
await this.doPcmEncoding(audioSample, shouldClose);
} else {
assert(this.encoder);
const audioData = audioSample.toAudioData();
this.encoder.encode(audioData);
audioData.close();
if (shouldClose) {
audioSample.close();
}
if (this.encoder.encodeQueueSize >= 4) {
await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true }));
}
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
}
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
} else if (this.isPcmEncoder) {
await this.doPcmEncoding(audioSample, shouldClose);
} else {
assert(this.encoder);
const audioData = audioSample.toAudioData();
this.encoder.encode(audioData);
audioData.close();
} finally {
if (shouldClose) {
// Make sure it's always closed, even if there was an error
audioSample.close();
}
if (this.encoder.encodeQueueSize >= 4) {
await new Promise(resolve => this.encoder!.addEventListener('dequeue', resolve, { once: true }));
}
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
}
}
@@ -1186,7 +1303,7 @@ export class AudioBufferSource extends AudioSource {
/** @internal */
private _encoder: AudioEncoderWrapper;
/** @internal */
private _accumulatedFrameCount = 0;
private _accumulatedTime = 0;
constructor(encodingConfig: AudioEncodingConfig) {
validateAudioEncodingConfig(encodingConfig);
@@ -1208,47 +1325,10 @@ export class AudioBufferSource extends AudioSource {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const MAX_FLOAT_COUNT = 64 * 1024 * 1024;
const audioSamples = AudioSample.fromAudioBuffer(audioBuffer, this._accumulatedTime);
const promises = audioSamples.map(sample => this._encoder.add(sample, true));
const numberOfChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const totalFrames = audioBuffer.length;
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
let currentRelativeFrame = 0;
let remainingFrames = totalFrames;
const promises: Promise<void>[] = [];
// Create AudioData in a chunked fashion so we don't create huge Float32Arrays
while (remainingFrames > 0) {
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
for (let channel = 0; channel < numberOfChannels; channel++) {
audioBuffer.copyFromChannel(
chunkData.subarray(channel * framesToCopy, channel * framesToCopy + framesToCopy),
channel,
currentRelativeFrame,
);
}
const audioSample = new AudioSample({
format: 'f32-planar',
sampleRate,
numberOfFrames: framesToCopy,
numberOfChannels,
timestamp: (this._accumulatedFrameCount + currentRelativeFrame) / sampleRate,
data: chunkData,
});
promises.push(this._encoder.add(audioSample, true));
currentRelativeFrame += framesToCopy;
remainingFrames -= framesToCopy;
}
this._accumulatedFrameCount += totalFrames;
this._accumulatedTime += audioBuffer.duration;
return Promise.all(promises);
}
@@ -1272,6 +1352,20 @@ export class MediaStreamAudioTrackSource extends AudioSource {
private _abortController: AbortController | null = null;
/** @internal */
private _track: MediaStreamAudioTrack;
/** @internal */
private _audioContext: AudioContext | null = null;
/** @internal */
private _scriptProcessorNode: ScriptProcessorNode | null = null; // Deprecated but goated
/** @internal */
private _promiseWithResolvers = promiseWithResolvers();
/** @internal */
private _errorPromiseAccessed = false;
/** A promise that rejects upon any error within this source. This promise never resolves. */
get errorPromise() {
this._errorPromiseAccessed = true;
return this._promiseWithResolvers.promise;
}
constructor(track: MediaStreamAudioTrack, encodingConfig: AudioEncodingConfig) {
if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') {
@@ -1285,41 +1379,104 @@ export class MediaStreamAudioTrackSource extends AudioSource {
}
/** @internal */
override _start() {
override async _start() {
if (!this._errorPromiseAccessed) {
console.warn(
'Make sure not to ignore the `errorPromise` field on MediaStreamVideoTrackSource, so that any internal'
+ ' errors get bubbled up properly.',
);
}
this._abortController = new AbortController();
let dataReceived = false;
if (typeof MediaStreamTrackProcessor !== 'undefined') {
// Great, MediaStreamTrackProcessor is supported, this is the preferred way of doing things
let firstAudioDataTimestamp: number | null = null;
const processor = new MediaStreamTrackProcessor({ track: this._track });
const consumer = new WritableStream<AudioData>({
write: (audioData) => {
if (!dataReceived) {
setMediaStreamTimestampOffset(this, audioData);
dataReceived = true;
const processor = new MediaStreamTrackProcessor({ track: this._track });
const consumer = new WritableStream<AudioData>({
write: (audioData) => {
if (firstAudioDataTimestamp === null) {
firstAudioDataTimestamp = audioData.timestamp / 1e6;
const muxer = this._connectedTrack!.output._muxer;
if (muxer.firstMediaStreamTimestamp === null) {
muxer.firstMediaStreamTimestamp = performance.now() / 1000;
this._timestampOffset = -firstAudioDataTimestamp;
} else {
this._timestampOffset = (performance.now() / 1000 - muxer.firstMediaStreamTimestamp)
- firstAudioDataTimestamp;
}
}
if (this._encoder.getQueueSize() >= 4) {
// Drop data if the encoder is overloaded
audioData.close();
return;
}
void this._encoder.add(new AudioSample(audioData), true)
.catch((error) => {
this._abortController?.abort();
this._promiseWithResolvers.reject(error);
});
},
});
processor.readable.pipeTo(consumer, {
signal: this._abortController.signal,
}).catch((error) => {
// Handle AbortError silently
if (error instanceof DOMException && error.name === 'AbortError') return;
this._promiseWithResolvers.reject(error);
});
} else {
// Let's fall back to an AudioContext approach
this._audioContext = new AudioContext({ sampleRate: this._track.getSettings().sampleRate });
const sourceNode = this._audioContext.createMediaStreamSource(new MediaStream([this._track]));
this._scriptProcessorNode = this._audioContext.createScriptProcessor(4096);
if (this._audioContext.state === 'suspended') {
await this._audioContext.resume();
}
sourceNode.connect(this._scriptProcessorNode);
this._scriptProcessorNode.connect(this._audioContext.destination);
let audioReceived = false;
let totalDuration = 0;
this._scriptProcessorNode.onaudioprocess = (event) => {
const audioSamples = AudioSample.fromAudioBuffer(event.inputBuffer, totalDuration);
totalDuration += event.inputBuffer.duration;
for (const audioSample of audioSamples) {
if (!audioReceived) {
audioReceived = true;
const muxer = this._connectedTrack!.output._muxer;
if (muxer.firstMediaStreamTimestamp === null) {
muxer.firstMediaStreamTimestamp = performance.now() / 1000;
} else {
this._timestampOffset = performance.now() / 1000 - muxer.firstMediaStreamTimestamp;
}
}
if (this._encoder.getQueueSize() >= 4) {
// Drop data if the encoder is overloaded
audioSample.close();
continue;
}
void this._encoder.add(audioSample, true)
.catch((error) => {
void this._audioContext!.suspend();
this._promiseWithResolvers.reject(error);
});
}
if (this._encoder.getQueueSize() >= 4) {
// Drop data if the encoder is overloaded
audioData.close();
return;
}
void this._encoder.add(new AudioSample(audioData), true)
.catch((error) => {
this._abortController?.abort();
throw error;
});
},
});
processor.readable.pipeTo(consumer, {
signal: this._abortController.signal,
}).catch((err) => {
// Handle abort error silently
if (err instanceof DOMException && err.name === 'AbortError') return;
// Handle other errors
console.error('Pipe error:', err);
});
};
}
}
/** @internal */
@@ -1329,22 +1486,175 @@ export class MediaStreamAudioTrackSource extends AudioSource {
this._abortController = null;
}
if (this._audioContext) {
assert(this._scriptProcessorNode);
this._scriptProcessorNode.disconnect();
await this._audioContext.suspend();
}
await this._encoder.flushAndClose();
}
}
const setMediaStreamTimestampOffset = (source: MediaSource, sample: VideoFrame | AudioData) => {
const timestampInSeconds = sample.timestamp / 1e6;
// === MEDIA STREAM TRACK PROCESSOR WORKER ===
assert(source._connectedTrack);
const muxer = source._connectedTrack.output._muxer;
if (muxer.firstMediaStreamTimestamp === null) {
// We're the first MediaStreamTrack of this output to receive data
muxer.firstMediaStreamTimestamp = timestampInSeconds;
type MediaStreamTrackProcessorWorkerMessage = {
type: 'support';
supported: boolean;
} | {
type: 'videoFrame';
trackId: number;
videoFrame: VideoFrame;
} | {
type: 'trackStopped';
trackId: number;
} | {
type: 'error';
trackId: number;
error: Error;
};
type MediaStreamTrackProcessorControllerMessage = {
type: 'videoTrack';
trackId: number;
track: MediaStreamVideoTrack;
} | {
type: 'stopTrack';
trackId: number;
};
const mediaStreamTrackProcessorWorkerCode = () => {
const sendMessage = (message: MediaStreamTrackProcessorWorkerMessage, transfer?: Transferable[]) => {
if (transfer) {
// The error is bullshit, it's using the wrong postMessage
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
self.postMessage(message, transfer as any);
} else {
self.postMessage(message);
}
};
// Immediately send a message to the main thread, letting them know of the support
sendMessage({
type: 'support',
supported: typeof MediaStreamTrackProcessor !== 'undefined',
});
const abortControllers = new Map<number, AbortController>();
const stoppedTracks = new Set<number>();
self.addEventListener('message', (event) => {
const message = event.data as MediaStreamTrackProcessorControllerMessage;
switch (message.type) {
case 'videoTrack': {
const processor = new MediaStreamTrackProcessor({ track: message.track });
const consumer = new WritableStream<VideoFrame>({
write: (videoFrame) => {
if (stoppedTracks.has(message.trackId)) {
videoFrame.close();
return;
}
// Send it to the main thread
sendMessage({
type: 'videoFrame',
trackId: message.trackId,
videoFrame,
}, [videoFrame]);
},
});
const abortController = new AbortController();
abortControllers.set(message.trackId, abortController);
processor.readable.pipeTo(consumer, {
signal: abortController.signal,
}).catch((error: Error) => {
// Handle AbortError silently
if (error instanceof DOMException && error.name === 'AbortError') return;
sendMessage({
type: 'error',
trackId: message.trackId,
error,
});
});
}; break;
case 'stopTrack': {
const abortController = abortControllers.get(message.trackId);
if (abortController) {
abortController.abort();
abortControllers.delete(message.trackId);
}
stoppedTracks.add(message.trackId);
sendMessage({
type: 'trackStopped',
trackId: message.trackId,
});
}; break;
default: assertNever(message);
}
});
};
let nextMediaStreamTrackProcessorWorkerId = 0;
let mediaStreamTrackProcessorWorker: Worker | null = null;
const initMediaStreamTrackProcessorWorker = () => {
const blob = new Blob(
[`(${mediaStreamTrackProcessorWorkerCode.toString()})()`],
{ type: 'application/javascript' },
);
const url = URL.createObjectURL(blob);
mediaStreamTrackProcessorWorker = new Worker(url);
};
let mediaStreamTrackProcessorIsSupportedInWorkerCache: boolean | null = null;
const mediaStreamTrackProcessorIsSupportedInWorker = async () => {
if (mediaStreamTrackProcessorIsSupportedInWorkerCache !== null) {
return mediaStreamTrackProcessorIsSupportedInWorkerCache;
}
// Math.min to ensure the timestamps can't get negative
source._timestampOffset = -Math.min(muxer.firstMediaStreamTimestamp, timestampInSeconds);
if (!mediaStreamTrackProcessorWorker) {
initMediaStreamTrackProcessorWorker();
}
return new Promise<boolean>((resolve) => {
assert(mediaStreamTrackProcessorWorker);
const listener = (event: MessageEvent) => {
const message = event.data as MediaStreamTrackProcessorWorkerMessage;
if (message.type === 'support') {
mediaStreamTrackProcessorIsSupportedInWorkerCache = message.supported;
mediaStreamTrackProcessorWorker!.removeEventListener('message', listener);
resolve(message.supported);
}
};
mediaStreamTrackProcessorWorker.addEventListener('message', listener);
});
};
const sendMessageToMediaStreamTrackProcessorWorker = (
message: MediaStreamTrackProcessorControllerMessage,
transfer?: Transferable[],
) => {
assert(mediaStreamTrackProcessorWorker);
if (transfer) {
mediaStreamTrackProcessorWorker.postMessage(message, transfer);
} else {
mediaStreamTrackProcessorWorker.postMessage(message);
}
};
/**
+9 -5
View File
@@ -48,7 +48,7 @@ export class Bitstream {
this.pos = 8 * byteOffset;
}
readBit() {
private readBit() {
const byteIndex = Math.floor(this.pos / 8);
const byte = this.bytes[byteIndex] ?? 0;
const bitIndex = 0b111 - (this.pos & 0b111);
@@ -59,6 +59,10 @@ export class Bitstream {
}
readBits(n: number) {
if (n === 1) {
return this.readBit();
}
let result = 0;
for (let i = 0; i < n; i++) {
@@ -100,7 +104,7 @@ export class Bitstream {
/** Reads an exponential-Golomb universal code from a Bitstream. */
export const readExpGolomb = (bitstream: Bitstream) => {
let leadingZeroBits = 0;
while (bitstream.readBit() === 0 && leadingZeroBits < 32) {
while (bitstream.readBits(1) === 0 && leadingZeroBits < 32) {
leadingZeroBits++;
}
@@ -511,15 +515,15 @@ export const retriedFetch = async (
try {
return await fetch(url, requestInit);
} catch (error) {
console.error('Retrying failed fetch. Error:', error);
attempts++;
const retryDelayInSeconds = getRetryDelay(attempts);
if (retryDelayInSeconds === null) {
throw error;
}
console.error('Retrying failed fetch. Error:', error);
if (!Number.isFinite(retryDelayInSeconds) || retryDelayInSeconds < 0) {
throw new TypeError('Retry delay must be a non-negative finite number.');
}
+2 -3
View File
@@ -344,9 +344,8 @@ export class Output<
await this._muxer.start();
for (const track of this._tracks) {
track.source._start();
}
const promises = this._tracks.map(track => track.source._start());
await Promise.all(promises);
release();
})();
+52
View File
@@ -1063,6 +1063,58 @@ export class AudioSample {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
(this.timestamp as number) = newTimestamp;
}
/**
* Creates AudioSamples from an AudioBuffer, starting at the given timestamp in seconds. Typically creates exactly
* one sample, but may create multiple if the AudioBuffer is exceedingly large.
*/
static fromAudioBuffer(audioBuffer: AudioBuffer, timestamp: number) {
if (!(audioBuffer instanceof AudioBuffer)) {
throw new TypeError('audioBuffer must be an AudioBuffer.');
}
const MAX_FLOAT_COUNT = 64 * 1024 * 1024;
const numberOfChannels = audioBuffer.numberOfChannels;
const sampleRate = audioBuffer.sampleRate;
const totalFrames = audioBuffer.length;
const maxFramesPerChunk = Math.floor(MAX_FLOAT_COUNT / numberOfChannels);
let currentRelativeFrame = 0;
let remainingFrames = totalFrames;
const result: AudioSample[] = [];
// Create AudioData in a chunked fashion so we don't create huge Float32Arrays
while (remainingFrames > 0) {
const framesToCopy = Math.min(maxFramesPerChunk, remainingFrames);
const chunkData = new Float32Array(numberOfChannels * framesToCopy);
for (let channel = 0; channel < numberOfChannels; channel++) {
audioBuffer.copyFromChannel(
chunkData.subarray(channel * framesToCopy, channel * framesToCopy + framesToCopy),
channel,
currentRelativeFrame,
);
}
const audioSample = new AudioSample({
format: 'f32-planar',
sampleRate,
numberOfFrames: framesToCopy,
numberOfChannels,
timestamp: timestamp + currentRelativeFrame / sampleRate,
data: chunkData,
});
result.push(audioSample);
currentRelativeFrame += framesToCopy;
remainingFrames -= framesToCopy;
}
return result;
}
}
const getBytesPerSample = (format: AudioSampleFormat): number => {
+27 -2
View File
@@ -218,7 +218,8 @@ export class UrlSource extends Source {
const buffer = await response.arrayBuffer();
if (!range) {
if (response.status === 200) {
// The server didn't return 206 Partial Content, so it's not a range response
this._fullData = buffer;
}
@@ -252,6 +253,26 @@ export class UrlSource extends Source {
return this._fullData.byteLength;
}
// First, try a HEAD request to get the size
try {
const headResponse = await retriedFetch(
this._url,
mergeObjectsDeeply(this._options.requestInit ?? {}, {
method: 'HEAD',
}),
this._options.getRetryDelay ?? (() => null),
);
if (headResponse.ok) {
const contentLength = headResponse.headers.get('Content-Length');
if (contentLength) {
return parseInt(contentLength);
}
}
} catch {
// We tried
}
// Try a range request to get the Content-Range header
const rangeResponse = await retriedFetch(
this._url,
@@ -267,9 +288,13 @@ export class UrlSource extends Source {
if (contentRange) {
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
if (match && match[1]) {
return parseInt(match[1], 10);
return parseInt(match[1]);
}
}
} else if (rangeResponse.status === 200) {
// The server just returned the whole thing
this._fullData = await rangeResponse.arrayBuffer();
return this._fullData.byteLength;
}
// If the range request didn't provide the size, make a full GET request