source: Klonkt/src/services/VideoCoverService.js@ b5a09ce2

main
Last change on this file since b5a09ce2 was ecbfa41, checked in by roboburr <roboburr@…>, 2 months ago

feat(video-cover): add VideoCoverService + node-webpmux (animated WebP / video -> muted loop MP4)

Foundation for Safari-smooth animated covers. node-webpmux (pure JS/WASM, NO native deps ->
installs everywhere, never breaks npm ci) decodes an animated WebP into RGBA frames; the bundled
ffmpeg-static encodes a muted H.264 loop MP4 + JPG poster. A real uploaded video goes straight
through ffmpeg. Best-effort: returns null -> caller keeps the still image. Pipeline verified
(animated webp -> valid 240x240 H.264 mp4 + poster).

  • package.json / package-lock.json — node-webpmux 3.2.1
  • src/services/VideoCoverService.js — isAnimatedWebp / animatedWebpToVideo / videoToLoop

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 4.5 KB
Line 
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) plus a
8 * JPG poster frame. A real uploaded video goes straight through ffmpeg. Both are best-effort: on
9 * any failure we return null and the caller keeps the still image.
10 */
11import { execFile } from 'child_process';
12import { promisify } from 'util';
13import fs from 'fs';
14import path from 'path';
15import ffmpegPath from 'ffmpeg-static';
16import WebP from 'node-webpmux';
17
18const execFileP = promisify(execFile);
19const MAX_SECONDS = 60; // cap a cover loop at one minute
20
21let _lib = null;
22function ensureLib() { if (!_lib) _lib = WebP.Image.initLib(); return _lib; }
23
24// True if the file is an animated WebP (a VP8X chunk with the animation flag set).
25export function isAnimatedWebp(filePath) {
26 try {
27 if (path.extname(filePath).toLowerCase() !== '.webp') return false;
28 const fd = fs.openSync(filePath, 'r');
29 try {
30 const b = Buffer.alloc(40);
31 const n = fs.readSync(fd, b, 0, 40, 0);
32 return n >= 21 && b.toString('ascii', 12, 16) === 'VP8X' && (b[20] & 0x02) !== 0;
33 } finally { fs.closeSync(fd); }
34 } catch { return false; }
35}
36
37// Animated WebP → muted loop MP4 + JPG poster. Returns { videoPath, posterPath } or null.
38export async function animatedWebpToVideo(srcPath, outDir, baseName) {
39 let rawPath = null;
40 try {
41 if (!ffmpegPath) return null;
42 await ensureLib();
43 const img = new WebP.Image();
44 await img.load(srcPath);
45 if (!img.hasAnim || !img.anim || !Array.isArray(img.anim.frames) || img.anim.frames.length < 2) return null;
46 const W = img.width, H = img.height, n = img.anim.frames.length;
47 const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
48 // getFrameData(i) returns the FULL-canvas RGBA (W*H*4) for frame i (already composited).
49 const bufs = [];
50 for (let i = 0; i < n; i++) bufs.push(Buffer.from(await img.getFrameData(i)));
51 await fs.promises.mkdir(outDir, { recursive: true });
52 rawPath = path.join(outDir, baseName + '.rgba.tmp');
53 await fs.promises.writeFile(rawPath, Buffer.concat(bufs));
54 const videoPath = path.join(outDir, baseName + '.mp4');
55 const posterPath = path.join(outDir, baseName + '.jpg');
56 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
57 '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', rawPath,
58 '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', // yuv420p needs even dimensions
59 '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
60 { timeout: 60000 });
61 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
62 '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-i', rawPath,
63 '-frames:v', '1', '-y', posterPath], { timeout: 30000 });
64 return { videoPath, posterPath };
65 } catch (e) {
66 console.warn('[videocover] animated webp → mp4 failed:', e.message);
67 return null;
68 } finally {
69 if (rawPath) try { await fs.promises.unlink(rawPath); } catch { /* ignore */ }
70 }
71}
72
73// An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s) + JPG poster. ffmpeg decodes
74// every video format, so no node-webpmux here. Returns { videoPath, posterPath } or null.
75export async function videoToLoop(srcPath, outDir, baseName) {
76 try {
77 if (!ffmpegPath) return null;
78 await fs.promises.mkdir(outDir, { recursive: true });
79 const videoPath = path.join(outDir, baseName + '.mp4');
80 const posterPath = path.join(outDir, baseName + '.jpg');
81 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
82 '-i', srcPath, '-t', String(MAX_SECONDS),
83 '-vf', "scale='min(1280,iw)':-2,pad=ceil(iw/2)*2:ceil(ih/2)*2",
84 '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
85 { timeout: 120000 });
86 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
87 '-i', videoPath, '-frames:v', '1', '-y', posterPath], { timeout: 30000 });
88 return { videoPath, posterPath };
89 } catch (e) {
90 console.warn('[videocover] video → loop failed:', e.message);
91 return null;
92 }
93}
94
95export default { isAnimatedWebp, animatedWebpToVideo, videoToLoop };
Note: See TracBrowser for help on using the repository browser.