Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision b5a09ce2f2e9daf88c11443526cecc77c3c84678)
+++ src/routes/activitypub.js	(revision 857a06fb0ed96db4010411e662b03b4f6fcb6bb0)
@@ -76,5 +76,5 @@
   if (!site) return res.status(404).end();
   const posts = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
+    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
      FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
      ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
@@ -108,5 +108,5 @@
   // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
   const posts = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
+    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
      FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
        AND pinned IS NOT NULL AND pinned > 0
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision b5a09ce2f2e9daf88c11443526cecc77c3c84678)
+++ src/routes/posts.js	(revision 857a06fb0ed96db4010411e662b03b4f6fcb6bb0)
@@ -286,5 +286,5 @@
       ActivityPubService.deliverCreate(site, {
         id: postId, slug: finalSlug, title: title || finalSlug,
-        content: cleanContent, cover_image_url: cover_image_url || null,
+        content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
         published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw,
       }).catch(() => { /* best-effort */ });
@@ -417,5 +417,5 @@
     const apPost = {
       id: post.id, slug: finalSlug, title: title || finalSlug,
-      content: cleanContent, cover_image_url: cover_image_url || null,
+      content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null,
       published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw,
     };
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision b5a09ce2f2e9daf88c11443526cecc77c3c84678)
+++ src/services/ActivityPubService.js	(revision 857a06fb0ed96db4010411e662b03b4f6fcb6bb0)
@@ -222,5 +222,5 @@
   const mediaType = (u) => {
     const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
-    return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
+    return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' })[(e || '').toLowerCase()] || 'image/jpeg';
   };
   const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
@@ -256,5 +256,8 @@
   // link/player card are mutually exclusive on Mastodon. Link-only audio (external)
   // keeps its cover (no player card to show).
-  if (post.cover_image_url && !noImages) urls.push(abs(post.cover_image_url));
+  // An animated cover federates as the muted loop MP4 (→ a Video attachment): animated WebP is
+  // unreliable on Mastodon and its iOS apps; the MP4 plays everywhere. Else the still cover image.
+  if (post.cover_video_url && !noImages) urls.push(abs(post.cover_video_url));
+  else if (post.cover_image_url && !noImages) urls.push(abs(post.cover_image_url));
   let body = post.content || '';
   // Only federate inline images we can actually serve: absolute http(s) URLs, or our own
@@ -976,5 +979,5 @@
   if (!site) return;
   const recent = db.prepare(
-    `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
+    `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
      FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
      ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
Index: src/services/Scheduler.js
===================================================================
--- src/services/Scheduler.js	(revision b5a09ce2f2e9daf88c11443526cecc77c3c84678)
+++ src/services/Scheduler.js	(revision 857a06fb0ed96db4010411e662b03b4f6fcb6bb0)
@@ -15,5 +15,5 @@
   try {
     const due = db.prepare(`
-      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.fan_only, p.nsfw, p.content_warning,
+      SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.cover_video_url, p.fan_only, p.nsfw, p.content_warning,
              p.published_at, p.publish_at, p.created_at, u.username
       FROM posts p JOIN users u ON u.id = p.author_id
@@ -38,5 +38,5 @@
           ActivityPubService.deliverCreate(site, {
             id: p.id, slug: p.slug, title: p.title || p.slug,
-            content: p.content, cover_image_url: p.cover_image_url || null,
+            content: p.content, cover_image_url: p.cover_image_url || null, cover_video_url: p.cover_video_url || null,
             published_at: p.published_at || p.publish_at, created_at: p.created_at, fan_only: p.fan_only, nsfw: p.nsfw, content_warning: p.content_warning,
           }).catch(() => { /* best-effort */ });
