source: Klonkt/src/services/ThumbnailService.js@ f70e010

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

fix(thumbnails): skip ffmpeg for animated remote WebP/GIF (serve original)

getRemoteThumbnail ran ffmpeg on every remote image, but ffmpeg-static can't
decode an animated WebP → the downscale failed for every animated remote cover,
logging '[thumb-remote] downscale failed' and wasting a fetch+write+ffmpeg per
cache-miss. It then fell back to the original anyway. Now detect an animated
WebP/GIF from the fetched buffer (same check as the local isAnimatedSrc) and
return null up front → the route serves the ORIGINAL, keeping the animation,
without the doomed ffmpeg call or the error log.

  • src/services/ThumbnailService.js — isAnimatedBuf() guard in getRemoteThumbnail

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

  • Property mode set to 100644
File size: 9.4 KB
RevLine 
[f79a471]1/**
2 * On-demand cover thumbnails.
3 *
4 * High-res covers (especially line-art) look jagged because the BROWSER downscales
5 * them to the small grid/list size. We instead downscale the stored original
6 * server-side with ffmpeg's lanczos filter to a small WebP and cache it on disk, so
7 * the browser receives a near-1:1 image → crisp lines.
8 *
9 * No re-upload / backfill: the original file is only READ, never modified, and
10 * thumbnails are generated lazily on first request. Cover filenames are content-
11 * hashed/UUID, so a cached thumbnail can never go stale (a new cover = a new name).
12 *
13 * Uses the bundled `ffmpeg-static` (always present); cwebp is not required here.
14 */
15import { execFile } from 'child_process';
16import ffmpegPath from 'ffmpeg-static';
17import path from 'path';
18import fs from 'fs';
[74c5abc]19import crypto from 'crypto';
[f79a471]20import { promisify } from 'util';
[74c5abc]21import { safeFetch } from './ActivityPubService.js';
[f79a471]22
23const execFileP = promisify(execFile);
24
[5bc67b4]25// Allowed widths (whitelist → no arbitrary-size abuse). 96 = small feed/comment avatars
[8308c9ca]26// (~44px); 128 = nav/profile avatars; 256 ≈ 2× a list cover; 480 ≈ 2× a grid tile; 1280 =
27// full-width timeline media (crisp on mobile retina, ~430px × 3 DPR). Keep these ~2× the
28// display size so the browser barely scales (avoids both jaggies and upscaling blur).
29export const THUMB_SIZES = new Set([96, 128, 256, 320, 480, 640, 1280]);
[f79a471]30
31let _seq = 0;
32
[201ec06]33// Limit concurrent ffmpeg spawns. A cold-cache, image-heavy page fires many thumbnail
34// requests at once; without a cap each spawns its own ffmpeg → CPU saturation makes the
35// WHOLE instance slow (the thundering herd). With the cap, excess requests wait briefly
36// for a slot → bounded CPU, the page still loads (images just appear progressively).
37const MAX_CONCURRENT = 3;
38let _active = 0;
39const _waiters = [];
40function acquireSlot() {
41 if (_active < MAX_CONCURRENT) { _active++; return Promise.resolve(); }
42 return new Promise((resolve) => _waiters.push(resolve));
43}
44function releaseSlot() {
45 const next = _waiters.shift();
46 if (next) next(); // transfer the slot directly to the next waiter (_active unchanged)
47 else _active--;
48}
49async function runFfmpeg(args) {
50 await acquireSlot();
51 try { await execFileP(ffmpegPath, args, { timeout: 20000 }); }
52 finally { releaseSlot(); }
53}
54
[f79a471]55function mediaRoot() {
56 return path.resolve(process.env.MEDIA_PATH || './storage/media');
57}
58
59// Resolve a safe absolute path for a relative media path; null on traversal attempts.
60function safeOriginal(rel) {
61 const root = mediaRoot();
62 const orig = path.resolve(root, rel);
63 if (orig !== root && !orig.startsWith(root + path.sep)) return null;
64 return orig;
65}
66
[4d1aefb]67// Is the source an animated image (animated WebP or GIF)? If so the thumbnail must keep ALL
68// frames (a downscaled animated WebP) instead of grabbing a single frame — otherwise an
69// animated cover shows up frozen on the site.
70function isAnimatedSrc(filePath) {
71 try {
72 const ext = path.extname(filePath).toLowerCase();
73 if (ext === '.gif') return true; // a flattened GIF would lose its animation too
74 if (ext !== '.webp') return false;
75 const fd = fs.openSync(filePath, 'r');
76 try {
77 const buf = Buffer.alloc(40);
78 const n = fs.readSync(fd, buf, 0, 40, 0);
79 // RIFF…WEBP, then a VP8X chunk (bytes 12-15) whose flags byte (20) has the animation bit.
80 return n >= 21 && buf.toString('ascii', 12, 16) === 'VP8X' && (buf[20] & 0x02) !== 0;
81 } finally { fs.closeSync(fd); }
82 } catch { return false; }
83}
84
[f79a471]85/**
86 * Return the on-disk path of the cached thumbnail, generating it if needed.
87 * @returns {Promise<string|null>} absolute path, or null if it can't be produced.
88 */
89export async function getThumbnail(rel, width) {
90 if (!THUMB_SIZES.has(width) || !ffmpegPath || !rel) return null;
91 const orig = safeOriginal(rel);
92 if (!orig || !fs.existsSync(orig)) return null;
93
94 const root = mediaRoot();
95 // Cache under <media>/.thumbs/<w>/<rel>.webp (dotted dir → never collides with media).
96 const cached = path.join(root, '.thumbs', String(width), rel) + '.webp';
97 if (fs.existsSync(cached)) return cached;
98
[4d1aefb]99 // ffmpeg-static can't decode an animated WebP ("image data not found"), so we can't make a
100 // scaled animated thumbnail. Return null → the route serves the ORIGINAL instead, which keeps
101 // animating. (Animated covers are usually already small, so skipping the downscale is fine.)
102 if (isAnimatedSrc(orig)) return null;
103
[f79a471]104 await fs.promises.mkdir(path.dirname(cached), { recursive: true });
105 const tmp = `${cached}.tmp-${process.pid}-${_seq++}`;
106 try {
[201ec06]107 await runFfmpeg([
[f79a471]108 '-hide_banner', '-loglevel', 'error', '-y',
109 '-i', orig,
110 // Downscale to `width` (never upscale past the original) with lanczos; even height.
111 '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
112 '-frames:v', '1',
113 '-c:v', 'libwebp', '-q:v', '82',
114 // Force the WebP muxer: the tmp filename has no .webp extension, so ffmpeg
115 // can't infer the output format from it.
116 '-f', 'webp',
117 tmp,
[201ec06]118 ]);
[f79a471]119 await fs.promises.rename(tmp, cached);
120 return cached;
121 } catch (e) {
122 try { await fs.promises.unlink(tmp); } catch {}
123 console.warn('[thumb] generation failed for', rel, '-', e.message);
124 return null;
125 }
126}
[74c5abc]127
128// ── Signed remote-image proxy ─────────────────────────────────────
129// Remote avatars/images (fediverse) live on OTHER servers, so we fetch them once
130// (SSRF-safe via safeFetch), downscale them identically, and cache. The proxy URL is
131// HMAC-signed so it can't be abused as an open image-resizer: only URLs that Klonkt
132// itself rendered are accepted.
133
134let _key;
135function imgKey() {
136 if (_key) return _key;
137 _key = process.env.SESSION_SECRET || '';
138 if (!_key) {
139 try {
140 const dataDir = path.dirname(path.resolve(process.env.DATABASE_PATH || './storage/database.sqlite'));
141 _key = fs.readFileSync(path.join(dataDir, '.session-secret'), 'utf8').trim();
142 } catch { _key = 'klonkt-img-proxy'; }
143 }
144 return _key;
145}
146
147function sign(url, w) {
148 return crypto.createHmac('sha256', imgKey()).update(`${w}:${url}`).digest('hex').slice(0, 24);
149}
150
151// Signed proxy URL for a remote image (used by the avatar() view helper).
152export function imgProxyUrl(url, width) {
153 return `/img/a/${width}?u=${encodeURIComponent(url)}&s=${sign(url, width)}`;
154}
155
156export function verifyImg(url, width, sig) {
157 if (!sig || !url) return false;
158 let want;
159 try { want = sign(url, width); } catch { return false; }
160 try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)); } catch { return false; }
161}
162
163/**
164 * Fetch a remote image (SSRF-safe), downscale to `width` (lanczos → WebP), cache it.
165 * @returns {Promise<string|null>} cached path, or null.
166 */
[f70e010]167// Same animation check as isAnimatedSrc but on an in-memory buffer (remote fetch): ffmpeg-static
168// can't decode an animated WebP, and flattening a GIF/animated WebP to one frame loses its motion.
169function isAnimatedBuf(buf) {
170 try {
171 if (!buf || buf.length < 21) return false;
172 if (buf.toString('ascii', 0, 3) === 'GIF') return true; // any GIF (a flattened one loses its animation)
173 // Animated WebP: RIFF…WEBP with a VP8X chunk whose flags byte (20) has the animation bit (0x02).
174 if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP'
175 && buf.toString('ascii', 12, 16) === 'VP8X' && (buf[20] & 0x02) !== 0) return true;
176 return false;
177 } catch { return false; }
178}
[74c5abc]179export async function getRemoteThumbnail(url, width) {
180 if (!THUMB_SIZES.has(width) || !ffmpegPath || !url) return null;
181 const root = mediaRoot();
182 const hash = crypto.createHash('sha256').update(url).digest('hex');
183 // Remote filenames are content-hashed (Mastodon/Klonkt) → URL-keyed cache never stales.
184 const cached = path.join(root, '.thumbs', 'remote', String(width), `${hash}.webp`);
185 if (fs.existsSync(cached)) return cached;
186
187 let buf;
188 try {
189 const r = await safeFetch(url);
190 if (!r.ok) return null;
191 if (!(r.headers.get('content-type') || '').startsWith('image/')) return null;
192 if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
193 buf = Buffer.from(await r.arrayBuffer());
194 } catch (e) {
195 console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
196 return null;
197 }
198 if (buf.length > 12 * 1024 * 1024) return null;
[f70e010]199 // ffmpeg-static can't decode an animated WebP (the doomed downscale just logs an error), and a
200 // flattened GIF/animated WebP loses its motion → skip it and let the route serve the ORIGINAL
201 // (keeps the animation; mirrors the local path's isAnimatedSrc guard).
202 if (isAnimatedBuf(buf)) return null;
[74c5abc]203
204 await fs.promises.mkdir(path.dirname(cached), { recursive: true });
205 const tmpIn = `${cached}.in-${process.pid}-${_seq++}`;
206 const tmpOut = `${cached}.out-${process.pid}-${_seq++}`;
207 try {
208 await fs.promises.writeFile(tmpIn, buf);
[201ec06]209 await runFfmpeg([
[74c5abc]210 '-hide_banner', '-loglevel', 'error', '-y',
211 '-i', tmpIn,
212 '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
213 '-frames:v', '1',
214 '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp',
215 tmpOut,
[201ec06]216 ]);
[74c5abc]217 await fs.promises.rename(tmpOut, cached);
218 return cached;
219 } catch (e) {
220 console.warn('[thumb-remote] downscale failed for', url, '-', e.message);
221 return null;
222 } finally {
223 fs.promises.unlink(tmpIn).catch(() => {});
224 fs.promises.unlink(tmpOut).catch(() => {});
225 }
226}
Note: See TracBrowser for help on using the repository browser.