← 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
M .gitignoreA data/reel-assets/watermark.pngA public/js/social-share.jsA public/reel-music/calm.mp3A public/reel-music/elegant.mp3A public/reel-music/lofi.mp3A public/reel-music/manifest.jsonA public/reel-music/upbeat.mp3A scripts/gen-reel-music.jsA scripts/reel-text.pyM server.jsA src/social.js
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 & 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 & 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 & a headline</h2>
+ <p class="hint">Every track is original & 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 & 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 & 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 →