diff --git a/docs/examples.md b/docs/examples.md
index 7941426..e53c04a 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -1,9 +1,10 @@
---
layout: home
+title: Examples
hero:
text: Examples
- tagline: Demos showcasing the usage of various features of Mediakit
+ tagline: Demos showcasing various features of Mediakit
features:
- title: Metadata extraction
@@ -26,6 +27,8 @@ features:
details: Generate a video file as fast as the hardware allows.
link: /examples/procedural-generation
target: _self
- - title: Feature C
- details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
+ - title: Live recording
+ details: Record a video from live sources and stream it to a video element.
+ link: /examples/live-recording
+ target: _self
---
diff --git a/docs/index.md b/docs/index.md
index 8a7767e..1ccf756 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -43,5 +43,5 @@ Examples:
✅ Media player
✅ Conversion demo: File compressor
✅ Procedurally generated video (faster than real time)
-- Video generated from live sources
-- Live streaming demo?
\ No newline at end of file
+✅ Video generated from live sources
+✅ Live streaming demo?
\ No newline at end of file
diff --git a/examples/file-compression/index.html b/examples/file-compression/index.html
index fb1ae13..32817dc 100644
--- a/examples/file-compression/index.html
+++ b/examples/file-compression/index.html
@@ -11,8 +11,8 @@
- File compression example
- Select or drop a media file, and Mediakit will convert it to a heavily-compressed MP4 file.
+ File compression example
+ Select or drop a media file, and Mediakit will convert it to a heavily-compressed MP4 file.
diff --git a/examples/live-recording/index.html b/examples/live-recording/index.html
new file mode 100644
index 0000000..a4ad838
--- /dev/null
+++ b/examples/live-recording/index.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+ Live recording example | Mediakit
+
+
+
+
+
+
+ Live recording example
+ The live canvas state and your microphone input will be written into a fragmented MP4 file and live-streamed to a <video> element.
+
+
+ Start recording
+
+
+
+
+
+
+
+
+
+
+
Mediakit
+
+
+
+
+ View source code
+
+
+
diff --git a/examples/live-recording/live-recording.ts b/examples/live-recording/live-recording.ts
new file mode 100644
index 0000000..2c49ee1
--- /dev/null
+++ b/examples/live-recording/live-recording.ts
@@ -0,0 +1,221 @@
+import {
+ CanvasSource,
+ MediaStreamAudioTrackSource,
+ Mp4OutputFormat,
+ Output,
+ QUALITY_MEDIUM,
+ StreamTarget,
+} from 'mediakit';
+
+const toggleRecordingButton = document.querySelector('#toggle-button') as HTMLButtonElement;
+const horizontalRule = document.querySelector('hr') as HTMLHRElement;
+const mainContainer = document.querySelector('#main-container') as HTMLDivElement;
+const videoElement = document.querySelector('video') as HTMLVideoElement;
+const downloadButton = document.querySelector('#download-button') as HTMLAnchorElement;
+const errorElement = document.querySelector('#error-element') as HTMLParagraphElement;
+
+const canvas = document.querySelector('canvas') as HTMLCanvasElement;
+const context = canvas.getContext('2d', { alpha: false, desynchronized: true })!;
+
+const frameRate = 30;
+
+const chunks: Uint8Array[] = [];
+let recording = false;
+let output: Output;
+let videoSource: CanvasSource;
+let videoCaptureInterval: number;
+let mediaStream: MediaStream;
+let startTime: number;
+let readyForMoreFrames = true;
+let lastFrameNumber = -1;
+
+const startRecording = async () => {
+ try {
+ // Reset DOM state
+ recording = true;
+ toggleRecordingButton.textContent = 'Starting...';
+ toggleRecordingButton.disabled = true;
+ mainContainer.style.display = 'none';
+ videoElement.src = '';
+ downloadButton.style.display = 'none';
+
+ // 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 });
+
+ 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
+ format: new Mp4OutputFormat({ fastStart: 'fragmented' }),
+ // We use StreamTarget to pipe the chunks to the SourceBuffer as soon as they are created
+ target: new StreamTarget(new WritableStream({
+ write(chunk) {
+ chunks.push(chunk.data);
+
+ if (sourceBuffer) {
+ appendToSourceBuffer(chunk.data);
+ }
+ },
+ })),
+ });
+
+ const mediaSource = new MediaSource();
+ let sourceBuffer: SourceBuffer | null = null;
+ videoElement.src = URL.createObjectURL(mediaSource);
+ void videoElement.play();
+
+ await new Promise(resolve => mediaSource.onsourceopen = resolve);
+
+ let appendPromise = Promise.resolve();
+ const appendToSourceBuffer = (source: BufferSource) => {
+ // Buffer appends must be serialized to avoid errors
+ appendPromise = appendPromise.then(() => {
+ sourceBuffer!.appendBuffer(source);
+ return new Promise(resolve => sourceBuffer!.onupdateend = () => resolve());
+ });
+ };
+
+ // Add the video track, with the canvas as the source
+ videoSource = new CanvasSource(canvas, {
+ codec: 'vp9',
+ bitrate: QUALITY_MEDIUM,
+ keyFrameInterval: 0.5,
+ latencyMode: 'realtime', // Allow the encoder to skip frames to keep up with real-time constraints
+ });
+ output.addVideoTrack(videoSource, { frameRate });
+
+ if (audioTrack) {
+ // Add the audio track, with the media stream track as the source
+ const audioSource = new MediaStreamAudioTrackSource(audioTrack, {
+ codec: 'opus',
+ bitrate: QUALITY_MEDIUM,
+ });
+ output.addAudioTrack(audioSource);
+ }
+
+ await output.start();
+
+ startTime = Number(document.timeline.currentTime);
+ readyForMoreFrames = true;
+ lastFrameNumber = -1;
+
+ // Start the video frame capture loop
+ void addVideoFrame();
+ videoCaptureInterval = window.setInterval(() => void addVideoFrame(), 1000 / frameRate);
+
+ const mimeType = await output.getMimeType();
+ sourceBuffer = mediaSource.addSourceBuffer(mimeType);
+
+ // Add all chunks that have been queued up until this point
+ chunks.forEach(chunk => appendToSourceBuffer(chunk));
+
+ 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;
+ }
+};
+
+const stopRecording = async () => {
+ toggleRecordingButton.textContent = 'Stopping...';
+ toggleRecordingButton.disabled = true;
+
+ clearInterval(videoCaptureInterval);
+ mediaStream.getTracks().forEach(track => track.stop());
+
+ await output.finalize();
+
+ // Show a download button
+ const blob = new Blob(chunks, { type: output.format.mimeType });
+ const url = URL.createObjectURL(blob);
+ downloadButton.style.display = '';
+ downloadButton.href = url;
+ downloadButton.download = 'michelangelo' + output.format.fileExtension;
+
+ toggleRecordingButton.textContent = 'Start recording';
+ toggleRecordingButton.disabled = false;
+ recording = false;
+};
+
+toggleRecordingButton.addEventListener('click', () => {
+ if (!recording) {
+ void startRecording();
+ } else {
+ void stopRecording();
+ }
+});
+
+const addVideoFrame = async () => {
+ if (!readyForMoreFrames) {
+ // The last frame hasn't finished encoding yet; let's drop this frame due to real-time constraints
+ return;
+ }
+
+ const elapsedSeconds = (Number(document.timeline.currentTime) - startTime) / 1000;
+ const frameNumber = Math.round(elapsedSeconds * frameRate);
+ if (frameNumber === lastFrameNumber) {
+ // Prevent multiple frames with the same timestamp
+ return;
+ }
+
+ lastFrameNumber = frameNumber;
+ const timestamp = frameNumber / frameRate;
+
+ readyForMoreFrames = false;
+ await videoSource.add(timestamp, 1 / frameRate);
+ readyForMoreFrames = true;
+};
+
+/* === CANVAS DRAWING STUFF === */
+
+let drawing = false;
+let lastPos = new DOMPoint(0, 0);
+
+const getRelativeMousePos = (event: PointerEvent) => {
+ const rect = canvas.getBoundingClientRect();
+ return new DOMPoint(
+ event.clientX - rect.x,
+ event.clientY - rect.y,
+ );
+};
+
+const drawLine = (from: DOMPoint, to: DOMPoint) => {
+ context.beginPath();
+ context.moveTo(from.x, from.y);
+ context.lineTo(to.x, to.y);
+ context.strokeStyle = '#27272a';
+ context.lineWidth = 5;
+ context.lineCap = 'round';
+ context.stroke();
+};
+
+canvas.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0) return;
+
+ drawing = true;
+ lastPos = getRelativeMousePos(event);
+ drawLine(lastPos, lastPos);
+});
+window.addEventListener('pointerup', () => {
+ drawing = false;
+});
+window.addEventListener('pointermove', (event) => {
+ if (!drawing) return;
+
+ const newPos = getRelativeMousePos(event);
+ drawLine(lastPos, newPos);
+ lastPos = newPos;
+});
diff --git a/examples/media-player/index.html b/examples/media-player/index.html
index ea21eb6..d2878b7 100644
--- a/examples/media-player/index.html
+++ b/examples/media-player/index.html
@@ -11,8 +11,8 @@
- Media player example
- Select or drop a media file, and a fully custom, Mediakit-powered player will appear.
+ Media player example
+ Select or drop a media file, and a fully custom, Mediakit-powered player will appear.
diff --git a/examples/metadata-extraction/index.html b/examples/metadata-extraction/index.html
index d3f5f70..a8f5145 100644
--- a/examples/metadata-extraction/index.html
+++ b/examples/metadata-extraction/index.html
@@ -11,8 +11,8 @@
- Metadata extraction example
- Select or drop a media file, and Mediakit will start extracting various metadata about that file.
+ Metadata extraction example
+ Select or drop a media file, and Mediakit will start extracting various metadata about that file.
diff --git a/examples/procedural-generation/index.html b/examples/procedural-generation/index.html
index f2fb95d..cb4d99f 100644
--- a/examples/procedural-generation/index.html
+++ b/examples/procedural-generation/index.html
@@ -11,8 +11,8 @@
- Procedural generation example
- Using Mediakit, this page will procedurally generate a video of musical bouncing balls as fast as possible.
+ Procedural generation example
+ Using Mediakit, this page will procedurally generate a video of musical bouncing balls as fast as possible.
diff --git a/examples/procedural-generation/procedural-generation.ts b/examples/procedural-generation/procedural-generation.ts
index 6e66d7e..3ded446 100644
--- a/examples/procedural-generation/procedural-generation.ts
+++ b/examples/procedural-generation/procedural-generation.ts
@@ -101,7 +101,7 @@ const generateVideo = async () => {
codec: videoCodec,
bitrate: QUALITY_HIGH,
});
- output.addVideoTrack(canvasSource);
+ output.addVideoTrack(canvasSource, { frameRate });
// For audio, we use ArrayBufferSource, because we'll be creating an ArrayBuffer with OfflineAudioContext
let audioBufferSource: AudioBufferSource | null = null;
diff --git a/examples/thumbnail-generation/index.html b/examples/thumbnail-generation/index.html
index 7725c5a..cb8832f 100644
--- a/examples/thumbnail-generation/index.html
+++ b/examples/thumbnail-generation/index.html
@@ -11,8 +11,8 @@
-
Thumbnail generation example
-
Select or drop a media file, and Mediakit will extract video thumbnails for it.
+
Thumbnail generation example
+
Select or drop a media file, and Mediakit will extract video thumbnails for it.