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/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