SPB Git

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%
4.4 KB · 123 lines javascript
Raw Blame History
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