Add thumbnail generation example, fix MP3 detection code error, prefer canvas element over OffscreenCanvas

This commit is contained in:
Vanilagy
2025-05-23 18:52:09 +02:00
parent c620b3e753
commit 4bbb6edf5d
7 changed files with 185 additions and 13 deletions
+7 -4
View File
@@ -3,14 +3,17 @@ layout: home
hero:
text: Examples
tagline: Various demos showcasing the usage of the features of Mediakit
tagline: Demos showcasing the usage of various features of Mediakit
features:
- title: Metadata extraction
details: See how you can extract various metadata from an input media file.
details: Extract various metadata from an input media file
link: /examples/metadata-extraction
- title: Feature B
details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
target: _self
- title: Thumbnail generation
details: Generate multiple small thumbnails for a video track
link: /examples/thumbnail-generation
target: _self
- title: Feature C
details: Lorem ipsum dolor sit amet, consectetur adipiscing elit
- title: Feature A
+1 -1
View File
@@ -291,7 +291,7 @@ for await (const sample of keyFrameSamples) {
While `VideoSampleSink` extracts raw decoded video samples, you can use `CanvasSink` to extract these samples as canvases instead. In doing so, certain operations such as scaling and rotating can also be handled by the sink. The downside is the additional VRAM requirements for the canvases' framebuffers.
::: info
This sink yields `OffscreenCanvas` whenever possible, and falls back to `HTMLCanvasElement` otherwise.
This sink yields `HTMLCanvasElement` whenever possible, and falls back to `OffscreenCanvas` otherwise (in Worker contexts, for example).
:::
Create the sink like so:
+2 -2
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="../index.css">
</head>
<body class="flex flex-col items-center py-10 bg-gray-50 text-gray-800">
<body class="flex flex-col items-center py-10 bg-gray-50 text-gray-800 px-2">
<h1 class="text-3xl">Metadata extraction example</h1>
<p>Select or drop a media file, and Mediakit will start extracting various metadata about that file.</p>
@@ -29,7 +29,7 @@
<p id="bytes-read" class="text-center text-xs font-medium mb-4"></p>
<div id="metadata-container" class="text-sm"></div>
<div class="fixed top-0 left-0 flex gap-2 p-2 items-center">
<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">
<p class="text-sm font-semibold">Mediakit</p>
</div>
+46
View File
@@ -0,0 +1,46 @@
<!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>Thumbnail generation example | Mediakit</title>
<script type="module" src="./thumbnail-generation.ts"></script>
<link rel="stylesheet" href="../index.css">
</head>
<body class="flex flex-col items-center py-10 bg-gray-50 text-gray-800 px-2">
<h1 class="text-3xl">Thumbnail generation example</h1>
<p>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 hover:bg-gray-300 px-5 py-1">
Select media file
</button>
<a href="../../assets/big-buck-bunny-trimmed.mp4" download class="rounded-lg bg-gray-200 hover:bg-gray-300 px-5 group grid place-items-center" title="Download sample file">
<img src="../../assets/download-icon.svg" class="opacity-30 group-hover:opacity-80">
</a>
</div>
<p class="text-xs opacity-60 mt-0.5" id="file-name"></p>
<hr class="w-96 my-4 border-gray-300" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<div id="thumbnail-container" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"></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">
<p class="text-sm font-semibold">Mediakit</p>
</div>
<a
href="https://github.com/Vanilagy/metamuxer/tree/main/examples/thumbnail-generation"
target="_blank"
class="flex items-center gap-2 fixed top-0 right-0 py-2 px-5 bg-gray-200 hover:bg-gray-300 rounded-bl-xl"
>
<img src="../../assets/github-mark.svg" class="size-6">
<p>View source code</p>
</a>
</body>
</html>
@@ -0,0 +1,121 @@
import { Input, ALL_FORMATS, BlobSource, CanvasSink } from 'mediakit';
const selectMediaButton = document.querySelector('button')!;
const fileNameElement = document.querySelector('#file-name')!;
const horizontalRule = document.querySelector('hr')!;
const thumbnailContainer = document.querySelector('#thumbnail-container')!;
const errorElement = document.querySelector('#error-element')!;
const THUMBNAIL_COUNT = 16;
const THUMBNAIL_SIZE = 200;
const generateThumbnails = async (file: File) => {
fileNameElement.textContent = file.name;
horizontalRule.style.display = '';
errorElement.innerHTML = '';
thumbnailContainer.innerHTML = '';
try {
// Create a new input from the file
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS, // Accept all formats
});
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error('File has no video track.');
}
// Compute width and height of the thumbnails such that the larger dimension is equal to THUMBNAIL_SIZE
const width = videoTrack.displayWidth > videoTrack.displayHeight
? THUMBNAIL_SIZE
: Math.floor(THUMBNAIL_SIZE * videoTrack.displayWidth / videoTrack.displayHeight);
const height = videoTrack.displayHeight > videoTrack.displayWidth
? THUMBNAIL_SIZE
: Math.floor(THUMBNAIL_SIZE * videoTrack.displayHeight / videoTrack.displayWidth);
// Create thumbnail elements
const thumbnailElements = [];
for (let i = 0; i < THUMBNAIL_COUNT; i++) {
const thumbnailElement = document.createElement('div');
thumbnailElement.className = 'rounded-lg overflow-hidden bg-gray-100 relative';
thumbnailElement.style.width = `${width}px`;
thumbnailElement.style.height = `${height}px`;
thumbnailElements.push(thumbnailElement);
thumbnailContainer.append(thumbnailElement);
}
// Prepare the timestamps for the thumbnails, equally spaced between the first and last timestamp of the video
const firstTimestamp = await videoTrack.getFirstTimestamp();
const lastTimestamp = await videoTrack.computeDuration();
const timestamps = Array.from(
{ length: THUMBNAIL_COUNT },
(_, i) => firstTimestamp + i * (lastTimestamp - firstTimestamp) / THUMBNAIL_COUNT,
);
// Create a CanvasSink for extracting resized frames from the video track
const sink = new CanvasSink(videoTrack, {
width: Math.floor(width * window.devicePixelRatio),
height: Math.floor(height * window.devicePixelRatio),
fit: 'fill',
});
// Iterate over all thumbnail canvases
let i = 0;
for await (const wrappedCanvas of sink.canvasesAtTimestamps(timestamps)) {
const container = thumbnailElements[i]!;
if (wrappedCanvas) {
const canvasElement = wrappedCanvas.canvas as HTMLCanvasElement;
canvasElement.className = 'size-full';
container.append(canvasElement);
canvasElement.animate(
[{ transform: 'scale(1.2)' }, { transform: 'scale(1)' }],
{ duration: 333, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
);
const timestampElement = document.createElement('p');
timestampElement.textContent = wrappedCanvas.timestamp.toFixed(2) + ' s';
timestampElement.className
= 'absolute bottom-0 right-0 bg-black/30 text-white px-1 py-0.5 text-[11px] rounded-tl-lg';
container.append(timestampElement);
}
i++;
}
} catch (e) {
errorElement.textContent = String(e);
thumbnailContainer.innerHTML = '';
}
};
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
return;
}
void generateThumbnails(file);
});
fileInput.click();
});
document.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
});
document.addEventListener('drop', (event) => {
event.preventDefault();
const files = event.dataTransfer?.files;
const file = files && files.length > 0 ? files[0] : undefined;
if (file) {
void generateThumbnails(file);
}
});
+2 -2
View File
@@ -226,7 +226,7 @@ export class Mp3InputFormat extends InputFormat {
const framesStartPos = mp3Reader.pos;
await mp3Reader.reader.loadRange(mp3Reader.pos, mp3Reader.pos + 4096);
const firstHeader = mp3Reader.readNextFrameHeader(framesStartPos + 4096);
const firstHeader = mp3Reader.readNextFrameHeader(Math.min(framesStartPos + 4096, sourceSize));
if (!firstHeader) {
return false;
}
@@ -239,7 +239,7 @@ export class Mp3InputFormat extends InputFormat {
// Fine, we found one frame header, but we're still not entirely sure this is MP3. Let's check if we can find
// another header nearby:
mp3Reader.pos = firstHeader.startPos + firstHeader.totalSize;
const secondHeader = mp3Reader.readNextFrameHeader(framesStartPos + 4096);
const secondHeader = mp3Reader.readNextFrameHeader(Math.min(framesStartPos + 4096, sourceSize));
if (!secondHeader) {
return false;
}
+6 -4
View File
@@ -922,6 +922,8 @@ export type CanvasSinkOptions = {
* A sink that renders video samples (frames) of the given video track to canvases. This is often more useful than
* directly retrieving frames, as it comes with common preprocessing steps such as resizing or applying rotation
* metadata.
*
* This sink will yield HTMLCanvasElements when in a DOM context, and OffscreenCanvases otherwise.
* @public
*/
export class CanvasSink {
@@ -1008,13 +1010,13 @@ export class CanvasSink {
_videoSampleToWrappedCanvas(sample: VideoSample): WrappedCanvas {
let canvas = this._canvasPool[this._nextCanvasIndex];
if (!canvas) {
if (typeof OffscreenCanvas !== 'undefined') {
// Prefer an OffscreenCanvas
canvas = new OffscreenCanvas(this._width, this._height);
} else {
if (typeof document !== 'undefined') {
// Prefer an HTMLCanvasElement
canvas = document.createElement('canvas');
canvas.width = this._width;
canvas.height = this._height;
} else {
canvas = new OffscreenCanvas(this._width, this._height);
}
if (this._canvasPool.length > 0) {