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.6 KB · 122 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/transcode.mjs8 *  Purpose : ffmpeg web-safe transcodes (H.264/AAC mp4) + audio waveform peaks9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { execFile } from 'node:child_process';14import { existsSync } from 'node:fs';15import { readFile, rename, rm, writeFile } from 'node:fs/promises';16import path from 'node:path';17import { promisify } from 'node:util';18import { config } from '../config.mjs';19import { blobPath } from '../storage/blobs.mjs';20import { enqueue, jobStatus } from './queue.mjs';21import { hasBin } from './thumbs.mjs';2223const execFileP = promisify(execFile);24const TRANSCODE_TIMEOUT = 30 * 60_000; // hostile/huge inputs get killed after 30 min2526const transcodeFile = (sha) => path.join(config.cacheDir, 'transcode', `${sha}.mp4`);27const peaksFile = (sha) => path.join(config.cacheDir, 'peaks', `${sha}.json`);2829/** Codecs browsers play natively — no transcode needed. */30export function isWebSafeVideo(probe, mime) {31  if (mime === 'video/mp4' || mime === 'video/webm') {32    const v = probe?.videoCodec;33    const a = probe?.audioCodec;34    if (!probe) return true; // no ffprobe — optimistically stream as-is35    const vOk = !v || ['h264', 'vp8', 'vp9', 'av1'].includes(v);36    const aOk = !a || ['aac', 'mp3', 'opus', 'vorbis'].includes(a);37    return vOk && aOk;38  }39  return false;40}4142/**43 * Video transcode status for the UI.44 * @returns {{state: 'ready'|'processing'|'queued'|'unavailable'|'none', path?: string}}45 */46export function transcodeState(sha) {47  const out = transcodeFile(sha);48  if (existsSync(out)) return { state: 'ready', path: out };49  const job = jobStatus(`transcode:${sha}`);50  if (job?.status === 'running') return { state: 'processing' };51  if (job?.status === 'queued') return { state: 'queued' };52  if (job?.status === 'error') return { state: 'unavailable' };53  return { state: 'none' };54}5556/**57 * Kick off (or join) a background H.264/AAC transcode for a blob.58 * Resolves with the mp4 path when done; null when ffmpeg is missing.59 */60export async function startTranscode(sha) {61  const out = transcodeFile(sha);62  if (existsSync(out)) return out;63  if (!(await hasBin('ffmpeg'))) return null;6465  return enqueue(`transcode:${sha}`, async () => {66    if (existsSync(out)) return out;67    const tmp = `${out}.tmp-${process.pid}.mp4`;68    try {69      await execFileP(70        'ffmpeg',71        ['-y', '-i', blobPath(sha),72          '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',73          '-vf', "scale='min(1920,iw)':-2",74          '-c:a', 'aac', '-b:a', '160k',75          '-movflags', '+faststart',76          tmp],77        { timeout: TRANSCODE_TIMEOUT, maxBuffer: 8 * 1024 * 1024 },78      );79      await rename(tmp, out);80      return out;81    } catch (err) {82      await rm(tmp, { force: true });83      throw err;84    }85  });86}8788/**89 * Pre-computed waveform peaks for the audio player (cached JSON).90 * @returns {Promise<number[]|null>} ~800 normalized peaks, or null91 */92export async function getAudioPeaks(sha) {93  const cache = peaksFile(sha);94  if (existsSync(cache)) return JSON.parse(await readFile(cache, 'utf8'));95  if (!(await hasBin('ffmpeg'))) return null;9697  return enqueue(`peaks:${sha}`, async () => {98    if (existsSync(cache)) return JSON.parse(await readFile(cache, 'utf8'));99    const target = 800;100    // Resample to mono s16le at a rate that yields ~target samples-per-bucket work.101    const rate = 4000;102    const { stdout } = await execFileP(103      'ffmpeg',104      ['-i', blobPath(sha), '-ac', '1', '-ar', String(rate), '-f', 's16le', '-'],105      { timeout: 10 * 60_000, encoding: 'buffer', maxBuffer: 512 * 1024 * 1024 },106    );107    const samples = new Int16Array(stdout.buffer, stdout.byteOffset, Math.floor(stdout.length / 2));108    const bucket = Math.max(1, Math.floor(samples.length / target));109    const peaks = [];110    for (let i = 0; i < samples.length; i += bucket) {111      let max = 0;112      for (let j = i; j < Math.min(i + bucket, samples.length); j += 1) {113        const v = Math.abs(samples[j]);114        if (v > max) max = v;115      }116      peaks.push(Number((max / 32768).toFixed(3)));117    }118    await writeFile(cache, JSON.stringify(peaks));119    return peaks;120  }).catch(() => null);121}122