[object Object]

← back to Dw Marketing Reels

console: Video Studio — buttons to queue IG reel / URL→reel / newsletter→video + rendered-video gallery (/api/videos) + gated runner (VIDEO_AUTORUN off = waits for studio session; never auto-posts)

1d2a1cb0bb8b01a26597a692489245706de87b78 · 2026-07-17 07:58:46 -0700 · Steve Abrams

Files touched

Diff

commit 1d2a1cb0bb8b01a26597a692489245706de87b78
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 17 07:58:46 2026 -0700

    console: Video Studio — buttons to queue IG reel / URL→reel / newsletter→video + rendered-video gallery (/api/videos) + gated runner (VIDEO_AUTORUN off = waits for studio session; never auto-posts)
---
 public/index.html        | 93 ++++++++++++++++++++++++++++++++++++++++++++++--
 scripts/video-runner.mjs | 56 +++++++++++++++++++++++++++++
 server.js                | 59 +++++++++++++++++++++++++++++-
 3 files changed, 204 insertions(+), 4 deletions(-)

diff --git a/public/index.html b/public/index.html
index eb5f003..4633ab3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -97,14 +97,20 @@
   <nav>
     <div class="grp">Browse</div>
     <a href="#sec-reels">🎞 Reels</a>
+    <a href="#sec-videos">🎬 Studio videos</a>
     <a href="#sec-accts">📷 Instagram accounts</a>
     <a href="#sec-na">🆕 New Arrivals</a>
   </nav>
   <div class="acts">
+    <div class="grp" style="margin:0 0 2px 0">Video Studio</div>
+    <button id="btnBuild">▶ New Arrivals reel</button>
+    <button class="ghost" id="btnStudioReel">🎬 IG reel · 9:16</button>
+    <button class="ghost" id="btnStudioUrl">🔗 URL → reel</button>
+    <button class="ghost" id="btnStudioEml">📧 Newsletter → video</button>
+    <div class="grp" style="margin:10px 0 2px 0">Distribute</div>
     <button class="gold" id="btnPublish">✦ Publish to Instagram</button>
     <button class="ghost" id="btnAddAcct">+ Add account</button>
     <button class="ghost" id="btnRefresh">↻ Refresh feed</button>
-    <button id="btnBuild">▶ Generate reel</button>
   </div>
 </aside>
 
@@ -118,6 +124,10 @@
   <h2 id="sec-reels">Reels</h2>
   <div class="reels" id="reels"></div>
 
+  <h2 id="sec-videos">Studio videos <span id="jobsInfo" style="font-size:13px;color:#8a8272"></span></h2>
+  <div id="jobs" class="badges"></div>
+  <div class="reels" id="videos"></div>
+
   <h2 id="sec-na">Current New Arrivals <span id="naCount" style="font-size:13px;color:#8a8272"></span></h2>
   <div class="controls">
     <div><label>Sort</label>
@@ -169,6 +179,27 @@
   </div>
 </div>
 
+<!-- ── VIDEO STUDIO MODAL ── -->
+<div class="overlay" id="ovStudio">
+  <div class="modal">
+    <div class="mhd"><h3 id="studioTitle">Make a video</h3><button class="x" data-close>×</button></div>
+    <div class="mbd">
+      <div id="studioUrlWrap"><label class="fld">Product or collection URL</label>
+        <input type="text" id="studioUrl" placeholder="https://designerwallcoverings.com/collections/new-arrivals" /></div>
+      <div id="studioEmlWrap" style="display:none"><label class="fld">Newsletter .eml file path</label>
+        <input type="text" id="studioEml" placeholder="~/Downloads/this-weeks-newsletter.eml" /></div>
+      <div><label class="fld">Format</label>
+        <select id="studioFmt"><option value="9:16">9:16 · Instagram Reels</option><option value="1:1">1:1 · Feed / Square</option></select></div>
+      <div class="note">Queues a <b>$0 local</b> render on the studio host. It runs automatically only when the host is armed (<code>VIDEO_AUTORUN=1</code>); otherwise it waits for a studio session. Nothing posts to Instagram — publishing stays a separate approved step.</div>
+      <div class="out" id="studioOut"></div>
+    </div>
+    <div class="mft">
+      <button class="ghost" data-close>Cancel</button>
+      <button class="gold" id="studioGo">Queue video</button>
+    </div>
+  </div>
+</div>
+
 <script>
 const $ = s => document.querySelector(s);
 const fmt = iso => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso||''; } };
@@ -311,6 +342,61 @@ $('#reels').addEventListener('click', e => {
   if (p) { $('#btnPublish').click(); $('#pubReel').value = p.dataset.file; }
 });
 
+// ── Video Studio (queue jobs + rendered-video gallery) ──
+async function loadVideos() {
+  let vids = []; try { vids = await (await fetch('api/videos')).json(); } catch {}
+  $('#videos').innerHTML = vids.length ? vids.map(v => `
+    <div class="reel">
+      <video src="${v.url}" controls preload="metadata" playsinline></video>
+      <div class="body">
+        <div class="when">🕓 ${fmt(new Date(v.mtime).toISOString())}</div>
+        <div class="titles">${escapeHtml(v.path)}</div>
+        <div class="row"><a href="${v.url}" download>Download</a>
+          <span style="font-size:12px;color:#9a917f">${v.kb} KB</span></div>
+      </div>
+    </div>`).join('') : '<p style="color:#8a8272">No studio videos yet — queue one from the Video Studio buttons.</p>';
+}
+async function loadJobs() {
+  let jobs = []; try { jobs = await (await fetch('api/video/jobs')).json(); } catch {}
+  const active = jobs.filter(j => j.status === 'queued' || j.status === 'running');
+  $('#jobs').innerHTML = active.map(j => {
+    const cls = j.status === 'running' ? 'wait' : 'sim';
+    const what = j.kind === 'new-arrivals-reel' ? 'New Arrivals reel'
+      : j.kind === 'url-reel' ? ('URL → ' + (j.url || '').slice(0, 44))
+      : ('Newsletter → ' + (j.eml || '').slice(0, 44));
+    return `<span class="badge ${cls}">${j.status === 'running' ? '⚙' : '◷'} ${escapeHtml(what)} · ${j.format}</span>`;
+  }).join('');
+  $('#jobsInfo').textContent = active.length ? `(${active.length} queued / running)` : '';
+  if (active.length) setTimeout(() => { loadJobs(); loadVideos(); }, 4000);
+}
+async function queueVideo(body) {
+  const out = $('#studioOut'); out.className = 'out show'; out.textContent = '→ queueing…';
+  const r = await fetch('api/video/queue', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
+  const j = await r.json().catch(()=>({}));
+  if (!r.ok) { out.textContent = '✗ ' + (j.error || 'failed'); return false; }
+  out.textContent = '✓ queued (' + j.queued.id + '). It renders on the studio host — watch “Studio videos”.';
+  loadJobs(); return true;
+}
+$('#btnStudioReel').addEventListener('click', () => queueVideo({ kind:'new-arrivals-reel', format:'9:16' })
+  .then(ok => { if (ok) $('#status').textContent = '🎬 New Arrivals reel queued'; }));
+function openStudio(mode) {
+  $('#studioTitle').textContent = mode === 'eml' ? 'Newsletter → video' : 'URL → reel';
+  $('#studioUrlWrap').style.display = mode === 'url' ? '' : 'none';
+  $('#studioEmlWrap').style.display = mode === 'eml' ? '' : 'none';
+  $('#studioGo').dataset.mode = mode;
+  const out = $('#studioOut'); out.className = 'out'; out.textContent = '';
+  openModal('#ovStudio');
+}
+$('#btnStudioUrl').addEventListener('click', () => openStudio('url'));
+$('#btnStudioEml').addEventListener('click', () => openStudio('eml'));
+$('#studioGo').addEventListener('click', async () => {
+  const mode = $('#studioGo').dataset.mode, format = $('#studioFmt').value;
+  if (mode === 'url') { const url = $('#studioUrl').value.trim(); if (!url) { $('#studioUrl').focus(); return; }
+    if (await queueVideo({ kind:'url-reel', url, format })) closeModal($('#ovStudio')); }
+  else { const eml = $('#studioEml').value.trim(); if (!eml) { $('#studioEml').focus(); return; }
+    if (await queueVideo({ kind:'newsletter-video', eml, format })) closeModal($('#ovStudio')); }
+});
+
 // ── new arrivals grid (sort + density, persisted) ──
 let NA = [];
 function renderGrid() {
@@ -362,13 +448,14 @@ $('#btnBuild').addEventListener('click', ()=>job('api/build','▶ generating ree
   try {
     const cfg = await (await fetch('api/config')).json();
     if (!cfg.allowBuild) {
-      ['#btnBuild','#btnRefresh','#btnPublish'].forEach(s => $(s).style.display = 'none');
+      ['#btnBuild','#btnRefresh','#btnPublish','#btnStudioReel','#btnStudioUrl','#btnStudioEml']
+        .forEach(s => { const el = $(s); if (el) el.style.display = 'none'; });
       $('#status').textContent = 'Reels are rendered + published on the studio host.';
     }
   } catch {}
 })();
 
-loadAccounts().then(loadReels); loadNA();
+loadAccounts().then(loadReels); loadNA(); loadVideos(); loadJobs();
 </script>
 </body>
 </html>
diff --git a/scripts/video-runner.mjs b/scripts/video-runner.mjs
new file mode 100644
index 0000000..92dc920
--- /dev/null
+++ b/scripts/video-runner.mjs
@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+// video-runner.mjs — fulfills queued Video Studio jobs (data/video-jobs.json).
+//
+// GATE: this only AUTO-executes a video skill when the host is armed with
+// VIDEO_AUTORUN=1. Autonomous skill execution runs `claude -p` headless, which is a
+// Steve-gated action (see ~/.claude/yolo-queue). Unarmed (default), the runner simply
+// reports that jobs are waiting for a live studio session to run them — nothing fires.
+//
+// Each job maps to a video skill:
+//   new-arrivals-reel → reels-producer (9:16 IG reel from data/new-arrivals.json)
+//   url-reel          → product-launch-video (promo/tour reel from a URL)
+//   newsletter-video  → newsletter-to-video (.eml → promo video)
+import { readFile, writeFile } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
+const JOBS = join(ROOT, 'data', 'video-jobs.json');
+const ARMED = process.env.VIDEO_AUTORUN === '1';
+
+const prompt = (j) => {
+  const fmt = j.format === '1:1' ? '1:1 (1080×1080)' : '9:16 Instagram Reels (1080×1920)';
+  if (j.kind === 'new-arrivals-reel')
+    return `Use the reels-producer skill to build ONE ${fmt} Instagram reel for Designer Wallcoverings New Arrivals from ~/Projects/dw-marketing-reels/data/new-arrivals.json (pick 6-8 varied products, download images locally, add a ready-to-post caption). $0 local. Report the MP4 path + caption.`;
+  if (j.kind === 'url-reel')
+    return `Use the product-launch-video skill to build ONE ${fmt} promo reel for: ${j.url} . $0 local (export HYPERFRAMES_PYTHON=~/.hyperframes/tts-venv/bin/python for audio). Report the MP4 path.`;
+  if (j.kind === 'newsletter-video')
+    return `Use the newsletter-to-video skill on the .eml at: ${j.eml} , output ${fmt}. $0 local. Report the MP4 path.`;
+  return null;
+};
+
+(async () => {
+  const jobs = existsSync(JOBS) ? JSON.parse(await readFile(JOBS, 'utf8')) : [];
+  const queued = jobs.filter(j => j.status === 'queued');
+  if (!queued.length) { console.log('video-runner: no queued jobs.'); return; }
+  if (!ARMED) {
+    console.log(`video-runner: ${queued.length} job(s) queued, waiting for a live studio session (VIDEO_AUTORUN not set).`);
+    return; // GATE: never auto-runs claude unarmed.
+  }
+  for (const j of queued) {
+    const pr = prompt(j); if (!pr) { j.status = 'error'; j.note = 'unknown kind'; continue; }
+    j.status = 'running'; j.started_at = new Date().toISOString();
+    await writeFile(JOBS, JSON.stringify(jobs, null, 2));
+    try {
+      await new Promise((resolve, reject) => {
+        const ch = spawn('claude', ['-p', pr], { cwd: ROOT, stdio: 'inherit' });
+        ch.on('close', code => code === 0 ? resolve() : reject(new Error('claude exit ' + code)));
+        ch.on('error', reject);
+      });
+      j.status = 'done'; j.finished_at = new Date().toISOString();
+    } catch (e) { j.status = 'error'; j.note = String(e.message).slice(0, 120); }
+    await writeFile(JOBS, JSON.stringify(jobs, null, 2));
+  }
+})().catch(e => { console.error('video-runner FAILED:', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index bcc0db9..524b535 100644
--- a/server.js
+++ b/server.js
@@ -4,7 +4,7 @@
 // the current New Arrivals grid (sort + density per standing rule), and lets marketing
 // refresh the feed + generate a new reel on demand ($0 local render).
 import http from 'node:http';
-import { readFile, writeFile, stat } from 'node:fs/promises';
+import { readFile, writeFile, stat, readdir } from 'node:fs/promises';
 import { createReadStream, existsSync } from 'node:fs';
 import { spawn } from 'node:child_process';
 import { fileURLToPath } from 'node:url';
@@ -140,6 +140,63 @@ const server = http.createServer(async (req, res) => {
       if (!file || !/^[\w.\-]+\.mp4$/.test(file)) return res.writeHead(400).end(JSON.stringify({ error: 'bad file' }));
       return run('publish-ig.mjs', res, ['--file', file]);
     }
+    // ── Video Studio: enqueue a video-generation job (reel / url→reel / newsletter→video).
+    // The job is QUEUED here; it is fulfilled by scripts/video-runner.mjs, which only
+    // AUTO-executes the skill (via headless claude) when the host is armed with
+    // VIDEO_AUTORUN=1 — otherwise the job waits for a live studio session to run it.
+    if (p === '/api/video/jobs') {
+      const fp = join(ROOT, 'data', 'video-jobs.json');
+      return res.writeHead(200, { 'content-type': 'application/json' }).end(existsSync(fp) ? await readFile(fp) : '[]');
+    }
+    if (p === '/api/video/queue' && req.method === 'POST') {
+      if (!ALLOW_BUILD) return res.writeHead(400, { 'content-type': 'application/json' })
+        .end(JSON.stringify({ error: 'Video jobs run on the studio host (Mac3).' }));
+      const b = await readBody(req);
+      const kind = String(b.kind || '').trim();
+      const KINDS = ['new-arrivals-reel', 'url-reel', 'newsletter-video'];
+      if (!KINDS.includes(kind)) return res.writeHead(400).end(JSON.stringify({ error: 'unknown kind' }));
+      const fp = join(ROOT, 'data', 'video-jobs.json');
+      const jobs = await readJson(fp, []);
+      const job = { id: 'vj-' + Date.now().toString(36), kind,
+        url: String(b.url || '').slice(0, 400), eml: String(b.eml || '').slice(0, 400),
+        format: b.format === '1:1' ? '1:1' : '9:16', status: 'queued', created_at: new Date().toISOString() };
+      jobs.unshift(job);
+      await writeFile(fp, JSON.stringify(jobs, null, 2));
+      // best-effort nudge the runner; it no-ops unless VIDEO_AUTORUN=1 (gated).
+      try {
+        const env = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}` };
+        spawn(process.execPath, [join(ROOT, 'scripts', 'video-runner.mjs')], { cwd: ROOT, env, detached: true, stdio: 'ignore' }).unref();
+      } catch {}
+      return res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ queued: job }));
+    }
+    // list every rendered mp4 (reels/ + videos/**/*.mp4 from the HyperFrames pipelines)
+    if (p === '/api/videos') {
+      const out = [];
+      async function walk(dir, rel) {
+        let ents; try { ents = await readdir(dir, { withFileTypes: true }); } catch { return; }
+        for (const e of ents) {
+          const full = join(dir, e.name), r = rel ? rel + '/' + e.name : e.name;
+          if (e.isDirectory()) { if (e.name !== 'node_modules') await walk(full, r); }
+          else if (e.name.toLowerCase().endsWith('.mp4')) {
+            const st = await stat(full);
+            out.push({ path: r, url: '/studio-media/' + r.split('/').map(encodeURIComponent).join('/'),
+              name: e.name, kb: Math.round(st.size / 1024), mtime: st.mtimeMs });
+          }
+        }
+      }
+      await walk(join(ROOT, 'videos'), 'videos');
+      await walk(join(ROOT, 'reels'), 'reels');
+      out.sort((a, b) => b.mtime - a.mtime);
+      return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(out));
+    }
+    // authed serve of any rendered mp4 under videos/ or reels/ (traversal-guarded)
+    if (p.startsWith('/studio-media/')) {
+      const rel = decodeURIComponent(p.slice('/studio-media/'.length));
+      const fp = normalize(join(ROOT, rel));
+      const okRoot = fp.startsWith(join(ROOT, 'videos')) || fp.startsWith(join(ROOT, 'reels'));
+      if (okRoot && extname(fp) === '.mp4' && existsSync(fp)) return await serveFile(fp, req, res);
+      return res.writeHead(404).end('not found');
+    }
     if (p.startsWith('/reels/')) {
       const fp = normalize(join(ROOT, 'reels', p.slice('/reels/'.length)));
       if (fp.startsWith(join(ROOT, 'reels')) && existsSync(fp)) return await serveFile(fp, req, res);

← f7b3987 shop-by-color wheel: -50% wheel+dots, mobile+touch, >=80 SKU  ·  back to Dw Marketing Reels  ·  New Arrivals reel: 7 curated heroes + local Kokoro VO + Musi f2f4363 →