mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-10 09:13:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a6b849ff8 | ||
|
|
b60d4cafed | ||
|
|
95091cce9c | ||
|
|
72ed933bf4 | ||
|
|
ef3b53ff8a | ||
|
|
ac8baa4873 | ||
|
|
232d1a6cd7 | ||
|
|
50fe065852 | ||
|
|
d38ad22559 | ||
|
|
9cc38329f2 | ||
|
|
09ed583b78 | ||
|
|
9de5b24ec5 |
@@ -22,9 +22,13 @@ Mediabunny is a JavaScript library for reading, writing, and converting media fi
|
|||||||
<a href="https://diffusion.studio/" target="_blank">
|
<a href="https://diffusion.studio/" target="_blank">
|
||||||
<img src="./docs/public/sponsors/diffusionstudio.png" width="60" height="60" alt="Diffusion Studio">
|
<img src="./docs/public/sponsors/diffusionstudio.png" width="60" height="60" alt="Diffusion Studio">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="https://kino.ai/" target="_blank">
|
||||||
|
<img src="./docs/public/sponsors/kino.jpg" width="60" height="60" alt="Kino">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
[Get featured](https://github.com/sponsors/Vanilagy)
|
[Sponsor Mediabunny's development](https://github.com/sponsors/Vanilagy)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -23,21 +23,29 @@
|
|||||||
format: new Mediabunny.Mp4OutputFormat(),
|
format: new Mediabunny.Mp4OutputFormat(),
|
||||||
});
|
});
|
||||||
if (videoTrack) {
|
if (videoTrack) {
|
||||||
output.addVideoTrack(new Mediabunny.MediaStreamVideoTrackSource(videoTrack, {
|
const source = new Mediabunny.MediaStreamVideoTrackSource(videoTrack, {
|
||||||
codec: 'avc',
|
codec: 'avc',
|
||||||
bitrate: Mediabunny.QUALITY_MEDIUM
|
bitrate: Mediabunny.QUALITY_MEDIUM
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
source.errorPromise.catch((d) => console.log("Hello?????", d));
|
||||||
|
|
||||||
|
output.addVideoTrack(source);
|
||||||
}
|
}
|
||||||
if (audioTrack) {
|
if (audioTrack) {
|
||||||
output.addAudioTrack(new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
|
const source = new Mediabunny.MediaStreamAudioTrackSource(audioTrack, {
|
||||||
codec: 'aac',
|
codec: 'aac',
|
||||||
bitrate: Mediabunny.QUALITY_MEDIUM
|
bitrate: Mediabunny.QUALITY_MEDIUM
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
source.errorPromise.catch((d) => console.log("Hello!!???", d));
|
||||||
|
|
||||||
|
output.addAudioTrack(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
await output.start();
|
await output.start();
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
await output.finalize();
|
await output.finalize();
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,9 @@ const videoTrackSource = new MediaStreamVideoTrackSource(videoTrack, {
|
|||||||
codec: 'vp9',
|
codec: 'vp9',
|
||||||
bitrate: 1e7,
|
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.
|
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.
|
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`
|
### `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.
|
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',
|
codec: 'opus',
|
||||||
bitrate: 128e3,
|
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.
|
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.
|
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`
|
### `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.
|
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.
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ An audio sample represents a section of audio data. It can be created directly f
|
|||||||
|
|
||||||
### Creating audio samples
|
### 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
|
```ts
|
||||||
import { AudioSample } from 'mediabunny';
|
import { AudioSample } from 'mediabunny';
|
||||||
@@ -417,6 +417,11 @@ const sample = new AudioSample({
|
|||||||
sampleRate: 44100, // in Hz
|
sampleRate: 44100, // in Hz
|
||||||
timestamp: 0, // in seconds
|
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:
|
The following audio sample formats are supported:
|
||||||
|
|||||||
@@ -90,12 +90,15 @@ const sponsors = {
|
|||||||
gold: [
|
gold: [
|
||||||
{ image: '/sponsors/gling.svg', name: 'Gling AI', url: 'https://www.gling.ai/' },
|
{ 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/diffusionstudio.png', name: 'Diffusion Studio', url: 'https://diffusion.studio/' },
|
||||||
|
{ image: '/sponsors/kino.jpg', name: 'Kino', url: 'https://kino.ai/' },
|
||||||
],
|
],
|
||||||
individual: [
|
individual: [
|
||||||
{ image: 'https://avatars.githubusercontent.com/u/84167135', name: 'Memenome', url: 'https://github.com/memenome' },
|
{ 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/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/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/91711202', name: 'ihasq', url: 'https://github.com/ihasq' },
|
||||||
|
{ image: 'https://avatars.githubusercontent.com/u/61233224', name: 'Allwhy', url: 'https://github.com/Allwhy' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -91,13 +91,17 @@ const compressFile = async (file: File) => {
|
|||||||
|
|
||||||
// Display the final media file
|
// Display the final media file
|
||||||
videoElement.style.display = '';
|
videoElement.style.display = '';
|
||||||
videoElement.src = URL.createObjectURL(new Blob([output.target.buffer!]));
|
videoElement.src = URL.createObjectURL(new Blob([output.target.buffer!], { type: output.format.mimeType }));
|
||||||
void videoElement.play();
|
void videoElement.play();
|
||||||
|
|
||||||
compressionFacts.style.display = '';
|
compressionFacts.style.display = '';
|
||||||
compressionFacts.textContent
|
compressionFacts.textContent
|
||||||
= `${(output.target.buffer!.byteLength / file.size * 100).toPrecision(3)}% of original size`;
|
= `${(output.target.buffer!.byteLength / file.size * 100).toPrecision(3)}% of original size`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
await currentConversion?.cancel();
|
||||||
|
|
||||||
errorElement.textContent = String(error);
|
errorElement.textContent = String(error);
|
||||||
clearInterval(currentIntervalId);
|
clearInterval(currentIntervalId);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<hr class="w-full max-w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
|
<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="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 gap-4" id="main-container" style="display: none;">
|
||||||
<div class="flex flex-col items-center">
|
<div class="flex flex-col items-center">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
canEncodeAudio,
|
||||||
CanvasSource,
|
CanvasSource,
|
||||||
MediaStreamAudioTrackSource,
|
MediaStreamAudioTrackSource,
|
||||||
Mp4OutputFormat,
|
Mp4OutputFormat,
|
||||||
@@ -13,6 +14,7 @@ const mainContainer = document.querySelector('#main-container') as HTMLDivElemen
|
|||||||
const videoElement = document.querySelector('video') as HTMLVideoElement;
|
const videoElement = document.querySelector('video') as HTMLVideoElement;
|
||||||
const downloadButton = document.querySelector('#download-button') as HTMLAnchorElement;
|
const downloadButton = document.querySelector('#download-button') as HTMLAnchorElement;
|
||||||
const errorElement = document.querySelector('#error-element') as HTMLParagraphElement;
|
const errorElement = document.querySelector('#error-element') as HTMLParagraphElement;
|
||||||
|
const warningElement = document.querySelector('#warning-element') as HTMLParagraphElement;
|
||||||
|
|
||||||
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
|
||||||
const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!;
|
const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!;
|
||||||
@@ -39,19 +41,29 @@ const startRecording = async () => {
|
|||||||
videoElement.src = '';
|
videoElement.src = '';
|
||||||
downloadButton.style.display = 'none';
|
downloadButton.style.display = 'none';
|
||||||
errorElement.textContent = '';
|
errorElement.textContent = '';
|
||||||
|
warningElement.textContent = '';
|
||||||
|
|
||||||
// Paint a white background to the canvas
|
// Paint a white background to the canvas
|
||||||
context.fillStyle = 'white';
|
context.fillStyle = 'white';
|
||||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const audioIsEncodable = await canEncodeAudio('opus', {
|
||||||
|
bitrate: QUALITY_MEDIUM,
|
||||||
|
});
|
||||||
|
|
||||||
|
let audioTrack: MediaStreamAudioTrack | null = null;
|
||||||
|
if (audioIsEncodable) {
|
||||||
// Get user microphone
|
// Get user microphone
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
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 = '';
|
horizontalRule.style.display = '';
|
||||||
mainContainer.style.display = '';
|
mainContainer.style.display = '';
|
||||||
|
|
||||||
const audioTrack = mediaStream.getAudioTracks()[0];
|
|
||||||
|
|
||||||
// Create a new output file
|
// Create a new output file
|
||||||
output = new Output({
|
output = new Output({
|
||||||
// We're using fragmented MP4 here; streamable WebM would also work
|
// We're using fragmented MP4 here; streamable WebM would also work
|
||||||
@@ -99,6 +111,8 @@ const startRecording = async () => {
|
|||||||
codec: 'opus',
|
codec: 'opus',
|
||||||
bitrate: QUALITY_MEDIUM,
|
bitrate: QUALITY_MEDIUM,
|
||||||
});
|
});
|
||||||
|
audioSource.errorPromise.catch(cancelRecording); // Make sure errors are bubbled up
|
||||||
|
|
||||||
output.addAudioTrack(audioSource);
|
output.addAudioTrack(audioSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,9 +122,9 @@ const startRecording = async () => {
|
|||||||
readyForMoreFrames = true;
|
readyForMoreFrames = true;
|
||||||
lastFrameNumber = -1;
|
lastFrameNumber = -1;
|
||||||
|
|
||||||
// Start the video frame capture loop
|
// Start the video frame capture loop, making sure errors are caught
|
||||||
void addVideoFrame();
|
void addVideoFrame().catch(cancelRecording);
|
||||||
videoCaptureInterval = window.setInterval(() => void addVideoFrame(), 1000 / frameRate);
|
videoCaptureInterval = window.setInterval(() => void addVideoFrame().catch(cancelRecording), 1000 / frameRate);
|
||||||
|
|
||||||
const mimeType = await output.getMimeType();
|
const mimeType = await output.getMimeType();
|
||||||
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
|
||||||
@@ -121,13 +135,27 @@ const startRecording = async () => {
|
|||||||
toggleRecordingButton.textContent = 'Stop recording';
|
toggleRecordingButton.textContent = 'Stop recording';
|
||||||
toggleRecordingButton.disabled = false;
|
toggleRecordingButton.disabled = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await cancelRecording(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRecording = async (error: unknown) => {
|
||||||
|
if (!recording) {
|
||||||
|
return; // Already canceled
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
errorElement.textContent = String(error);
|
errorElement.textContent = String(error);
|
||||||
|
|
||||||
|
clearInterval(videoCaptureInterval);
|
||||||
mainContainer.style.display = 'none';
|
mainContainer.style.display = 'none';
|
||||||
toggleRecordingButton.textContent = 'Start recording';
|
toggleRecordingButton.textContent = 'Start recording';
|
||||||
toggleRecordingButton.disabled = false;
|
toggleRecordingButton.disabled = false;
|
||||||
recording = false;
|
recording = false;
|
||||||
}
|
await output?.cancel();
|
||||||
|
|
||||||
|
mediaStream?.getTracks().forEach(track => track.stop());
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopRecording = async () => {
|
const stopRecording = async () => {
|
||||||
@@ -135,7 +163,7 @@ const stopRecording = async () => {
|
|||||||
toggleRecordingButton.disabled = true;
|
toggleRecordingButton.disabled = true;
|
||||||
|
|
||||||
clearInterval(videoCaptureInterval);
|
clearInterval(videoCaptureInterval);
|
||||||
mediaStream.getTracks().forEach(track => track.stop());
|
mediaStream?.getTracks().forEach(track => track.stop());
|
||||||
|
|
||||||
await output.finalize();
|
await output.finalize();
|
||||||
|
|
||||||
|
|||||||
@@ -175,8 +175,10 @@ const initMediaPlayer = async (file: File) => {
|
|||||||
controlsElement.style.opacity = '1';
|
controlsElement.style.opacity = '1';
|
||||||
playerContainer.style.cursor = '';
|
playerContainer.style.cursor = '';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
errorElement.textContent = String(e);
|
console.error(error);
|
||||||
|
|
||||||
|
errorElement.textContent = String(error);
|
||||||
playerContainer.style.display = 'none';
|
playerContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ const renderObject = (object: Record<string, unknown>) => {
|
|||||||
listItem.removeChild(loadingSpan);
|
listItem.removeChild(loadingSpan);
|
||||||
listItem.appendChild(renderValue(resolvedValue));
|
listItem.appendChild(renderValue(resolvedValue));
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
// Show the promise error
|
// Show the promise error
|
||||||
listItem.removeChild(loadingSpan);
|
listItem.removeChild(loadingSpan);
|
||||||
const errorSpan = document.createElement('span');
|
const errorSpan = document.createElement('span');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
QUALITY_HIGH,
|
QUALITY_HIGH,
|
||||||
getFirstEncodableAudioCodec,
|
getFirstEncodableAudioCodec,
|
||||||
getFirstEncodableVideoCodec,
|
getFirstEncodableVideoCodec,
|
||||||
|
OutputFormat,
|
||||||
} from 'mediabunny';
|
} from 'mediabunny';
|
||||||
|
|
||||||
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
|
const durationSlider = document.querySelector('#duration-slider') as HTMLInputElement;
|
||||||
@@ -57,6 +58,8 @@ let currentScaleIndex = 0;
|
|||||||
let collisionCount = 0;
|
let collisionCount = 0;
|
||||||
let collisionsPerScale = 0;
|
let collisionsPerScale = 0;
|
||||||
|
|
||||||
|
let output: Output<OutputFormat, BufferTarget>;
|
||||||
|
|
||||||
/** === MAIN VIDEO FILE GENERATION LOGIC === */
|
/** === MAIN VIDEO FILE GENERATION LOGIC === */
|
||||||
|
|
||||||
const generateVideo = async () => {
|
const generateVideo = async () => {
|
||||||
@@ -82,7 +85,7 @@ const generateVideo = async () => {
|
|||||||
initScene(duration);
|
initScene(duration);
|
||||||
|
|
||||||
// Create a new output file
|
// Create a new output file
|
||||||
const output = new Output({
|
output = new Output({
|
||||||
target: new BufferTarget(), // Stored in memory
|
target: new BufferTarget(), // Stored in memory
|
||||||
format: new Mp4OutputFormat(),
|
format: new Mp4OutputFormat(),
|
||||||
});
|
});
|
||||||
@@ -177,13 +180,17 @@ const generateVideo = async () => {
|
|||||||
videoInfo.style.display = '';
|
videoInfo.style.display = '';
|
||||||
|
|
||||||
// Display and play the resulting media file
|
// Display and play the resulting media file
|
||||||
const videoBlob = new Blob([output.target.buffer!], { type: 'video/mp4' });
|
const videoBlob = new Blob([output.target.buffer!], { type: output.format.mimeType });
|
||||||
resultVideo.src = URL.createObjectURL(videoBlob);
|
resultVideo.src = URL.createObjectURL(videoBlob);
|
||||||
void resultVideo.play();
|
void resultVideo.play();
|
||||||
|
|
||||||
const fileSizeMiB = (videoBlob.size / (1024 * 1024)).toPrecision(3);
|
const fileSizeMiB = (videoBlob.size / (1024 * 1024)).toPrecision(3);
|
||||||
videoInfo.textContent = `File size: ${fileSizeMiB} MiB`;
|
videoInfo.textContent = `File size: ${fileSizeMiB} MiB`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
await output?.cancel();
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
errorElement.textContent = String(error);
|
errorElement.textContent = String(error);
|
||||||
progressBarContainer.style.display = 'none';
|
progressBarContainer.style.display = 'none';
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ const generateThumbnails = async (file: File) => {
|
|||||||
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
errorElement.textContent = String(e);
|
console.error(error);
|
||||||
|
|
||||||
|
errorElement.textContent = String(error);
|
||||||
thumbnailContainer.innerHTML = '';
|
thumbnailContainer.innerHTML = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"version": "1.3.3",
|
"version": "1.4.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"version": "1.3.3",
|
"version": "1.4.4",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/dom-mediacapture-transform": "^0.1.11",
|
"@types/dom-mediacapture-transform": "^0.1.11",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mediabunny",
|
"name": "mediabunny",
|
||||||
"author": "Vanilagy",
|
"author": "Vanilagy",
|
||||||
"version": "1.3.3",
|
"version": "1.4.4",
|
||||||
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
"description": "Pure TypeScript media toolkit for reading, writing, and converting media files, directly in the browser.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/bundles/mediabunny.cjs",
|
"main": "./dist/bundles/mediabunny.cjs",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const checkDocblocks = (filePath: string) => {
|
|||||||
ts.isInterfaceDeclaration(node)
|
ts.isInterfaceDeclaration(node)
|
||||||
|| ts.isClassDeclaration(node)
|
|| ts.isClassDeclaration(node)
|
||||||
|| ts.isMethodDeclaration(node)
|
|| ts.isMethodDeclaration(node)
|
||||||
|
|| ts.isGetAccessorDeclaration(node)
|
||||||
|
|| ts.isSetAccessorDeclaration(node)
|
||||||
|| ts.isPropertyDeclaration(node)
|
|| ts.isPropertyDeclaration(node)
|
||||||
|| ts.isFunctionDeclaration(node)
|
|| ts.isFunctionDeclaration(node)
|
||||||
|| ts.isTypeAliasDeclaration(node)
|
|| ts.isTypeAliasDeclaration(node)
|
||||||
|
|||||||
@@ -1941,7 +1941,12 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getFirstPacket(options: PacketRetrievalOptions) {
|
async getFirstPacket(options: PacketRetrievalOptions) {
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
const regularPacket = await this.fetchPacketForSampleIndex(0, options);
|
||||||
|
if (regularPacket || !this.internalTrack.demuxer.isFragmented) {
|
||||||
|
// If there's a non-fragmented packet, always prefer that
|
||||||
|
return regularPacket;
|
||||||
|
}
|
||||||
|
|
||||||
return this.performFragmentedLookup(
|
return this.performFragmentedLookup(
|
||||||
() => {
|
() => {
|
||||||
const startFragment = this.internalTrack.demuxer.fragments[0] ?? null;
|
const startFragment = this.internalTrack.demuxer.fragments[0] ?? null;
|
||||||
@@ -1978,9 +1983,6 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fetchPacketForSampleIndex(0, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private mapTimestampIntoTimescale(timestamp: number) {
|
private mapTimestampIntoTimescale(timestamp: number) {
|
||||||
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
|
// Do a little rounding to catch cases where the result is very close to an integer. If it is, it's likely
|
||||||
// that the number was originally an integer divided by the timescale. For stability, it's best
|
// that the number was originally an integer divided by the timescale. For stability, it's best
|
||||||
@@ -1991,22 +1993,31 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
|
async getPacket(timestamp: number, options: PacketRetrievalOptions) {
|
||||||
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
||||||
|
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
||||||
|
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
||||||
|
const regularPacket = await this.fetchPacketForSampleIndex(sampleIndex, options);
|
||||||
|
|
||||||
|
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
|
||||||
|
// Prefer the non-fragmented packet
|
||||||
|
return regularPacket;
|
||||||
|
}
|
||||||
|
|
||||||
return this.performFragmentedLookup(
|
return this.performFragmentedLookup(
|
||||||
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
|
() => this.findSampleInFragmentsForTimestamp(timestampInTimescale),
|
||||||
timestampInTimescale,
|
timestampInTimescale,
|
||||||
timestampInTimescale,
|
timestampInTimescale,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
||||||
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
|
||||||
return this.fetchPacketForSampleIndex(sampleIndex, options);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
async getNextPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
const regularSampleIndex = this.packetToSampleIndex.get(packet);
|
||||||
|
|
||||||
|
if (regularSampleIndex !== undefined) {
|
||||||
|
// Prefer the non-fragmented packet
|
||||||
|
return this.fetchPacketForSampleIndex(regularSampleIndex + 1, options);
|
||||||
|
}
|
||||||
|
|
||||||
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
||||||
if (locationInFragment === undefined) {
|
if (locationInFragment === undefined) {
|
||||||
throw new Error('Packet was not created from this track.');
|
throw new Error('Packet was not created from this track.');
|
||||||
@@ -2067,17 +2078,21 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sampleIndex = this.packetToSampleIndex.get(packet);
|
|
||||||
if (sampleIndex === undefined) {
|
|
||||||
throw new Error('Packet was not created from this track.');
|
|
||||||
}
|
|
||||||
return this.fetchPacketForSampleIndex(sampleIndex + 1, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
|
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
|
||||||
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
|
||||||
|
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
||||||
|
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
||||||
|
const keyFrameSampleIndex = sampleIndex === -1
|
||||||
|
? -1
|
||||||
|
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
|
||||||
|
const regularPacket = await this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
|
||||||
|
|
||||||
|
if (!sampleTableIsEmpty(sampleTable) || !this.internalTrack.demuxer.isFragmented) {
|
||||||
|
// Prefer the non-fragmented packet
|
||||||
|
return regularPacket;
|
||||||
|
}
|
||||||
|
|
||||||
return this.performFragmentedLookup(
|
return this.performFragmentedLookup(
|
||||||
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
|
() => this.findKeySampleInFragmentsForTimestamp(timestampInTimescale),
|
||||||
timestampInTimescale,
|
timestampInTimescale,
|
||||||
@@ -2086,16 +2101,15 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
||||||
|
const regularSampleIndex = this.packetToSampleIndex.get(packet);
|
||||||
|
if (regularSampleIndex !== undefined) {
|
||||||
|
// Prefer the non-fragmented packet
|
||||||
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
||||||
const sampleIndex = getSampleIndexForTimestamp(sampleTable, timestampInTimescale);
|
const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, regularSampleIndex);
|
||||||
const keyFrameSampleIndex = sampleIndex === -1
|
return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);
|
||||||
? -1
|
|
||||||
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
|
|
||||||
return this.fetchPacketForSampleIndex(keyFrameSampleIndex, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNextKeyPacket(packet: EncodedPacket, options: PacketRetrievalOptions) {
|
|
||||||
if (this.internalTrack.demuxer.isFragmented) {
|
|
||||||
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
const locationInFragment = this.packetToFragmentLocation.get(packet);
|
||||||
if (locationInFragment === undefined) {
|
if (locationInFragment === undefined) {
|
||||||
throw new Error('Packet was not created from this track.');
|
throw new Error('Packet was not created from this track.');
|
||||||
@@ -2163,15 +2177,6 @@ abstract class IsobmffTrackBacking implements InputTrackBacking {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sampleIndex = this.packetToSampleIndex.get(packet);
|
|
||||||
if (sampleIndex === undefined) {
|
|
||||||
throw new Error('Packet was not created from this track.');
|
|
||||||
}
|
|
||||||
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(this.internalTrack);
|
|
||||||
const nextKeyFrameSampleIndex = getNextKeyframeIndexForSample(sampleTable, sampleIndex);
|
|
||||||
return this.fetchPacketForSampleIndex(nextKeyFrameSampleIndex, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchPacketForSampleIndex(sampleIndex: number, options: PacketRetrievalOptions) {
|
private async fetchPacketForSampleIndex(sampleIndex: number, options: PacketRetrievalOptions) {
|
||||||
if (sampleIndex === -1) {
|
if (sampleIndex === -1) {
|
||||||
return null;
|
return null;
|
||||||
@@ -2701,3 +2706,7 @@ const extractRotationFromMatrix = (matrix: TransformationMatrix) => {
|
|||||||
// Invert the rotation because matrices are post-multiplied in ISOBMFF
|
// Invert the rotation because matrices are post-multiplied in ISOBMFF
|
||||||
return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
|
return -Math.atan2(sinTheta, cosTheta) * (180 / Math.PI);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sampleTableIsEmpty = (sampleTable: SampleTable) => {
|
||||||
|
return sampleTable.sampleSizes.length === 0;
|
||||||
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
transformAnnexBToLengthPrefixed,
|
transformAnnexBToLengthPrefixed,
|
||||||
} from '../codec-data';
|
} from '../codec-data';
|
||||||
import { buildIsobmffMimeType } from './isobmff-misc';
|
import { buildIsobmffMimeType } from './isobmff-misc';
|
||||||
|
import { MAX_BOX_HEADER_SIZE, MIN_BOX_HEADER_SIZE } from './isobmff-reader';
|
||||||
|
|
||||||
export const GLOBAL_TIMESCALE = 1000;
|
export const GLOBAL_TIMESCALE = 1000;
|
||||||
const TIMESTAMP_OFFSET = 2_082_844_800; // Seconds between Jan 1 1904 and Jan 1 1970
|
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 moofOffset = this.writer.getPos();
|
||||||
const mdatStartPos = moofOffset + this.boxWriter.measureBox(moofBox);
|
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.
|
let currentPos = mdatStartPos + MIN_BOX_HEADER_SIZE;
|
||||||
const mdatHeaderSize = 16;
|
|
||||||
|
|
||||||
let currentPos = mdatStartPos + mdatHeaderSize;
|
|
||||||
let fragmentStartTimestamp = Infinity;
|
let fragmentStartTimestamp = Infinity;
|
||||||
for (const trackData of tracksInFragment) {
|
for (const trackData of tracksInFragment) {
|
||||||
trackData.currentChunk!.offset = currentPos;
|
trackData.currentChunk!.offset = currentPos;
|
||||||
@@ -1006,6 +1004,15 @@ export class IsobmffMuxer extends Muxer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mdatSize = currentPos - mdatStartPos;
|
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) {
|
if (this.format._options.onMoof) {
|
||||||
this.writer.startTrackingWrites();
|
this.writer.startTrackingWrites();
|
||||||
@@ -1025,11 +1032,11 @@ export class IsobmffMuxer extends Muxer {
|
|||||||
this.writer.startTrackingWrites();
|
this.writer.startTrackingWrites();
|
||||||
}
|
}
|
||||||
|
|
||||||
const mdatBox = mdat(mdatSize >= 2 ** 32);
|
const mdatBox = mdat(needsLargeMdatSize);
|
||||||
mdatBox.size = mdatSize;
|
mdatBox.size = mdatSize;
|
||||||
this.boxWriter.writeBox(mdatBox);
|
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
|
// Write sample data
|
||||||
for (const trackData of tracksInFragment) {
|
for (const trackData of tracksInFragment) {
|
||||||
|
|||||||
@@ -220,13 +220,16 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
|
|
||||||
const fileSize = await this.input.source.getSize();
|
const fileSize = await this.input.source.getSize();
|
||||||
|
|
||||||
|
// Loop over all top-level elements in the file
|
||||||
while (this.metadataReader.pos <= fileSize - MIN_HEADER_SIZE) {
|
while (this.metadataReader.pos <= fileSize - MIN_HEADER_SIZE) {
|
||||||
await this.metadataReader.reader.loadRange(
|
await this.metadataReader.reader.loadRange(
|
||||||
this.metadataReader.pos,
|
this.metadataReader.pos,
|
||||||
this.metadataReader.pos + MAX_HEADER_SIZE,
|
this.metadataReader.pos + MAX_HEADER_SIZE,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { id, size } = this.metadataReader.readElementHeader();
|
const header = this.metadataReader.readElementHeader();
|
||||||
|
const id = header.id;
|
||||||
|
let size = header.size;
|
||||||
const startPos = this.metadataReader.pos;
|
const startPos = this.metadataReader.pos;
|
||||||
|
|
||||||
if (id === EBMLId.EBML) {
|
if (id === EBMLId.EBML) {
|
||||||
@@ -242,6 +245,26 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
// and only segment
|
// and only segment
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} else if (id === EBMLId.Cluster) {
|
||||||
|
// Clusters are not a top-level element in Matroska, but some files contain a Segment whose size
|
||||||
|
// doesn't contain any of the clusters that follow it. In the case, we apply the following logic: if
|
||||||
|
// we find a top-level cluster, attribute it to the previous segment.
|
||||||
|
|
||||||
|
if (size === null) {
|
||||||
|
// Just in case this is one of those weird sizeless clusters, let's do our best and still try to
|
||||||
|
// determine its size.
|
||||||
|
const nextElementPos = await this.clusterReader.searchForNextElementId(
|
||||||
|
LEVEL_0_AND_1_EBML_IDS,
|
||||||
|
fileSize,
|
||||||
|
);
|
||||||
|
size = (nextElementPos ?? fileSize) - startPos;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSegment = last(this.segments);
|
||||||
|
if (lastSegment) {
|
||||||
|
// Extend the previous segment's size
|
||||||
|
lastSegment.elementEndPos = startPos + size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assertDefinedSize(size);
|
assertDefinedSize(size);
|
||||||
@@ -368,6 +391,13 @@ export class MatroskaDemuxer extends Demuxer {
|
|||||||
this.readContiguousElements(this.metadataReader, size);
|
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
|
// Put default tracks first
|
||||||
this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
this.currentSegment.tracks.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
||||||
|
|
||||||
|
|||||||
+394
-84
@@ -24,7 +24,7 @@ import {
|
|||||||
VideoCodec,
|
VideoCodec,
|
||||||
} from './codec';
|
} from './codec';
|
||||||
import { OutputAudioTrack, OutputSubtitleTrack, OutputTrack, OutputVideoTrack } from './output';
|
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 { Muxer } from './muxer';
|
||||||
import { SubtitleParser } from './subtitles';
|
import { SubtitleParser } from './subtitles';
|
||||||
import { toAlaw, toUlaw } from './pcm';
|
import { toAlaw, toUlaw } from './pcm';
|
||||||
@@ -78,7 +78,7 @@ export abstract class MediaSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
_start() {}
|
async _start() {}
|
||||||
/** @internal */
|
/** @internal */
|
||||||
async _flushAndClose() {}
|
async _flushAndClose() {}
|
||||||
|
|
||||||
@@ -272,6 +272,7 @@ class VideoEncoderWrapper {
|
|||||||
constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {}
|
constructor(private source: VideoSource, private encodingConfig: VideoEncodingConfig) {}
|
||||||
|
|
||||||
async add(videoSample: VideoSample, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) {
|
async add(videoSample: VideoSample, shouldClose: boolean, encodeOptions?: VideoEncoderEncodeOptions) {
|
||||||
|
try {
|
||||||
this.checkForEncoderError();
|
this.checkForEncoderError();
|
||||||
this.source._ensureValidAdd();
|
this.source._ensureValidAdd();
|
||||||
|
|
||||||
@@ -294,9 +295,10 @@ class VideoEncoderWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No, this "if" statement is not useless. Sometimes, the above call to `ensureEncoder` might have
|
// 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
|
// synchronously completed and the encoder is already initialized. In this case, we don't need to await
|
||||||
// promise anymore. This also fixes nasty async race condition bugs when multiple code paths are calling
|
// the promise anymore. This also fixes nasty async race condition bugs when multiple code paths are
|
||||||
// this method: It's important that the call that initialized the encoder go through this code first.
|
// calling this method: It's important that the call that initialized the encoder go through this
|
||||||
|
// code first.
|
||||||
if (!this.encoderInitialized) {
|
if (!this.encoderInitialized) {
|
||||||
await this.ensureEncoderPromise;
|
await this.ensureEncoderPromise;
|
||||||
}
|
}
|
||||||
@@ -352,6 +354,12 @@ class VideoEncoderWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureEncoder(videoSample: VideoSample) {
|
private async ensureEncoder(videoSample: VideoSample) {
|
||||||
@@ -575,6 +583,20 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
|||||||
private _abortController: AbortController | null = null;
|
private _abortController: AbortController | null = null;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
private _track: MediaStreamVideoTrack;
|
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) {
|
constructor(track: MediaStreamVideoTrack, encodingConfig: VideoEncodingConfig) {
|
||||||
if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') {
|
if (!(track instanceof MediaStreamTrack) || track.kind !== 'video') {
|
||||||
@@ -593,17 +615,36 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @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();
|
this._abortController = new AbortController();
|
||||||
|
|
||||||
let frameReceived = false;
|
let firstVideoFrameTimestamp: number | null = null;
|
||||||
|
let errored = false;
|
||||||
|
|
||||||
const processor = new MediaStreamTrackProcessor({ track: this._track });
|
const onVideoFrame = (videoFrame: VideoFrame) => {
|
||||||
const consumer = new WritableStream<VideoFrame>({
|
if (errored) {
|
||||||
write: (videoFrame) => {
|
videoFrame.close();
|
||||||
if (!frameReceived) {
|
return;
|
||||||
setMediaStreamTimestampOffset(this, videoFrame);
|
}
|
||||||
frameReceived = true;
|
|
||||||
|
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) {
|
if (this._encoder.getQueueSize() >= 4) {
|
||||||
@@ -614,20 +655,62 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
|||||||
|
|
||||||
void this._encoder.add(new VideoSample(videoFrame), true)
|
void this._encoder.add(new VideoSample(videoFrame), true)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
errored = true;
|
||||||
|
|
||||||
this._abortController?.abort();
|
this._abortController?.abort();
|
||||||
throw error;
|
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, {
|
processor.readable.pipeTo(consumer, {
|
||||||
signal: this._abortController.signal,
|
signal: this._abortController.signal,
|
||||||
}).catch((err) => {
|
}).catch((error) => {
|
||||||
// Handle abort error silently
|
// Handle AbortError silently
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
// Handle other errors
|
|
||||||
console.error('Pipe error:', err);
|
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 */
|
/** @internal */
|
||||||
@@ -637,6 +720,32 @@ export class MediaStreamVideoTrackSource extends VideoSource {
|
|||||||
this._abortController = null;
|
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();
|
await this._encoder.flushAndClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -783,6 +892,7 @@ class AudioEncoderWrapper {
|
|||||||
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
|
constructor(private source: AudioSource, private encodingConfig: AudioEncodingConfig) {}
|
||||||
|
|
||||||
async add(audioSample: AudioSample, shouldClose: boolean) {
|
async add(audioSample: AudioSample, shouldClose: boolean) {
|
||||||
|
try {
|
||||||
this.checkForEncoderError();
|
this.checkForEncoderError();
|
||||||
this.source._ensureValidAdd();
|
this.source._ensureValidAdd();
|
||||||
|
|
||||||
@@ -809,9 +919,10 @@ class AudioEncoderWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// No, this "if" statement is not useless. Sometimes, the above call to `ensureEncoder` might have
|
// 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
|
// synchronously completed and the encoder is already initialized. In this case, we don't need to await
|
||||||
// promise anymore. This also fixes nasty async race condition bugs when multiple code paths are calling
|
// the promise anymore. This also fixes nasty async race condition bugs when multiple code paths are
|
||||||
// this method: It's important that the call that initialized the encoder go through this code first.
|
// calling this method: It's important that the call that initialized the encoder go through this
|
||||||
|
// code first.
|
||||||
if (!this.encoderInitialized) {
|
if (!this.encoderInitialized) {
|
||||||
await this.ensureEncoderPromise;
|
await this.ensureEncoderPromise;
|
||||||
}
|
}
|
||||||
@@ -856,6 +967,12 @@ class AudioEncoderWrapper {
|
|||||||
|
|
||||||
await this.muxer!.mutex.currentPromise; // Allow the writer to apply backpressure
|
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
|
||||||
|
audioSample.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doPcmEncoding(audioSample: AudioSample, shouldClose: boolean) {
|
private async doPcmEncoding(audioSample: AudioSample, shouldClose: boolean) {
|
||||||
@@ -1186,7 +1303,7 @@ export class AudioBufferSource extends AudioSource {
|
|||||||
/** @internal */
|
/** @internal */
|
||||||
private _encoder: AudioEncoderWrapper;
|
private _encoder: AudioEncoderWrapper;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
private _accumulatedFrameCount = 0;
|
private _accumulatedTime = 0;
|
||||||
|
|
||||||
constructor(encodingConfig: AudioEncodingConfig) {
|
constructor(encodingConfig: AudioEncodingConfig) {
|
||||||
validateAudioEncodingConfig(encodingConfig);
|
validateAudioEncodingConfig(encodingConfig);
|
||||||
@@ -1208,47 +1325,10 @@ export class AudioBufferSource extends AudioSource {
|
|||||||
throw new TypeError('audioBuffer must be an AudioBuffer.');
|
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;
|
this._accumulatedTime += audioBuffer.duration;
|
||||||
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;
|
|
||||||
return Promise.all(promises);
|
return Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1272,6 +1352,20 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
|||||||
private _abortController: AbortController | null = null;
|
private _abortController: AbortController | null = null;
|
||||||
/** @internal */
|
/** @internal */
|
||||||
private _track: MediaStreamAudioTrack;
|
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) {
|
constructor(track: MediaStreamAudioTrack, encodingConfig: AudioEncodingConfig) {
|
||||||
if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') {
|
if (!(track instanceof MediaStreamTrack) || track.kind !== 'audio') {
|
||||||
@@ -1285,17 +1379,34 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @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();
|
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 processor = new MediaStreamTrackProcessor({ track: this._track });
|
||||||
const consumer = new WritableStream<AudioData>({
|
const consumer = new WritableStream<AudioData>({
|
||||||
write: (audioData) => {
|
write: (audioData) => {
|
||||||
if (!dataReceived) {
|
if (firstAudioDataTimestamp === null) {
|
||||||
setMediaStreamTimestampOffset(this, audioData);
|
firstAudioDataTimestamp = audioData.timestamp / 1e6;
|
||||||
dataReceived = true;
|
|
||||||
|
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) {
|
if (this._encoder.getQueueSize() >= 4) {
|
||||||
@@ -1307,19 +1418,65 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
|||||||
void this._encoder.add(new AudioSample(audioData), true)
|
void this._encoder.add(new AudioSample(audioData), true)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this._abortController?.abort();
|
this._abortController?.abort();
|
||||||
throw error;
|
this._promiseWithResolvers.reject(error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
processor.readable.pipeTo(consumer, {
|
processor.readable.pipeTo(consumer, {
|
||||||
signal: this._abortController.signal,
|
signal: this._abortController.signal,
|
||||||
}).catch((err) => {
|
}).catch((error) => {
|
||||||
// Handle abort error silently
|
// Handle AbortError silently
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
// Handle other errors
|
|
||||||
console.error('Pipe error:', err);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
@@ -1329,22 +1486,175 @@ export class MediaStreamAudioTrackSource extends AudioSource {
|
|||||||
this._abortController = null;
|
this._abortController = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._audioContext) {
|
||||||
|
assert(this._scriptProcessorNode);
|
||||||
|
|
||||||
|
this._scriptProcessorNode.disconnect();
|
||||||
|
await this._audioContext.suspend();
|
||||||
|
}
|
||||||
|
|
||||||
await this._encoder.flushAndClose();
|
await this._encoder.flushAndClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setMediaStreamTimestampOffset = (source: MediaSource, sample: VideoFrame | AudioData) => {
|
// === MEDIA STREAM TRACK PROCESSOR WORKER ===
|
||||||
const timestampInSeconds = sample.timestamp / 1e6;
|
|
||||||
|
|
||||||
assert(source._connectedTrack);
|
type MediaStreamTrackProcessorWorkerMessage = {
|
||||||
const muxer = source._connectedTrack.output._muxer;
|
type: 'support';
|
||||||
if (muxer.firstMediaStreamTimestamp === null) {
|
supported: boolean;
|
||||||
// We're the first MediaStreamTrack of this output to receive data
|
} | {
|
||||||
muxer.firstMediaStreamTimestamp = timestampInSeconds;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Math.min to ensure the timestamps can't get negative
|
// Send it to the main thread
|
||||||
source._timestampOffset = -Math.min(muxer.firstMediaStreamTimestamp, timestampInSeconds);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-3
@@ -344,9 +344,8 @@ export class Output<
|
|||||||
|
|
||||||
await this._muxer.start();
|
await this._muxer.start();
|
||||||
|
|
||||||
for (const track of this._tracks) {
|
const promises = this._tracks.map(track => track.source._start());
|
||||||
track.source._start();
|
await Promise.all(promises);
|
||||||
}
|
|
||||||
|
|
||||||
release();
|
release();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export class VideoSample {
|
|||||||
return new VideoSample(this._data.clone(), {
|
return new VideoSample(this._data.clone(), {
|
||||||
timestamp: this.timestamp,
|
timestamp: this.timestamp,
|
||||||
duration: this.duration,
|
duration: this.duration,
|
||||||
|
rotation: this.rotation,
|
||||||
});
|
});
|
||||||
} else if (this._data instanceof Uint8Array) {
|
} else if (this._data instanceof Uint8Array) {
|
||||||
return new VideoSample(this._data.slice(), {
|
return new VideoSample(this._data.slice(), {
|
||||||
@@ -245,6 +246,7 @@ export class VideoSample {
|
|||||||
timestamp: this.timestamp,
|
timestamp: this.timestamp,
|
||||||
duration: this.duration,
|
duration: this.duration,
|
||||||
colorSpace: this.colorSpace,
|
colorSpace: this.colorSpace,
|
||||||
|
rotation: this.rotation,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return new VideoSample(this._data, {
|
return new VideoSample(this._data, {
|
||||||
@@ -254,6 +256,7 @@ export class VideoSample {
|
|||||||
timestamp: this.timestamp,
|
timestamp: this.timestamp,
|
||||||
duration: this.duration,
|
duration: this.duration,
|
||||||
colorSpace: this.colorSpace,
|
colorSpace: this.colorSpace,
|
||||||
|
rotation: this.rotation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1063,6 +1066,58 @@ export class AudioSample {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||||
(this.timestamp as number) = newTimestamp;
|
(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 => {
|
const getBytesPerSample = (format: AudioSampleFormat): number => {
|
||||||
|
|||||||
Reference in New Issue
Block a user