Index: package-lock.json
===================================================================
--- package-lock.json	(revision 28f797d8a94b9137b2bdd474564ecba17384af03)
+++ package-lock.json	(revision ecbfa417315102faa2b0b25640f48006f7b172c7)
@@ -1,5 +1,5 @@
 {
   "name": "klonkt",
-  "version": "1.0.0-beta.3",
+  "version": "1.0.0",
   "lockfileVersion": 3,
   "requires": true,
@@ -7,5 +7,5 @@
     "": {
       "name": "klonkt",
-      "version": "1.0.0-beta.3",
+      "version": "1.0.0",
       "license": "AGPL-3.0-or-later",
       "dependencies": {
@@ -25,8 +25,8 @@
         "marked": "^11.1.1",
         "multer": "^1.4.5-lts.1",
+        "node-webpmux": "^3.2.1",
         "nodemailer": "^6.9.15",
         "sanitize-html": "^2.17.3",
-        "uuid": "^9.0.1",
-        "ws": "^8.18.0"
+        "uuid": "^9.0.1"
       },
       "engines": {
@@ -1585,4 +1585,10 @@
       }
     },
+    "node_modules/node-webpmux": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/node-webpmux/-/node-webpmux-3.2.1.tgz",
+      "integrity": "sha512-MKgpq9nFgo44pIVNx/umD3nkqb2E8oqQTfmstVsfNdx9uV4cX7a4LqA+d8AZd3v5tgJXwENKUFsXNP3bRLP8nQ==",
+      "license": "LGPL-3.0-or-later"
+    },
     "node_modules/nodemailer": {
       "version": "6.10.1",
@@ -2255,25 +2261,4 @@
       "license": "ISC"
     },
-    "node_modules/ws": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
-      "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.0.0"
-      },
-      "peerDependencies": {
-        "bufferutil": "^4.0.1",
-        "utf-8-validate": ">=5.0.2"
-      },
-      "peerDependenciesMeta": {
-        "bufferutil": {
-          "optional": true
-        },
-        "utf-8-validate": {
-          "optional": true
-        }
-      }
-    },
     "node_modules/xtend": {
       "version": "4.0.2",
Index: package.json
===================================================================
--- package.json	(revision 28f797d8a94b9137b2bdd474564ecba17384af03)
+++ package.json	(revision ecbfa417315102faa2b0b25640f48006f7b172c7)
@@ -30,4 +30,5 @@
     "marked": "^11.1.1",
     "multer": "^1.4.5-lts.1",
+    "node-webpmux": "^3.2.1",
     "nodemailer": "^6.9.15",
     "sanitize-html": "^2.17.3",
Index: src/services/VideoCoverService.js
===================================================================
--- src/services/VideoCoverService.js	(revision ecbfa417315102faa2b0b25640f48006f7b172c7)
+++ src/services/VideoCoverService.js	(revision ecbfa417315102faa2b0b25640f48006f7b172c7)
@@ -0,0 +1,95 @@
+/**
+ * VideoCoverService — turn an animated cover into a small, Safari-friendly muted loop video.
+ *
+ * Safari renders animated WebP poorly; a muted <video> loop plays smoothly everywhere (iOS too).
+ * 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.
+ */
+import { execFile } from 'child_process';
+import { promisify } from 'util';
+import fs from 'fs';
+import path from 'path';
+import ffmpegPath from 'ffmpeg-static';
+import WebP from 'node-webpmux';
+
+const execFileP = promisify(execFile);
+const MAX_SECONDS = 60; // cap a cover loop at one minute
+
+let _lib = null;
+function ensureLib() { if (!_lib) _lib = WebP.Image.initLib(); return _lib; }
+
+// True if the file is an animated WebP (a VP8X chunk with the animation flag set).
+export function isAnimatedWebp(filePath) {
+  try {
+    if (path.extname(filePath).toLowerCase() !== '.webp') return false;
+    const fd = fs.openSync(filePath, 'r');
+    try {
+      const b = Buffer.alloc(40);
+      const n = fs.readSync(fd, b, 0, 40, 0);
+      return n >= 21 && b.toString('ascii', 12, 16) === 'VP8X' && (b[20] & 0x02) !== 0;
+    } finally { fs.closeSync(fd); }
+  } catch { return false; }
+}
+
+// Animated WebP → muted loop MP4 + JPG poster. Returns { videoPath, posterPath } or null.
+export async function animatedWebpToVideo(srcPath, outDir, baseName) {
+  let rawPath = null;
+  try {
+    if (!ffmpegPath) return null;
+    await ensureLib();
+    const img = new WebP.Image();
+    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, n = img.anim.frames.length;
+    const fps = Math.max(1, Math.min(30, Math.round(1000 / (img.anim.frames[0].delay || 100))));
+    // getFrameData(i) returns the FULL-canvas RGBA (W*H*4) for frame i (already composited).
+    const bufs = [];
+    for (let i = 0; i < n; i++) bufs.push(Buffer.from(await img.getFrameData(i)));
+    await fs.promises.mkdir(outDir, { recursive: true });
+    rawPath = path.join(outDir, baseName + '.rgba.tmp');
+    await fs.promises.writeFile(rawPath, Buffer.concat(bufs));
+    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,
+      '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', // yuv420p needs even dimensions
+      '-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 };
+  } catch (e) {
+    console.warn('[videocover] animated webp → mp4 failed:', e.message);
+    return null;
+  } finally {
+    if (rawPath) try { await fs.promises.unlink(rawPath); } catch { /* ignore */ }
+  }
+}
+
+// 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.
+export async function videoToLoop(srcPath, outDir, baseName) {
+  try {
+    if (!ffmpegPath) return null;
+    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),
+      '-vf', "scale='min(1280,iw)':-2,pad=ceil(iw/2)*2:ceil(ih/2)*2",
+      '-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 };
+  } catch (e) {
+    console.warn('[videocover] video → loop failed:', e.message);
+    return null;
+  }
+}
+
+export default { isAnimatedWebp, animatedWebpToVideo, videoToLoop };
