[object Object]

← back to Wallco Ai

Add social share layer + reel maker to wallco.ai

383c03b2569ccee30c36c90f6a65cc5386daea26 · 2026-05-19 08:21:22 -0700 · Steve

Every design is now shareable to socials and clients can build a
TikTok/Instagram reel from up to 5 designs.

- src/social.js: /reel maker page + /api/reel/* (ffmpeg Ken-Burns
  slideshow → 1080x1920 MP4) + /api/social/caption/:id
- public/js/social-share.js: self-injecting share sheet (Facebook, X,
  Pinterest, LinkedIn, WhatsApp, Instagram, TikTok, Email, post-to-all,
  copy caption, download) on grid cards + design pages
- scripts/gen-reel-music.js: 4 synthesized copyright-safe music beds
  (no IG/TikTok music strikes)
- scripts/reel-text.py: PIL text→PNG for watermark + headline overlay
- server.js: mounts social layer, injects share script on grid/detail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 383c03b2569ccee30c36c90f6a65cc5386daea26
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 08:21:22 2026 -0700

    Add social share layer + reel maker to wallco.ai
    
    Every design is now shareable to socials and clients can build a
    TikTok/Instagram reel from up to 5 designs.
    
    - src/social.js: /reel maker page + /api/reel/* (ffmpeg Ken-Burns
      slideshow → 1080x1920 MP4) + /api/social/caption/:id
    - public/js/social-share.js: self-injecting share sheet (Facebook, X,
      Pinterest, LinkedIn, WhatsApp, Instagram, TikTok, Email, post-to-all,
      copy caption, download) on grid cards + design pages
    - scripts/gen-reel-music.js: 4 synthesized copyright-safe music beds
      (no IG/TikTok music strikes)
    - scripts/reel-text.py: PIL text→PNG for watermark + headline overlay
    - server.js: mounts social layer, injects share script on grid/detail
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 .gitignore                      |   1 +
 data/reel-assets/watermark.png  | Bin 0 -> 9728 bytes
 public/js/social-share.js       | 396 ++++++++++++++++++++++
 public/reel-music/calm.mp3      | Bin 0 -> 112341 bytes
 public/reel-music/elegant.mp3   | Bin 0 -> 144381 bytes
 public/reel-music/lofi.mp3      | Bin 0 -> 113469 bytes
 public/reel-music/manifest.json |  30 ++
 public/reel-music/upbeat.mp3    | Bin 0 -> 70173 bytes
 scripts/gen-reel-music.js       | 128 +++++++
 scripts/reel-text.py            | 128 +++++++
 server.js                       |  20 ++
 src/social.js                   | 715 ++++++++++++++++++++++++++++++++++++++++
 12 files changed, 1418 insertions(+)

diff --git a/.gitignore b/.gitignore
index e2d3e85..f5b1fae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,4 +16,5 @@ public/marketplace/uploads/colorway-*.png
 public/marketplace/uploads/colorway-*.jpg
 public/marketplace/uploads/colorway-*.webp
 data/marketplace/payouts/
+public/reel/out/
 .deploy.conf
diff --git a/data/reel-assets/watermark.png b/data/reel-assets/watermark.png
new file mode 100644
index 0000000..8d0ebdf
Binary files /dev/null and b/data/reel-assets/watermark.png differ
diff --git a/public/js/social-share.js b/public/js/social-share.js
new file mode 100644
index 0000000..228b3ef
--- /dev/null
+++ b/public/js/social-share.js
@@ -0,0 +1,396 @@
+/* ───────────────────────────────────────────────────────────────────────────
+ * social-share.js — wallco.ai universal social-share layer (client-side).
+ *
+ * Loaded on the /designs grid and every /design/:id page. It:
+ *   • adds a "Share" affordance to every design card (incl. lazy-loaded ones)
+ *   • upgrades the detail-page #cta-share button into a full share sheet
+ *   • adds a "Make a Reel" button on the detail page
+ *   • the share sheet posts to Facebook / X / Pinterest / LinkedIn / WhatsApp /
+ *     Email, copies a ready-made caption, downloads the image for Instagram &
+ *     TikTok (which have no web-post URL), and links into the /reel maker.
+ *
+ * No dependencies. Self-injecting styles. Safe to load more than once.
+ * ───────────────────────────────────────────────────────────────────────── */
+(function () {
+  'use strict';
+  if (window.__wallcoShareLoaded) return;
+  window.__wallcoShareLoaded = true;
+
+  var ORIGIN = location.origin;
+
+  // ── styles ────────────────────────────────────────────────────────────────
+  var css = '\
+.ss-overlay{position:fixed;inset:0;background:rgba(20,15,8,.62);backdrop-filter:blur(3px);\
+ display:none;align-items:center;justify-content:center;z-index:9000;padding:20px}\
+.ss-overlay.on{display:flex}\
+.ss-modal{background:#faf8f3;color:#2a2014;width:100%;max-width:420px;border-radius:14px;\
+ box-shadow:0 18px 60px rgba(0,0,0,.4);overflow:hidden;font:14px/1.5 \"Helvetica Neue\",Arial,sans-serif;\
+ max-height:92vh;overflow-y:auto;animation:ssin .18s ease}\
+@keyframes ssin{from{transform:translateY(14px) scale(.98);opacity:0}to{transform:none;opacity:1}}\
+.ss-head{display:flex;gap:12px;align-items:center;padding:16px 16px 12px;border-bottom:1px solid #e6dcc6}\
+.ss-head img{width:52px;height:52px;border-radius:8px;object-fit:cover;border:1px solid #ddd3bf;flex:none}\
+.ss-head .t{flex:1;min-width:0}\
+.ss-head .t b{display:block;font:400 16px Georgia,serif;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\
+.ss-head .t span{font-size:11.5px;color:#9b8f78}\
+.ss-x{border:0;background:transparent;font-size:20px;cursor:pointer;color:#9b8f78;line-height:1;padding:4px}\
+.ss-body{padding:14px 16px 18px}\
+.ss-lbl{font:600 10.5px \"Helvetica Neue\",Arial,sans-serif;letter-spacing:.09em;text-transform:uppercase;\
+ color:#9b8f78;margin:14px 0 8px}\
+.ss-lbl:first-child{margin-top:0}\
+.ss-net{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}\
+.ss-net button,.ss-net a{display:flex;flex-direction:column;align-items:center;gap:6px;border:1px solid #ddd3bf;\
+ background:#fff;border-radius:10px;padding:11px 4px;cursor:pointer;text-decoration:none;color:#2a2014;\
+ font:600 10.5px \"Helvetica Neue\",Arial,sans-serif;transition:border-color .12s,transform .1s}\
+.ss-net button:hover,.ss-net a:hover{border-color:#2a2014;transform:translateY(-1px)}\
+.ss-ico{width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;\
+ color:#fff;font-size:14px;font-weight:700}\
+.ss-act{display:flex;flex-direction:column;gap:7px;margin-top:4px}\
+.ss-act button{display:flex;align-items:center;gap:9px;border:1px solid #ddd3bf;background:#fff;\
+ border-radius:9px;padding:10px 13px;cursor:pointer;font:500 13px \"Helvetica Neue\",Arial,sans-serif;\
+ color:#2a2014;text-align:left;width:100%}\
+.ss-act button:hover{border-color:#2a2014}\
+.ss-act button .k{font-size:15px;width:20px;text-align:center;flex:none}\
+.ss-postall{margin-top:12px;width:100%;border:0;border-radius:9px;background:#2a2014;color:#faf8f3;\
+ padding:13px;font:600 13.5px \"Helvetica Neue\",Arial,sans-serif;cursor:pointer}\
+.ss-postall:hover{background:#3a2e1c}\
+.ss-reel{margin-top:8px;width:100%;display:flex;align-items:center;justify-content:center;gap:8px;\
+ border:1px solid #c9a14b;border-radius:9px;background:#fffaf0;color:#7a5e1a;padding:12px;\
+ font:600 13px \"Helvetica Neue\",Arial,sans-serif;cursor:pointer;text-decoration:none}\
+.ss-reel:hover{background:#fff3da}\
+.ss-note{font-size:11px;color:#9b8f78;line-height:1.5;margin-top:11px}\
+.ss-toast{position:fixed;left:50%;bottom:30px;transform:translateX(-50%);background:#2a2014;color:#faf8f3;\
+ padding:11px 22px;border-radius:30px;font:13px \"Helvetica Neue\",Arial,sans-serif;z-index:9100;\
+ opacity:0;transition:opacity .25s;pointer-events:none}\
+.ss-toast.on{opacity:1}\
+.chov-btn.ss-card-btn{cursor:pointer}\
+.ss-fab{position:absolute;top:8px;left:8px;width:30px;height:30px;border-radius:50%;border:0;\
+ background:rgba(255,255,255,.92);box-shadow:0 1px 5px rgba(0,0,0,.22);cursor:pointer;font-size:13px;\
+ color:#2a2014;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .14s;z-index:5}\
+.design-card:hover .ss-fab,.design-card:focus-within .ss-fab{opacity:1}';
+  var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
+
+  // ── toast ────────────────────────────────────────────────────────────────
+  var toastEl, toastT;
+  function toast(msg) {
+    if (!toastEl) { toastEl = document.createElement('div'); toastEl.className = 'ss-toast'; document.body.appendChild(toastEl); }
+    toastEl.textContent = msg; toastEl.classList.add('on');
+    clearTimeout(toastT); toastT = setTimeout(function () { toastEl.classList.remove('on'); }, 2400);
+  }
+
+  // ── helpers ──────────────────────────────────────────────────────────────
+  function absUrl(u) { return /^https?:/.test(u) ? u : ORIGIN + (u.charAt(0) === '/' ? '' : '/') + u; }
+  function pageUrl(id) { return ORIGIN + '/design/' + id; }
+
+  function popup(url) {
+    window.open(url, '_blank', 'noopener,noreferrer,width=640,height=620');
+  }
+
+  function copyText(txt) {
+    if (navigator.clipboard && navigator.clipboard.writeText) {
+      return navigator.clipboard.writeText(txt);
+    }
+    return new Promise(function (res, rej) {
+      try {
+        var ta = document.createElement('textarea'); ta.value = txt;
+        ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta);
+        ta.select(); document.execCommand('copy'); document.body.removeChild(ta); res();
+      } catch (e) { rej(e); }
+    });
+  }
+
+  function downloadImage(imgUrl, name) {
+    return fetch(imgUrl).then(function (r) { return r.blob(); }).then(function (b) {
+      var a = document.createElement('a');
+      a.href = URL.createObjectURL(b);
+      a.download = (name || 'wallco-design').replace(/[^\w.-]+/g, '-') + '.png';
+      document.body.appendChild(a); a.click();
+      setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 4000);
+    });
+  }
+
+  // caption cache
+  var capCache = {};
+  function getCaption(id) {
+    if (capCache[id]) return Promise.resolve(capCache[id]);
+    return fetch('/api/social/caption/' + id).then(function (r) { return r.json(); })
+      .then(function (j) {
+        capCache[id] = j && j.full ? j : { full: 'Designed at wallco.ai', caption: '', hashtags: '' };
+        return capCache[id];
+      }).catch(function () { return { full: 'Designed at wallco.ai', caption: '', hashtags: '' }; });
+  }
+
+  // native share of the image file (mobile → Instagram / TikTok / etc.)
+  function shareImageNative(ctx, capFull) {
+    return fetch(ctx.imageUrl).then(function (r) { return r.blob(); }).then(function (blob) {
+      var file = new File([blob], 'wallco-design.png', { type: blob.type || 'image/png' });
+      if (navigator.canShare && navigator.canShare({ files: [file] })) {
+        return navigator.share({ files: [file], title: ctx.title, text: capFull });
+      }
+      if (navigator.share) return navigator.share({ title: ctx.title, text: capFull, url: ctx.pageUrl });
+      throw new Error('no native share');
+    });
+  }
+
+  // ── share modal (built once, reused) ─────────────────────────────────────
+  var overlay, modal, cur = null;
+  function buildModal() {
+    overlay = document.createElement('div');
+    overlay.className = 'ss-overlay';
+    overlay.innerHTML =
+      '<div class="ss-modal" role="dialog" aria-modal="true" aria-label="Share design">' +
+        '<div class="ss-head">' +
+          '<img class="ss-thumb" alt="">' +
+          '<div class="t"><b class="ss-title"></b><span>Share to your socials</span></div>' +
+          '<button class="ss-x" aria-label="Close">✕</button>' +
+        '</div>' +
+        '<div class="ss-body">' +
+          '<div class="ss-lbl">Post to a network</div>' +
+          '<div class="ss-net">' +
+            netBtn('fb', 'Facebook', '#1877f2', 'f') +
+            netBtn('x', 'X', '#0f1419', '𝕏') +
+            netBtn('pin', 'Pinterest', '#e60023', 'P') +
+            netBtn('li', 'LinkedIn', '#0a66c2', 'in') +
+            netBtn('wa', 'WhatsApp', '#25d366', '☎') +
+            netBtn('ig', 'Instagram', 'linear-gradient(45deg,#f09433,#e6683c,#dc2743,#cc2366,#bc1888)', '◉') +
+            netBtn('tt', 'TikTok', '#010101', '♪') +
+            netBtn('em', 'Email', '#6b5e4a', '✉') +
+          '</div>' +
+          '<button class="ss-postall">↗ Post to all networks</button>' +
+          '<a class="ss-reel" href="/reel">🎬 Make a video reel with this design</a>' +
+          '<div class="ss-lbl">Or grab it yourself</div>' +
+          '<div class="ss-act">' +
+            '<button data-a="copylink"><span class="k">🔗</span> Copy link to this design</button>' +
+            '<button data-a="copycap"><span class="k">✎</span> Copy caption + hashtags</button>' +
+            '<button data-a="dl"><span class="k">⬇</span> Download image</button>' +
+          '</div>' +
+          '<p class="ss-note">Instagram &amp; TikTok don’t allow posting from a browser. ' +
+            'Those buttons download the image and copy a caption — then post it in the app. ' +
+            'On a phone, they open your share sheet directly.</p>' +
+        '</div>' +
+      '</div>';
+    document.body.appendChild(overlay);
+
+    overlay.addEventListener('click', function (e) { if (e.target === overlay) close(); });
+    overlay.querySelector('.ss-x').addEventListener('click', close);
+    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
+
+    overlay.querySelectorAll('.ss-net [data-net]').forEach(function (b) {
+      b.addEventListener('click', function (e) { e.preventDefault(); doNetwork(b.getAttribute('data-net')); });
+    });
+    overlay.querySelector('.ss-postall').addEventListener('click', postAll);
+    overlay.querySelectorAll('.ss-act [data-a]').forEach(function (b) {
+      b.addEventListener('click', function () { doAction(b.getAttribute('data-a')); });
+    });
+    modal = overlay.querySelector('.ss-modal');
+  }
+  function netBtn(net, label, bg, glyph) {
+    return '<button data-net="' + net + '" aria-label="Share to ' + label + '">' +
+      '<span class="ss-ico" style="background:' + bg + '">' + glyph + '</span>' + label + '</button>';
+  }
+
+  function open(ctx) {
+    if (!overlay) buildModal();
+    cur = ctx;
+    overlay.querySelector('.ss-thumb').src = ctx.imageUrl;
+    overlay.querySelector('.ss-title').textContent = ctx.title;
+    overlay.querySelector('.ss-reel').setAttribute('href', '/reel');
+    overlay.classList.add('on');
+    document.documentElement.style.overflow = 'hidden';
+    getCaption(ctx.id); // warm the cache
+  }
+  function close() {
+    if (overlay) overlay.classList.remove('on');
+    document.documentElement.style.overflow = '';
+  }
+
+  function doNetwork(net) {
+    var c = cur; if (!c) return;
+    getCaption(c.id).then(function (cap) {
+      var u = encodeURIComponent(c.pageUrl);
+      var img = encodeURIComponent(c.imageUrl);
+      var text = encodeURIComponent(cap.caption || c.title);
+      var full = encodeURIComponent(cap.full || c.title);
+      switch (net) {
+        case 'fb':  popup('https://www.facebook.com/sharer/sharer.php?u=' + u); break;
+        case 'x':   popup('https://twitter.com/intent/tweet?text=' + text + '&url=' + u); break;
+        case 'pin': popup('https://www.pinterest.com/pin/create/button/?url=' + u + '&media=' + img + '&description=' + text); break;
+        case 'li':  popup('https://www.linkedin.com/sharing/share-offsite/?url=' + u); break;
+        case 'wa':  popup('https://wa.me/?text=' + full + '%20' + u); break;
+        case 'em':
+          location.href = 'mailto:?subject=' + encodeURIComponent(c.title + ' — wallco.ai') +
+            '&body=' + full + '%0A%0A' + u; break;
+        case 'ig': case 'tt': instagramTikTok(net, c, cap); break;
+      }
+    });
+  }
+
+  // Instagram / TikTok: native share on mobile, else download + copy caption.
+  function instagramTikTok(net, c, cap) {
+    var name = net === 'ig' ? 'Instagram' : 'TikTok';
+    shareImageNative(c, cap.full).then(function () {
+      toast('Shared — pick ' + name + ' from the menu');
+    }).catch(function () {
+      Promise.all([
+        downloadImage(c.imageUrl, c.title),
+        copyText(cap.full),
+      ]).then(function () {
+        toast('Image saved + caption copied — open ' + name + ' to post');
+      }).catch(function () { toast('Could not prepare the post'); });
+    });
+  }
+
+  function doAction(a) {
+    var c = cur; if (!c) return;
+    if (a === 'copylink') {
+      copyText(c.pageUrl).then(function () { toast('Link copied'); })
+        .catch(function () { window.prompt('Copy this link:', c.pageUrl); });
+    } else if (a === 'copycap') {
+      getCaption(c.id).then(function (cap) {
+        copyText(cap.full).then(function () { toast('Caption + hashtags copied'); })
+          .catch(function () { window.prompt('Copy caption:', cap.full); });
+      });
+    } else if (a === 'dl') {
+      downloadImage(c.imageUrl, c.title).then(function () { toast('Image downloaded'); })
+        .catch(function () { toast('Download failed'); });
+    }
+  }
+
+  // "Post to all" — copy caption + save image (for IG/TikTok) then open every
+  // web-intent network. Browsers may queue the popups; that's expected.
+  function postAll() {
+    var c = cur; if (!c) return;
+    getCaption(c.id).then(function (cap) {
+      copyText(cap.full).catch(function () {});
+      downloadImage(c.imageUrl, c.title).catch(function () {});
+      var u = encodeURIComponent(c.pageUrl);
+      var img = encodeURIComponent(c.imageUrl);
+      var text = encodeURIComponent(cap.caption || c.title);
+      var urls = [
+        'https://www.facebook.com/sharer/sharer.php?u=' + u,
+        'https://twitter.com/intent/tweet?text=' + text + '&url=' + u,
+        'https://www.pinterest.com/pin/create/button/?url=' + u + '&media=' + img + '&description=' + text,
+        'https://www.linkedin.com/sharing/share-offsite/?url=' + u,
+      ];
+      urls.forEach(function (url, i) { setTimeout(function () { popup(url); }, i * 350); });
+      toast('Caption copied + image saved — networks opening');
+    });
+  }
+
+  // ── context extraction ───────────────────────────────────────────────────
+  function ctxFromCard(card) {
+    var id = parseInt(card.getAttribute('data-design-id') || card.getAttribute('data-id'), 10);
+    if (!id) return null;
+    var titleEl = card.querySelector('.card-title');
+    var imgEl = card.querySelector('.card-img');
+    var bg = imgEl && imgEl.style.backgroundImage || '';
+    var m = bg.match(/url\(['"]?([^'")]+)['"]?\)/);
+    var img = m ? m[1] : (imgEl && imgEl.querySelector('img') && imgEl.querySelector('img').src) || '';
+    return {
+      id: id,
+      title: (titleEl && titleEl.textContent.trim()) || ('Design #' + id),
+      imageUrl: absUrl(img),
+      pageUrl: pageUrl(id),
+    };
+  }
+
+  function ctxFromDetail() {
+    var btn = document.getElementById('cta-share');
+    var m = (btn && btn.dataset.shareUrl || location.href).match(/\/design\/(\d+)/);
+    var id = m ? parseInt(m[1], 10) : 0;
+    var img = document.getElementById('detail-img');
+    var title = (btn && btn.dataset.shareTitle) ||
+      (document.querySelector('h1') && document.querySelector('h1').textContent.trim()) || 'wallco.ai design';
+    title = title.replace(/\s*—\s*wallco\.ai\s*$/i, '').trim();
+    return {
+      id: id,
+      title: title,
+      imageUrl: img ? absUrl(img.getAttribute('src')) : '',
+      pageUrl: id ? pageUrl(id) : location.href,
+    };
+  }
+
+  // ── wire design cards (grid) ─────────────────────────────────────────────
+  function wireCard(card) {
+    if (card.__ssWired) return;
+    card.__ssWired = true;
+    var ctx = ctxFromCard(card);
+    if (!ctx) return;
+
+    // corner FAB (always reachable, even without the hover row)
+    var fab = document.createElement('button');
+    fab.className = 'ss-fab';
+    fab.type = 'button';
+    fab.title = 'Share this design';
+    fab.setAttribute('aria-label', 'Share ' + ctx.title);
+    fab.textContent = '↗';
+    fab.addEventListener('click', function (e) {
+      e.preventDefault(); e.stopPropagation();
+      open(ctxFromCard(card) || ctx);
+    });
+    card.appendChild(fab);
+
+    // also drop a chip into the hover action row when present
+    var row = card.querySelector('.card-hover-actions');
+    if (row && !row.querySelector('.ss-card-btn')) {
+      var b = document.createElement('a');
+      b.className = 'chov-btn ss-card-btn';
+      b.href = '#';
+      b.textContent = 'Share';
+      b.addEventListener('click', function (e) {
+        e.preventDefault(); e.stopPropagation();
+        open(ctxFromCard(card) || ctx);
+      });
+      row.appendChild(b);
+    }
+  }
+
+  function wireAllCards() {
+    document.querySelectorAll('.design-card[data-design-id], .design-card[data-id]').forEach(wireCard);
+  }
+
+  // ── wire detail page ─────────────────────────────────────────────────────
+  function wireDetail() {
+    var btn = document.getElementById('cta-share');
+    if (btn && !btn.__ssWired) {
+      btn.__ssWired = true;
+      // capture-phase + stopImmediatePropagation overrides the basic inline handler
+      btn.addEventListener('click', function (e) {
+        e.preventDefault();
+        e.stopImmediatePropagation();
+        open(ctxFromDetail());
+      }, true);
+    }
+    // add a "Make a Reel" button to the trade-tools row
+    var tools = document.querySelector('.pro-tools');
+    if (tools && !tools.querySelector('.ss-reel-btn')) {
+      var ctx = ctxFromDetail();
+      var a = document.createElement('a');
+      a.className = 'btn-outline ss-reel-btn';
+      a.href = '/reel';
+      a.style.cssText = 'font:12px var(--sans);padding:7px 14px;text-decoration:none;display:inline-flex;align-items:center;gap:6px';
+      a.title = 'Build a TikTok / Instagram reel';
+      a.innerHTML = '<span aria-hidden="true">🎬</span> Make a Reel';
+      a.addEventListener('click', function () {
+        try {
+          var picks = JSON.parse(localStorage.getItem('wallco.reel.picks') || '[]');
+          if (picks.indexOf(ctx.id) < 0) { picks.push(ctx.id); }
+          localStorage.setItem('wallco.reel.picks', JSON.stringify(picks.slice(0, 5)));
+        } catch (e) {}
+      });
+      tools.appendChild(a);
+    }
+  }
+
+  // ── init ─────────────────────────────────────────────────────────────────
+  function init() {
+    wireAllCards();
+    wireDetail();
+    // catch lazily-appended cards (infinite scroll)
+    var grid = document.getElementById('grid') || document.querySelector('.designs-grid, .grid');
+    if (grid && window.MutationObserver) {
+      new MutationObserver(function () { wireAllCards(); }).observe(grid, { childList: true });
+    }
+  }
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
+  else init();
+})();
diff --git a/public/reel-music/calm.mp3 b/public/reel-music/calm.mp3
new file mode 100644
index 0000000..39f1dc4
Binary files /dev/null and b/public/reel-music/calm.mp3 differ
diff --git a/public/reel-music/elegant.mp3 b/public/reel-music/elegant.mp3
new file mode 100644
index 0000000..362d187
Binary files /dev/null and b/public/reel-music/elegant.mp3 differ
diff --git a/public/reel-music/lofi.mp3 b/public/reel-music/lofi.mp3
new file mode 100644
index 0000000..2f54cf2
Binary files /dev/null and b/public/reel-music/lofi.mp3 differ
diff --git a/public/reel-music/manifest.json b/public/reel-music/manifest.json
new file mode 100644
index 0000000..55c0d5e
--- /dev/null
+++ b/public/reel-music/manifest.json
@@ -0,0 +1,30 @@
+[
+  {
+    "id": "calm",
+    "label": "Calm & Airy",
+    "mood": "Soft, weightless — lets the pattern lead",
+    "file": "/reel-music/calm.mp3",
+    "duration": 28
+  },
+  {
+    "id": "elegant",
+    "label": "Elegant Minor",
+    "mood": "Moody, editorial — luxury-brand calm",
+    "file": "/reel-music/elegant.mp3",
+    "duration": 36
+  },
+  {
+    "id": "upbeat",
+    "label": "Bright & Upbeat",
+    "mood": "Pop I-V-vi-IV lift — scroll-stopping",
+    "file": "/reel-music/upbeat.mp3",
+    "duration": 17
+  },
+  {
+    "id": "lofi",
+    "label": "Lo-Fi Mellow",
+    "mood": "Warm jazz-7th haze — relaxed browse",
+    "file": "/reel-music/lofi.mp3",
+    "duration": 28
+  }
+]
\ No newline at end of file
diff --git a/public/reel-music/upbeat.mp3 b/public/reel-music/upbeat.mp3
new file mode 100644
index 0000000..657f34a
Binary files /dev/null and b/public/reel-music/upbeat.mp3 differ
diff --git a/scripts/gen-reel-music.js b/scripts/gen-reel-music.js
new file mode 100644
index 0000000..8882903
--- /dev/null
+++ b/scripts/gen-reel-music.js
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+/**
+ * gen-reel-music.js — synthesize copyright-safe instrumental loops for the
+ * wallco.ai reel maker.
+ *
+ * Why synthesized, not a licensed library: Instagram & TikTok issue copyright
+ * strikes on commercial music. Every track here is generated from scratch with
+ * ffmpeg sine sources, so a client can post a reel to any platform with zero
+ * copyright risk. That safety IS the feature.
+ *
+ * Output: public/reel-music/<id>.mp3  +  public/reel-music/manifest.json
+ * Run once (idempotent): node scripts/gen-reel-music.js
+ */
+const { execFileSync } = require('child_process');
+const fs   = require('fs');
+const path = require('path');
+
+const OUT_DIR = path.join(__dirname, '..', 'public', 'reel-music');
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+// ── Note table (Hz) ────────────────────────────────────────────────────────
+const N = {
+  C3:130.81, D3:146.83, E3:164.81, F3:174.61, G3:196.00, A3:220.00, B3:246.94,
+  C4:261.63, D4:293.66, E4:329.63, F4:349.23, G4:392.00, A4:440.00, B4:493.88,
+  C5:523.25, D5:587.33, E5:659.25, F5:698.46, G5:783.99, A5:880.00,
+};
+
+// ── Track definitions ──────────────────────────────────────────────────────
+// chordDur seconds per chord; progression repeated twice for a ~loopable bed.
+const TRACKS = [
+  {
+    id: 'calm', label: 'Calm & Airy', mood: 'Soft, weightless — lets the pattern lead',
+    chordDur: 4.0, cutoff: 2600, tremRate: 3.5, tremDepth: 0.10,
+    chords: [
+      [N.C4, N.E4, N.G4], [N.A3, N.C4, N.E4],
+      [N.F3, N.A3, N.C4], [N.G3, N.B3, N.D4],
+    ],
+  },
+  {
+    id: 'elegant', label: 'Elegant Minor', mood: 'Moody, editorial — luxury-brand calm',
+    chordDur: 5.0, cutoff: 2100, tremRate: 2.8, tremDepth: 0.08,
+    chords: [
+      [N.A3, N.C4, N.E4], [N.D3, N.A3, N.D4],
+      [N.E3, N.B3, N.E4], [N.A3, N.C4, N.E4],
+    ],
+  },
+  {
+    id: 'upbeat', label: 'Bright & Upbeat', mood: 'Pop I-V-vi-IV lift — scroll-stopping',
+    chordDur: 2.6, cutoff: 3400, tremRate: 6.0, tremDepth: 0.16,
+    chords: [
+      [N.C4, N.E4, N.G4, N.C5], [N.G3, N.D4, N.G4, N.B4],
+      [N.A3, N.C4, N.E4, N.A4], [N.F3, N.A3, N.C4, N.F4],
+    ],
+  },
+  {
+    id: 'lofi', label: 'Lo-Fi Mellow', mood: 'Warm jazz-7th haze — relaxed browse',
+    chordDur: 4.0, cutoff: 1500, tremRate: 4.4, tremDepth: 0.13,
+    chords: [
+      [N.C4, N.E4, N.G4, N.B4], [N.A3, N.C4, N.E4, N.G4],
+      [N.D3, N.A3, N.C4, N.F4], [N.G3, N.B3, N.D4, N.F4],
+    ],
+  },
+];
+
+const XFADE = 0.6; // crossfade seconds between chord segments
+
+// Build the aevalsrc expression for one chord. No commas — uses a sine-based
+// swell envelope (floor 0.6 so adjacent chords never gap to silence) and a
+// gentle tremolo. Higher partials are rolled off so thick chords stay warm.
+function chordExpr(freqs, dur, tremRate, tremDepth) {
+  const env  = `(0.6+0.4*sin(PI*t/${dur}))`;
+  const trem = `(${(1 - tremDepth).toFixed(3)}+${tremDepth.toFixed(3)}*sin(2*PI*${tremRate}*t))`;
+  const base = 0.5 / freqs.length;
+  const sines = freqs.map((f, i) => {
+    const amp = (base * (1 - 0.10 * i)).toFixed(4);
+    return `${amp}*sin(2*PI*${f.toFixed(2)}*t)`;
+  }).join('+');
+  return `${env}*${trem}*(${sines})`;
+}
+
+function buildTrack(t) {
+  const segs = [...t.chords, ...t.chords]; // progression ×2
+  const total = segs.length * t.chordDur - (segs.length - 1) * XFADE;
+  const args = ['-y'];
+
+  segs.forEach((freqs) => {
+    const expr = chordExpr(freqs, t.chordDur, t.tremRate, t.tremDepth);
+    args.push('-f', 'lavfi', '-t', String(t.chordDur),
+      '-i', `aevalsrc=exprs=${expr}|${expr}:s=44100`);
+  });
+
+  // Crossfade-chain every segment so chord changes blend instead of click.
+  const fc = [];
+  let prev = '[0:a]';
+  for (let i = 1; i < segs.length; i++) {
+    const out = i === segs.length - 1 ? '[mix]' : `[x${i}]`;
+    fc.push(`${prev}[${i}:a]acrossfade=d=${XFADE}:c1=tri:c2=tri${out}`);
+    prev = out;
+  }
+  const fadeOut = Math.max(0, total - 3).toFixed(2);
+  fc.push(`[mix]aecho=0.85:0.35:50:0.22,lowpass=f=${t.cutoff},`
+        + `afade=t=in:st=0:d=1.2,afade=t=out:st=${fadeOut}:d=3,`
+        + `loudnorm=I=-16:TP=-1.5:LRA=11,alimiter=limit=0.95[out]`);
+
+  const outFile = path.join(OUT_DIR, `${t.id}.mp3`);
+  args.push('-filter_complex', fc.join(';'),
+    '-map', '[out]', '-c:a', 'libmp3lame', '-q:a', '4', outFile);
+
+  execFileSync('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
+  return { id: t.id, label: t.label, mood: t.mood,
+           file: `/reel-music/${t.id}.mp3`, duration: Math.round(total) };
+}
+
+console.log('Generating copyright-safe reel music…');
+const manifest = [];
+for (const t of TRACKS) {
+  process.stdout.write(`  ${t.id} … `);
+  try {
+    const m = buildTrack(t);
+    manifest.push(m);
+    console.log(`ok (${m.duration}s)`);
+  } catch (e) {
+    console.error('FAILED:', e.message);
+    process.exit(1);
+  }
+}
+fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
+console.log(`Done — ${manifest.length} tracks → ${OUT_DIR}`);
diff --git a/scripts/reel-text.py b/scripts/reel-text.py
new file mode 100644
index 0000000..a175df8
--- /dev/null
+++ b/scripts/reel-text.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""
+reel-text.py — render a text string to a transparent PNG for the wallco.ai
+reel maker. Used for the "wallco.ai" watermark and the optional user headline.
+
+This ffmpeg build has no drawtext (no libfreetype), so text overlays are
+pre-rendered to PNG with PIL and composited via the ffmpeg `overlay` filter.
+
+Usage:
+  reel-text.py --text "wallco.ai" --out wm.png --size 46 --color 255,255,255,235
+               [--weight bold] [--stroke 3] [--track 2] [--maxwidth 920]
+"""
+import argparse, sys
+
+try:
+    from PIL import Image, ImageDraw, ImageFont, ImageFilter
+except Exception as e:                      # PIL missing → caller skips overlay
+    sys.stderr.write(f"PIL unavailable: {e}\n")
+    sys.exit(3)
+
+FONTS = {
+    "regular": "/System/Library/Fonts/Supplemental/Georgia.ttf",
+    "bold":    "/System/Library/Fonts/Supplemental/Georgia Bold.ttf",
+}
+
+
+def load_font(weight, size):
+    import os
+    path = FONTS.get(weight, FONTS["regular"])
+    if not os.path.exists(path):
+        path = FONTS["regular"]
+    if not os.path.exists(path):
+        return ImageFont.load_default()
+    return ImageFont.truetype(path, size)
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--text", required=True)
+    ap.add_argument("--out", required=True)
+    ap.add_argument("--size", type=int, default=44)
+    ap.add_argument("--color", default="255,255,255,235")    # r,g,b,a
+    ap.add_argument("--weight", default="regular")
+    ap.add_argument("--stroke", type=int, default=0)
+    ap.add_argument("--track", type=int, default=0)          # letter-spacing px
+    ap.add_argument("--maxwidth", type=int, default=0)       # 0 = no wrap
+    args = ap.parse_args()
+
+    color = tuple(int(x) for x in args.color.split(","))
+    if len(color) == 3:
+        color = color + (255,)
+    font = load_font(args.weight, args.size)
+    text = args.text.strip()
+    if not text:
+        sys.exit(2)
+
+    # Optional word-wrap so long headlines stay inside the frame.
+    lines = [text]
+    if args.maxwidth > 0:
+        words, lines, cur = text.split(), [], ""
+        scratch = ImageDraw.Draw(Image.new("RGBA", (8, 8)))
+        for w in words:
+            trial = (cur + " " + w).strip()
+            if scratch.textlength(trial, font=font) <= args.maxwidth or not cur:
+                cur = trial
+            else:
+                lines.append(cur)
+                cur = w
+        if cur:
+            lines.append(cur)
+
+    pad, shadow_blur = 40, 6
+    scratch = ImageDraw.Draw(Image.new("RGBA", (8, 8)))
+
+    def line_w(s):
+        base = scratch.textlength(s, font=font)
+        return base + max(0, len(s) - 1) * args.track
+
+    line_h = int(args.size * 1.34)
+    text_w = int(max(line_w(s) for s in lines))
+    text_h = line_h * len(lines)
+    W = text_w + pad * 2
+    H = text_h + pad * 2
+
+    img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
+
+    # Soft drop shadow on its own layer, then blurred — keeps text legible
+    # over bright wallpaper photography.
+    shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
+    sd = ImageDraw.Draw(shadow)
+    draw = ImageDraw.Draw(img)
+
+    for i, s in enumerate(lines):
+        lw = line_w(s)
+        x = (W - lw) / 2
+        y = pad + i * line_h
+        if args.track > 0:                  # manual letter-spacing
+            cx = x
+            for ch in s:
+                sd.text((cx + 3, y + 3), ch, font=font, fill=(0, 0, 0, 170))
+                cx += scratch.textlength(ch, font=font) + args.track
+        else:
+            sd.text((x + 3, y + 3), s, font=font, fill=(0, 0, 0, 170))
+
+    shadow = shadow.filter(ImageFilter.GaussianBlur(shadow_blur))
+    img = Image.alpha_composite(img, shadow)
+    draw = ImageDraw.Draw(img)
+
+    for i, s in enumerate(lines):
+        lw = line_w(s)
+        x = (W - lw) / 2
+        y = pad + i * line_h
+        if args.track > 0:
+            cx = x
+            for ch in s:
+                draw.text((cx, y), ch, font=font, fill=color,
+                          stroke_width=args.stroke, stroke_fill=(0, 0, 0, 210))
+                cx += scratch.textlength(ch, font=font) + args.track
+        else:
+            draw.text((x, y), s, font=font, fill=color,
+                      stroke_width=args.stroke, stroke_fill=(0, 0, 0, 210))
+
+    img.save(args.out)
+    print(args.out)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/server.js b/server.js
index 559fdfb..5a493e4 100644
--- a/server.js
+++ b/server.js
@@ -114,6 +114,26 @@ try { require('./src/admin').mount(app); console.log('  Admin layer mounted'); }
 // ── Marketplace layer: designer-powered marketplace (Patternbank × Material Bank × Spoonflower)
 try { require('./src/marketplace').mount(app); } catch (e) { console.error('Marketplace mount failed:', e.message); }
 
+// ── Social layer: share sheet + /reel maker (select up to 5 designs → MP4 reel)
+try { require('./src/social').mount(app); } catch (e) { console.error('Social mount failed:', e.message); }
+
+// Inject the universal share layer onto the designs grid + every design page.
+// Done as a response rewrite so the 863KB server.js page templates stay untouched.
+app.use((req, res, next) => {
+  if (req.path === '/designs' || /^\/design\/\d+$/.test(req.path)) {
+    const origSend = res.send.bind(res);
+    res.send = (body) => {
+      if (typeof body === 'string' && body.indexOf('</body>') !== -1
+          && body.indexOf('social-share.js') === -1) {
+        body = body.replace('</body>',
+          '<script src="/js/social-share.js" defer></script>\n</body>');
+      }
+      return origSend(body);
+    };
+  }
+  next();
+});
+
 // ── Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
 app.use((req, res, next) => {
   if (req.accepts('html') && !req.path.startsWith('/designs/img')) {
diff --git a/src/social.js b/src/social.js
new file mode 100644
index 0000000..6ca27fc
--- /dev/null
+++ b/src/social.js
@@ -0,0 +1,715 @@
+// ─────────────────────────────────────────────────────────────────────────
+// src/social.js — wallco.ai social layer
+//
+// server.js calls: require('./src/social').mount(app)
+//
+// Two customer-facing capabilities:
+//   1. SHARE — every design image is shareable to socials. The client-side
+//      share sheet (public/js/social-share.js) handles Facebook / X / Pinterest
+//      / LinkedIn / WhatsApp / native-share / download / copy-caption.
+//      This module backs it with /api/social/caption/:id.
+//   2. REEL MAKER — /reel lets a client pick up to 5 designs and render a
+//      vertical (1080×1920) TikTok / Instagram reel with a copyright-safe
+//      music bed. ffmpeg builds a Ken-Burns slideshow with crossfades.
+//
+// All music is synthesized (scripts/gen-reel-music.js) so a client can post
+// the reel to any platform with zero copyright-strike risk.
+//
+// Routes:
+//   GET  /reel                       — reel maker page (public)
+//   GET  /api/reel/music             — music manifest
+//   GET  /api/reel/designs           — lightweight design list for the picker
+//   POST /api/reel/create            — start a render job  → { jobId }
+//   GET  /api/reel/status/:jobId     — poll  → { status, progress, url, poster }
+//   GET  /api/social/caption/:id     — ready-made social caption + hashtags
+//   (generated reels are served from /reel/out/* by the global static handler)
+// ─────────────────────────────────────────────────────────────────────────
+'use strict';
+const express = require('express');
+const path    = require('path');
+const fs      = require('fs');
+const crypto  = require('crypto');
+const { spawn } = require('child_process');
+
+const ROOT      = path.join(__dirname, '..');
+const DATA_FILE = path.join(ROOT, 'data', 'designs.json');
+const IMG_DIR   = path.join(ROOT, 'data', 'generated');
+const PUBLIC    = path.join(ROOT, 'public');
+const OUT_DIR   = path.join(PUBLIC, 'reel', 'out');
+const TMP_DIR   = path.join(ROOT, 'tmp', 'reel');
+const MUSIC_DIR = path.join(PUBLIC, 'reel-music');
+const WM_PNG    = path.join(ROOT, 'data', 'reel-assets', 'watermark.png');
+
+fs.mkdirSync(OUT_DIR, { recursive: true });
+fs.mkdirSync(TMP_DIR, { recursive: true });
+fs.mkdirSync(path.dirname(WM_PNG), { recursive: true });
+
+// ── Reel render parameters ─────────────────────────────────────────────────
+const REEL_W = 1080, REEL_H = 1920, FPS = 30;
+const XFADE  = 0.7;                       // crossfade seconds between slides
+const TRANSITIONS = ['fade', 'dissolve', 'slideleft', 'wipeup', 'smoothright'];
+const MAX_IMAGES = 5;
+const MAX_CONCURRENT = 2;                 // ffmpeg jobs running at once
+
+const jobs = new Map();                   // jobId → { status, progress, url, poster, error }
+let running = 0;
+
+// ── Design lookup ──────────────────────────────────────────────────────────
+function loadDesigns() {
+  try { return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); }
+  catch (_) { return []; }
+}
+function digOf(d) { return d.dig_number || 'DIG_AI_' + String(d.id).padStart(6, '0'); }
+
+// Resolve a design's on-disk image file from its public image_url.
+function resolveImage(d) {
+  const u = d && d.image_url || '';
+  let p = null;
+  if (u.startsWith('/designs/img/')) p = path.join(IMG_DIR, u.slice('/designs/img/'.length));
+  else if (u.startsWith('/'))        p = path.join(PUBLIC, u.replace(/^\/+/, ''));
+  if (p && fs.existsSync(p)) return p;
+  return null;
+}
+
+// ── ffmpeg helper ──────────────────────────────────────────────────────────
+function ff(args) {
+  return new Promise((resolve, reject) => {
+    const p = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
+    let err = '';
+    p.stderr.on('data', d => { err += d; if (err.length > 8000) err = err.slice(-8000); });
+    p.on('error', reject);
+    p.on('close', code => code === 0 ? resolve() : reject(new Error('ffmpeg ' + code + '\n' + err.slice(-1200))));
+  });
+}
+
+// One-time: render the "wallco.ai" watermark PNG (best-effort — overlay is
+// skipped gracefully if PIL/fonts are unavailable on this host).
+function ensureWatermark() {
+  if (fs.existsSync(WM_PNG)) return Promise.resolve(true);
+  return new Promise(resolve => {
+    const p = spawn('python3', [
+      path.join(ROOT, 'scripts', 'reel-text.py'),
+      '--text', 'wallco.ai', '--out', WM_PNG,
+      '--size', '44', '--weight', 'bold', '--track', '4',
+      '--color', '255,255,255,236',
+    ], { stdio: 'ignore' });
+    p.on('error', () => resolve(false));
+    p.on('close', code => resolve(code === 0 && fs.existsSync(WM_PNG)));
+  });
+}
+
+// Render the optional user headline to a PNG. Returns the path or null.
+function renderHeadline(text, jobDir) {
+  return new Promise(resolve => {
+    const out = path.join(jobDir, 'headline.png');
+    const p = spawn('python3', [
+      path.join(ROOT, 'scripts', 'reel-text.py'),
+      '--text', text, '--out', out,
+      '--size', '66', '--weight', 'bold', '--stroke', '3',
+      '--maxwidth', '880', '--color', '255,255,255,255',
+    ], { stdio: 'ignore' });
+    p.on('error', () => resolve(null));
+    p.on('close', code => resolve(code === 0 && fs.existsSync(out) ? out : null));
+  });
+}
+
+// ── Reel build pipeline ────────────────────────────────────────────────────
+async function buildReel(job, opts) {
+  const { images, music, headline, perSlide } = opts;
+  const jobDir = path.join(TMP_DIR, job.id);
+  fs.mkdirSync(jobDir, { recursive: true });
+
+  const D     = perSlide;
+  const n     = images.length;
+  const total = n === 1 ? D : (n * D - (n - 1) * XFADE);
+  const zInc  = (0.14 / (D * FPS)).toFixed(6);
+
+  job.status = 'running'; job.progress = 0.06;
+
+  // ── Step 1: one Ken-Burns clip per image ────────────────────────────────
+  const clips = [];
+  for (let i = 0; i < n; i++) {
+    const clip = path.join(jobDir, `clip${i}.mp4`);
+    const vf =
+      `scale=${REEL_W * 1.2}:${REEL_H * 1.2}:force_original_aspect_ratio=increase,` +
+      `crop=${REEL_W * 1.2}:${REEL_H * 1.2},` +
+      `zoompan=z='min(zoom+${zInc},1.15)':d=1:` +
+      `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${REEL_W}x${REEL_H}:fps=${FPS},` +
+      `format=yuv420p`;
+    await ff([
+      '-y', '-loop', '1', '-framerate', String(FPS), '-t', String(D), '-i', images[i],
+      '-vf', vf, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
+      '-pix_fmt', 'yuv420p', '-an', clip,
+    ]);
+    clips.push(clip);
+    job.progress = 0.10 + 0.55 * ((i + 1) / n);
+  }
+
+  // ── Overlays ────────────────────────────────────────────────────────────
+  const wmOk   = await ensureWatermark();
+  const hlPng  = headline ? await renderHeadline(headline, jobDir) : null;
+  job.progress = 0.70;
+
+  // ── Step 2: crossfade chain + overlays + music → final mp4 ──────────────
+  const inputs = [];
+  clips.forEach(c => inputs.push('-i', c));
+  let wmIdx = -1, hlIdx = -1, muIdx = -1;
+  if (wmOk)            { wmIdx = (inputs.length / 2) | 0; inputs.push('-i', WM_PNG); }
+  if (hlPng)           { hlIdx = (inputs.length / 2) | 0; inputs.push('-i', hlPng); }
+  let musicFile = null;
+  if (music && music !== 'none') {
+    musicFile = path.join(MUSIC_DIR, music + '.mp3');
+    if (fs.existsSync(musicFile)) { muIdx = (inputs.length / 2) | 0; inputs.push('-stream_loop', '-1', '-i', musicFile); }
+    else musicFile = null;
+  }
+
+  const fc = [];
+  let cur;
+  if (n === 1) {
+    cur = '[0:v]';
+  } else {
+    cur = '[0:v]';
+    for (let i = 1; i < n; i++) {
+      const off = (i * (D - XFADE)).toFixed(3);
+      const lbl = `[xf${i}]`;
+      const tr  = TRANSITIONS[(i - 1) % TRANSITIONS.length];
+      fc.push(`${cur}[${i}:v]xfade=transition=${tr}:duration=${XFADE}:offset=${off}${lbl}`);
+      cur = lbl;
+    }
+  }
+  // Watermark bottom-centre, headline near the top.
+  if (wmIdx >= 0) {
+    const out = hlIdx >= 0 ? '[ovw]' : '[vout]';
+    fc.push(`${cur}[${wmIdx}:v]overlay=x=(W-w)/2:y=H-h-70${out}`);
+    cur = out;
+  }
+  if (hlIdx >= 0) {
+    fc.push(`${cur}[${hlIdx}:v]overlay=x=(W-w)/2:y=130[vout]`);
+    cur = '[vout]';
+  }
+  // Guarantee a labelled video output for -map.
+  if (cur !== '[vout]') { fc.push(`${cur}null[vout]`); }
+
+  const args = ['-y', ...inputs, '-filter_complex'];
+  let audioMap = [];
+  if (muIdx >= 0) {
+    fc.push(`[${muIdx}:a]atrim=0:${total.toFixed(2)},asetpts=PTS-STARTPTS,` +
+            `afade=t=in:st=0:d=0.8,afade=t=out:st=${Math.max(0, total - 1.8).toFixed(2)}:d=1.8,` +
+            `volume=0.9[aout]`);
+    audioMap = ['-map', '[aout]', '-c:a', 'aac', '-b:a', '160k'];
+  }
+  args.push(fc.join(';'), '-map', '[vout]', ...audioMap);
+  if (muIdx < 0) args.push('-an');
+
+  const outFile    = path.join(OUT_DIR, job.id + '.mp4');
+  const posterFile = path.join(OUT_DIR, job.id + '.jpg');
+  args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20',
+    '-pix_fmt', 'yuv420p', '-r', String(FPS), '-movflags', '+faststart',
+    '-t', total.toFixed(2), outFile);
+
+  await ff(args);
+  job.progress = 0.94;
+
+  // Poster frame for the <video> element + social previews.
+  try {
+    await ff(['-y', '-ss', '0.6', '-i', outFile, '-frames:v', '1', '-q:v', '3', posterFile]);
+  } catch (_) { /* poster is optional */ }
+
+  job.status   = 'done';
+  job.progress = 1;
+  job.url      = '/reel/out/' + job.id + '.mp4';
+  job.poster   = fs.existsSync(posterFile) ? '/reel/out/' + job.id + '.jpg' : null;
+  job.duration = Math.round(total);
+
+  // Tidy the working directory.
+  try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch (_) {}
+}
+
+// Remove reels older than 24h + stale tmp dirs (housekeeping on mount).
+function sweep() {
+  const now = Date.now();
+  try {
+    for (const f of fs.readdirSync(OUT_DIR)) {
+      const fp = path.join(OUT_DIR, f);
+      if (now - fs.statSync(fp).mtimeMs > 24 * 3600e3) fs.rmSync(fp, { force: true });
+    }
+  } catch (_) {}
+  try {
+    for (const d of fs.readdirSync(TMP_DIR)) {
+      const dp = path.join(TMP_DIR, d);
+      if (now - fs.statSync(dp).mtimeMs > 3600e3) fs.rmSync(dp, { recursive: true, force: true });
+    }
+  } catch (_) {}
+}
+
+// ── Caption builder ────────────────────────────────────────────────────────
+const BASE_TAGS = ['#wallco', '#wallpaper', '#wallcovering', '#interiordesign',
+  '#homedecor', '#interiorstyling', '#designerwallcoverings', '#aidesign'];
+
+function buildCaption(d) {
+  const title = d.title || ('Design #' + d.id);
+  const cat   = (d.category || '').replace(/[-_]/g, ' ').trim();
+  const tags  = BASE_TAGS.slice();
+  if (cat && !/^(other|misc)$/i.test(cat)) tags.splice(3, 0, '#' + cat.replace(/\s+/g, ''));
+  const line = cat
+    ? `${title} — a ${cat} wallpaper from the wallco.ai design studio.`
+    : `${title} — an original wallpaper from the wallco.ai design studio.`;
+  return { title, caption: line, hashtags: tags.join(' '),
+           full: line + '\n\n' + tags.join(' ') };
+}
+
+// ── HTML escaping ──────────────────────────────────────────────────────────
+function esc(s) {
+  return String(s == null ? '' : s).replace(/[&<>"']/g,
+    c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}
+
+// ── Reel maker page ────────────────────────────────────────────────────────
+function reelPage() {
+  return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Make a Reel — wallco.ai</title>
+<meta name="description" content="Pick up to 5 wallco.ai designs and render a TikTok / Instagram reel with a copyright-safe music bed.">
+<meta name="theme-color" content="#faf8f3">
+<meta property="og:title" content="Make a Reel — wallco.ai">
+<meta property="og:description" content="Turn wallco.ai designs into a ready-to-post social reel.">
+<style>
+  :root{--bg:#faf8f3;--ink:#2a2014;--ink-soft:#6b5e4a;--ink-faint:#9b8f78;
+        --line:#ddd3bf;--gold:#c9a14b;--card:#fff;--sans:'Helvetica Neue',Arial,sans-serif;
+        --serif:Georgia,'Times New Roman',serif;}
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--sans);
+       -webkit-font-smoothing:antialiased}
+  a{color:inherit}
+  .wrap{max-width:1180px;margin:0 auto;padding:0 22px 90px}
+  header.hero{text-align:center;padding:54px 22px 26px}
+  header.hero h1{font:300 40px/1.1 var(--serif);margin:0 0 8px;letter-spacing:.01em}
+  header.hero p{margin:0;color:var(--ink-soft);font-size:14.5px}
+  .step{background:var(--card);border:1px solid var(--line);border-radius:12px;
+        padding:22px 24px;margin:20px 0;box-shadow:0 2px 14px rgba(40,30,12,.04)}
+  .step h2{font:400 20px/1.2 var(--serif);margin:0 0 4px;display:flex;align-items:center;gap:10px}
+  .step h2 .n{display:inline-flex;width:26px;height:26px;border-radius:50%;
+       background:var(--ink);color:#faf8f3;font:600 13px var(--sans);
+       align-items:center;justify-content:center;flex:none}
+  .step .hint{color:var(--ink-faint);font-size:12.5px;margin:0 0 14px}
+  .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 14px}
+  .controls input[type=search]{flex:1;min-width:180px;padding:8px 12px;border:1px solid var(--line);
+       border-radius:7px;font:13px var(--sans);background:var(--bg)}
+  .controls select{padding:8px 10px;border:1px solid var(--line);border-radius:7px;
+       font:13px var(--sans);background:var(--bg)}
+  .controls label{font-size:12px;color:var(--ink-soft);display:flex;align-items:center;gap:7px}
+  .grid{display:grid;gap:10px;grid-template-columns:repeat(var(--cols,5),1fr)}
+  .tile{position:relative;border:1px solid var(--line);border-radius:9px;overflow:hidden;
+        cursor:pointer;background:var(--bg);aspect-ratio:1;transition:transform .12s}
+  .tile:hover{transform:translateY(-2px)}
+  .tile img{width:100%;height:100%;object-fit:cover;display:block}
+  .tile .pick{position:absolute;top:6px;right:6px;width:26px;height:26px;border-radius:50%;
+       background:rgba(255,255,255,.92);border:1px solid var(--line);display:flex;
+       align-items:center;justify-content:center;font:600 12px var(--sans);color:var(--ink-soft)}
+  .tile.sel{outline:3px solid var(--gold);outline-offset:-3px}
+  .tile.sel .pick{background:var(--gold);color:#1a1408;border-color:var(--gold)}
+  .tile .cap{position:absolute;left:0;right:0;bottom:0;padding:14px 7px 5px;
+       font:11px var(--sans);color:#fff;background:linear-gradient(transparent,rgba(0,0,0,.72));
+       white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .tray{display:flex;gap:8px;flex-wrap:wrap;min-height:78px;padding:10px;border:1px dashed var(--line);
+        border-radius:9px;background:var(--bg)}
+  .tray .slot{width:64px;height:64px;border-radius:7px;border:1px solid var(--line);
+       position:relative;overflow:hidden;background:#fff}
+  .tray .slot img{width:100%;height:100%;object-fit:cover}
+  .tray .slot .x{position:absolute;top:2px;right:2px;width:18px;height:18px;border-radius:50%;
+       background:rgba(0,0,0,.66);color:#fff;border:0;cursor:pointer;font:12px var(--sans);line-height:1}
+  .tray .slot .ord{position:absolute;bottom:2px;left:2px;background:var(--gold);color:#1a1408;
+       font:600 10px var(--sans);padding:1px 5px;border-radius:4px}
+  .tray .empty{color:var(--ink-faint);font-size:12.5px;align-self:center}
+  .music{display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(190px,1fr))}
+  .mcard{border:1px solid var(--line);border-radius:9px;padding:11px 12px;cursor:pointer;background:var(--bg)}
+  .mcard.sel{outline:2px solid var(--gold);outline-offset:-2px;background:#fffdf6}
+  .mcard .mt{font:600 13.5px var(--sans);display:flex;justify-content:space-between;align-items:center}
+  .mcard .md{font-size:11.5px;color:var(--ink-faint);margin-top:3px}
+  .mcard .play{border:1px solid var(--line);background:#fff;border-radius:50%;width:26px;height:26px;
+       cursor:pointer;font-size:11px;flex:none}
+  .field{margin:6px 0 2px}
+  .field label{display:block;font-size:12px;color:var(--ink-soft);margin-bottom:4px}
+  .field input[type=text]{width:100%;padding:9px 12px;border:1px solid var(--line);
+       border-radius:7px;font:14px var(--sans);background:var(--bg)}
+  .field input[type=range]{width:220px;vertical-align:middle}
+  .btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:8px;cursor:pointer;
+       font:600 14px var(--sans);padding:13px 28px}
+  .btn.primary{background:var(--ink);color:#faf8f3}
+  .btn.primary:disabled{opacity:.4;cursor:not-allowed}
+  .btn.gold{background:var(--gold);color:#1a1408}
+  .btn.ghost{background:transparent;border:1px solid var(--line);color:var(--ink);font-weight:500;padding:10px 18px}
+  .makebar{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+  .makebar .cnt{font-size:12.5px;color:var(--ink-soft)}
+  #progwrap{display:none;margin-top:14px}
+  .bar{height:7px;background:var(--line);border-radius:4px;overflow:hidden}
+  .bar > i{display:block;height:100%;width:0;background:var(--gold);transition:width .4s}
+  #progwrap .ptxt{font-size:12px;color:var(--ink-soft);margin-top:6px}
+  #result{display:none;margin-top:18px}
+  .resrow{display:flex;gap:22px;flex-wrap:wrap;align-items:flex-start}
+  #result video{width:280px;border-radius:12px;border:1px solid var(--line);background:#000}
+  .reshare{flex:1;min-width:240px}
+  .reshare h3{font:400 18px var(--serif);margin:0 0 8px}
+  .sbtns{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}
+  .sbtn{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:#fff;
+        border-radius:8px;padding:9px 14px;font:600 12.5px var(--sans);cursor:pointer;text-decoration:none;color:var(--ink)}
+  .sbtn:hover{border-color:var(--ink)}
+  .sbtn .dot{width:9px;height:9px;border-radius:50%}
+  .note{font-size:11.5px;color:var(--ink-faint);line-height:1.5;margin-top:8px}
+  .toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--ink);
+       color:#faf8f3;padding:11px 20px;border-radius:30px;font-size:13px;opacity:0;
+       transition:opacity .25s;pointer-events:none;z-index:90}
+  .toast.on{opacity:1}
+  @media(max-width:760px){.grid{grid-template-columns:repeat(3,1fr)!important}
+       header.hero h1{font-size:30px}}
+</style></head><body>
+<header class="hero">
+  <h1>Make a Reel</h1>
+  <p>Pick up to ${MAX_IMAGES} designs, choose a track, and we'll render a vertical reel ready for TikTok &amp; Instagram.</p>
+</header>
+<div class="wrap">
+
+  <div class="step">
+    <h2><span class="n">1</span> Choose your designs</h2>
+    <p class="hint">Tap to select — up to ${MAX_IMAGES}. The order you pick is the order they appear.</p>
+    <div class="controls">
+      <input type="search" id="q" placeholder="Search designs by name…" autocomplete="off">
+      <select id="sort" aria-label="Sort designs">
+        <option value="newest">Newest</option>
+        <option value="title">Title A→Z</option>
+        <option value="color">Color</option>
+      </select>
+      <label>Density <input type="range" id="dens" min="3" max="8" value="5"></label>
+    </div>
+    <div class="tray" id="tray"><span class="empty">No designs selected yet.</span></div>
+    <div class="grid" id="grid" style="margin-top:14px"></div>
+  </div>
+
+  <div class="step">
+    <h2><span class="n">2</span> Add music &amp; a headline</h2>
+    <p class="hint">Every track is original &amp; copyright-safe — post anywhere with no music strikes.</p>
+    <div class="music" id="music"></div>
+    <div class="field" style="margin-top:16px">
+      <label for="headline">On-screen headline <span style="color:var(--ink-faint)">(optional, max 60 chars)</span></label>
+      <input type="text" id="headline" maxlength="60" placeholder="e.g. New Spring Collection">
+    </div>
+    <div class="field">
+      <label>Seconds per design — <b id="psv">4.0</b>s</label>
+      <input type="range" id="ps" min="2.5" max="6" step="0.5" value="4">
+    </div>
+  </div>
+
+  <div class="step">
+    <h2><span class="n">3</span> Create your reel</h2>
+    <div class="makebar">
+      <button class="btn primary" id="make" disabled>● Create Reel</button>
+      <span class="cnt" id="cnt">0 / ${MAX_IMAGES} selected</span>
+    </div>
+    <div id="progwrap"><div class="bar"><i id="barfill"></i></div><div class="ptxt" id="ptxt">Starting…</div></div>
+    <div id="result">
+      <div class="resrow">
+        <video id="rvid" controls playsinline></video>
+        <div class="reshare">
+          <h3>Your reel is ready ✦</h3>
+          <a class="btn gold" id="dl" download>↓ Download MP4</a>
+          <div class="sbtns" id="rsbtns"></div>
+          <p class="note">Instagram &amp; TikTok don't allow posting from a browser — download the
+            MP4, then upload it in the app. On a phone, tap <b>Share reel</b> to send it straight
+            to any installed app. The music is original &amp; cleared for all platforms.</p>
+        </div>
+      </div>
+    </div>
+  </div>
+
+</div>
+<div class="toast" id="toast"></div>
+<script src="/corner-nav.js" defer></script>
+<script>
+(function(){
+  var MAX=${MAX_IMAGES}, picks=[], DESIGNS=[], MUSIC=[], music='calm';
+  var grid=document.getElementById('grid'), tray=document.getElementById('tray');
+  var toastEl=document.getElementById('toast'), toastT=null;
+  function toast(m){toastEl.textContent=m;toastEl.classList.add('on');
+    clearTimeout(toastT);toastT=setTimeout(function(){toastEl.classList.remove('on');},2200);}
+
+  // restore prefs
+  try{var dn=localStorage.getItem('wallco.reel.dens');if(dn)document.getElementById('dens').value=dn;}catch(e){}
+  try{var so=localStorage.getItem('wallco.reel.sort');if(so)document.getElementById('sort').value=so;}catch(e){}
+  document.documentElement.style.setProperty('--cols',document.getElementById('dens').value);
+
+  function colorKey(hex){
+    if(!hex)return 9; hex=hex.replace('#',''); if(hex.length<6)return 9;
+    var r=parseInt(hex.slice(0,2),16),g=parseInt(hex.slice(2,4),16),b=parseInt(hex.slice(4,6),16);
+    var mx=Math.max(r,g,b),mn=Math.min(r,g,b);
+    if(mx-mn<26)return mx<70?0:(mx>200?1:2);            // black / white / grey
+    var h; if(mx===r)h=((g-b)/(mx-mn))%6;else if(mx===g)h=(b-r)/(mx-mn)+2;else h=(r-g)/(mx-mn)+4;
+    h=(h*60+360)%360; return 3+Math.floor(h/60);
+  }
+  function sortDesigns(list){
+    var s=document.getElementById('sort').value;
+    var a=list.slice();
+    if(s==='title')a.sort(function(x,y){return (x.title||'').localeCompare(y.title||'');});
+    else if(s==='color')a.sort(function(x,y){return colorKey(x.dominant_hex)-colorKey(y.dominant_hex);});
+    else a.sort(function(x,y){return y.id-x.id;});
+    return a;
+  }
+  function renderGrid(){
+    var q=(document.getElementById('q').value||'').toLowerCase().trim();
+    var list=DESIGNS.filter(function(d){return !q||(d.title||'').toLowerCase().indexOf(q)>=0
+      ||String(d.id).indexOf(q)>=0||(d.dig||'').toLowerCase().indexOf(q)>=0;});
+    list=sortDesigns(list).slice(0,180);
+    grid.innerHTML='';
+    list.forEach(function(d){
+      var sel=picks.indexOf(d.id)>=0;
+      var t=document.createElement('div');
+      t.className='tile'+(sel?' sel':'');
+      t.innerHTML='<img loading="lazy" src="'+d.image_url+'" alt="">'+
+        '<span class="pick">'+(sel?(picks.indexOf(d.id)+1):'+')+'</span>'+
+        '<span class="cap">'+(d.title||('#'+d.id)).replace(/[<>]/g,'')+'</span>';
+      t.addEventListener('click',function(){toggle(d);});
+      grid.appendChild(t);
+    });
+  }
+  function renderTray(){
+    var cnt=document.getElementById('cnt');
+    cnt.textContent=picks.length+' / '+MAX+' selected';
+    document.getElementById('make').disabled=picks.length<1;
+    if(!picks.length){tray.innerHTML='<span class="empty">No designs selected yet.</span>';return;}
+    tray.innerHTML='';
+    picks.forEach(function(id,i){
+      var d=DESIGNS.find(function(x){return x.id===id;});if(!d)return;
+      var s=document.createElement('div');s.className='slot';
+      s.innerHTML='<img src="'+d.image_url+'" alt=""><span class="ord">'+(i+1)+'</span>'+
+        '<button class="x" title="Remove">×</button>';
+      s.querySelector('.x').addEventListener('click',function(){toggle(d);});
+      tray.appendChild(s);
+    });
+  }
+  function persist(){try{localStorage.setItem('wallco.reel.picks',JSON.stringify(picks));}catch(e){}}
+  function toggle(d){
+    var i=picks.indexOf(d.id);
+    if(i>=0)picks.splice(i,1);
+    else{if(picks.length>=MAX){toast('Up to '+MAX+' designs per reel');return;}picks.push(d.id);}
+    persist();renderGrid();renderTray();
+  }
+  document.getElementById('q').addEventListener('input',renderGrid);
+  document.getElementById('sort').addEventListener('change',function(){
+    try{localStorage.setItem('wallco.reel.sort',this.value);}catch(e){}renderGrid();});
+  document.getElementById('dens').addEventListener('input',function(){
+    document.documentElement.style.setProperty('--cols',this.value);
+    try{localStorage.setItem('wallco.reel.dens',this.value);}catch(e){}});
+  document.getElementById('ps').addEventListener('input',function(){
+    document.getElementById('psv').textContent=parseFloat(this.value).toFixed(1);});
+
+  // music
+  function renderMusic(){
+    var m=document.getElementById('music');m.innerHTML='';
+    MUSIC.forEach(function(tk){
+      var c=document.createElement('div');c.className='mcard'+(tk.id===music?' sel':'');
+      c.innerHTML='<div class="mt">'+tk.label+' <button class="play" title="Preview">▶</button></div>'+
+        '<div class="md">'+tk.mood+'</div>';
+      c.addEventListener('click',function(e){
+        if(e.target.classList.contains('play'))return;
+        music=tk.id;renderMusic();});
+      c.querySelector('.play').addEventListener('click',function(e){
+        e.stopPropagation();
+        document.querySelectorAll('audio.prev').forEach(function(a){a.pause();});
+        var a=new Audio(tk.file);a.className='prev';a.volume=.85;a.play();
+        var btn=this;btn.textContent='❚❚';
+        a.addEventListener('ended',function(){btn.textContent='▶';});
+        setTimeout(function(){a.pause();btn.textContent='▶';},9000);
+      });
+      m.appendChild(c);
+    });
+    var none=document.createElement('div');none.className='mcard'+(music==='none'?' sel':'');
+    none.innerHTML='<div class="mt">No music</div><div class="md">Silent — add your own in-app</div>';
+    none.addEventListener('click',function(){music='none';renderMusic();});
+    m.appendChild(none);
+  }
+
+  // create
+  document.getElementById('make').addEventListener('click',function(){
+    if(!picks.length)return;
+    var btn=this;btn.disabled=true;
+    document.getElementById('result').style.display='none';
+    var pw=document.getElementById('progwrap');pw.style.display='block';
+    var fill=document.getElementById('barfill'),ptxt=document.getElementById('ptxt');
+    fill.style.width='6%';ptxt.textContent='Starting render…';
+    fetch('/api/reel/create',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({designIds:picks,music:music,
+        headline:document.getElementById('headline').value,
+        perSlide:parseFloat(document.getElementById('ps').value)})})
+    .then(function(r){return r.json();})
+    .then(function(j){
+      if(!j.jobId){throw new Error(j.error||'Could not start');}
+      poll(j.jobId);
+    })
+    .catch(function(e){ptxt.textContent='Error: '+e.message;btn.disabled=false;});
+  });
+  function poll(id){
+    var fill=document.getElementById('barfill'),ptxt=document.getElementById('ptxt');
+    fetch('/api/reel/status/'+id).then(function(r){return r.json();}).then(function(j){
+      if(j.status==='error'){ptxt.textContent='Render failed: '+(j.error||'unknown');
+        document.getElementById('make').disabled=false;return;}
+      var p=Math.round((j.progress||0)*100);
+      fill.style.width=Math.max(6,p)+'%';
+      ptxt.textContent=j.status==='done'?'Done!':'Rendering your reel… '+p+'%';
+      if(j.status==='done'){showResult(j);return;}
+      setTimeout(function(){poll(id);},1400);
+    }).catch(function(){setTimeout(function(){poll(id);},2000);});
+  }
+  function showResult(j){
+    document.getElementById('progwrap').style.display='none';
+    document.getElementById('make').disabled=false;
+    var r=document.getElementById('result');r.style.display='block';
+    var v=document.getElementById('rvid');v.src=j.url;if(j.poster)v.poster=j.poster;
+    var dl=document.getElementById('dl');dl.href=j.url;
+    dl.setAttribute('download','wallco-reel.mp4');
+    var abs=location.origin+j.url;
+    var SH=[
+      ['Share reel','#c9a14b','native'],
+      ['Facebook','#1877f2','fb'],
+      ['X','#0f1419','x'],
+      ['Pinterest','#e60023','pin'],
+      ['WhatsApp','#25d366','wa'],
+      ['Copy link','#6b5e4a','copy']
+    ];
+    var box=document.getElementById('rsbtns');box.innerHTML='';
+    SH.forEach(function(s){
+      var b=document.createElement(s[2]==='native'||s[2]==='copy'?'button':'a');
+      b.className='sbtn';b.innerHTML='<span class="dot" style="background:'+s[1]+'"></span>'+s[0];
+      if(s[2]==='fb'){b.href='https://www.facebook.com/sharer/sharer.php?u='+encodeURIComponent(abs);b.target='_blank';}
+      else if(s[2]==='x'){b.href='https://twitter.com/intent/tweet?text='+encodeURIComponent('Made with wallco.ai')+'&url='+encodeURIComponent(abs);b.target='_blank';}
+      else if(s[2]==='pin'){b.href='https://www.pinterest.com/pin/create/button/?url='+encodeURIComponent(abs)+'&description='+encodeURIComponent('wallco.ai reel');b.target='_blank';}
+      else if(s[2]==='wa'){b.href='https://wa.me/?text='+encodeURIComponent('Made with wallco.ai '+abs);b.target='_blank';}
+      else if(s[2]==='copy'){b.addEventListener('click',function(){
+        navigator.clipboard.writeText(abs).then(function(){toast('Link copied');});});}
+      else if(s[2]==='native'){b.addEventListener('click',function(){shareNative(j.url);});}
+      box.appendChild(b);
+    });
+    r.scrollIntoView({behavior:'smooth',block:'center'});
+  }
+  function shareNative(url){
+    fetch(url).then(function(r){return r.blob();}).then(function(blob){
+      var file=new File([blob],'wallco-reel.mp4',{type:'video/mp4'});
+      if(navigator.canShare&&navigator.canShare({files:[file]})){
+        navigator.share({files:[file],title:'wallco.ai reel',
+          text:'Made with wallco.ai'}).catch(function(){});
+      }else if(navigator.share){
+        navigator.share({title:'wallco.ai reel',url:location.origin+url}).catch(function(){});
+      }else{toast('Download the MP4, then upload it in the app');}
+    }).catch(function(){toast('Download the MP4 to share');});
+  }
+
+  // load data
+  Promise.all([
+    fetch('/api/reel/designs').then(function(r){return r.json();}),
+    fetch('/api/reel/music').then(function(r){return r.json();})
+  ]).then(function(res){
+    DESIGNS=res[0].designs||[];MUSIC=res[1].tracks||[];
+    if(MUSIC.length)music=MUSIC[0].id;
+    try{var saved=JSON.parse(localStorage.getItem('wallco.reel.picks')||'[]');
+      if(Array.isArray(saved))picks=saved.filter(function(id){
+        return DESIGNS.some(function(d){return d.id===id;});}).slice(0,MAX);}catch(e){}
+    renderGrid();renderTray();renderMusic();
+  }).catch(function(){
+    grid.innerHTML='<p style="color:var(--ink-faint)">Could not load designs. Refresh to retry.</p>';
+  });
+})();
+</script>
+</body></html>`;
+}
+
+// ── Mount ──────────────────────────────────────────────────────────────────
+function mount(app) {
+  sweep();
+  setInterval(sweep, 3600e3).unref();
+  ensureWatermark().catch(() => {});
+
+  app.get('/reel', (_req, res) => {
+    res.type('html').send(reelPage());
+  });
+
+  app.get('/api/reel/music', (_req, res) => {
+    let tracks = [];
+    try { tracks = JSON.parse(fs.readFileSync(path.join(MUSIC_DIR, 'manifest.json'), 'utf8')); }
+    catch (_) {}
+    res.json({ tracks });
+  });
+
+  app.get('/api/reel/designs', (_req, res) => {
+    const designs = loadDesigns()
+      .filter(d => d && !d.user_removed && resolveImage(d))
+      .sort((a, b) => b.id - a.id)
+      .slice(0, 320)
+      .map(d => ({ id: d.id, title: d.title || ('Design #' + d.id),
+        image_url: d.image_url, category: d.category || '',
+        dominant_hex: d.dominant_hex || '', dig: digOf(d) }));
+    res.json({ designs });
+  });
+
+  app.get('/api/social/caption/:id', (req, res) => {
+    const id = parseInt(req.params.id, 10);
+    const d  = loadDesigns().find(x => x.id === id);
+    if (!d) return res.status(404).json({ error: 'not found' });
+    res.json(buildCaption(d));
+  });
+
+  app.post('/api/reel/create', express.json({ limit: '8kb' }), async (req, res) => {
+    try {
+      const body = req.body || {};
+      let ids = Array.isArray(body.designIds) ? body.designIds : [];
+      ids = [...new Set(ids.map(n => parseInt(n, 10)).filter(Number.isFinite))].slice(0, MAX_IMAGES);
+      if (!ids.length) return res.status(400).json({ error: 'Pick at least one design' });
+
+      const all = loadDesigns();
+      const images = [];
+      for (const id of ids) {
+        const d = all.find(x => x.id === id);
+        const p = d && resolveImage(d);
+        if (p) images.push(p);
+      }
+      if (!images.length) return res.status(400).json({ error: 'No usable images for the chosen designs' });
+
+      if (running >= MAX_CONCURRENT) {
+        return res.status(429).json({ error: 'Reel maker is busy — try again in a moment' });
+      }
+
+      let perSlide = parseFloat(body.perSlide);
+      if (!Number.isFinite(perSlide)) perSlide = 4;
+      perSlide = Math.min(6, Math.max(2.5, perSlide));
+
+      const music = typeof body.music === 'string' ? body.music.replace(/[^a-z]/gi, '').slice(0, 16) : 'calm';
+      const headline = typeof body.headline === 'string'
+        ? body.headline.replace(/[\r\n]+/g, ' ').trim().slice(0, 60) : '';
+
+      const jobId = Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
+      const job = { id: jobId, status: 'queued', progress: 0 };
+      jobs.set(jobId, job);
+      res.json({ jobId });
+
+      running++;
+      buildReel(job, { images, music, headline, perSlide })
+        .catch(e => { job.status = 'error'; job.error = (e && e.message || 'render failed').slice(0, 300); })
+        .finally(() => {
+          running--;
+          // expire the job record after 30 min
+          setTimeout(() => jobs.delete(jobId), 30 * 60e3).unref();
+        });
+    } catch (e) {
+      res.status(500).json({ error: 'create failed' });
+    }
+  });
+
+  app.get('/api/reel/status/:jobId', (req, res) => {
+    const job = jobs.get(req.params.jobId);
+    if (!job) return res.status(404).json({ status: 'error', error: 'job not found or expired' });
+    res.json({ status: job.status, progress: job.progress || 0,
+      url: job.url || null, poster: job.poster || null, error: job.error || null,
+      duration: job.duration || null });
+  });
+
+  console.log('  Social layer mounted (/reel + share API)');
+}
+
+module.exports = { mount };

← 6ae5721 wallco generators: wire settlement gate into both launchd ti  ·  back to Wallco Ai  ·  Reel maker: bump to 6 designs + admin publish-to-own-channel e0266e6 →