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

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

fix(thumbnails): serve animated WebP/GIF covers as-is (they were frozen to 1 frame)

The thumbnail pipeline runs ffmpeg with -frames:v 1, so an animated WebP/GIF cover showed up
frozen on the site. ffmpeg-static can't even decode an animated WebP to re-scale it ("image data
not found"), so a scaled animated thumbnail isn't possible — instead, detect an animated source
(WebP VP8X animation flag, or GIF) and skip the thumbnail (getThumbnail returns null) so the
/media/thumb route serves the original, which keeps animating. Static images thumbnail as before.

  • src/services/ThumbnailService.js — isAnimatedSrc() + skip thumbnailing for animated sources

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

  • Property mode set to 100644
File size: 8.3 KB
Line 
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';
19import crypto from 'crypto';
20import { promisify } from 'util';
21import { safeFetch } from './ActivityPubService.js';
22
23const execFileP = promisify(execFile);
24
25// Allowed widths (whitelist → no arbitrary-size abuse). 96 = small feed/comment avatars
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]);
30
31let _seq = 0;
32
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
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
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
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
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
104 await fs.promises.mkdir(path.dirname(cached), { recursive: true });
105 const tmp = `${cached}.tmp-${process.pid}-${_seq++}`;
106 try {
107 await runFfmpeg([
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,
118 ]);
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}
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 */
167export async function getRemoteThumbnail(url, width) {
168 if (!THUMB_SIZES.has(width) || !ffmpegPath || !url) return null;
169 const root = mediaRoot();
170 const hash = crypto.createHash('sha256').update(url).digest('hex');
171 // Remote filenames are content-hashed (Mastodon/Klonkt) → URL-keyed cache never stales.
172 const cached = path.join(root, '.thumbs', 'remote', String(width), `${hash}.webp`);
173 if (fs.existsSync(cached)) return cached;
174
175 let buf;
176 try {
177 const r = await safeFetch(url);
178 if (!r.ok) return null;
179 if (!(r.headers.get('content-type') || '').startsWith('image/')) return null;
180 if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
181 buf = Buffer.from(await r.arrayBuffer());
182 } catch (e) {
183 console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
184 return null;
185 }
186 if (buf.length > 12 * 1024 * 1024) return null;
187
188 await fs.promises.mkdir(path.dirname(cached), { recursive: true });
189 const tmpIn = `${cached}.in-${process.pid}-${_seq++}`;
190 const tmpOut = `${cached}.out-${process.pid}-${_seq++}`;
191 try {
192 await fs.promises.writeFile(tmpIn, buf);
193 await runFfmpeg([
194 '-hide_banner', '-loglevel', 'error', '-y',
195 '-i', tmpIn,
196 '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
197 '-frames:v', '1',
198 '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp',
199 tmpOut,
200 ]);
201 await fs.promises.rename(tmpOut, cached);
202 return cached;
203 } catch (e) {
204 console.warn('[thumb-remote] downscale failed for', url, '-', e.message);
205 return null;
206 } finally {
207 fs.promises.unlink(tmpIn).catch(() => {});
208 fs.promises.unlink(tmpOut).catch(() => {});
209 }
210}
Note: See TracBrowser for help on using the repository browser.