Add live recording example

This commit is contained in:
Vanilagy
2025-06-08 16:50:52 +02:00
parent c7c57e2132
commit f009d0603e
10 changed files with 297 additions and 16 deletions
+6 -3
View File
@@ -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
---
+2 -2
View File
@@ -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?
✅ Video generated from live sources
✅ Live streaming demo?
+2 -2
View File
@@ -11,8 +11,8 @@
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
<h1 class="text-3xl font-bold text-teal-800 dark:text-teal-200">File compression example</h1>
<p>Select or drop a media file, and Mediakit will convert it to a heavily-compressed MP4 file.</p>
<h1 class="text-3xl font-bold text-teal-800 dark:text-teal-200 text-center">File compression example</h1>
<p class="max-w-lg text-center">Select or drop a media file, and Mediakit will convert it to a heavily-compressed MP4 file.</p>
<div class="flex gap-2 mt-4">
<button class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-2">
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live recording example | Mediakit</title>
<script type="module" src="../base.ts"></script>
<script type="module" src="./live-recording.ts"></script>
<link rel="stylesheet" href="../base.css">
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
<h1 class="text-3xl font-bold text-orange-800 dark:text-orange-200 text-center">Live recording example</h1>
<p class="max-w-lg text-center">The live canvas state and your microphone input will be written into a fragmented MP4 file and live-streamed to a <code>&lt;video&gt;</code> element.</p>
<button id="toggle-button" class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-2 mt-4">
Start recording
</button>
<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>
<div class="flex gap-4" id="main-container" style="display: none;">
<div class="flex flex-col items-center">
<p class="text-xs font-medium mb-1">Draw something!</p>
<canvas width="640" height="480" class="bg-white rounded-xl shadow"></canvas>
</div>
<div class="w-px bg-zinc-300 dark:bg-zinc-700"></div>
<div class="self-center flex flex-col items-center gap-4">
<video class="max-w-96 rounded-lg shadow" controls></video>
<a id="download-button" class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-2" style="display: none;">
Download file
</a>
</div>
</div>
<div class="fixed top-0 left-0 flex gap-2 py-2 px-5 items-center">
<img src="../../assets/mediakit-logo.svg" class="size-6 dark:invert">
<p class="text-sm font-semibold">Mediakit</p>
</div>
<a
href="https://github.com/Vanilagy/metamuxer/tree/main/examples/live-recording"
target="_blank"
class="flex items-center gap-2 fixed top-0 right-0 py-2 px-5 bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded-bl-xl"
>
<img src="../../assets/github-mark.svg" class="size-6 dark:invert">
<p>View source code</p>
</a>
</body>
</html>
+221
View File
@@ -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;
});
+2 -2
View File
@@ -11,8 +11,8 @@
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2 h-svh">
<h1 class="text-3xl font-bold text-purple-800 dark:text-purple-200">Media player example</h1>
<p>Select or drop a media file, and a fully custom, Mediakit-powered player will appear.</p>
<h1 class="text-3xl font-bold text-purple-800 dark:text-purple-200 text-center">Media player example</h1>
<p class="max-w-lg text-center">Select or drop a media file, and a fully custom, Mediakit-powered player will appear.</p>
<div class="flex gap-2 mt-4">
<button class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-2">
+2 -2
View File
@@ -11,8 +11,8 @@
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
<h1 class="text-3xl font-bold text-blue-800 dark:text-blue-200">Metadata extraction example</h1>
<p>Select or drop a media file, and Mediakit will start extracting various metadata about that file.</p>
<h1 class="text-3xl font-bold text-blue-800 dark:text-blue-200 text-center">Metadata extraction example</h1>
<p class="max-w-lg text-center">Select or drop a media file, and Mediakit will start extracting various metadata about that file.</p>
<div class="flex gap-2 mt-4">
<button class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-2">
+2 -2
View File
@@ -11,8 +11,8 @@
</head>
<body class="flex flex-col items-center py-10 bg-zinc-50 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
<h1 class="text-3xl font-bold text-teal-800 dark:text-teal-200">Procedural generation example</h1>
<p>Using Mediakit, this page will procedurally generate a video of musical bouncing balls as fast as possible.</p>
<h1 class="text-3xl font-bold text-teal-800 dark:text-teal-200 text-center">Procedural generation example</h1>
<p class="max-w-lg text-center">Using Mediakit, this page will procedurally generate a video of musical bouncing balls as fast as possible.</p>
<div class="flex flex-col gap-6 mt-8 w-full max-w-80">
<div class="flex flex-col gap-2">
@@ -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;
+2 -2
View File
@@ -11,8 +11,8 @@
</head>
<body class="flex flex-col items-center py-10 bg-gray-50 text-gray-800 dark:bg-zinc-900 dark:text-zinc-200 px-2">
<h1 class="text-3xl font-bold text-emerald-800 dark:text-emerald-200">Thumbnail generation example</h1>
<p>Select or drop a media file, and Mediakit will extract video thumbnails for it.</p>
<h1 class="text-3xl font-bold text-emerald-800 dark:text-emerald-200 text-center">Thumbnail generation example</h1>
<p class="max-w-lg text-center">Select or drop a media file, and Mediakit will extract video thumbnails for it.</p>
<div class="flex gap-2 mt-4">
<button class="rounded-lg bg-gray-200 dark:bg-zinc-750 hover:bg-gray-300 dark:hover:bg-zinc-700 px-5 py-1">