import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; import ffmpegStatic from 'ffmpeg-static'; import sharp from 'sharp'; const execFileAsync = promisify(execFile); /** Longest edge; presentation uses the original asset. */ export const SCENE_PREVIEW_THUMB_MAX_PX = 320; /** * Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds). */ export async function generateScenePreviewThumbnailBytes( sourceAbsPath: string, kind: 'image' | 'video', ): Promise { try { if (kind === 'image') { return await sharp(sourceAbsPath) .rotate() .resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, { fit: 'inside', withoutEnlargement: true, }) .webp({ quality: 82 }) .toBuffer(); } const ffmpegPath = ffmpegStatic; if (!ffmpegPath) return null; const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-thumb-')); const tmpPng = path.join(tmpDir, 'frame.png'); try { const seekSeconds = ['0.5', '0.25', '0']; let extracted = false; for (const ss of seekSeconds) { await fs.rm(tmpPng, { force: true }).catch(() => undefined); try { await execFileAsync( ffmpegPath, [ '-hide_banner', '-loglevel', 'error', '-y', '-ss', ss, '-i', sourceAbsPath, '-frames:v', '1', tmpPng, ], { maxBuffer: 16 * 1024 * 1024 }, ); const st = await fs.stat(tmpPng).catch(() => null); if (st !== null && st.isFile() && st.size > 0) { extracted = true; break; } } catch { /* try next seek */ } } if (!extracted) return null; return await sharp(tmpPng) .resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, { fit: 'inside', withoutEnlargement: true, }) .webp({ quality: 82 }) .toBuffer(); } finally { await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined); } } catch { return null; } }