[object Object]

← 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

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 &amp; 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 →