112 lines
4.1 KiB
JavaScript
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;
|
|
}
|