spb/drive Public
SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.
JavaScript 82.7%
CSS 10.6%
Nunjucks 3.6%
Shell 1.8%
SQL 1.3%
1/**2 * ─────────────────────────────────────────────3 * SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 * Author : Simon-Pierre Boucher6 * Contact : contact@spboucher.ai7 * File : src/preview/thumbs.mjs8 * Purpose : Thumbnail generation — sharp for images, ffmpeg poster for video,9 * pdftoppm for PDF page 1; cached by (blob sha, size)10 * License : MIT © Simon-Pierre Boucher11 * ─────────────────────────────────────────────12 */1314import { execFile } from 'node:child_process';15import { existsSync } from 'node:fs';16import { rename, rm } from 'node:fs/promises';17import path from 'node:path';18import { promisify } from 'node:util';19import sharp from 'sharp';20import { config } from '../config.mjs';21import { blobPath } from '../storage/blobs.mjs';22import { enqueue } from './queue.mjs';2324const execFileP = promisify(execFile);25const SIZES = new Set([256, 512]);26const EXEC_TIMEOUT = 60_000;2728const thumbFile = (sha, size) => path.join(config.cacheDir, 'thumbs', `${sha}.${size}.webp`);2930/** True if a binary exists on PATH (cached). */31const binCache = new Map();32export async function hasBin(name) {33 if (binCache.has(name)) return binCache.get(name);34 try {35 await execFileP('which', [name]);36 binCache.set(name, true);37 } catch {38 binCache.set(name, false);39 }40 return binCache.get(name);41}4243/**44 * Return the cached thumbnail path for a blob, generating it if needed.45 * @param {'image'|'video'|'pdf'} kind46 * @returns {Promise<string|null>} webp path, or null if not generatable47 */48export async function getThumb(sha, kind, size = 256) {49 const sz = SIZES.has(Number(size)) ? Number(size) : 256;50 const out = thumbFile(sha, sz);51 if (existsSync(out)) return out;5253 return enqueue(`thumb:${sha}:${sz}`, async () => {54 if (existsSync(out)) return out;55 const src = blobPath(sha);56 const tmp = `${out}.tmp-${process.pid}`;57 try {58 if (kind === 'image') {59 await sharp(src, { failOn: 'none', limitInputPixels: 512 * 1024 * 1024 })60 .rotate()61 .resize(sz, sz, { fit: 'inside', withoutEnlargement: true })62 .webp({ quality: 80 })63 .toFile(tmp);64 } else if (kind === 'video') {65 if (!(await hasBin('ffmpeg'))) return null;66 // Grab a frame ~10% in; fall back to first frame for tiny clips.67 await execFileP(68 'ffmpeg',69 ['-y', '-ss', '00:00:01', '-i', src, '-frames:v', '1',70 '-vf', `scale='min(${sz},iw)':-2`, '-f', 'webp', tmp],71 { timeout: EXEC_TIMEOUT },72 ).catch(() =>73 execFileP('ffmpeg', ['-y', '-i', src, '-frames:v', '1',74 '-vf', `scale='min(${sz},iw)':-2`, '-f', 'webp', tmp], { timeout: EXEC_TIMEOUT }),75 );76 } else if (kind === 'pdf') {77 if (!(await hasBin('pdftoppm'))) return null;78 const base = `${tmp}-pg`;79 await execFileP('pdftoppm', ['-png', '-f', '1', '-l', '1', '-scale-to', String(sz), src, base], {80 timeout: EXEC_TIMEOUT,81 });82 const png = `${base}-1.png`;83 if (!existsSync(png)) return null;84 await sharp(png).webp({ quality: 80 }).toFile(tmp);85 await rm(png, { force: true });86 } else {87 return null;88 }89 if (!existsSync(tmp)) return null;90 await rename(tmp, out);91 return out;92 } catch {93 await rm(tmp, { force: true });94 return null;95 }96 });97}9899/** Probe media duration/dimensions with ffprobe (null if unavailable). */100export async function probeMedia(sha) {101 if (!(await hasBin('ffprobe'))) return null;102 try {103 const { stdout } = await execFileP(104 'ffprobe',105 ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', blobPath(sha)],106 { timeout: 30_000 },107 );108 const info = JSON.parse(stdout);109 const video = info.streams?.find((s) => s.codec_type === 'video');110 const audio = info.streams?.find((s) => s.codec_type === 'audio');111 return {112 duration: Number(info.format?.duration ?? 0),113 width: video?.width ?? null,114 height: video?.height ?? null,115 videoCodec: video?.codec_name ?? null,116 audioCodec: audio?.codec_name ?? null,117 container: info.format?.format_name ?? null,118 };119 } catch {120 return null;121 }122}123