mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-03 05:43:50 +02:00
Add live recording example
This commit is contained in:
+6
-3
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
layout: home
|
layout: home
|
||||||
|
title: Examples
|
||||||
|
|
||||||
hero:
|
hero:
|
||||||
text: Examples
|
text: Examples
|
||||||
tagline: Demos showcasing the usage of various features of Mediakit
|
tagline: Demos showcasing various features of Mediakit
|
||||||
|
|
||||||
features:
|
features:
|
||||||
- title: Metadata extraction
|
- title: Metadata extraction
|
||||||
@@ -26,6 +27,8 @@ features:
|
|||||||
details: Generate a video file as fast as the hardware allows.
|
details: Generate a video file as fast as the hardware allows.
|
||||||
link: /examples/procedural-generation
|
link: /examples/procedural-generation
|
||||||
target: _self
|
target: _self
|
||||||
- title: Feature C
|
- title: Live recording
|
||||||
details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
|
details: Record a video from live sources and stream it to a video element.
|
||||||
|
link: /examples/live-recording
|
||||||
|
target: _self
|
||||||
---
|
---
|
||||||
|
|||||||
+2
-2
@@ -43,5 +43,5 @@ Examples:
|
|||||||
✅ Media player
|
✅ Media player
|
||||||
✅ Conversion demo: File compressor
|
✅ Conversion demo: File compressor
|
||||||
✅ Procedurally generated video (faster than real time)
|
✅ Procedurally generated video (faster than real time)
|
||||||
- Video generated from live sources
|
✅ Video generated from live sources
|
||||||
- Live streaming demo?
|
✅ Live streaming demo?
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
</head>
|
</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">
|
<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>
|
<h1 class="text-3xl font-bold text-teal-800 dark:text-teal-200 text-center">File compression example</h1>
|
||||||
<p>Select or drop a media file, and Mediakit will convert it to a heavily-compressed MP4 file.</p>
|
<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">
|
<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">
|
<button class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-2">
|
||||||
|
|||||||
@@ -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><video></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>
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
</head>
|
</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">
|
<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>
|
<h1 class="text-3xl font-bold text-purple-800 dark:text-purple-200 text-center">Media player example</h1>
|
||||||
<p>Select or drop a media file, and a fully custom, Mediakit-powered player will appear.</p>
|
<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">
|
<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">
|
<button class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-2">
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
</head>
|
</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">
|
<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>
|
<h1 class="text-3xl font-bold text-blue-800 dark:text-blue-200 text-center">Metadata extraction example</h1>
|
||||||
<p>Select or drop a media file, and Mediakit will start extracting various metadata about that file.</p>
|
<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">
|
<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">
|
<button class="rounded-lg bg-zinc-200 dark:bg-zinc-750 hover:bg-zinc-300 dark:hover:bg-zinc-700 px-5 py-2">
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
</head>
|
</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">
|
<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>
|
<h1 class="text-3xl font-bold text-teal-800 dark:text-teal-200 text-center">Procedural generation example</h1>
|
||||||
<p>Using Mediakit, this page will procedurally generate a video of musical bouncing balls as fast as possible.</p>
|
<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-6 mt-8 w-full max-w-80">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const generateVideo = async () => {
|
|||||||
codec: videoCodec,
|
codec: videoCodec,
|
||||||
bitrate: QUALITY_HIGH,
|
bitrate: QUALITY_HIGH,
|
||||||
});
|
});
|
||||||
output.addVideoTrack(canvasSource);
|
output.addVideoTrack(canvasSource, { frameRate });
|
||||||
|
|
||||||
// For audio, we use ArrayBufferSource, because we'll be creating an ArrayBuffer with OfflineAudioContext
|
// For audio, we use ArrayBufferSource, because we'll be creating an ArrayBuffer with OfflineAudioContext
|
||||||
let audioBufferSource: AudioBufferSource | null = null;
|
let audioBufferSource: AudioBufferSource | null = null;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
</head>
|
</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">
|
<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>
|
<h1 class="text-3xl font-bold text-emerald-800 dark:text-emerald-200 text-center">Thumbnail generation example</h1>
|
||||||
<p>Select or drop a media file, and Mediakit will extract video thumbnails for it.</p>
|
<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">
|
<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">
|
<button class="rounded-lg bg-gray-200 dark:bg-zinc-750 hover:bg-gray-300 dark:hover:bg-zinc-700 px-5 py-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user