Index: src/routes/admin-media.js
===================================================================
--- src/routes/admin-media.js	(revision 01e36787cd94f382fdb69a76b81e6efa0211ed09)
+++ src/routes/admin-media.js	(revision f2943c183e2bc207f2a9a498ba3e571e9856f84f)
@@ -48,12 +48,12 @@
 function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
 
-router.get('/', requireGod, (req, res) => {
-  const site = res.locals.site;
-  if (!site) return res.status(404).send('Site required');
-  const used = usageMap(site.id);
+// All non-sibling images, each with its loop-MP4 sibling + how many posts use it. Shared by the
+// list view and the cleanup route so the readdir/filter/usage logic lives in one place.
+function imageEntries(siteId) {
+  const used = usageMap(siteId);
   let all = [];
   try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
   const present = new Set(all);
-  const items = all
+  return all
     .filter(f => IMG_EXT.test(f) && !isSibling(f))
     .map(f => {
@@ -62,13 +62,20 @@
       const hasVideo = present.has(mp4);
       const ids = new Set([...(used.get(f) || []), ...(hasVideo ? (used.get(mp4) || []) : [])]);
-      return {
-        file: f,
-        url: `/media/post-images/${f}`,
-        kb: Math.round((statSize(f) + (hasVideo ? statSize(mp4) : 0)) / 1024),
-        hasVideo,
-        usedCount: ids.size,
-        _mtime: statMtime(f),
-      };
-    })
+      return { file: f, stem, mp4, hasVideo, usedCount: ids.size };
+    });
+}
+
+router.get('/', requireGod, (req, res) => {
+  const site = res.locals.site;
+  if (!site) return res.status(404).send('Site required');
+  const items = imageEntries(site.id)
+    .map(e => ({
+      file: e.file,
+      url: `/media/post-images/${e.file}`,
+      kb: Math.round((statSize(e.file) + (e.hasVideo ? statSize(e.mp4) : 0)) / 1024),
+      hasVideo: e.hasVideo,
+      usedCount: e.usedCount,
+      _mtime: statMtime(e.file),
+    }))
     .sort((a, b) => b._mtime - a._mtime); // newest first
   renderPage(req, res, 'pages/admin-media', {
@@ -101,15 +108,8 @@
   const site = res.locals.site;
   if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
-  const used = usageMap(site.id);
-  let all = [];
-  try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* */ }
-  const present = new Set(all);
   let removed = 0;
-  for (const f of all.filter(x => IMG_EXT.test(x) && !isSibling(x))) {
-    const stem = f.replace(/\.[^.]+$/, '');
-    const mp4 = `${stem}-v.mp4`;
-    const ids = new Set([...(used.get(f) || []), ...(present.has(mp4) ? (used.get(mp4) || []) : [])]);
-    if (ids.size) continue; // still in use
-    for (const name of [f, mp4, `${stem}-v.jpg`]) {
+  for (const e of imageEntries(site.id)) {
+    if (e.usedCount) continue; // still in use
+    for (const name of [e.file, e.mp4, `${e.stem}-v.jpg`]) {
       try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
     }
Index: src/services/VideoCoverService.js
===================================================================
--- src/services/VideoCoverService.js	(revision 01e36787cd94f382fdb69a76b81e6efa0211ed09)
+++ src/services/VideoCoverService.js	(revision f2943c183e2bc207f2a9a498ba3e571e9856f84f)
@@ -5,7 +5,7 @@
  * ffmpeg-static can't DECODE an animated WebP, so we decode it with node-webpmux (pure JS/WASM,
  * NO native deps → installs on every platform, never breaks `npm ci`) into RGBA frames, then
- * encode with the bundled ffmpeg-static into an H.264 MP4 (yuv420p + faststart + no audio) plus a
- * JPG poster frame. A real uploaded video goes straight through ffmpeg. Both are best-effort: on
- * any failure we return null and the caller keeps the still image.
+ * encode with the bundled ffmpeg-static into an H.264 MP4 (yuv420p + faststart + no audio). A real
+ * uploaded video goes straight through ffmpeg. Both are best-effort: on any failure (or an oversized
+ * input) we return null and the caller keeps the still image.
  */
 import { execFile } from 'child_process';
@@ -17,5 +17,7 @@
 
 const execFileP = promisify(execFile);
-const MAX_SECONDS = 60; // cap a cover loop at one minute
+const MAX_SECONDS = 60;            // cap a cover loop at one minute
+const MAX_FRAMES = 600;            // skip a pathological animated WebP (keep the still image)
+const MAX_WORK = 250_000_000;      // W*H*frames cap — bounds compositor memory churn + CPU/time
 
 let _lib = null;
@@ -24,47 +26,49 @@
 // node-webpmux's getFrameData(i) returns ONLY frame i's own sub-region (x,y,width,height) — it does
 // NOT composite onto the canvas. Real (tool-made) animated WebPs use partial frames of varying size,
-// so we composite each onto a persistent W×H canvas (honoring blend + dispose) and emit consistent
-// full frames. Feeding ffmpeg the raw varying-size sub-regions desyncs the stream → a torn/tiled video.
-async function compositeFrames(img) {
+// so we composite each onto a persistent W×H canvas (honoring blend + dispose) and STREAM consistent
+// full frames straight to `rawPath` — one canvas in memory, not all N frames. Feeding ffmpeg the raw
+// varying-size sub-regions desyncs the stream → a torn/tiled video.
+async function compositeFramesToFile(img, rawPath) {
   const W = img.width, H = img.height, frames = img.anim.frames;
   const canvas = Buffer.alloc(W * H * 4); // transparent black
-  const out = [];
-  let prev = null; // previous frame's rect + dispose
-  for (let i = 0; i < frames.length; i++) {
-    const fr = frames[i];
-    if (prev && prev.dispose) { // dispose-to-background: clear the previous frame's rect first
-      for (let row = 0; row < prev.h; row++) {
-        const y = prev.y + row; if (y < 0 || y >= H) continue;
-        canvas.fill(0, (y * W + prev.x) * 4, (y * W + prev.x + prev.w) * 4);
+  const fd = fs.openSync(rawPath, 'w');
+  try {
+    let prev = null; // previous frame's rect + dispose
+    for (let i = 0; i < frames.length; i++) {
+      const fr = frames[i];
+      if (prev && prev.dispose) { // dispose-to-background: clear the previous frame's rect first
+        for (let row = 0; row < prev.h; row++) {
+          const y = prev.y + row; if (y < 0 || y >= H) continue;
+          canvas.fill(0, (y * W + prev.x) * 4, (y * W + prev.x + prev.w) * 4);
+        }
       }
-    }
-    const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
-    // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame
-    // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel
-    // position — else partial frames land at half-offset and ghost over the base. width/height are fine.
-    const fx = fr.x * 2, fy = fr.y * 2, fw = fr.width, fh = fr.height, blend = fr.blend;
-    if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
-      data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
-    } else {
-      for (let row = 0; row < fh; row++) {
-        const cy = fy + row; if (cy < 0 || cy >= H) continue;
-        for (let col = 0; col < fw; col++) {
-          const cx = fx + col; if (cx < 0 || cx >= W) continue;
-          const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
-          if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
-          else if (sa !== 0) { // alpha-over the existing canvas pixel
-            const a = sa / 255, ia = 1 - a;
-            canvas[d]     = (data[s]     * a + canvas[d]     * ia) | 0;
-            canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
-            canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
-            canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
+      const data = Buffer.from(await img.getFrameData(i)); // fr.width*fr.height*4 RGBA sub-region
+      // node-webpmux returns the raw ANMF offset, which the WebP spec stores as actual/2 (frame
+      // offsets are always even); libwebp/webpmux double it. So ×2 the x/y to get the true pixel
+      // position — else partial frames land at half-offset and ghost over the base. width/height are fine.
+      const fx = fr.x * 2, fy = fr.y * 2, fw = fr.width, fh = fr.height, blend = fr.blend;
+      if (fx === 0 && fy === 0 && fw === W && fh === H && !blend) {
+        data.copy(canvas, 0); // full opaque overwrite (the typical base frame)
+      } else {
+        for (let row = 0; row < fh; row++) {
+          const cy = fy + row; if (cy < 0 || cy >= H) continue;
+          for (let col = 0; col < fw; col++) {
+            const cx = fx + col; if (cx < 0 || cx >= W) continue;
+            const s = (row * fw + col) * 4, d = (cy * W + cx) * 4, sa = data[s + 3];
+            if (!blend || sa === 255) { canvas[d] = data[s]; canvas[d + 1] = data[s + 1]; canvas[d + 2] = data[s + 2]; canvas[d + 3] = sa; }
+            else if (sa !== 0) { // alpha-over the existing canvas pixel
+              const a = sa / 255, ia = 1 - a;
+              canvas[d]     = (data[s]     * a + canvas[d]     * ia) | 0;
+              canvas[d + 1] = (data[s + 1] * a + canvas[d + 1] * ia) | 0;
+              canvas[d + 2] = (data[s + 2] * a + canvas[d + 2] * ia) | 0;
+              canvas[d + 3] = Math.min(255, sa + ((canvas[d + 3] * ia) | 0));
+            }
           }
         }
       }
+      fs.writeSync(fd, canvas); // stream the full composited canvas (bounds memory to one frame)
+      prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
     }
-    out.push(Buffer.from(canvas)); // snapshot the full composited canvas
-    prev = { x: fx, y: fy, w: fw, h: fh, dispose: fr.dispose };
-  }
-  return Buffer.concat(out);
+  } finally { fs.closeSync(fd); }
 }
 
@@ -82,5 +86,5 @@
 }
 
-// Animated WebP → muted loop MP4 + JPG poster. Returns { videoPath, posterPath } or null.
+// Animated WebP → muted loop MP4. Returns { videoPath } or null (caller keeps the still image).
 export async function animatedWebpToVideo(srcPath, outDir, baseName) {
   let rawPath = null;
@@ -91,11 +95,15 @@
     await img.load(srcPath);
     if (!img.hasAnim || !img.anim || !Array.isArray(img.anim.frames) || img.anim.frames.length < 2) return null;
-    const W = img.width, H = img.height;
+    const W = img.width, H = img.height, n = img.anim.frames.length;
+    // Guard a pathologically large cover (memory/CPU): keep the still image instead of converting.
+    if (!W || !H || n > MAX_FRAMES || W * H * n > MAX_WORK) {
+      console.warn(`[videocover] cover too large to convert (${W}x${H}, ${n} frames) — keeping the still image`);
+      return null;
+    }
     const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
     await fs.promises.mkdir(outDir, { recursive: true });
     rawPath = path.join(outDir, baseName + '.rgba.tmp');
-    await fs.promises.writeFile(rawPath, await compositeFrames(img)); // full composited W×H frames
+    await compositeFramesToFile(img, rawPath); // streams full composited W×H frames to disk
     const videoPath = path.join(outDir, baseName + '.mp4');
-    const posterPath = path.join(outDir, baseName + '.jpg');
     await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
       '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-r', String(fps), '-i', rawPath,
@@ -103,8 +111,5 @@
       '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
       { timeout: 60000 });
-    await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
-      '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${W}x${H}`, '-i', rawPath,
-      '-frames:v', '1', '-y', posterPath], { timeout: 30000 });
-    return { videoPath, posterPath };
+    return { videoPath };
   } catch (e) {
     console.warn('[videocover] animated webp → mp4 failed:', e.message);
@@ -115,6 +120,6 @@
 }
 
-// An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s) + JPG poster. ffmpeg decodes
-// every video format, so no node-webpmux here. Returns { videoPath, posterPath } or null.
+// An uploaded video → muted loop MP4 (scaled ≤1280w, capped 60s). ffmpeg decodes every video format,
+// so no node-webpmux here. Returns { videoPath } or null.
 export async function videoToLoop(srcPath, outDir, baseName) {
   try {
@@ -122,5 +127,4 @@
     await fs.promises.mkdir(outDir, { recursive: true });
     const videoPath = path.join(outDir, baseName + '.mp4');
-    const posterPath = path.join(outDir, baseName + '.jpg');
     await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
       '-i', srcPath, '-t', String(MAX_SECONDS),
@@ -128,7 +132,5 @@
       '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-an', '-y', videoPath],
       { timeout: 120000 });
-    await execFileP(ffmpegPath, ['-hide_banner', '-loglevel', 'error',
-      '-i', videoPath, '-frames:v', '1', '-y', posterPath], { timeout: 30000 });
-    return { videoPath, posterPath };
+    return { videoPath };
   } catch (e) {
     console.warn('[videocover] video → loop failed:', e.message);
