Changeset 4d1aefb in Klonkt


Ignore:
Timestamp:
06/30/2026 03:16:22 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
28bf7f0
Parents:
98a1990
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ThumbnailService.js

    r98a1990 r4d1aefb  
    6565}
    6666
     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
    6785/**
    6886 * Return the on-disk path of the cached thumbnail, generating it if needed.
     
    7896  const cached = path.join(root, '.thumbs', String(width), rel) + '.webp';
    7997  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;
    80103
    81104  await fs.promises.mkdir(path.dirname(cached), { recursive: true });
Note: See TracChangeset for help on using the changeset viewer.