source: Klonkt/src/services/VideoCoverService.js@ 1c33804

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

fix(video-cover): double the ANMF frame offset (node-webpmux quirk)

node-webpmux returns the raw WebP frame X/Y, which the spec stores as actual/2 (frame offsets are
always even); libwebp/webpmux double it. We placed partial frames at HALF offset -> the moving
sub-frame ghosted over the base frame (a doubled/garbled torus on Mastodon). x2 the x/y offset so
frames land at their true pixel position. Verified against the libwebp anim_dump reference: the
composited frame now matches.

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

  • Property mode set to 100644
File size: 7.0 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// node-webpmux's getFrameData(i) returns ONLY frame i's own sub-region (x,y,width,height) — it does
25// NOT composite onto the canvas. Real (tool-made) animated WebPs use partial frames of varying size,
26// so we composite each onto a persistent W×H canvas (honoring blend + dispose) and emit consistent
27// full frames. Feeding ffmpeg the raw varying-size sub-regions desyncs the stream → a torn/tiled video.
28async function compositeFrames(img) {
29 const W = img.width, H = img.height, frames = img.anim.frames;
30 const canvas = Buffer.alloc(W * H * 4); // transparent black
31 const out = [];
32 let prev = null; // previous frame's rect + dispose
33 for (let i = 0; i < frames.length; i++) {
34 const fr = frames[i];
35 if (prev && prev.dispose) { // dispose-to-background: clear the previous frame's rect first
36 for (let row = 0; row < prev.h; row++) {
37 const y = prev.y + row; if (y < 0 || y >= H) continue;
38 canvas.fill(0, (y * W + prev.x) * 4, (y * W + prev.x + prev.w) * 4);
39 }
40 }
41 const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
42 // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame
43 // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel
44 // position — else partial frames land at half-offset and ghost over the base. width/height are fine.
45 const fx = fr.x * 2, fy = fr.y * 2, fw = fr.width, fh = fr.height, blend = fr.blend;
46 if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
47 data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
48 } else {
49 for (let row = 0; row < fh; row++) {
50 const cy = fy + row; if (cy < 0 || cy >= H) continue;
51 for (let col = 0; col < fw; col++) {
52 const cx = fx + col; if (cx < 0 || cx >= W) continue;
53 const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
54 if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
55 else if (sa !== 0) { // alpha-over the existing canvas pixel
56 const a = sa / 255, ia = 1 - a;
57 canvas[d] = (data[s] * a + canvas[d] * ia) | 0;
58 canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
59 canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
60 canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
61 }
62 }
63 }
64 }
65 out.push(Buffer.from(canvas)); // snapshot the full composited canvas
66 prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
67 }
68 return Buffer.concat(out);
69}
70
71// True if the file is an animated WebP (a VP8X chunk with the animation flag set).
72export function isAnimatedWebp(filePath) {
73 try {
74 if (path.extname(filePath).toLowerCase() !== '.webp') return false;
75 const fd = fs.openSync(filePath, 'r');
76 try {
77 const b = Buffer.alloc(40);
78 const n = fs.readSync(fd, b, 0, 40, 0);
79 return n >= 21 && b.toString('ascii', 12, 16) === 'VP8X' && (b[20] & 0x02) !== 0;
80 } finally { fs.closeSync(fd); }
81 } catch { return false; }
82}
83
84// Animated WebP → muted loop MP4 + JPG poster. Returns { videoPath, posterPath } or null.
85export async function animatedWebpToVideo(srcPath, outDir, baseName) {
86 let rawPath = null;
87 try {
88 if (!ffmpegPath) return null;
89 await ensureLib();
90 const img = new WebP.Image();
91 await img.load(srcPath);
92 if (!img.hasAnim || !img.anim || !Array.isArray(img.anim.frames) || img.anim.frames.length < 2) return null;
93 const W = img.width, H = img.height;
94 const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
95 await fs.promises.mkdir(outDir, { recursive: true });
96 rawPath = path.join(outDir, baseName + '.rgba.tmp');
97 await fs.promises.writeFile(rawPath, await compositeFrames(img)); // full composited W×H frames
98 const videoPath = path.join(outDir, baseName + '.mp4');
99 const posterPath = path.join(outDir, baseName + '.jpg');
100 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
101 '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', rawPath,
102 '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', // yuv420p needs even dimensions
103 '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
104 { timeout: 60000 });
105 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
106 '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-i', rawPath,
107 '-frames:v', '1', '-y', posterPath], { timeout: 30000 });
108 return { videoPath, posterPath };
109 } catch (e) {
110 console.warn('[videocover] animated webp → mp4 failed:', e.message);
111 return null;
112 } finally {
113 if (rawPath) try { await fs.promises.unlink(rawPath); } catch { /* ignore */ }
114 }
115}
116
117// An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s) + JPG poster. ffmpeg decodes
118// every video format, so no node-webpmux here. Returns { videoPath, posterPath } or null.
119export async function videoToLoop(srcPath, outDir, baseName) {
120 try {
121 if (!ffmpegPath) return null;
122 await fs.promises.mkdir(outDir, { recursive: true });
123 const videoPath = path.join(outDir, baseName + '.mp4');
124 const posterPath = path.join(outDir, baseName + '.jpg');
125 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
126 '-i', srcPath, '-t', String(MAX_SECONDS),
127 '-vf', "scale='min(1280,iw)':-2,pad=ceil(iw/2)*2:ceil(ih/2)*2",
128 '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
129 { timeout: 120000 });
130 await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
131 '-i', videoPath, '-frames:v', '1', '-y', posterPath], { timeout: 30000 });
132 return { videoPath, posterPath };
133 } catch (e) {
134 console.warn('[videocover] video → loop failed:', e.message);
135 return null;
136 }
137}
138
139export default { isAnimatedWebp, animatedWebpToVideo, videoToLoop };
Note: See TracBrowser for help on using the repository browser.