Index: CHANGELOG.de.md
===================================================================
--- CHANGELOG.de.md	(revision 5e524483d9c634a2c984c8f8613fcdeac45ad3ab)
+++ CHANGELOG.de.md	(revision e67828ebc6ef294ec4f57b90c12c7022909ad3f6)
@@ -42,4 +42,8 @@
 
 ### Behoben
+- **Entfernte Videos zeigen ein Vorschaubild.** Ein Video im News-Feed oder auf einer Zirkel-Kachel
+  (z.B. von Loops oder PeerTube) erschien als schwarze Fläche, bis man auf Abspielen drückte; jetzt
+  gibt es ein echtes Posterbild. (Längere Videos behalten bewusst ihre Steuerung — nur Clips unter
+  30 Sekunden laufen automatisch wie ein GIF.)
 - **Erwähnungen, Hashtags und Links in Klammern funktionieren jetzt.** Eine Erwähnung wie
   `(@benutzer@server)`, ein `(#hashtag)` oder eine URL in Klammern föderierte als reiner Text —
Index: CHANGELOG.md
===================================================================
--- CHANGELOG.md	(revision 5e524483d9c634a2c984c8f8613fcdeac45ad3ab)
+++ CHANGELOG.md	(revision e67828ebc6ef294ec4f57b90c12c7022909ad3f6)
@@ -37,4 +37,8 @@
 
 ### Fixed
+- **Remote videos show a preview frame.** A video in the News feed or a Circle tile (e.g. from
+  Loops or PeerTube) used to appear as a black box until you pressed play; it now shows a real
+  poster frame. (Longer videos keep their player controls by design — only clips under 30 seconds
+  autoplay like a GIF.)
 - **Mentions, hashtags and links inside brackets now work.** A mention like `(@user@server)`, a
   `(#hashtag)` or a bracketed URL federated as plain text — and the mentioned person was never
Index: CHANGELOG.nl.md
===================================================================
--- CHANGELOG.nl.md	(revision 5e524483d9c634a2c984c8f8613fcdeac45ad3ab)
+++ CHANGELOG.nl.md	(revision e67828ebc6ef294ec4f57b90c12c7022909ad3f6)
@@ -40,4 +40,8 @@
 
 ### Opgelost
+- **Remote video's tonen een preview-frame.** Een video in de News-feed of op een Cirkel-tegel
+  (bv. van Loops of PeerTube) verscheen als zwart vlak tot je op afspelen drukte; er staat nu een
+  echt poster-frame. (Langere video's houden bewust hun bediening — alleen clips onder de 30
+  seconden spelen automatisch als een GIF.)
 - **Vermeldingen, hashtags en links tussen haakjes werken nu.** Een vermelding als
   `(@gebruiker@server)`, een `(#hashtag)` of een URL tussen haakjes federeerde als platte tekst —
Index: src/services/ThumbnailService.js
===================================================================
--- src/services/ThumbnailService.js	(revision 5e524483d9c634a2c984c8f8613fcdeac45ad3ab)
+++ src/services/ThumbnailService.js	(revision e67828ebc6ef294ec4f57b90c12c7022909ad3f6)
@@ -185,11 +185,25 @@
   if (fs.existsSync(cached)) return cached;
 
-  let buf;
+  let buf, isVideo = false;
   try {
     const r = await safeFetch(url);
     if (!r.ok) return null;
-    if (!(r.headers.get('content-type') || '').startsWith('image/')) return null;
-    if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
-    buf = Buffer.from(await r.arrayBuffer());
+    const ct = r.headers.get('content-type') || '';
+    isVideo = ct.startsWith('video/');
+    if (!ct.startsWith('image/') && !isVideo) return null;
+    if (isVideo) {
+      // Remote video → poster frame (feed/tile posters). Don't buffer the whole file: re-fetch
+      // a bounded head (first 4MB) — enough for ffmpeg to decode the first frame of a faststart
+      // mp4 (the web-streaming norm). A moov-at-end file just fails → null → the route's 302
+      // fallback, same as before this path existed.
+      try { if (r.body && r.body.cancel) r.body.cancel(); } catch { /* ignore */ }
+      const rv = await safeFetch(url, { headers: { Range: 'bytes=0-4194303' } });
+      if (!rv.ok && rv.status !== 206) return null;
+      buf = Buffer.from(await rv.arrayBuffer());
+      if (!buf.length) return null;
+    } else {
+      if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
+      buf = Buffer.from(await r.arrayBuffer());
+    }
   } catch (e) {
     console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
@@ -199,6 +213,7 @@
   // ffmpeg-static can't decode an animated WebP (the doomed downscale just logs an error), and a
   // flattened GIF/animated WebP loses its motion → skip it and let the route serve the ORIGINAL
-  // (keeps the animation; mirrors the local path's isAnimatedSrc guard).
-  if (isAnimatedBuf(buf)) return null;
+  // (keeps the animation; mirrors the local path's isAnimatedSrc guard). Video heads skip this
+  // (they're not webp/gif) and go straight to the single-frame extract.
+  if (!isVideo && isAnimatedBuf(buf)) return null;
 
   await fs.promises.mkdir(path.dirname(cached), { recursive: true });
Index: src/views/pages/news.ejs
===================================================================
--- src/views/pages/news.ejs	(revision 5e524483d9c634a2c984c8f8613fcdeac45ad3ab)
+++ src/views/pages/news.ejs	(revision e67828ebc6ef294ec4f57b90c12c7022909ad3f6)
@@ -122,5 +122,5 @@
             </div>
           <% } %>
-          <% vids.forEach(function(m){ %><video class="tl-media-video" src="<%= m.url %>" controls preload="metadata" playsinline></video><% }); %>
+          <% vids.forEach(function(m){ %><video class="tl-media-video" src="<%= m.url %>" poster="<%= thumb(m.url, 1280) %>" controls preload="metadata" playsinline></video><% }); %>
           <% /* A Klonkt audio post carries an embed player (embedHtml/embedUrl) that already
                 covers these tracks, so don't ALSO render the raw Audio attachments as bare
