Files
newbury-nights/lib/ghost-media.js
T

112 lines
4.1 KiB
JavaScript

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;
}