| 1 | /**
|
|---|
| 2 | * VideoCoverService — turn an animated cover into a small, Safari-friendly muted loop video.
|
|---|
| 3 | *
|
|---|
| 4 | * Safari renders animated WebP poorly; a muted <video> loop plays smoothly everywhere (iOS too).
|
|---|
| 5 | * ffmpeg-static can't DECODE an animated WebP, so we decode it with node-webpmux (pure JS/WASM,
|
|---|
| 6 | * NO native deps → installs on every platform, never breaks `npm ci`) into RGBA frames, then
|
|---|
| 7 | * encode with the bundled ffmpeg-static into an H.264 MP4 (yuv420p + faststart + no audio). A real
|
|---|
| 8 | * uploaded video goes straight through ffmpeg. Both are best-effort: on any failure (or an oversized
|
|---|
| 9 | * input) we return null and the caller keeps the still image.
|
|---|
| 10 | */
|
|---|
| 11 | import { execFile } from 'child_process';
|
|---|
| 12 | import { promisify } from 'util';
|
|---|
| 13 | import fs from 'fs';
|
|---|
| 14 | import path from 'path';
|
|---|
| 15 | import ffmpegPath from 'ffmpeg-static';
|
|---|
| 16 | import WebP from 'node-webpmux';
|
|---|
| 17 |
|
|---|
| 18 | const execFileP = promisify(execFile);
|
|---|
| 19 | const MAX_SECONDS = 60; // cap a cover loop at one minute
|
|---|
| 20 | const MAX_FRAMES = 600; // skip a pathological animated WebP (keep the still image)
|
|---|
| 21 | const MAX_WORK = 250_000_000; // W*H*frames cap — bounds compositor memory churn + CPU/time
|
|---|
| 22 |
|
|---|
| 23 | let _lib = null;
|
|---|
| 24 | function ensureLib() { if (!_lib) _lib = WebP.Image.initLib(); return _lib; }
|
|---|
| 25 |
|
|---|
| 26 | // node-webpmux's getFrameData(i) returns ONLY frame i's own sub-region (x,y,width,height) — it does
|
|---|
| 27 | // NOT composite onto the canvas. Real (tool-made) animated WebPs use partial frames of varying size,
|
|---|
| 28 | // so we composite each onto a persistent W×H canvas (honoring blend + dispose) and STREAM consistent
|
|---|
| 29 | // full frames straight to `rawPath` — one canvas in memory, not all N frames. Feeding ffmpeg the raw
|
|---|
| 30 | // varying-size sub-regions desyncs the stream → a torn/tiled video.
|
|---|
| 31 | async function compositeFramesToFile(img, rawPath) {
|
|---|
| 32 | const W = img.width, H = img.height, frames = img.anim.frames;
|
|---|
| 33 | const canvas = Buffer.alloc(W * H * 4); // transparent black
|
|---|
| 34 | const fd = fs.openSync(rawPath, 'w');
|
|---|
| 35 | try {
|
|---|
| 36 | let prev = null; // previous frame's rect + dispose
|
|---|
| 37 | for (let i = 0; i < frames.length; i++) {
|
|---|
| 38 | const fr = frames[i];
|
|---|
| 39 | if (prev && prev.dispose) { // dispose-to-background: clear the previous frame's rect first
|
|---|
| 40 | for (let row = 0; row < prev.h; row++) {
|
|---|
| 41 | const y = prev.y + row; if (y < 0 || y >= H) continue;
|
|---|
| 42 | canvas.fill(0, (y * W + prev.x) * 4, (y * W + prev.x + prev.w) * 4);
|
|---|
| 43 | }
|
|---|
| 44 | }
|
|---|
| 45 | const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
|
|---|
| 46 | // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame
|
|---|
| 47 | // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel
|
|---|
| 48 | // position — else partial frames land at half-offset and ghost over the base. width/height are fine.
|
|---|
| 49 | const fx = fr.x * 2, fy = fr.y * 2, fw = fr.width, fh = fr.height, blend = fr.blend;
|
|---|
| 50 | if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
|
|---|
| 51 | data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
|
|---|
| 52 | } else {
|
|---|
| 53 | for (let row = 0; row < fh; row++) {
|
|---|
| 54 | const cy = fy + row; if (cy < 0 || cy >= H) continue;
|
|---|
| 55 | for (let col = 0; col < fw; col++) {
|
|---|
| 56 | const cx = fx + col; if (cx < 0 || cx >= W) continue;
|
|---|
| 57 | const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
|
|---|
| 58 | if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
|
|---|
| 59 | else if (sa !== 0) { // alpha-over the existing canvas pixel
|
|---|
| 60 | const a = sa / 255, ia = 1 - a;
|
|---|
| 61 | canvas[d] = (data[s] * a + canvas[d] * ia) | 0;
|
|---|
| 62 | canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
|
|---|
| 63 | canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
|
|---|
| 64 | canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
|
|---|
| 65 | }
|
|---|
| 66 | }
|
|---|
| 67 | }
|
|---|
| 68 | }
|
|---|
| 69 | fs.writeSync(fd, canvas); // stream the full composited canvas (bounds memory to one frame)
|
|---|
| 70 | prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
|
|---|
| 71 | }
|
|---|
| 72 | } finally { fs.closeSync(fd); }
|
|---|
| 73 | }
|
|---|
| 74 |
|
|---|
| 75 | // True if the file is an animated WebP (a VP8X chunk with the animation flag set).
|
|---|
| 76 | export function isAnimatedWebp(filePath) {
|
|---|
| 77 | try {
|
|---|
| 78 | if (path.extname(filePath).toLowerCase() !== '.webp') return false;
|
|---|
| 79 | const fd = fs.openSync(filePath, 'r');
|
|---|
| 80 | try {
|
|---|
| 81 | const b = Buffer.alloc(40);
|
|---|
| 82 | const n = fs.readSync(fd, b, 0, 40, 0);
|
|---|
| 83 | return n >= 21 && b.toString('ascii', 12, 16) === 'VP8X' && (b[20] & 0x02) !== 0;
|
|---|
| 84 | } finally { fs.closeSync(fd); }
|
|---|
| 85 | } catch { return false; }
|
|---|
| 86 | }
|
|---|
| 87 |
|
|---|
| 88 | // Animated WebP → muted loop MP4. Returns { videoPath } or null (caller keeps the still image).
|
|---|
| 89 | export async function animatedWebpToVideo(srcPath, outDir, baseName) {
|
|---|
| 90 | let rawPath = null;
|
|---|
| 91 | try {
|
|---|
| 92 | if (!ffmpegPath) return null;
|
|---|
| 93 | await ensureLib();
|
|---|
| 94 | const img = new WebP.Image();
|
|---|
| 95 | await img.load(srcPath);
|
|---|
| 96 | if (!img.hasAnim || !img.anim || !Array.isArray(img.anim.frames) || img.anim.frames.length < 2) return null;
|
|---|
| 97 | const W = img.width, H = img.height, n = img.anim.frames.length;
|
|---|
| 98 | // Guard a pathologically large cover (memory/CPU): keep the still image instead of converting.
|
|---|
| 99 | if (!W || !H || n > MAX_FRAMES || W * H * n > MAX_WORK) {
|
|---|
| 100 | console.warn(`[videocover] cover too large to convert (${W}x${H}, ${n} frames) — keeping the still image`);
|
|---|
| 101 | return null;
|
|---|
| 102 | }
|
|---|
| 103 | const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
|
|---|
| 104 | await fs.promises.mkdir(outDir, { recursive: true });
|
|---|
| 105 | rawPath = path.join(outDir, baseName + '.rgba.tmp');
|
|---|
| 106 | await compositeFramesToFile(img, rawPath); // streams full composited W×H frames to disk
|
|---|
| 107 | const videoPath = path.join(outDir, baseName + '.mp4');
|
|---|
| 108 | await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
|
|---|
| 109 | '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', rawPath,
|
|---|
| 110 | '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', // yuv420p needs even dimensions
|
|---|
| 111 | '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
|
|---|
| 112 | { timeout: 60000 });
|
|---|
| 113 | return { videoPath };
|
|---|
| 114 | } catch (e) {
|
|---|
| 115 | console.warn('[videocover] animated webp → mp4 failed:', e.message);
|
|---|
| 116 | return null;
|
|---|
| 117 | } finally {
|
|---|
| 118 | if (rawPath) try { await fs.promises.unlink(rawPath); } catch { /* ignore */ }
|
|---|
| 119 | }
|
|---|
| 120 | }
|
|---|
| 121 |
|
|---|
| 122 | // An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s). ffmpeg decodes every video format,
|
|---|
| 123 | // so no node-webpmux here. Returns { videoPath } or null.
|
|---|
| 124 | export async function videoToLoop(srcPath, outDir, baseName) {
|
|---|
| 125 | try {
|
|---|
| 126 | if (!ffmpegPath) return null;
|
|---|
| 127 | await fs.promises.mkdir(outDir, { recursive: true });
|
|---|
| 128 | const videoPath = path.join(outDir, baseName + '.mp4');
|
|---|
| 129 | await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
|
|---|
| 130 | '-i', srcPath, '-t', String(MAX_SECONDS),
|
|---|
| 131 | '-vf', "scale='min(1280,iw)':-2,pad=ceil(iw/2)*2:ceil(ih/2)*2",
|
|---|
| 132 | '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
|
|---|
| 133 | { timeout: 120000 });
|
|---|
| 134 | return { videoPath };
|
|---|
| 135 | } catch (e) {
|
|---|
| 136 | console.warn('[videocover] video → loop failed:', e.message);
|
|---|
| 137 | return null;
|
|---|
| 138 | }
|
|---|
| 139 | }
|
|---|
| 140 |
|
|---|
| 141 | export default { isAnimatedWebp, animatedWebpToVideo, videoToLoop };
|
|---|