Changeset e67828e in Klonkt for src/services


Ignore:
Timestamp:
07/01/2026 10:38:02 PM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
0877771
Parents:
5e52448
Message:

fix(media): poster frames for remote videos (News feed + Circle tiles)

A remote video (Loops/PeerTube/Mastodon attachment) rendered as a black box until
played: the image proxy refused non-image content, so the tile/feed poster 302'd to
the raw mp4 and never displayed. The proxy now extracts a poster frame from remote
video too — via a bounded range fetch (first 4MB, enough for a faststart mp4's first
frame; moov-at-end files fall back to the old 302). The News feed's <video> gets a
poster so long videos show a frame instead of black. (>30s keeping controls instead
of autoplaying is by design — that was the reported "didn't load like the others".)

  • src/services/ThumbnailService.js — getRemoteThumbnail accepts video/* via a 4MB ranged fetch + single-frame extract; animated-buf guard scoped to images.
  • src/views/pages/news.ejs — feed videos carry poster=thumb(url, 1280).
  • CHANGELOG(.nl/.de).md — under Fixed.

Closes prutfolio-src-k1q.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ThumbnailService.js

    r5e52448 re67828e  
    185185  if (fs.existsSync(cached)) return cached;
    186186
    187   let buf;
     187  let buf, isVideo = false;
    188188  try {
    189189    const r = await safeFetch(url);
    190190    if (!r.ok) return null;
    191     if (!(r.headers.get('content-type') || '').startsWith('image/')) return null;
    192     if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
    193     buf = Buffer.from(await r.arrayBuffer());
     191    const ct = r.headers.get('content-type') || '';
     192    isVideo = ct.startsWith('video/');
     193    if (!ct.startsWith('image/') && !isVideo) return null;
     194    if (isVideo) {
     195      // Remote video → poster frame (feed/tile posters). Don't buffer the whole file: re-fetch
     196      // a bounded head (first 4MB) — enough for ffmpeg to decode the first frame of a faststart
     197      // mp4 (the web-streaming norm). A moov-at-end file just fails → null → the route's 302
     198      // fallback, same as before this path existed.
     199      try { if (r.body && r.body.cancel) r.body.cancel(); } catch { /* ignore */ }
     200      const rv = await safeFetch(url, { headers: { Range: 'bytes=0-4194303' } });
     201      if (!rv.ok && rv.status !== 206) return null;
     202      buf = Buffer.from(await rv.arrayBuffer());
     203      if (!buf.length) return null;
     204    } else {
     205      if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
     206      buf = Buffer.from(await r.arrayBuffer());
     207    }
    194208  } catch (e) {
    195209    console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
     
    199213  // ffmpeg-static can't decode an animated WebP (the doomed downscale just logs an error), and a
    200214  // flattened GIF/animated WebP loses its motion → skip it and let the route serve the ORIGINAL
    201   // (keeps the animation; mirrors the local path's isAnimatedSrc guard).
    202   if (isAnimatedBuf(buf)) return null;
     215  // (keeps the animation; mirrors the local path's isAnimatedSrc guard). Video heads skip this
     216  // (they're not webp/gif) and go straight to the single-frame extract.
     217  if (!isVideo && isAnimatedBuf(buf)) return null;
    203218
    204219  await fs.promises.mkdir(path.dirname(cached), { recursive: true });
Note: See TracChangeset for help on using the changeset viewer.