← 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
M public/index.htmlA scripts/video-runner.mjsM server.js
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 →