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

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

feat(images): on-demand lanczos cover thumbnails for crisp grid/list

High-res covers (esp. line-art) looked jagged because the browser downscaled them to
the grid/list size. Downscale server-side with ffmpeg lanczos to a small cached WebP
instead. No re-upload/backfill: reads the existing original lazily on first request.

  • services/ThumbnailService.js — ffmpeg lanczos downscale -> WebP, disk-cached
  • server.js — GET /media/thumb/:w/* route (whitelist 320/480/640) before the /media static
  • middleware/render.js — thumb(url,w) helper (rewrites local /media covers)
  • views/partials/post-tile.ejs (grid 480) + post-card.ejs (list 320)
  • Property mode set to 100644
File size: 2.8 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 { promisify } from 'util';
20
21const execFileP = promisify(execFile);
22
23// Allowed widths (whitelist → no arbitrary-size abuse). 480 ≈ 2× a grid tile (retina).
24export const THUMB_SIZES = new Set([320, 480, 640]);
25
26let _seq = 0;
27
28function mediaRoot() {
29 return path.resolve(process.env.MEDIA_PATH || './storage/media');
30}
31
32// Resolve a safe absolute path for a relative media path; null on traversal attempts.
33function safeOriginal(rel) {
34 const root = mediaRoot();
35 const orig = path.resolve(root, rel);
36 if (orig !== root && !orig.startsWith(root + path.sep)) return null;
37 return orig;
38}
39
40/**
41 * Return the on-disk path of the cached thumbnail, generating it if needed.
42 * @returns {Promise<string|null>} absolute path, or null if it can't be produced.
43 */
44export async function getThumbnail(rel, width) {
45 if (!THUMB_SIZES.has(width) || !ffmpegPath || !rel) return null;
46 const orig = safeOriginal(rel);
47 if (!orig || !fs.existsSync(orig)) return null;
48
49 const root = mediaRoot();
50 // Cache under <media>/.thumbs/<w>/<rel>.webp (dotted dir → never collides with media).
51 const cached = path.join(root, '.thumbs', String(width), rel) + '.webp';
52 if (fs.existsSync(cached)) return cached;
53
54 await fs.promises.mkdir(path.dirname(cached), { recursive: true });
55 const tmp = `${cached}.tmp-${process.pid}-${_seq++}`;
56 try {
57 await execFileP(ffmpegPath, [
58 '-hide_banner', '-loglevel', 'error', '-y',
59 '-i', orig,
60 // Downscale to `width` (never upscale past the original) with lanczos; even height.
61 '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
62 '-frames:v', '1',
63 '-c:v', 'libwebp', '-q:v', '82',
64 // Force the WebP muxer: the tmp filename has no .webp extension, so ffmpeg
65 // can't infer the output format from it.
66 '-f', 'webp',
67 tmp,
68 ], { timeout: 20000 });
69 await fs.promises.rename(tmp, cached);
70 return cached;
71 } catch (e) {
72 try { await fs.promises.unlink(tmp); } catch {}
73 console.warn('[thumb] generation failed for', rel, '-', e.message);
74 return null;
75 }
76}
Note: See TracBrowser for help on using the repository browser.