get thumbnail from attached pic if present
deploy / build-and-deploy (push) Successful in 22s

This commit is contained in:
bytedream committed 2026-10-31 12:23:09 +01:00
1 parent c8e38d51f4
commit 133667de9b
1 file changed
+46 -25
+46 -25
View File
@@ -58,20 +58,45 @@ export async function resolveVideo(name: string): Promise<ResolvedVideo | null>
return null;
}
export interface VideoMetadata {
title: string;
description: string | null;
longDescription: string | null;
}
interface FFprobeResult {
streams?: {
index?: number;
codec_type?: string;
tags?: Record<string, string>;
disposition?: {
attached_pic?: number;
};
}[];
format?: {
tags?: Record<string, string>;
};
}
async function getVideoFFprobeMetadata(filePath: string): Promise<FFprobeResult | null> {
try {
const { stdout } = await execFileAsync(
'ffprobe',
// prettier-ignore
[
'-v', 'error',
'-show_entries', 'format_tags=title,description,synopsis,longdescription:stream=index,codec_type:stream_tags=title,description,synopsis,longdescription:stream_disposition=attached_pic',
'-of', 'json',
filePath
],
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
);
return JSON.parse(stdout) as FFprobeResult;
} catch (e) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while reading video metadata');
return null;
}
}
export interface VideoMetadata {
title: string;
description: string | null;
longDescription: string | null;
}
export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetadata | null> {
const modifiedAtMs = video.modifiedAt.getTime();
const cached = videoMetadataCache.get(video.path);
@@ -82,26 +107,16 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
let metadata: VideoMetadata;
try {
const { stdout } = await execFileAsync(
'ffprobe',
// prettier-ignore
[
'-v', 'error',
'-show_entries', 'format_tags=title,description,synopsis,longdescription:stream=codec_type:stream_tags=title,description,synopsis,longdescription',
'-of', 'json',
video.path
],
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
);
const probe = await getVideoFFprobeMetadata(video.path);
if (!probe) return null;
const parsed = JSON.parse(stdout) as FFprobeResult;
const videoStream = parsed.streams?.find((stream) => stream.codec_type === 'video');
const tags = { ...videoStream?.tags, ...parsed.format?.tags };
const videoStream = probe.streams?.find((stream) => stream.codec_type === 'video');
const tags = { ...videoStream?.tags, ...probe.format?.tags };
metadata = {
title: tags['title'] || video.name,
description: tags['description'],
longDescription: tags['synopsis'],
longDescription: tags['synopsis']
};
} catch (e) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while reading video metadata');
@@ -125,14 +140,14 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThum
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 cacheKey = createHash('sha1')
.update(`cover:${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' };
}
if (stats.isFile()) 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');
@@ -143,6 +158,11 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThum
try {
await fs.promises.mkdir(cacheDirectory, { recursive: true });
const probe = await getVideoFFprobeMetadata(video.path);
const coverIndex = probe?.streams?.find((stream) => stream.disposition?.attached_pic === 1)?.index ?? null;
// extract embedded cover or first frame
await execFileAsync(
'ffmpeg',
// prettier-ignore
@@ -150,10 +170,11 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThum
'-v', 'error',
'-y',
'-i', video.path,
...(coverIndex !== null ? ['-map', `0:${coverIndex}`] : []),
'-frames:v', '1',
'-vf', 'scale=1280:-2',
'-q:v', '2',
tempPath
tempPath,
],
{ timeout: 20_000, maxBuffer: 1024 * 1024 }
);