Changeset f79a471 in Klonkt


Ignore:
Timestamp:
06/28/2026 04:32:37 PM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
74c5abc
Parents:
1ff0043
git-author:
Robin Genis <roboburr@…> (06/28/2026 04:31:09 PM)
git-committer:
Robin Genis <roboburr@…> (06/28/2026 04:32:37 PM)
Message:

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)
Location:
src
Files:
1 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/middleware/render.js

    r1ff0043 rf79a471  
    160160    formatDate,
    161161    formatDateTime,
     162    // Rewrite a local /media/<file> cover to its on-demand downscaled thumbnail
     163    // (crisp grid/list images). External URLs + already-thumb URLs pass through.
     164    thumb: (url, w) => (typeof url === 'string' && url.startsWith('/media/') && !url.startsWith('/media/thumb/'))
     165      ? `/media/thumb/${w || 480}/${url.slice(7)}`
     166      : url,
    162167    pageTitle: data.pageTitle || (data.site && data.site.title) || 'Klonkt',
    163168    appVersion: APP_VERSION,
  • src/server.js

    r1ff0043 rf79a471  
    2020import { SqliteSessionStore } from './services/SqliteSessionStore.js';
    2121import { ensurePrimarySite } from './services/ensurePrimarySite.js';
     22import { getThumbnail, THUMB_SIZES } from './services/ThumbnailService.js';
    2223import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
    2324import { isViewer } from './middleware/auth.js';
     
    213214
    214215app.use('/assets', express.static(path.join(__dirname, 'assets'), { maxAge: isDev ? 0 : '1y' }));
     216
     217// On-demand cover thumbnails: /media/thumb/<w>/<path> → a small lanczos-downscaled WebP
     218// (cached on disk), so the browser doesn't jaggily shrink a high-res cover for the grid/
     219// list. Mounted BEFORE the /media static so it catches the thumb path first.
     220app.get('/media/thumb/:w/*', async (req, res) => {
     221  const w = parseInt(req.params.w, 10);
     222  const rel = req.params[0] || '';
     223  if (!THUMB_SIZES.has(w)) return res.status(400).end();
     224  let file = null;
     225  try { file = await getThumbnail(rel, w); } catch { /* fall through to original */ }
     226  if (!file) {
     227    // Generation unavailable/failed → serve the original instead of 404'ing.
     228    return res.redirect(302, '/media/' + rel.split('/').map(encodeURIComponent).join('/'));
     229  }
     230  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
     231  res.setHeader('Cache-Control', isDev ? 'no-cache' : 'public, max-age=31536000, immutable');
     232  res.type('webp');
     233  res.sendFile(file);
     234});
     235
    215236app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
    216237  // Public media (post covers, avatars) must be cross-origin embeddable by other
  • src/views/partials/post-card.ejs

    r1ff0043 rf79a471  
    4949       <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>
    5050       aria-label="<%= post.title || '(zonder titel)' %>" tabindex="-1">
    51       <img src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async">
     51      <img src="<%= thumb(post.cover_image_url, 320) %>" alt="" loading="lazy" decoding="async">
    5252      <% if (post.nsfw) { %><span class="nsfw-veil"><span class="nsfw-veil-label">🔞 <%= post.content_warning || t('post.nsfw_warning') %></span><span class="nsfw-veil-btn"><%= t('post.nsfw_show') %></span></span><% } %>
    5353    </a>
  • src/views/partials/post-tile.ejs

    r1ff0043 rf79a471  
    1919   <% if (_ext) { %>target="_blank" rel="noopener"<% } else { %>hx-get="<%= _href %>?partial=1" hx-target="#pcms-main" hx-swap="innerHTML" hx-push-url="<%= _href %>" hx-indicator="#pcms-loading"<% } %>>
    2020
    21   <% if (_hasCover) { %><img class="grid-tile-img" src="<%= post.cover_image_url %>" alt="" loading="lazy" decoding="async"><% } %>
     21  <% if (_hasCover) { %><img class="grid-tile-img" src="<%= thumb(post.cover_image_url, 480) %>" alt="" loading="lazy" decoding="async"><% } %>
    2222
    2323  <% if (_typeLabel) { %>
Note: See TracChangeset for help on using the changeset viewer.