Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision 1d6f9a2206f4836d6c91855b6c7c7494cd58c8f4)
+++ src/views/pages/post-edit.ejs	(revision b5a09ce2f2e9daf88c11443526cecc77c3c84678)
@@ -106,4 +106,6 @@
                    placeholder="<%= t('pedit.cover_url_placeholder') %>">
           </label>
+          <%# Muted loop MP4 for an animated cover (auto-made from an animated WebP). Hidden — set by the uploader. %>
+          <input type="hidden" name="cover_video_url" id="cover-video-field" value="<%= post.cover_video_url || '' %>">
           <div class="pe-cover-upload">
             <button type="button" class="pe-btn pe-btn-secondary" id="cover-upload-trigger">
@@ -1170,4 +1172,5 @@
   const coverTrigger = document.getElementById('cover-upload-trigger');
   const coverUrl     = document.getElementById('cover-url-field');
+  const coverVideo   = document.getElementById('cover-video-field');
   const coverStatus  = document.getElementById('cover-upload-status');
   const coverWrap    = document.getElementById('cover-preview-wrap');
@@ -1329,6 +1332,7 @@
         const j = await uploadImage(edited);
         coverUrl.value = j.url;
+        if (coverVideo) coverVideo.value = j.video || ''; // muted loop MP4 for an animated cover
         showCoverPreview(j.url);
-        coverStatus.textContent = '<%= t('pedit.js_uploaded') %> ✓';
+        coverStatus.textContent = (j.video ? '🎬 ' : '') + '<%= t('pedit.js_uploaded') %> ✓';
         setTimeout(() => { coverStatus.textContent = ''; }, 2000);
       } catch (e) {
Index: src/views/pages/post.ejs
===================================================================
--- src/views/pages/post.ejs	(revision 1d6f9a2206f4836d6c91855b6c7c7494cd58c8f4)
+++ src/views/pages/post.ejs	(revision b5a09ce2f2e9daf88c11443526cecc77c3c84678)
@@ -20,5 +20,5 @@
   <% if (post.cover_image_url) { %>
     <figure class="post-cover">
-      <img src="<%= post.cover_image_url %>" alt="">
+      <img src="<%= post.cover_image_url %>" alt=""<% if (post.cover_video_url) { %> data-ios-mp4="<%= post.cover_video_url %>"<% } %>>
     </figure>
   <% } %>
Index: src/views/shell.ejs
===================================================================
--- src/views/shell.ejs	(revision 1d6f9a2206f4836d6c91855b6c7c7494cd58c8f4)
+++ src/views/shell.ejs	(revision b5a09ce2f2e9daf88c11443526cecc77c3c84678)
@@ -771,4 +771,32 @@
   document.body.addEventListener('htmx:afterSettle', function (e) { scan(e.target); });
 })();
+
+// iOS animated-cover → video: an animated WebP is janky on iOS Safari, so on iOS we swap any
+// <img data-ios-mp4="…"> for a muted, looping, inline <video> (the WebP's matching MP4). Every
+// other browser keeps the crisp WebP. Runs on load + htmx swaps.
+(function () {
+  if (window.__iosVideoWired) return; window.__iosVideoWired = true;
+  var ua = navigator.userAgent || '';
+  var IS_IOS = /iP(hone|od|ad)/.test(navigator.platform || '') || /iPad|iPhone|iPod/.test(ua) ||
+               (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1); // iPadOS reports as Mac
+  if (!IS_IOS) return;
+  function swap(root) {
+    (root || document).querySelectorAll('img[data-ios-mp4]').forEach(function (img) {
+      var mp4 = img.getAttribute('data-ios-mp4');
+      if (!mp4 || img.dataset.iosSwapped) return;
+      img.dataset.iosSwapped = '1';
+      var v = document.createElement('video');
+      v.src = mp4; v.muted = true; v.loop = true; v.autoplay = true;
+      v.setAttribute('muted', ''); v.setAttribute('playsinline', ''); v.setAttribute('webkit-playsinline', '');
+      v.poster = img.getAttribute('src') || '';
+      v.className = img.className;
+      if (img.getAttribute('style')) v.setAttribute('style', img.getAttribute('style'));
+      if (img.parentNode) img.parentNode.replaceChild(v, img);
+      var p = v.play && v.play(); if (p && p.catch) p.catch(function () {});
+    });
+  }
+  swap(document);
+  document.body.addEventListener('htmx:afterSettle', function (e) { swap(e.target); });
+})();
 </script>
 
