/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/preview/thumbs.mjs * Purpose : Thumbnail generation — sharp for images, ffmpeg poster for video, * pdftoppm for PDF page 1; cached by (blob sha, size) * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { rename, rm } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import sharp from 'sharp'; import { config } from '../config.mjs'; import { blobPath } from '../storage/blobs.mjs'; import { enqueue } from './queue.mjs'; const execFileP = promisify(execFile); const SIZES = new Set([256, 512]); const EXEC_TIMEOUT = 60_000; const thumbFile = (sha, size) => path.join(config.cacheDir, 'thumbs', `${sha}.${size}.webp`); /** True if a binary exists on PATH (cached). */ const binCache = new Map(); export async function hasBin(name) { if (binCache.has(name)) return binCache.get(name); try { await execFileP('which', [name]); binCache.set(name, true); } catch { binCache.set(name, false); } return binCache.get(name); } /** * Return the cached thumbnail path for a blob, generating it if needed. * @param {'image'|'video'|'pdf'} kind * @returns {Promise} webp path, or null if not generatable */ export async function getThumb(sha, kind, size = 256) { const sz = SIZES.has(Number(size)) ? Number(size) : 256; const out = thumbFile(sha, sz); if (existsSync(out)) return out; return enqueue(`thumb:${sha}:${sz}`, async () => { if (existsSync(out)) return out; const src = blobPath(sha); const tmp = `${out}.tmp-${process.pid}`; try { if (kind === 'image') { await sharp(src, { failOn: 'none', limitInputPixels: 512 * 1024 * 1024 }) .rotate() .resize(sz, sz, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 80 }) .toFile(tmp); } else if (kind === 'video') { if (!(await hasBin('ffmpeg'))) return null; // Grab a frame ~10% in; fall back to first frame for tiny clips. await execFileP( 'ffmpeg', ['-y', '-ss', '00:00:01', '-i', src, '-frames:v', '1', '-vf', `scale='min(${sz},iw)':-2`, '-f', 'webp', tmp], { timeout: EXEC_TIMEOUT }, ).catch(() => execFileP('ffmpeg', ['-y', '-i', src, '-frames:v', '1', '-vf', `scale='min(${sz},iw)':-2`, '-f', 'webp', tmp], { timeout: EXEC_TIMEOUT }), ); } else if (kind === 'pdf') { if (!(await hasBin('pdftoppm'))) return null; const base = `${tmp}-pg`; await execFileP('pdftoppm', ['-png', '-f', '1', '-l', '1', '-scale-to', String(sz), src, base], { timeout: EXEC_TIMEOUT, }); const png = `${base}-1.png`; if (!existsSync(png)) return null; await sharp(png).webp({ quality: 80 }).toFile(tmp); await rm(png, { force: true }); } else { return null; } if (!existsSync(tmp)) return null; await rename(tmp, out); return out; } catch { await rm(tmp, { force: true }); return null; } }); } /** Probe media duration/dimensions with ffprobe (null if unavailable). */ export async function probeMedia(sha) { if (!(await hasBin('ffprobe'))) return null; try { const { stdout } = await execFileP( 'ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', blobPath(sha)], { timeout: 30_000 }, ); const info = JSON.parse(stdout); const video = info.streams?.find((s) => s.codec_type === 'video'); const audio = info.streams?.find((s) => s.codec_type === 'audio'); return { duration: Number(info.format?.duration ?? 0), width: video?.width ?? null, height: video?.height ?? null, videoCodec: video?.codec_name ?? null, audioCodec: audio?.codec_name ?? null, container: info.format?.format_name ?? null, }; } catch { return null; } }