import { spawn } from 'node:child_process'; import { existsSync, statSync } from 'node:fs'; import { basename, extname, join } from 'node:path'; /* * ghost-media.js — server-side ghost sprite conversion. * * Turns an uploaded black-background ghost MP4 into a transparent animated * WebM (VP9 + alpha) plus an animated WebP fallback, using luma keying so the * black becomes transparent and the glow keeps soft semi-transparent edges. * * Shells out to the system `ffmpeg` binary (no npm dependency). If ffmpeg is * not installed, conversion fails gracefully and the caller keeps the original. * * This mirrors the standalone ghostify.sh pipeline so local and server output * match. */ const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg'; // Tunables (env-overridable) — kept in sync with ghostify.sh defaults. const THRESH = process.env.GHOST_KEY_THRESHOLD || '0.06'; const TOL = process.env.GHOST_KEY_TOLERANCE || '0.10'; const SOFT = process.env.GHOST_KEY_SOFTNESS || '0.15'; const FPS = process.env.GHOST_FPS || '15'; const MAXW = process.env.GHOST_MAXW || '512'; const WEBP_Q = process.env.GHOST_WEBP_QUALITY || '80'; const VP9_CRF = process.env.GHOST_VP9_CRF || '30'; // The shared luma-key + downscale filter chain. function keyChain() { const key = `format=rgba,lumakey=threshold=${THRESH}:tolerance=${TOL}:softness=${SOFT}`; const scale = `scale='min(iw,${MAXW})':-1:flags=lanczos`; return `${key},fps=${FPS},${scale}`; } function run(args) { return new Promise((resolve, reject) => { const proc = spawn(FFMPEG, args, { stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; proc.stderr.on('data', (d) => { stderr += d.toString(); }); proc.on('error', reject); // e.g. ffmpeg not found (ENOENT) proc.on('close', (code) => { if (code === 0) resolve(); else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`)); }); }); } /** True if a usable ffmpeg binary is on PATH. */ export function ffmpegAvailable() { return new Promise((resolve) => { const proc = spawn(FFMPEG, ['-version'], { stdio: 'ignore' }); proc.on('error', () => resolve(false)); proc.on('close', (code) => resolve(code === 0)); }); } function outName(srcFilename, ext) { // wraith-1718.mp4 -> wraith-1718.webm / .webp, written alongside the source. const stem = basename(srcFilename, extname(srcFilename)); return `${stem}.${ext}`; } /** * Convert a source MP4 (already saved in uploadDir) into transparent webm+webp. * * @param {string} uploadDir absolute uploads directory * @param {string} srcFilename the multer filename of the uploaded mp4 * @returns {Promise<{ webm: string|null, webp: string|null }>} new filenames * (not full paths). Either may be null if that encode failed. */ export async function convertGhostMp4(uploadDir, srcFilename) { const srcPath = join(uploadDir, srcFilename); if (!existsSync(srcPath)) throw new Error(`source not found: ${srcPath}`); const vf = keyChain(); const result = { webm: null, webp: null }; // --- WebM (VP9 + alpha): the primary, browser-decoded sprite --- const webmName = outName(srcFilename, 'webm'); try { await run([ '-hide_banner', '-loglevel', 'error', '-y', '-i', srcPath, '-vf', vf, '-an', '-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p', '-b:v', '0', '-crf', VP9_CRF, '-auto-alt-ref', '0', join(uploadDir, webmName), ]); if (statSync(join(uploadDir, webmName)).size > 0) result.webm = webmName; } catch (e) { console.error('[ghost-media] webm encode failed:', e.message); } // --- WebP fallback: for iOS Safari / no-VP9-alpha browsers --- const webpName = outName(srcFilename, 'webp'); try { await run([ '-hide_banner', '-loglevel', 'error', '-y', '-i', srcPath, '-vf', vf, '-loop', '0', '-an', '-c:v', 'libwebp_anim', '-lossless', '0', '-q:v', WEBP_Q, '-preset', 'drawing', '-compression_level', '6', join(uploadDir, webpName), ]); if (statSync(join(uploadDir, webpName)).size > 0) result.webp = webpName; } catch (e) { console.error('[ghost-media] webp encode failed:', e.message); } return result; }