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.

+ + + +

+ + + +
+ +

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.