Merge branch 'main' into prores

This commit is contained in:
Vanilagy
2026-06-12 15:41:58 +02:00
301 changed files with 53249 additions and 4691 deletions
+22 -10
View File
@@ -62,29 +62,41 @@ const compressFile = async (resource: File | string) => {
currentConversion = await Conversion.init({
input,
output,
tracks: 'primary', // Keep only one track per type
video: {
width: 320, // Height will be deduced automatically to retain aspect ratio
bitrate: QUALITY_VERY_LOW,
},
audio: {
bitrate: 32e3,
bitrate: QUALITY_VERY_LOW,
},
});
if (!currentConversion.isValid) {
console.info(currentConversion.discardedTracks);
throw new Error('Conversion is invalid and cannot be executed; see the console for more.');
}
// Keep track of progress
let progress = 0;
currentConversion.onProgress = newProgress => progress = newProgress;
let processedTime = 0;
let startTime: number | null = null;
const fileDuration = await input.computeDuration();
const startTime = performance.now();
currentConversion.onProgress = (newProgress, newProcessedTime) => {
progress = newProgress;
processedTime = newProcessedTime;
startTime ??= performance.now();
};
const updateProgress = () => {
progressBar.style.width = `${progress * 100}%`;
const now = performance.now();
const elapsedSeconds = (now - startTime) / 1000;
const factor = fileDuration / (elapsedSeconds / progress);
speedometer.textContent = `Speed: ~${factor.toPrecision(3)}x real time`;
if (startTime !== null) {
const now = performance.now();
const elapsedSeconds = (now - startTime) / 1000;
const factor = processedTime / elapsedSeconds;
speedometer.textContent = `Speed: ~${factor.toPrecision(3)}x real time`;
}
};
// Update the progress indicator regularly
@@ -124,7 +136,7 @@ const compressFile = async (resource: File | string) => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
@@ -141,7 +153,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
+19 -4
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<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>File compression example | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and Mediabunny will convert it to a heavily-compressed MP4 file.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./file-compression.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/file-compression/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="File compression example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and Mediabunny will convert it to a heavily-compressed MP4 file.">
<meta property="og:url" content="https://mediabunny.dev/examples/file-compression/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="File compression example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and Mediabunny will convert it to a heavily-compressed MP4 file.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"File compression"}]}</script>
</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">
@@ -31,11 +46,11 @@
</a>
</div>
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
<p class="text-xs opacity-60 mt-2 mx-auto text-center max-w-[36rem]" id="file-name"></p>
<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>
<p id="error-element" class="text-red-500 mx-auto text-center max-w-[36rem]"></p>
<div class="w-full max-w-80 h-2 rounded-full bg-zinc-200 dark:bg-zinc-750 overflow-hidden" id="progress-bar-container" style="display: none;">
<div class="h-full bg-emerald-500 w-0" id="progress-bar"></div>
+272
View File
@@ -0,0 +1,272 @@
import {
Input,
ALL_FORMATS,
BlobSource,
UrlSource,
Output,
PathedTarget,
BufferTarget,
HlsOutputFormat,
MpegTsOutputFormat,
Conversion,
QUALITY_VERY_HIGH,
QUALITY_HIGH,
QUALITY_MEDIUM,
QUALITY_LOW,
QUALITY_VERY_LOW,
} from 'mediabunny';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
declare global {
interface Window {
showDirectoryPicker(options: { mode: 'readwrite' }): Promise<FileSystemDirectoryHandle>;
}
}
const selectDirectoryButton = document.querySelector('#select-directory') as HTMLButtonElement;
const directoryNameElement = document.querySelector('#directory-name') as HTMLParagraphElement;
const selectMediaButton = document.querySelector('#select-file') as HTMLButtonElement;
const loadSampleButton = document.querySelector('#load-sample') as HTMLButtonElement;
const fileNameElement = document.querySelector('#file-name') as HTMLParagraphElement;
const dashboard = document.querySelector('#dashboard') as HTMLDivElement;
const statusElement = document.querySelector('#status') as HTMLParagraphElement;
const progressBar = document.querySelector('#progress-bar') as HTMLDivElement;
const percentIndicator = document.querySelector('#percent-indicator') as HTMLParagraphElement;
const speedometer = document.querySelector('#speedometer') as HTMLParagraphElement;
const bytesWrittenElement = document.querySelector('#bytes-written') as HTMLParagraphElement;
const filesCreatedElement = document.querySelector('#files-created') as HTMLParagraphElement;
const latestFileElement = document.querySelector('#latest-file') as HTMLParagraphElement;
const errorElement = document.querySelector('#error-element') as HTMLParagraphElement;
let currentConversion: Conversion | null = null;
let directoryHandle: FileSystemDirectoryHandle | null = null;
let progress = 0;
let processedTime = 0;
let startTime: number | null = null;
let bytesWritten = 0;
let filesCreated = 0;
let latestFile = '-';
let status = 'Waiting for directory';
let fileName = '';
let directoryName = '';
let errorMessage = '';
let renderIntervalId = -1;
const filePromises: Promise<void>[] = [];
const convertToHls = async (resource: File | string) => {
await currentConversion?.cancel();
resetDashboard();
fileName = resource instanceof File ? resource.name : resource;
updateFileUi();
clearInterval(renderIntervalId);
renderIntervalId = window.setInterval(render, 1000 / 60);
render();
try {
// Load the input
const source = resource instanceof File
? new BlobSource(resource)
: new UrlSource(resource);
const input = new Input({
source,
formats: ALL_FORMATS,
});
const output = new Output({
// Define the output format (HLS with MPEG-TS segments)
format: new HlsOutputFormat({
segmentFormat: new MpegTsOutputFormat(),
}),
// Describe where the files will be written
target: new PathedTarget(
'master.m3u8',
({ path }) => createFileTarget(path),
),
onFinalize: () => Promise.all(filePromises),
});
currentConversion = await Conversion.init({
input,
output,
tracks: 'primary', // Use only the primary video and audio tracks of the input
video: [
{ codec: 'avc', height: 1080, bitrate: QUALITY_VERY_HIGH },
{ codec: 'avc', height: 720, bitrate: QUALITY_HIGH },
{ codec: 'avc', height: 480, bitrate: QUALITY_MEDIUM },
{ codec: 'avc', height: 360, bitrate: QUALITY_LOW },
{ codec: 'avc', height: 240, bitrate: QUALITY_VERY_LOW },
],
audio: [
{ codec: 'aac', bitrate: QUALITY_HIGH },
],
});
if (!currentConversion.isValid) {
console.info(currentConversion.discardedTracks);
throw new Error('Conversion is invalid and cannot be executed; see the console for more.');
}
currentConversion.onProgress = (newProgress, newProcessedTime) => {
progress = newProgress;
processedTime = newProcessedTime;
startTime ??= performance.now();
};
status = 'Encoding renditions and writing HLS files';
await currentConversion.execute();
progress = 1;
status = 'HLS manifest complete';
} catch (error) {
console.error(error);
await currentConversion?.cancel();
status = 'Conversion failed';
errorMessage = String(error);
updateFileUi();
} finally {
clearInterval(renderIntervalId);
renderIntervalId = -1;
render();
}
};
const formatBytes = (bytes: number) => {
if (bytes < 1000) {
return `${bytes} B`;
}
const units = ['kB', 'MB', 'GB', 'TB'];
let size = bytes / 1000;
let unitIndex = 0;
while (size >= 1000) {
size /= 1000;
unitIndex++;
}
return `${+size.toFixed(2)} ${units[unitIndex]}`;
};
const render = () => {
const percentage = Math.floor(progress * 100);
const displayedPercentage = status === 'HLS manifest complete' ? 100 : Math.min(percentage, 99);
progressBar.style.width = `${displayedPercentage}%`;
percentIndicator.textContent = `${displayedPercentage}%`;
bytesWrittenElement.textContent = formatBytes(bytesWritten);
filesCreatedElement.textContent = filesCreated.toString();
latestFileElement.textContent = latestFile;
statusElement.textContent = status;
if (startTime !== null) {
const elapsedSeconds = (performance.now() - startTime) / 1000;
const factor = processedTime / elapsedSeconds;
speedometer.textContent = `${factor.toPrecision(3)}x`;
} else {
speedometer.textContent = '-';
}
};
const updateFileUi = () => {
directoryNameElement.textContent = directoryName;
fileNameElement.textContent = fileName;
errorElement.textContent = errorMessage;
};
const resetDashboard = () => {
progress = 0;
processedTime = 0;
startTime = null;
bytesWritten = 0;
filesCreated = 0;
latestFile = '-';
status = 'Preparing HLS output';
errorMessage = '';
filePromises.length = 0;
dashboard.classList.remove('opacity-50');
updateFileUi();
};
const createFileTarget = async (path: string) => {
const target = new BufferTarget({
onFinalize: (buffer) => {
filePromises.push((async () => {
const handle = await directoryHandle!.getFileHandle(path, { create: true });
const writable = await handle.createWritable();
await writable.write(buffer);
await writable.close();
})());
},
});
let fileBytes = 0;
filesCreated++;
latestFile = path;
target.on('write', ({ end }) => {
const newFileBytes = Math.max(fileBytes, end);
bytesWritten += newFileBytes - fileBytes;
fileBytes = newFileBytes;
});
return target;
};
// eslint-disable-next-line @typescript-eslint/no-misused-promises
selectDirectoryButton.addEventListener('click', async () => {
directoryHandle = await window.showDirectoryPicker({ mode: 'readwrite' });
directoryName = `Selected directory: ${directoryHandle.name}`;
status = 'Waiting for source video';
selectDirectoryButton.style.display = 'none';
selectMediaButton.disabled = false;
loadSampleButton.disabled = false;
directoryNameElement.style.display = '';
directoryNameElement.textContent = directoryName;
statusElement.textContent = status;
});
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts';
fileInput.addEventListener('change', () => {
const file = fileInput.files![0];
if (file) {
void convertToHls(file);
}
});
fileInput.click();
});
loadSampleButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
}
void convertToHls(url);
});
document.addEventListener('dragover', (event) => {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
});
document.addEventListener('drop', (event) => {
event.preventDefault();
const files = event.dataTransfer!.files;
const file = files[0];
if (file) {
void convertToHls(file);
}
});
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en-US" 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>HLS transcoding example | Mediabunny</title>
<meta name="description" content="Select a directory, then a video. Mediabunny will create a complete HLS VOD package with five video renditions and one audio track.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./hls-transcoding.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/hls-transcoding/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="HLS transcoding example | Mediabunny">
<meta property="og:description" content="Select a directory, then a video. Mediabunny will create a complete HLS VOD package with five video renditions and one audio track.">
<meta property="og:url" content="https://mediabunny.dev/examples/hls-transcoding/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="HLS transcoding example | Mediabunny">
<meta name="twitter:description" content="Select a directory, then a video. Mediabunny will create a complete HLS VOD package with five video renditions and one audio track.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"HLS transcoding"}]}</script>
</head>
<body class="flex flex-col items-center bg-zinc-50 px-2 py-10 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200">
<h1 class="text-center text-3xl font-bold text-cyan-500">HLS transcoding example</h1>
<p class="max-w-2xl text-center">
Select a directory, then a video. Mediabunny will create a complete HLS VOD package with five video renditions and one audio track.
</p>
<div class="mt-5 flex flex-col items-center gap-1">
<div class="flex flex-wrap justify-center gap-2">
<button id="select-directory" class="rounded-lg bg-cyan-500 px-5 py-2 font-medium text-white hover:bg-cyan-600">
Select directory
</button>
<button id="select-file" disabled class="rounded-lg bg-zinc-200 px-5 py-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-zinc-750 dark:hover:bg-zinc-700 hover:bg-zinc-300">
Select local file
</button>
<button id="load-sample" disabled class="rounded-lg bg-zinc-200 px-5 py-2 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-zinc-750 dark:hover:bg-zinc-700 hover:bg-zinc-300">
Load remote URL
</button>
</div>
<a id="sample-file-download" download="big-buck-bunny-trimmed.mp4" class="text-xs opacity-50 hover:opacity-70 hover:underline">
Download sample file
</a>
</div>
<p id="directory-name" class="mt-3 max-w-[36rem] text-center text-xs opacity-60" style="display: none;"></p>
<p id="file-name" class="min-h-5 max-w-[36rem] text-center text-xs opacity-60"></p>
<p id="error-element" class="mx-auto mt-2 max-w-[36rem] text-center text-red-500"></p>
<hr class="my-4 w-full max-w-4xl border-zinc-300 dark:border-zinc-700">
<div class="grid w-full max-w-4xl gap-4 md:grid-cols-2">
<section class="rounded-xl bg-zinc-200 p-5 dark:bg-zinc-750">
<h2 class="text-lg font-bold text-cyan-500">Renditions</h2>
<div class="mt-3 grid gap-2 text-sm">
<div class="grid grid-cols-[5rem_1fr] rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
<p class="font-bold">1080p</p>
<p class="opacity-70">AVC video</p>
</div>
<div class="grid grid-cols-[5rem_1fr] rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
<p class="font-bold">720p</p>
<p class="opacity-70">AVC video</p>
</div>
<div class="grid grid-cols-[5rem_1fr] rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
<p class="font-bold">480p</p>
<p class="opacity-70">AVC video</p>
</div>
<div class="grid grid-cols-[5rem_1fr] rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
<p class="font-bold">360p</p>
<p class="opacity-70">AVC video</p>
</div>
<div class="grid grid-cols-[5rem_1fr] rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
<p class="font-bold">240p</p>
<p class="opacity-70">AVC video</p>
</div>
<hr class="my-1 border-zinc-300 dark:border-zinc-700">
<div class="grid grid-cols-[5rem_1fr] rounded-lg bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
<p class="font-bold">Audio</p>
<p class="opacity-70">AAC audio</p>
</div>
</div>
</section>
<section id="dashboard" class="rounded-xl bg-zinc-200 p-5 opacity-50 transition-opacity dark:bg-zinc-750">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-bold text-cyan-500">Progress</h2>
<p id="status" class="mt-1 text-sm opacity-70">Waiting for directory</p>
</div>
<p id="percent-indicator" class="text-4xl font-bold tabular-nums text-cyan-500">0%</p>
</div>
<div id="progress-bar-container" class="mt-4 h-3 overflow-hidden rounded-full bg-zinc-300 dark:bg-zinc-900">
<div id="progress-bar" class="h-full w-0 bg-emerald-500 transition-[width] duration-150"></div>
</div>
<div class="mt-4 grid grid-cols-2 gap-2 text-sm">
<div class="rounded-lg bg-zinc-100 p-3 dark:bg-zinc-800">
<p class="text-xs opacity-60">Real-time speed</p>
<p id="speedometer" class="mt-1 text-lg font-bold tabular-nums">-</p>
</div>
<div class="rounded-lg bg-zinc-100 p-3 dark:bg-zinc-800">
<p class="text-xs opacity-60">Bytes written</p>
<p id="bytes-written" class="mt-1 text-lg font-bold tabular-nums">0 B</p>
</div>
<div class="rounded-lg bg-zinc-100 p-3 dark:bg-zinc-800">
<p class="text-xs opacity-60">Files created</p>
<p id="files-created" class="mt-1 text-lg font-bold tabular-nums">0</p>
</div>
<div class="rounded-lg bg-zinc-100 p-3 dark:bg-zinc-800">
<p class="text-xs opacity-60">Latest file</p>
<p id="latest-file" class="mt-1 truncate text-lg font-bold tabular-nums">-</p>
</div>
</div>
</section>
</div>
<a href="/" class="fixed left-0 top-0 flex items-center gap-2 px-5 py-2">
<img src="../../docs/public/mediabunny-logo.svg" class="size-6">
<p class="text-sm font-medium">Mediabunny</p>
</a>
<a
href="https://github.com/Vanilagy/mediabunny/tree/main/examples/hls-transcoding"
target="_blank"
class="fixed right-0 top-0 flex items-center gap-2 rounded-bl-xl bg-zinc-200 px-5 py-2 hover:bg-zinc-300 dark:bg-zinc-750 dark:hover:bg-zinc-700"
>
<img src="../../docs/assets/github-mark.svg" class="size-6 dark:invert">
<p>View source code</p>
</a>
</body>
</html>
+19 -4
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<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 & streaming example | Mediabunny</title>
<meta name="description" content="The live canvas state and your microphone input will be written into a fragmented MP4 file and live-streamed to a &lt;video&gt; element.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./live-recording.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/live-recording/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Live recording & streaming example | Mediabunny">
<meta property="og:description" content="The live canvas state and your microphone input will be written into a fragmented MP4 file and live-streamed to a &lt;video&gt; element.">
<meta property="og:url" content="https://mediabunny.dev/examples/live-recording/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Live recording & streaming example | Mediabunny">
<meta name="twitter:description" content="The live canvas state and your microphone input will be written into a fragmented MP4 file and live-streamed to a &lt;video&gt; element.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Live recording & streaming"}]}</script>
</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">
@@ -21,8 +36,8 @@
<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>
<p id="warning-element" class="text-amber-500"></p>
<p id="error-element" class="text-red-500 mx-auto text-center max-w-[36rem]"></p>
<p id="warning-element" class="text-amber-500 mx-auto text-center max-w-[36rem]"></p>
<div class="flex gap-4" id="main-container" style="display: none;">
<div class="flex flex-col items-center">
+23 -6
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<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>Media player example | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./media-player.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/media-player/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Media player example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<meta property="og:url" content="https://mediabunny.dev/examples/media-player/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Media player example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and a fully custom, Mediabunny-powered player will appear.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Media player"}]}</script>
</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">
@@ -31,13 +46,13 @@
</a>
</div>
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
<p class="text-xs opacity-60 mt-2 mx-auto text-center max-w-[36rem]" id="file-name"></p>
<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>
<p id="warning-element" class="text-amber-500 mb-1"></p>
<p id="loading-element" class="text-sm animate-pulse text-zinc-500" style="display: none;">Loading...</p>
<p id="error-element" class="text-red-500 mx-auto text-center max-w-[36rem]"></p>
<p id="warning-element" class="text-amber-500 mb-1 mx-auto text-center max-w-[36rem]"></p>
<p id="loading-element" class="text-sm animate-pulse text-zinc-500 mx-auto text-center max-w-[36rem]" style="display: none;">Loading...</p>
<div id="player" class="relative bg-black rounded-xl shrink min-h-14 min-w-0 w-full max-w-5xl overflow-hidden select-none" style="display: none;">
<canvas class="size-full object-contain" width="1280" height="720"></canvas>
@@ -78,6 +93,8 @@
<p id="duration" class="tabular-nums w-10 sm:w-24 text-sm"></p>
<button id="live-dot" title="Seek to live edge" style="display: none;" class="mx-2 size-3 rounded-full bg-red-500 cursor-pointer shrink-0 animate-pulse"></button>
<button class="p-2 group outline-none" id="fullscreen-button">
<div class="size-6 invert group-hover:scale-110">
<img src="../../docs/assets/fullscreen-icon.svg" class="size-full">
+101 -32
View File
@@ -32,6 +32,7 @@ const volumeBarContainer = document.querySelector('#volume-bar-container') as HT
const volumeBar = document.querySelector('#volume-bar') as HTMLDivElement;
const volumeIconWrapper = document.querySelector('#volume-icon-wrapper') as HTMLDivElement;
const volumeButton = document.querySelector('#volume-button') as HTMLButtonElement;
const liveDot = document.querySelector('#live-dot') as HTMLButtonElement;
const fullscreenButton = document.querySelector('#fullscreen-button') as HTMLButtonElement;
const errorElement = document.querySelector('#error-element') as HTMLDivElement;
const warningElement = document.querySelector('#warning-element') as HTMLDivElement;
@@ -45,7 +46,9 @@ let fileLoaded = false;
let videoSink: CanvasSink | null = null;
let audioSink: AudioBufferSink | null = null;
let totalDuration = 0;
let firstTimestamp = 0;
let endTimestamp = 0;
let isRelativeToUnixEpoch = false;
/** The value of the audio context's currentTime the moment the playback was started. */
let audioContextStartTime: number | null = null;
let playing = false;
@@ -63,6 +66,8 @@ const queuedAudioNodes: Set<AudioBufferSourceNode> = new Set();
*/
let asyncId = 0;
let liveRefreshIntervalId = -1;
let draggingProgressBar = false;
let volume = 0.7;
let draggingVolumeBar = false;
@@ -83,6 +88,7 @@ const initMediaPlayer = async (resource: File | string) => {
void videoFrameIterator?.return();
void audioBufferIterator?.return();
asyncId++;
fileLoaded = false;
@@ -92,27 +98,47 @@ const initMediaPlayer = async (resource: File | string) => {
playerContainer.style.display = 'none';
errorElement.textContent = '';
warningElement.textContent = '';
liveDot.style.display = 'none';
clearTimeout(liveRefreshIntervalId);
// Create an Input from the resource
const source = resource instanceof File
? new BlobSource(resource)
: new UrlSource(resource);
const input = new Input({
source,
formats: ALL_FORMATS,
source: typeof resource === 'string'
? new UrlSource(resource)
: new BlobSource(resource),
formats: ALL_FORMATS, // Accept all formats
});
playbackTimeAtStart = 0;
totalDuration = await input.computeDuration();
durationElement.textContent = formatSeconds(totalDuration);
let videoTrack = await input.getPrimaryVideoTrack();
let audioTrack = await input.getPrimaryAudioTrack();
const tracks = [videoTrack, audioTrack].filter(t => t !== null);
firstTimestamp = Math.max(
await input.getFirstTimestamp(tracks),
0,
);
endTimestamp = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
?? await input.computeDuration(tracks, { skipLiveWait: true });
isRelativeToUnixEpoch = (await Promise.all(tracks.map(t => t.isRelativeToUnixEpoch()))).some(Boolean);
playbackTimeAtStart = firstTimestamp;
// Configure the time display elements accordingly
const timestampFontSize = isRelativeToUnixEpoch ? '12px' : '';
const timestampWhiteSpace = isRelativeToUnixEpoch ? 'pre' : '';
const timestampTextAlign = isRelativeToUnixEpoch ? 'center' : '';
currentTimeElement.style.fontSize = timestampFontSize;
currentTimeElement.style.whiteSpace = timestampWhiteSpace;
currentTimeElement.style.textAlign = timestampTextAlign;
durationElement.style.fontSize = timestampFontSize;
durationElement.style.whiteSpace = timestampWhiteSpace;
durationElement.style.textAlign = timestampTextAlign;
durationElement.textContent = formatTimestamp(endTimestamp);
let problemMessage = '';
if (videoTrack) {
if (videoTrack.codec === null) {
if (await videoTrack.getCodec() === null) {
problemMessage += 'Unsupported video codec. ';
videoTrack = null;
} else if (!(await videoTrack.canDecode())) {
@@ -122,7 +148,7 @@ const initMediaPlayer = async (resource: File | string) => {
}
if (audioTrack) {
if (audioTrack.codec === null) {
if (await audioTrack.getCodec() === null) {
problemMessage += 'Unsupported audio codec. ';
audioTrack = null;
} else if (!(await audioTrack.canDecode())) {
@@ -148,7 +174,7 @@ const initMediaPlayer = async (resource: File | string) => {
// We must create the audio context with the matching sample rate for correct acoustic results
// (especially for low-sample rate files)
audioContext = new AudioContext({ sampleRate: audioTrack?.sampleRate });
audioContext = new AudioContext({ sampleRate: await audioTrack?.getSampleRate() });
gainNode = audioContext.createGain();
gainNode.connect(audioContext.destination);
updateVolume();
@@ -172,8 +198,8 @@ const initMediaPlayer = async (resource: File | string) => {
// Show the canvas if there's a video track, otherwise hide it
if (videoTrack) {
canvas.style.display = '';
canvas.width = videoTrack.displayWidth;
canvas.height = videoTrack.displayHeight;
canvas.width = await videoTrack.getDisplayWidth();
canvas.height = await videoTrack.getDisplayHeight();
} else {
canvas.style.display = 'none';
}
@@ -205,6 +231,38 @@ const initMediaPlayer = async (resource: File | string) => {
controlsElement.style.pointerEvents = '';
playerContainer.style.cursor = '';
}
const refreshIntervals = await Promise.all(tracks.map(t => t.getLiveRefreshInterval()));
const nonNullIntervals = refreshIntervals.filter(x => x !== null);
if (nonNullIntervals.length > 0) {
// At least one track is live! This means that we'll need to continually refresh the end timestamp of the
// media to allow continuous live playback.
const interval = Math.min(...nonNullIntervals);
liveDot.style.display = '';
liveDot.onclick = () => {
void seekToTime(endTimestamp - interval * 1.5);
};
const scheduleLiveRefresh = () => {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
liveRefreshIntervalId = window.setTimeout(async () => {
endTimestamp = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
?? await input.computeDuration(tracks, { skipLiveWait: true });
durationElement.textContent = formatTimestamp(endTimestamp);
// Check if we're still live
const stillLive = await Promise.all(tracks.map(t => t.isLive()));
if (stillLive.every(live => !live)) {
liveDot.style.display = 'none';
} else {
scheduleLiveRefresh();
}
}, interval * 1000);
};
scheduleLiveRefresh();
}
} catch (error) {
console.error(error);
@@ -246,10 +304,10 @@ const startVideoIterator = async () => {
const render = (requestFrame = true) => {
if (fileLoaded) {
const playbackTime = getPlaybackTime();
if (playbackTime >= totalDuration) {
if (playbackTime >= endTimestamp) {
// Pause playback once the end is reached
pause();
playbackTimeAtStart = totalDuration;
playbackTimeAtStart = endTimestamp;
}
// Check if the current playback time has caught up to the next frame
@@ -319,7 +377,9 @@ const runAudioIterator = async () => {
node.buffer = buffer;
node.connect(gainNode!);
const startTimestamp = audioContextStartTime! + timestamp - playbackTimeAtStart;
let startTimestamp = audioContextStartTime! + timestamp - playbackTimeAtStart;
// Round timestamp to the context's sample boundaries to prevent subsample audio glitches
startTimestamp = Math.round(audioContext!.sampleRate * startTimestamp) / audioContext!.sampleRate;
// Two cases: Either, the audio starts in the future or in the past
if (startTimestamp >= audioContext!.currentTime) {
@@ -368,9 +428,9 @@ const play = async () => {
await audioContext!.resume();
}
if (getPlaybackTime() === totalDuration) {
if (getPlaybackTime() === endTimestamp) {
// If we're at the end, let's snap back to the start
playbackTimeAtStart = 0;
playbackTimeAtStart = firstTimestamp;
await startVideoIterator();
}
@@ -425,7 +485,7 @@ const seekToTime = async (seconds: number) => {
await startVideoIterator();
if (wasPlaying && playbackTimeAtStart < totalDuration) {
if (wasPlaying && playbackTimeAtStart < endTimestamp) {
void play();
}
};
@@ -433,8 +493,8 @@ const seekToTime = async (seconds: number) => {
/** === PROGRESS BAR LOGIC === */
const updateProgressBarTime = (seconds: number) => {
currentTimeElement.textContent = formatSeconds(seconds);
progressBar.style.width = `${(seconds / totalDuration) * 100}%`;
currentTimeElement.textContent = formatTimestamp(seconds);
progressBar.style.width = `${((seconds - firstTimestamp) / (endTimestamp - firstTimestamp)) * 100}%`;
};
progressBarContainer.addEventListener('pointerdown', (event) => {
@@ -443,7 +503,7 @@ progressBarContainer.addEventListener('pointerdown', (event) => {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
updateProgressBarTime(completion * totalDuration);
updateProgressBarTime(firstTimestamp + completion * (endTimestamp - firstTimestamp));
clearTimeout(hideControlsTimeout);
@@ -453,7 +513,7 @@ progressBarContainer.addEventListener('pointerdown', (event) => {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
const newTime = completion * totalDuration;
const newTime = firstTimestamp + completion * (endTimestamp - firstTimestamp);
void seekToTime(newTime);
showControlsTemporarily();
@@ -464,7 +524,7 @@ progressBarContainer.addEventListener('pointermove', (event) => {
if (draggingProgressBar) {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
updateProgressBarTime(completion * totalDuration);
updateProgressBarTime(firstTimestamp + completion * (endTimestamp - firstTimestamp));
}
});
@@ -581,10 +641,10 @@ window.addEventListener('keydown', (e) => {
} else if (e.code === 'KeyF') {
fullscreenButton.click();
} else if (e.code === 'ArrowLeft') {
const newTime = Math.max(getPlaybackTime() - 5, 0);
const newTime = Math.max(getPlaybackTime() - 5, firstTimestamp);
void seekToTime(newTime);
} else if (e.code === 'ArrowRight') {
const newTime = Math.min(getPlaybackTime() + 5, totalDuration);
const newTime = Math.min(getPlaybackTime() + 5, endTimestamp);
void seekToTime(newTime);
} else if (e.code === 'KeyM') {
volumeButton.click();
@@ -630,6 +690,15 @@ controlsElement.addEventListener('click', (event) => {
/** === UTILS === */
const formatTimestamp = (seconds: number) => {
if (isRelativeToUnixEpoch) {
const iso = new Date(seconds * 1000).toISOString();
return iso.replace('T', '\n');
}
return formatSeconds(seconds);
};
const formatSeconds = (seconds: number) => {
const showMilliseconds = window.innerWidth >= 640;
@@ -656,9 +725,9 @@ const formatSeconds = (seconds: number) => {
};
window.addEventListener('resize', () => {
if (totalDuration) {
if (endTimestamp) {
updateProgressBarTime(getPlaybackTime());
durationElement.textContent = formatSeconds(totalDuration);
durationElement.textContent = formatTimestamp(endTimestamp);
}
});
@@ -667,7 +736,7 @@ window.addEventListener('resize', () => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
@@ -684,7 +753,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
+18 -3
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<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>Metadata extraction example | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and Mediabunny will start extracting various metadata about that file.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./metadata-extraction.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/metadata-extraction/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Metadata extraction example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and Mediabunny will start extracting various metadata about that file.">
<meta property="og:url" content="https://mediabunny.dev/examples/metadata-extraction/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Metadata extraction example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and Mediabunny will start extracting various metadata about that file.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Metadata extraction"}]}</script>
</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">
@@ -31,7 +46,7 @@
</a>
</div>
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
<p class="text-xs opacity-60 mt-2 mx-auto text-center max-w-[36rem]" id="file-name"></p>
<hr class="w-full max-w-96 my-4 border-zinc-300 dark:border-zinc-700" style="display: none;">
@@ -1,4 +1,4 @@
import { Input, ALL_FORMATS, BlobSource, UrlSource } from 'mediabunny';
import { ALL_FORMATS, BlobSource, Input, UrlSource } from 'mediabunny';
import SampleFileUrl from '../../docs/assets/big-buck-bunny-trimmed.mp4';
(document.querySelector('#sample-file-download') as HTMLAnchorElement).href = SampleFileUrl;
@@ -12,55 +12,74 @@ const metadataContainer = document.querySelector('#metadata-container') as HTMLD
const extractMetadata = (resource: File | string) => {
// Create a new input from the resource
const source = resource instanceof File
? new BlobSource(resource)
: new UrlSource(resource);
const input = new Input({
source,
source: typeof resource === 'string'
? new UrlSource(resource)
: new BlobSource(resource),
formats: ALL_FORMATS, // Accept all formats
});
let bytesRead = 0;
let fileSize: number | null = null;
let obtainedSources = 0;
const updateBytesRead = () => {
bytesReadElement.textContent = `Bytes read: ${bytesRead} / ${fileSize === null ? '?' : fileSize}`;
if (obtainedSources > 1) {
bytesReadElement.textContent = `Bytes read: ${bytesRead} across ${obtainedSources} files`;
} else {
bytesReadElement.textContent = `Bytes read: ${bytesRead} / ${fileSize === null ? '?' : fileSize}`;
if (fileSize !== null) {
bytesReadElement.textContent += ` (${(100 * bytesRead / fileSize).toPrecision(3)}% of entire file)`;
if (fileSize !== null) {
bytesReadElement.textContent += ` (${(100 * bytesRead / fileSize).toPrecision(3)}% of entire file)`;
}
}
};
input.source.onread = (start, end) => {
bytesRead += end - start;
updateBytesRead();
};
input.on('source', ({ source, isRoot }) => {
if (isRoot) {
// Get the input's size
void source.getSize().then((size) => {
fileSize = size;
updateBytesRead();
});
}
// Get the input's size
void input.source.getSize().then(size => fileSize = size);
obtainedSources++;
updateBytesRead();
source.on('read', ({ start, end }) => {
bytesRead += end - start;
updateBytesRead();
});
});
// This object contains all the data that gets displayed:
const object = {
'Format': input.getFormat().then(format => format.name),
'Full MIME type': input.getMimeType(),
'Duration': input.computeDuration().then(duration => `${duration} seconds`),
'Starts at': input.getFirstTimestamp().then(start => `${start} seconds`),
'Ends at': input.computeDuration().then(duration => `${duration} seconds`),
'Tracks': input.getTracks().then(tracks => tracks.map(track => ({
'Type': track.type,
'Codec': track.codec,
'Codec': track.getCodec(),
'Full codec string': track.getCodecParameterString(),
'Duration': track.computeDuration().then(duration => `${duration} seconds`),
'Language code': track.languageCode,
'Starts at': track.getFirstTimestamp().then(start => `${start} seconds`),
'Ends at': track.computeDuration().then(duration => `${duration} seconds`),
'Language code': track.getLanguageCode(),
...(track.isVideoTrack()
? {
'Coded width': `${track.codedWidth} pixels`,
'Coded height': `${track.codedHeight} pixels`,
'Rotation': `${track.rotation}° clockwise`,
'Coded width': track.getCodedWidth().then(w => `${w} pixels`),
'Coded height': track.getCodedHeight().then(h => `${h} pixels`),
'Rotation': track.getRotation().then(rot => `${rot}° clockwise`),
'Pixel aspect ratio': track.getPixelAspectRatio().then(par => `${par.num}:${par.den}`),
'Display width': track.getDisplayWidth().then(w => `${w} pixels`),
'Display height': track.getDisplayHeight().then(h => `${h} pixels`),
'Transparency': track.canBeTransparent(),
}
: track.isAudioTrack()
? {
'Number of channels': track.numberOfChannels,
'Sample rate': `${track.sampleRate} Hz`,
'Number of channels': track.getNumberOfChannels(),
'Sample rate': track.getSampleRate().then(rate => `${rate} Hz`),
}
: {}),
'Packet statistics': shortDelay().then(() => track.computePacketStats()).then(stats => ({
@@ -202,7 +221,7 @@ const shortDelay = () => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
@@ -219,7 +238,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;
+19 -4
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<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>Procedural Generation example | Mediabunny</title>
<meta name="description" content="Using Mediabunny, this page will procedurally generate a video of musical bouncing balls as fast as possible.">
<script type="module" src="../base.ts"></script>
<script type="module" src="./procedural-generation.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/procedural-generation/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Procedural Generation example | Mediabunny">
<meta property="og:description" content="Using Mediabunny, this page will procedurally generate a video of musical bouncing balls as fast as possible.">
<meta property="og:url" content="https://mediabunny.dev/examples/procedural-generation/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Procedural Generation example | Mediabunny">
<meta name="twitter:description" content="Using Mediabunny, this page will procedurally generate a video of musical bouncing balls as fast as possible.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Procedural generation"}]}</script>
</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">
@@ -35,7 +50,7 @@
<hr class="w-full max-w-96 my-6 border-zinc-300 dark:border-zinc-700" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<p id="error-element" class="text-red-500 mx-auto text-center max-w-[36rem]"></p>
<div class="w-full max-w-80 h-2 rounded-full bg-zinc-200 dark:bg-zinc-750 overflow-hidden" id="progress-bar-container" style="display: none;">
<div class="h-full bg-pink-500 w-0" id="progress-bar"></div>
@@ -59,4 +74,4 @@
<p>View source code</p>
</a>
</body>
</html>
</html>
+19 -4
View File
@@ -1,14 +1,29 @@
<!DOCTYPE html>
<html lang="en" translate="no">
<html lang="en-US" translate="no">
<head>
<meta charset="UTF-8">
<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 | Mediabunny</title>
<meta name="description" content="Select or drop a media file, and Mediabunny will extract video thumbnails for it.">
<script type="module" src="./../base.ts"></script>
<script type="module" src="./thumbnail-generation.ts"></script>
<link rel="stylesheet" href="../base.css">
<link rel="icon" href="../../docs/public/mediabunny-logo.svg">
<link rel="canonical" href="https://mediabunny.dev/examples/thumbnail-generation/">
<meta property="og:site_name" content="Mediabunny">
<meta property="og:type" content="website">
<meta property="og:title" content="Thumbnail generation example | Mediabunny">
<meta property="og:description" content="Select or drop a media file, and Mediabunny will extract video thumbnails for it.">
<meta property="og:url" content="https://mediabunny.dev/examples/thumbnail-generation/">
<meta property="og:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<meta property="og:locale" content="en-US">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="@vanilagy">
<meta name="twitter:title" content="Thumbnail generation example | Mediabunny">
<meta name="twitter:description" content="Select or drop a media file, and Mediabunny will extract video thumbnails for it.">
<meta name="twitter:image" content="https://mediabunny.dev/mediabunny-og-image.png">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Examples","item":"https://mediabunny.dev/examples"},{"@type":"ListItem","position":2,"name":"Thumbnail generation"}]}</script>
</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">
@@ -31,11 +46,11 @@
</a>
</div>
<p class="text-xs opacity-60 mt-2" id="file-name"></p>
<p class="text-xs opacity-60 mt-2 mx-auto text-center max-w-[36rem]" id="file-name"></p>
<hr class="w-full max-w-96 my-4 border-gray-300 dark:border-zinc-700" style="display: none;">
<p id="error-element" class="text-red-500"></p>
<p id="error-element" class="text-red-500 mx-auto text-center max-w-[36rem]"></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>
<a href="/" class="fixed top-0 left-0 flex gap-2 py-2 px-5 items-center">
@@ -34,7 +34,7 @@ const generateThumbnails = async (resource: File | string) => {
throw new Error('File has no video track.');
}
if (videoTrack.codec === null) {
if (await videoTrack.getCodec() === null) {
throw new Error('Unsupported video codec.');
}
@@ -43,12 +43,14 @@ const generateThumbnails = async (resource: File | string) => {
}
// Compute width and height of the thumbnails such that the larger dimension is equal to THUMBNAIL_SIZE
const width = videoTrack.displayWidth > videoTrack.displayHeight
const displayWidth = await videoTrack.getDisplayWidth();
const displayHeight = await videoTrack.getDisplayHeight();
const width = displayWidth > displayHeight
? THUMBNAIL_SIZE
: Math.floor(THUMBNAIL_SIZE * videoTrack.displayWidth / videoTrack.displayHeight);
const height = videoTrack.displayHeight > videoTrack.displayWidth
: Math.floor(THUMBNAIL_SIZE * displayWidth / displayHeight);
const height = displayHeight > displayWidth
? THUMBNAIL_SIZE
: Math.floor(THUMBNAIL_SIZE * videoTrack.displayHeight / videoTrack.displayWidth);
: Math.floor(THUMBNAIL_SIZE * displayHeight / displayWidth);
// Create thumbnail elements
const thumbnailElements = [];
@@ -120,7 +122,7 @@ const generateThumbnails = async (resource: File | string) => {
selectMediaButton.addEventListener('click', () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'video/*,video/x-matroska,audio/*,audio/aac';
fileInput.accept = 'video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac';
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) {
@@ -137,7 +139,7 @@ loadUrlButton.addEventListener('click', () => {
const url = prompt(
'Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the'
+ ' right CORS headers set.',
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://mediabunny.dev/big-buck-bunny.mp4',
);
if (!url) {
return;