mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 19:03:46 +02:00
Add thumbnail generation example, fix MP3 detection code error, prefer canvas element over OffscreenCanvas
This commit is contained in:
+7
-4
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user