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

main
Last change on this file since c72e45e was e276d03, checked in by Robin <roboburr@…>, 8 weeks ago

Fix: remote video thumbnails for moov-at-end files (Loops.video)

The poster-frame path fetched only the first 4MB, which decodes only a
faststart mp4. Loops.video and phone exports put the moov atom at the end,
so the head lacked it and ffmpeg produced no thumbnail (looked like a
'long video' regression). Now fetch the whole file when content-length is
within a 64MB cap (works regardless of moov position), falling back to an
8MB head only when the size is unknown or very large. The post-fetch 12MB
cap is scoped to images so it no longer rejects valid video buffers.

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

  • Property mode set to 100644
File size: 10.9 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
[e67828e]187 let buf, isVideo = false;
[74c5abc]188 try {
189 const r = await safeFetch(url);
190 if (!r.ok) return null;
[e67828e]191 const ct = r.headers.get('content-type') || '';
192 isVideo = ct.startsWith('video/');
193 if (!ct.startsWith('image/') && !isVideo) return null;
194 if (isVideo) {
[e276d03]195 // Remote video → poster frame (feed/tile posters). A bounded head (first 4MB) only decodes
196 // a faststart mp4 (moov atom up front). Many platforms (Loops.video, phone exports) put the
197 // moov atom at the END, so a head-only fetch fails → no thumbnail. So: if the file is small
198 // enough (content-length within VIDEO_CAP) grab it WHOLE — that works regardless of moov
199 // position. Only when the size is unknown or very large do we fall back to a bounded head
200 // (best-effort; a large moov-at-end file still yields null → the route's 302 fallback).
201 const VIDEO_CAP = 64 * 1024 * 1024; // 64MB — covers short-form clips incl. moov-at-end
202 const clen = parseInt(r.headers.get('content-length') || '0', 10);
[e67828e]203 try { if (r.body && r.body.cancel) r.body.cancel(); } catch { /* ignore */ }
[e276d03]204 let rv;
205 if (clen && clen <= VIDEO_CAP) {
206 rv = await safeFetch(url);
207 if (!rv.ok) return null;
208 } else {
209 rv = await safeFetch(url, { headers: { Range: 'bytes=0-8388607' } }); // 8MB head fallback
210 if (!rv.ok && rv.status !== 206) return null;
211 }
[e67828e]212 buf = Buffer.from(await rv.arrayBuffer());
[e276d03]213 if (!buf.length || buf.length > VIDEO_CAP) return null;
[e67828e]214 } else {
215 if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
216 buf = Buffer.from(await r.arrayBuffer());
217 }
[74c5abc]218 } catch (e) {
219 console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
220 return null;
221 }
[e276d03]222 if (!isVideo && buf.length > 12 * 1024 * 1024) return null; // video already capped at VIDEO_CAP
[f70e010]223 // ffmpeg-static can't decode an animated WebP (the doomed downscale just logs an error), and a
224 // flattened GIF/animated WebP loses its motion → skip it and let the route serve the ORIGINAL
[e67828e]225 // (keeps the animation; mirrors the local path's isAnimatedSrc guard). Video heads skip this
226 // (they're not webp/gif) and go straight to the single-frame extract.
227 if (!isVideo && isAnimatedBuf(buf)) return null;
[74c5abc]228
229 await fs.promises.mkdir(path.dirname(cached), { recursive: true });
230 const tmpIn = `${cached}.in-${process.pid}-${_seq++}`;
231 const tmpOut = `${cached}.out-${process.pid}-${_seq++}`;
232 try {
233 await fs.promises.writeFile(tmpIn, buf);
[201ec06]234 await runFfmpeg([
[74c5abc]235 '-hide_banner', '-loglevel', 'error', '-y',
236 '-i', tmpIn,
237 '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
238 '-frames:v', '1',
239 '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp',
240 tmpOut,
[201ec06]241 ]);
[74c5abc]242 await fs.promises.rename(tmpOut, cached);
243 return cached;
244 } catch (e) {
245 console.warn('[thumb-remote] downscale failed for', url, '-', e.message);
246 return null;
247 } finally {
248 fs.promises.unlink(tmpIn).catch(() => {});
249 fs.promises.unlink(tmpOut).catch(() => {});
250 }
251}
Note: See TracBrowser for help on using the repository browser.