← back to Wallco Ai
Reel maker: bump to 6 designs + admin publish-to-own-channels
e0266e6b02bcf59440e122461a54c1f122f4c68a · 2026-05-19 08:35:09 -0700 · Steve
- MAX_IMAGES 5 → 6 (reel page + share-sheet "Make a Reel" both follow)
- New admin-only panel on the reel result: publish the finished reel to
wallco's OWN Instagram (Reel or Story) via Norma's instagram-agent, or
flag TikTok as not-yet-connected. Gated by admin-gate (localhost /
dw_auth cookie / ?admin token); non-admins never see it.
- POST /api/reel/:jobId/publish + GET /api/reel/admin
- Reports the TRUE outcome — posted / simulated / unreachable /
not_connected — never a fake success. Real IG posting still needs
IG creds on the agent; this wires the path so it's a one-flip switch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M public/js/social-share.jsM src/social.js
Diff
commit e0266e6b02bcf59440e122461a54c1f122f4c68a
Author: Steve <steve@designerwallcoverings.com>
Date: Tue May 19 08:35:09 2026 -0700
Reel maker: bump to 6 designs + admin publish-to-own-channels
- MAX_IMAGES 5 → 6 (reel page + share-sheet "Make a Reel" both follow)
- New admin-only panel on the reel result: publish the finished reel to
wallco's OWN Instagram (Reel or Story) via Norma's instagram-agent, or
flag TikTok as not-yet-connected. Gated by admin-gate (localhost /
dw_auth cookie / ?admin token); non-admins never see it.
- POST /api/reel/:jobId/publish + GET /api/reel/admin
- Reports the TRUE outcome — posted / simulated / unreachable /
not_connected — never a fake success. Real IG posting still needs
IG creds on the agent; this wires the path so it's a one-flip switch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
public/js/social-share.js | 2 +-
src/social.js | 164 +++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 162 insertions(+), 4 deletions(-)
diff --git a/public/js/social-share.js b/public/js/social-share.js
index 228b3ef..68f647f 100644
--- a/public/js/social-share.js
+++ b/public/js/social-share.js
@@ -374,7 +374,7 @@
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)));
+ localStorage.setItem('wallco.reel.picks', JSON.stringify(picks.slice(0, 6)));
} catch (e) {}
});
tools.appendChild(a);
diff --git a/src/social.js b/src/social.js
index 6ca27fc..6e87155 100644
--- a/src/social.js
+++ b/src/social.js
@@ -31,6 +31,17 @@ const fs = require('fs');
const crypto = require('crypto');
const { spawn } = require('child_process');
+// Shared admin gate — localhost / signed dw_auth cookie / ?admin=<ADMIN_TOKEN>.
+let isAdmin;
+try { ({ isAdmin } = require('./admin-gate')); }
+catch (_) { isAdmin = () => false; }
+
+// Norma's instagram-agent — single wallco IG business account, Meta Graph
+// Content Publishing API. Posts for real once IG_USER_ID + IG_ACCESS_TOKEN
+// are set on that agent; simulates otherwise.
+const IG_AGENT_URL = process.env.IG_AGENT_URL || 'http://127.0.0.1:9810';
+const IG_AGENT_AUTH = process.env.IG_AGENT_AUTH || 'admin:';
+
const ROOT = path.join(__dirname, '..');
const DATA_FILE = path.join(ROOT, 'data', 'designs.json');
const IMG_DIR = path.join(ROOT, 'data', 'generated');
@@ -48,7 +59,7 @@ fs.mkdirSync(path.dirname(WM_PNG), { recursive: true });
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_IMAGES = 6;
const MAX_CONCURRENT = 2; // ffmpeg jobs running at once
const jobs = new Map(); // jobId → { status, progress, url, poster, error }
@@ -258,6 +269,46 @@ function buildCaption(d) {
full: line + '\n\n' + tags.join(' ') };
}
+// ── Publish a finished reel to wallco's own Instagram (admin only) ─────────
+// Hands the public MP4 URL to Norma's instagram-agent. The agent posts for
+// real when IG creds are configured, simulates otherwise — and this function
+// reports whichever actually happened, never a fake success.
+async function publishInstagram(videoUrl, caption, kind) {
+ const ep = kind === 'story' ? '/api/skill/story' : '/api/skill/reel';
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), 15000);
+ try {
+ const r = await fetch(IG_AGENT_URL + ep, {
+ method: 'POST', signal: ctrl.signal,
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: 'Basic ' + Buffer.from(IG_AGENT_AUTH).toString('base64'),
+ },
+ body: JSON.stringify({ video_url: videoUrl, media_url: videoUrl,
+ caption, share_to_feed: true, pipeline_id: 'wallco-reel' }),
+ });
+ clearTimeout(timer);
+ const j = await r.json().catch(() => ({}));
+ const res = j.result || {};
+ if (res.simulated) {
+ return { ok: true, status: 'simulated', permalink: res.permalink || null,
+ message: 'Instagram agent reached, but it is in SIMULATION mode — no real post. '
+ + 'Set IG_USER_ID + IG_ACCESS_TOKEN on the instagram-agent (and enable its live API block) to post for real.' };
+ }
+ if (res.posted) {
+ return { ok: true, status: 'posted', permalink: res.permalink || null,
+ message: 'Posted to Instagram.' };
+ }
+ return { ok: false, status: 'failed',
+ message: j.error || 'Instagram agent did not confirm the post.' };
+ } catch (e) {
+ clearTimeout(timer);
+ return { ok: false, status: 'unavailable',
+ message: 'Instagram agent unreachable at ' + IG_AGENT_URL
+ + ' (' + (e.name === 'AbortError' ? 'timeout' : e.message) + ').' };
+ }
+}
+
// ── HTML escaping ──────────────────────────────────────────────────────────
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g,
@@ -356,6 +407,19 @@ function reelPage() {
.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}
+ #pubpanel{display:none;margin-top:16px;border-top:1px solid var(--line);padding-top:14px}
+ .publbl{font:600 10.5px var(--sans);letter-spacing:.09em;text-transform:uppercase;color:#7a5e1a;margin-bottom:8px}
+ #pubcap{width:100%;padding:8px 11px;border:1px solid var(--line);border-radius:7px;
+ font:13px var(--sans);background:var(--bg);resize:vertical;margin-bottom:8px}
+ .pubrow{display:flex;flex-wrap:wrap;gap:8px}
+ .pubbtn{border:1px solid #c9a14b;background:#fffaf0;color:#7a5e1a;border-radius:8px;
+ padding:9px 14px;font:600 12.5px var(--sans);cursor:pointer}
+ .pubbtn:hover{background:#fff3da}
+ .pubbtn:disabled{opacity:.5;cursor:wait}
+ .pubstatus{font-size:12px;line-height:1.5;margin-top:9px;padding:0}
+ .pubstatus.ok{color:#3a7d44}
+ .pubstatus.sim{color:#9a7b1a}
+ .pubstatus.err{color:#b4453a}
.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}
@@ -418,6 +482,16 @@ function reelPage() {
to any installed app. The music is original & cleared for all platforms.</p>
</div>
</div>
+ <div id="pubpanel">
+ <div class="publbl">⚙ Admin · publish to wallco's own channels</div>
+ <textarea id="pubcap" rows="2" placeholder="Caption for the post…"></textarea>
+ <div class="pubrow">
+ <button class="pubbtn" data-pub="instagram" data-kind="reel">Instagram Reel</button>
+ <button class="pubbtn" data-pub="instagram" data-kind="story">Instagram Story</button>
+ <button class="pubbtn" data-pub="tiktok" data-kind="video">TikTok</button>
+ </div>
+ <div id="pubstatus" class="pubstatus"></div>
+ </div>
</div>
</div>
@@ -427,6 +501,8 @@ function reelPage() {
<script>
(function(){
var MAX=${MAX_IMAGES}, picks=[], DESIGNS=[], MUSIC=[], music='calm';
+ var isAdminUser=false, curJob=null;
+ var ADMIN_TOK=new URLSearchParams(location.search).get('admin')||'';
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');
@@ -587,8 +663,47 @@ function reelPage() {
else if(s[2]==='native'){b.addEventListener('click',function(){shareNative(j.url);});}
box.appendChild(b);
});
+ curJob=j.id||((j.url.match(/\\/([^/.]+)\\.mp4/)||[])[1])||null;
+ setupPublish();
r.scrollIntoView({behavior:'smooth',block:'center'});
}
+ // Admin-only: publish the finished reel to wallco's OWN IG / TikTok.
+ function setupPublish(){
+ var panel=document.getElementById('pubpanel');
+ if(!isAdminUser||!curJob){panel.style.display='none';return;}
+ panel.style.display='block';
+ var cap=document.getElementById('pubcap');
+ if(!cap.value){
+ var hl=document.getElementById('headline').value.trim();
+ cap.value=(hl?hl+' — ':'')+'New from the wallco.ai studio. #wallco #wallpaper #interiordesign';
+ }
+ }
+ document.querySelectorAll('.pubbtn').forEach(function(btn){
+ btn.addEventListener('click',function(){
+ if(!curJob)return;
+ var stat=document.getElementById('pubstatus');
+ var all=document.querySelectorAll('.pubbtn');
+ all.forEach(function(b){b.disabled=true;});
+ stat.className='pubstatus';stat.textContent='Publishing to '+btn.dataset.pub+'…';
+ var hdrs={'Content-Type':'application/json'};
+ if(ADMIN_TOK)hdrs['X-Admin-Token']=ADMIN_TOK;
+ fetch('/api/reel/'+curJob+'/publish',{method:'POST',headers:hdrs,
+ body:JSON.stringify({platform:btn.dataset.pub,kind:btn.dataset.kind,
+ caption:document.getElementById('pubcap').value})})
+ .then(function(r){return r.json();})
+ .then(function(j){
+ all.forEach(function(b){b.disabled=false;});
+ if(j.status==='posted'){stat.className='pubstatus ok';
+ stat.innerHTML='✓ '+j.message+(j.permalink?' <a href="'+j.permalink+'" target="_blank">View ↗</a>':'');}
+ else if(j.status==='simulated'){stat.className='pubstatus sim';
+ stat.textContent='◐ '+j.message;}
+ else{stat.className='pubstatus err';
+ stat.textContent='✗ '+(j.message||j.error||'Could not publish');}
+ })
+ .catch(function(e){all.forEach(function(b){b.disabled=false;});
+ stat.className='pubstatus err';stat.textContent='✗ '+e.message;});
+ });
+ });
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'});
@@ -601,6 +716,11 @@ function reelPage() {
}).catch(function(){toast('Download the MP4 to share');});
}
+ // admin check — gates the "publish to wallco's channels" panel
+ fetch('/api/reel/admin',{headers:ADMIN_TOK?{'X-Admin-Token':ADMIN_TOK}:{}})
+ .then(function(r){return r.json();})
+ .then(function(a){isAdminUser=!!a.admin;}).catch(function(){});
+
// load data
Promise.all([
fetch('/api/reel/designs').then(function(r){return r.json();}),
@@ -704,12 +824,50 @@ function mount(app) {
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,
+ res.json({ id: job.id, 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)');
+ // Is the current viewer an admin? Drives the "publish to wallco's channels"
+ // panel on the reel page — non-admins never see it.
+ app.get('/api/reel/admin', (req, res) => {
+ res.json({ admin: !!isAdmin(req) });
+ });
+
+ // Publish a finished reel to wallco's OWN social channels (admin only).
+ // This posts to accounts wallco controls — not to a visitor's account,
+ // which no website can do.
+ app.post('/api/reel/:jobId/publish', express.json({ limit: '8kb' }), async (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ ok: false, error: 'not found' });
+ const jobId = String(req.params.jobId).replace(/[^a-z0-9]/gi, '');
+ const file = path.join(OUT_DIR, jobId + '.mp4');
+ if (!jobId || !fs.existsSync(file)) {
+ return res.status(404).json({ ok: false, error: 'Reel not found or expired — re-render it' });
+ }
+ const body = req.body || {};
+ const platform = String(body.platform || '').toLowerCase();
+ const kind = String(body.kind || 'reel').toLowerCase();
+ const caption = String(body.caption || '').slice(0, 2200);
+ const host = req.get('host') || 'wallco.ai';
+ const proto = req.get('x-forwarded-proto') || (req.secure ? 'https' : 'http');
+ const videoUrl = `${proto}://${host}/reel/out/${jobId}.mp4`;
+
+ if (platform === 'instagram') {
+ const r = await publishInstagram(videoUrl, caption, kind);
+ return res.json({ platform: 'instagram', kind, video_url: videoUrl, ...r });
+ }
+ if (platform === 'tiktok') {
+ // No TikTok Content Posting API connection for wallco.ai yet.
+ return res.json({ platform: 'tiktok', ok: false, status: 'not_connected',
+ video_url: videoUrl,
+ message: 'TikTok auto-posting needs the TikTok Content Posting API connected to wallco.ai '
+ + '(app approval + account OAuth). Until then, download the MP4 and upload it in the TikTok app.' });
+ }
+ return res.status(400).json({ ok: false, error: 'unknown platform' });
+ });
+
+ console.log(' Social layer mounted (/reel + share API + publish)');
}
module.exports = { mount };
← 383c03b Add social share layer + reel maker to wallco.ai
·
back to Wallco Ai
·
wallco generator: seed colour from dw-fashion-templates styl 174a5bd →