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; return null;
} }
export interface VideoMetadata {
title: string;
description: string | null;
longDescription: string | null;
}
interface FFprobeResult { interface FFprobeResult {
streams?: { streams?: {
index?: number;
codec_type?: string; codec_type?: string;
tags?: Record<string, string>; tags?: Record<string, string>;
disposition?: {
attached_pic?: number;
};
}[]; }[];
format?: { format?: {
tags?: Record<string, string>; 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> { export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetadata | null> {
const modifiedAtMs = video.modifiedAt.getTime(); const modifiedAtMs = video.modifiedAt.getTime();
const cached = videoMetadataCache.get(video.path); const cached = videoMetadataCache.get(video.path);
@@ -82,26 +107,16 @@ export async function getVideoMetadata(video: ResolvedVideo): Promise<VideoMetad
let metadata: VideoMetadata; let metadata: VideoMetadata;
try { try {
const { stdout } = await execFileAsync( const probe = await getVideoFFprobeMetadata(video.path);
'ffprobe', if (!probe) return null;
// 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 parsed = JSON.parse(stdout) as FFprobeResult; const videoStream = probe.streams?.find((stream) => stream.codec_type === 'video');
const videoStream = parsed.streams?.find((stream) => stream.codec_type === 'video'); const tags = { ...videoStream?.tags, ...probe.format?.tags };
const tags = { ...videoStream?.tags, ...parsed.format?.tags };
metadata = { metadata = {
title: tags['title'] || video.name, title: tags['title'] || video.name,
description: tags['description'], description: tags['description'],
longDescription: tags['synopsis'], longDescription: tags['synopsis']
}; };
} catch (e) { } catch (e) {
logger.warn({ error: e instanceof Error ? e.message : String(e) }, 'error while reading video metadata'); 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; if (!VIDEO_PATH) return null;
const cacheDirectory = path.join(path.resolve(CACHE_PATH), '.thumbnails'); 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`); const thumbnailPath = path.join(cacheDirectory, `${cacheKey}.jpg`);
try { try {
const stats = await fs.promises.stat(thumbnailPath); const stats = await fs.promises.stat(thumbnailPath);
if (stats.isFile() && stats.size > 0) { if (stats.isFile()) return { path: thumbnailPath, contentType: 'image/jpeg' };
return { path: thumbnailPath, contentType: 'image/jpeg' };
}
} catch (e) { } catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.warn({ error: (e as Error).message }, 'error while reading video thumbnail'); 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 { try {
await fs.promises.mkdir(cacheDirectory, { recursive: true }); 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( await execFileAsync(
'ffmpeg', 'ffmpeg',
// prettier-ignore // prettier-ignore
@@ -150,10 +170,11 @@ export async function getVideoThumbnail(video: ResolvedVideo): Promise<VideoThum
'-v', 'error', '-v', 'error',
'-y', '-y',
'-i', video.path, '-i', video.path,
...(coverIndex !== null ? ['-map', `0:${coverIndex}`] : []),
'-frames:v', '1', '-frames:v', '1',
'-vf', 'scale=1280:-2', '-vf', 'scale=1280:-2',
'-q:v', '2', '-q:v', '2',
tempPath tempPath,
], ],
{ timeout: 20_000, maxBuffer: 1024 * 1024 } { timeout: 20_000, maxBuffer: 1024 * 1024 }
); );