add video thumbnail generation for opengraph
deploy / build-and-deploy (push) Successful in 45s

This commit is contained in:
bytedream committed 2026-10-31 03:59:25 +01:00
1 parent 2a0e5e5591
commit 83b91bb122
8 files changed
+149 -216

No files matched your search

+3 -2
View File
@@ -8,13 +8,14 @@
src: string;
type?: string;
title?: string;
poster?: string;
}
let { src, type, title }: Props = $props();
let { src, type, title, poster }: Props = $props();
</script>
<media-i18n lang="de">
<video-player content-title={title}>
<video-player content-title={title} {poster}>
<video-skin>
<!-- svelte-ignore a11y_media_has_caption -->
<video {src} {type} playsinline preload="metadata">
+9 -2
View File
@@ -2,7 +2,7 @@
import WebsiteLayout from '@layouts/website/WebsiteLayout.astro';
import VideoPlayer from '@app/website/video/VideoPlayer.svelte';
import { getVideoMetadata, resolveVideo } from '@util/video.ts';
import { siteBase, siteUrl } from '@util/url.ts';
import { siteUrl } from '@util/url.ts';
const { name } = Astro.params;
@@ -14,11 +14,18 @@ if (!metadata) return new Response(null, { status: 404 });
const { title, description, width, height, duration } = metadata;
const streamUrl = siteUrl(`video/${encodeURIComponent(video.name)}/stream`);
const thumbnailUrl = siteUrl(`video/${encodeURIComponent(video.name)}/thumbnail.jpg`);
const thumbnailWidth = 1280;
const thumbnailHeight = width && Math.round((height / width) * thumbnailWidth);
---
<WebsiteLayout title={`${title} - Video`} description={description ?? undefined} openGraph="minimal">
<Fragment slot="head">
<meta property="og:type" content="video" />
<meta property="og:image" content={thumbnailUrl} />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:width" content={thumbnailWidth.toString()} />
<meta property="og:image:height" content={thumbnailHeight.toString()} />
<meta property="og:video" content={streamUrl} />
{streamUrl.startsWith('https') && <meta property="og:video:secure_url" content={streamUrl} />}
<meta property="og:video:type" content={video.contentType} />
@@ -33,7 +40,7 @@ const streamUrl = siteUrl(`video/${encodeURIComponent(video.name)}/stream`);
<h1 class="mt-1 text-2xl font-semibold break-words lg:text-4xl">{title}</h1>
</div>
<VideoPlayer client:load src={streamUrl} type={video.contentType} title={title} />
<VideoPlayer client:load src={streamUrl} type={video.contentType} title={title} poster={thumbnailUrl} />
{description && (
<p class="max-w-3xl text-base leading-relaxed whitespace-pre-line text-base-content/70">{description}</p>
+26
View File
@@ -0,0 +1,26 @@
import fs from 'node:fs';
import type { APIContext, APIRoute } from 'astro';
import { getVideoThumbnail, resolveVideo } from '@util/video.ts';
async function handle(context: APIContext, headersOnly = false) {
const video = await resolveVideo(context.params.name ?? '');
if (!video) return new Response(null, { status: 404 });
const thumbnail = await getVideoThumbnail(video);
if (!thumbnail) return new Response(null, { status: 404 });
const stats = await fs.promises.stat(thumbnail.path);
const headers = {
'Content-Type': thumbnail.contentType,
'Content-Length': stats.size.toString(),
'Cache-Control': 'public, max-age=86400'
};
if (headersOnly) return new Response(null, { headers });
const data = await fs.promises.readFile(thumbnail.path);
return new Response(new Uint8Array(data), { headers });
}
export const HEAD: APIRoute = (context) => handle(context, true);
export const GET: APIRoute = (context) => handle(context);
+107 -29
View File
@@ -1,7 +1,9 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseFile } from 'music-metadata';
import { VIDEO_PATH } from 'astro:env/server';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { createHash, randomUUID } from 'node:crypto';
import { CACHE_PATH, VIDEO_PATH } from 'astro:env/server';
import { logger } from './log';
const videoTypes = [
@@ -9,6 +11,8 @@ const videoTypes = [
['webm', 'video/webm']
];
const execFileAsync = promisify(execFile);
export interface ResolvedVideo {
name: string;
filename: string;
@@ -23,12 +27,32 @@ export interface VideoRange {
end: number;
}
export interface VideoThumbnail {
path: string;
contentType: string;
}
export interface VideoMetadata {
title: string;
description: string | null;
width: number | null;
height: number | null;
duration: number | null;
width: number;
height: number;
duration: number;
}
interface FFprobeStream {
codec_type?: string;
width?: number;
height?: number;
tags?: Record<string, string>;
}
interface FFprobeResult {
streams?: FFprobeStream[];
format?: {
duration?: string;
tags?: Record<string, string>;
};
}
interface VideoMetadataCacheEntry {
@@ -63,17 +87,12 @@ export async function resolveVideo(name: string): Promise<ResolvedVideo | null>
size: stats.size,
modifiedAt: stats.mtime
};
} catch (e: NodeJS.ErrnoException) {
} catch (e) {
// check if file doesn't exist
if (e.code === 'ENOENT') {
if ((e as NodeJS.ErrnoException).code === 'ENOENT') {
continue;
}
logger.warn(
{
error: e.message
},
'error while reading video file'
);
logger.warn({ error: e }, 'error while reading video file');
}
}
@@ -90,26 +109,38 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
let metadata: VideoMetadata;
try {
const parsed = await parseFile(video.path, {
duration: false,
skipCovers: true
});
const { stdout } = await execFileAsync(
'ffprobe',
// prettier-ignore
[
'-v',
'error', '-show_entries',
'format=duration:format_tags=title,description,synopsis:stream=codec_type,width,height:stream_tags=title,description,synopsis',
'-of', 'json',
video.path
],
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
);
const videoTrack = parsed.format.trackInfo?.find((track) => track.video);
const duration = parsed.format.containerDuration ?? parsed.format.duration;
const parsed = JSON.parse(stdout) as FFprobeResult;
if (!parsed.format) throw new Error('invalid video stream');
const videoStream = parsed.streams?.find((stream) => stream.codec_type === 'video');
if (!videoStream) throw new Error('no video stream');
else if (!videoStream.width || !videoStream.height) throw new Error('invalid video stream height');
const tags = { ...videoStream.tags, ...parsed.format.tags };
const duration = Number(parsed.format.duration);
metadata = {
title: parsed.common.title?.trim() || video.name,
description:
parsed.common.longDescription?.trim() ||
parsed.common.description?.map((value) => value.trim()).find(Boolean) ||
null,
width: videoTrack?.video?.displayWidth ?? videoTrack?.video?.pixelWidth ?? null,
height: videoTrack?.video?.displayHeight ?? videoTrack?.video?.pixelHeight ?? null,
duration: duration ? Math.round(duration) : null
title: tags['title'] || video.name,
description: (tags['synopsis'] || tags['longdescription'] || tags['description']) ?? null,
width: videoStream.width,
height: videoStream.height,
duration: Math.round(duration)
};
} catch {
// couldn't read metadata
} catch (e) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while reading video metadata');
return null;
}
@@ -122,6 +153,53 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
return metadata;
}
export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThumbnail | null> {
if (!VIDEO_PATH) return null;
const cacheDirectory = path.join(path.resolve(CACHE_PATH), '.thumbnails');
const cacheKey = createHash('sha1').update(`${video.path}:${video.size}:${video.modifiedAt.getTime()}`).digest('hex');
const thumbnailPath = path.join(cacheDirectory, `${cacheKey}.jpg`);
try {
const stats = await fs.promises.stat(thumbnailPath);
if (stats.isFile() && stats.size > 0) {
return { path: thumbnailPath, contentType: 'image/jpeg' };
}
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn({ error: (e as Error).message }, 'error while reading video thumbnail');
}
}
const tempPath = path.join(cacheDirectory, `${cacheKey}-${randomUUID()}.jpg`);
try {
await fs.promises.mkdir(cacheDirectory, { recursive: true });
await execFileAsync(
'ffmpeg',
// prettier-ignore
[
'-v', 'error',
'-y',
'-i', video.path,
'-frames:v', '1',
'-vf', 'scale=1280:-2',
'-q:v', '2',
tempPath
],
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
);
await fs.promises.rename(tempPath, thumbnailPath);
return { path: thumbnailPath, contentType: 'image/jpeg' };
} catch (e) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while generating video thumbnail');
return null;
}
}
export function parseVideoRange(rangeHeader: string | null, size: number): VideoRange | 'invalid' | null {
if (!rangeHeader) return null;
if (size <= 0) return 'invalid';