mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-10-02 13:23:51 +02:00
Add HLS transcoding example
This commit is contained in:
@@ -43,4 +43,10 @@ features:
|
|||||||
target: _self
|
target: _self
|
||||||
icon:
|
icon:
|
||||||
src: /mingcute--microphone-line.svg
|
src: /mingcute--microphone-line.svg
|
||||||
|
- title: HLS transcoding
|
||||||
|
details: Convert one video into a full HLS manifest with five video renditions and one audio track.
|
||||||
|
link: /examples/hls-transcoding/
|
||||||
|
target: _self
|
||||||
|
icon:
|
||||||
|
src: /mingcute--live-line.svg
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -89,16 +89,23 @@ console.log(writtenFiles);
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
const root = await navigator.storage.getDirectory();
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const writePromises: Promise<void>[] = [];
|
||||||
|
|
||||||
const output = new Output({
|
const output = new Output({
|
||||||
target: new PathedTarget(
|
target: new PathedTarget(
|
||||||
'master.m3u8',
|
'master.m3u8',
|
||||||
async ({ path }) => {
|
async ({ path }) => new BufferTarget({
|
||||||
const handle = await root.getFileHandle(path, { create: true });
|
onFinalize: (buffer) => {
|
||||||
const writable = await handle.createWritable();
|
writePromises.push((async () => {
|
||||||
return new StreamTarget(writable);
|
const handle = await root.getFileHandle(path, { create: true });
|
||||||
},
|
const writable = await handle.createWritable();
|
||||||
|
await writable.write(buffer);
|
||||||
|
await writable.close();
|
||||||
|
})());
|
||||||
|
},
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
|
onFinalize: () => Promise.all(writePromises),
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" fill-rule="evenodd"><path d="m12.594 23.258l-.012.002l-.071.035l-.02.004l-.014-.004l-.071-.036q-.016-.004-.024.006l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.016-.018m.264-.113l-.014.002l-.184.093l-.01.01l-.003.011l.018.43l.005.012l.008.008l.201.092q.019.005.029-.008l.004-.014l-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.003-.011l.018-.43l-.003-.012l-.01-.01z"/><path fill="#06b6d4" d="M16.95 2.586a1 1 0 0 1 0 1.414l-3 3H19a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h3.636L7.05 5.414A1 1 0 0 1 8.465 4l2.474 2.475a.5.5 0 0 0 .707 0l3.89-3.89a1 1 0 0 1 1.414 0M19 9H5v10h14zM8.98 11.547a1.232 1.232 0 0 1 1.72-.994a22 22 0 0 1 2.2 1.123a22 22 0 0 1 2.075 1.346c.668.494.67 1.489 0 1.984A22 22 0 0 1 12.9 16.35c-.997.576-1.785.943-2.2 1.124a1.23 1.23 0 0 1-1.72-.993a23 23 0 0 1-.128-2.467c0-1.14.078-2.014.128-2.467m1.902 1.306a23 23 0 0 0 0 2.32a23 23 0 0 0 2.008-1.16a23 23 0 0 0-2.008-1.16"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -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://remotion.media/BigBuckBunny.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!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>HLS transcoding example | Mediabunny</title>
|
||||||
|
<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">
|
||||||
|
</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>
|
||||||
Reference in New Issue
Block a user