/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/preview/transcode.mjs * Purpose : ffmpeg web-safe transcodes (H.264/AAC mp4) + audio waveform peaks * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { readFile, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import { config } from '../config.mjs'; import { blobPath } from '../storage/blobs.mjs'; import { enqueue, jobStatus } from './queue.mjs'; import { hasBin } from './thumbs.mjs'; const execFileP = promisify(execFile); const TRANSCODE_TIMEOUT = 30 * 60_000; // hostile/huge inputs get killed after 30 min const transcodeFile = (sha) => path.join(config.cacheDir, 'transcode', `${sha}.mp4`); const peaksFile = (sha) => path.join(config.cacheDir, 'peaks', `${sha}.json`); /** Codecs browsers play natively — no transcode needed. */ export function isWebSafeVideo(probe, mime) { if (mime === 'video/mp4' || mime === 'video/webm') { const v = probe?.videoCodec; const a = probe?.audioCodec; if (!probe) return true; // no ffprobe — optimistically stream as-is const vOk = !v || ['h264', 'vp8', 'vp9', 'av1'].includes(v); const aOk = !a || ['aac', 'mp3', 'opus', 'vorbis'].includes(a); return vOk && aOk; } return false; } /** * Video transcode status for the UI. * @returns {{state: 'ready'|'processing'|'queued'|'unavailable'|'none', path?: string}} */ export function transcodeState(sha) { const out = transcodeFile(sha); if (existsSync(out)) return { state: 'ready', path: out }; const job = jobStatus(`transcode:${sha}`); if (job?.status === 'running') return { state: 'processing' }; if (job?.status === 'queued') return { state: 'queued' }; if (job?.status === 'error') return { state: 'unavailable' }; return { state: 'none' }; } /** * Kick off (or join) a background H.264/AAC transcode for a blob. * Resolves with the mp4 path when done; null when ffmpeg is missing. */ export async function startTranscode(sha) { const out = transcodeFile(sha); if (existsSync(out)) return out; if (!(await hasBin('ffmpeg'))) return null; return enqueue(`transcode:${sha}`, async () => { if (existsSync(out)) return out; const tmp = `${out}.tmp-${process.pid}.mp4`; try { await execFileP( 'ffmpeg', ['-y', '-i', blobPath(sha), '-c:v', 'libx264', '-preset', 'fast', '-crf', '23', '-vf', "scale='min(1920,iw)':-2", '-c:a', 'aac', '-b:a', '160k', '-movflags', '+faststart', tmp], { timeout: TRANSCODE_TIMEOUT, maxBuffer: 8 * 1024 * 1024 }, ); await rename(tmp, out); return out; } catch (err) { await rm(tmp, { force: true }); throw err; } }); } /** * Pre-computed waveform peaks for the audio player (cached JSON). * @returns {Promise} ~800 normalized peaks, or null */ export async function getAudioPeaks(sha) { const cache = peaksFile(sha); if (existsSync(cache)) return JSON.parse(await readFile(cache, 'utf8')); if (!(await hasBin('ffmpeg'))) return null; return enqueue(`peaks:${sha}`, async () => { if (existsSync(cache)) return JSON.parse(await readFile(cache, 'utf8')); const target = 800; // Resample to mono s16le at a rate that yields ~target samples-per-bucket work. const rate = 4000; const { stdout } = await execFileP( 'ffmpeg', ['-i', blobPath(sha), '-ac', '1', '-ar', String(rate), '-f', 's16le', '-'], { timeout: 10 * 60_000, encoding: 'buffer', maxBuffer: 512 * 1024 * 1024 }, ); const samples = new Int16Array(stdout.buffer, stdout.byteOffset, Math.floor(stdout.length / 2)); const bucket = Math.max(1, Math.floor(samples.length / target)); const peaks = []; for (let i = 0; i < samples.length; i += bucket) { let max = 0; for (let j = i; j < Math.min(i + bucket, samples.length); j += 1) { const v = Math.abs(samples[j]); if (v > max) max = v; } peaks.push(Number((max / 32768).toFixed(3))); } await writeFile(cache, JSON.stringify(peaks)); return peaks; }).catch(() => null); }