← back to Dw Marketing Reels
Add hypergen daily video-suggestions tab (4 ideas/day, dated + used-stamp)
96a00f6f2d5ab98a5577f63a9487559e776ee627 · 2026-07-22 07:06:32 -0700 · Steve
- scripts/suggest-videos.mjs: $0-local daily generator, 4 ideas/day from
New Arrivals catalog, append-only history, idempotent per PT date
- server.js: GET /api/video/suggestions, POST /api/video/suggest,
POST /api/video/suggestions/use (queues the render + stamps used_at/used_where);
shared buildVideoJob/enqueueVideoJob helpers reused by the manual queue door
- public/index.html: Suggested-videos tab — day-grouped cards, created date+time
chip, Use-it button, and a when/where used-stamp; gated on allowBuild
- com.steve.dw-video-suggest launchd agent: fills today's 4 daily at 7:05 PT (ideas only)
Files touched
A data/video-suggestions.jsonM public/index.htmlA scripts/suggest-videos.mjsM server.js
Diff
commit 96a00f6f2d5ab98a5577f63a9487559e776ee627
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 22 07:06:32 2026 -0700
Add hypergen daily video-suggestions tab (4 ideas/day, dated + used-stamp)
- scripts/suggest-videos.mjs: $0-local daily generator, 4 ideas/day from
New Arrivals catalog, append-only history, idempotent per PT date
- server.js: GET /api/video/suggestions, POST /api/video/suggest,
POST /api/video/suggestions/use (queues the render + stamps used_at/used_where);
shared buildVideoJob/enqueueVideoJob helpers reused by the manual queue door
- public/index.html: Suggested-videos tab — day-grouped cards, created date+time
chip, Use-it button, and a when/where used-stamp; gated on allowBuild
- com.steve.dw-video-suggest launchd agent: fills today's 4 daily at 7:05 PT (ideas only)
---
data/video-suggestions.json | 155 ++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 88 ++++++++++++++++++++++++-
scripts/suggest-videos.mjs | 120 ++++++++++++++++++++++++++++++++++
server.js | 106 ++++++++++++++++++++++--------
4 files changed, 438 insertions(+), 31 deletions(-)
diff --git a/data/video-suggestions.json b/data/video-suggestions.json
new file mode 100644
index 0000000..3212e5e
--- /dev/null
+++ b/data/video-suggestions.json
@@ -0,0 +1,155 @@
+[
+ {
+ "id": "sg-2026-07-22-1-xbczae",
+ "date": "2026-07-22",
+ "slot": 1,
+ "created_at": "2026-07-22T13:58:58.056Z",
+ "kind": "new-arrivals-reel",
+ "format": "9:16",
+ "url": "",
+ "title": "New Arrivals reel — the 2026-07-22 drop",
+ "idea": "A 9:16 Instagram reel of this week's freshest 6–8 arrivals, varied by color & scale. Lead with the most striking image, jewel-tone first. Ready-to-post DW caption.",
+ "hero_image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R18523_interior1_1200x.jpg?v=1784619621",
+ "refs": [
+ {
+ "pattern": "Flow Mini Deep Blue",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R18523_interior1_1200x.jpg?v=1784619621",
+ "url": "https://designerwallcoverings.com/products/flow-mini-deep-blue-wallcoverings-dwrw-361333"
+ },
+ {
+ "pattern": "Flow Lines",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21820_interior1_1200x.webp?v=1784619618",
+ "url": "https://designerwallcoverings.com/products/flow-lines-gray-wallcoverings-dwrw-361332"
+ },
+ {
+ "pattern": "Flow Lines",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21819_interior1_1200x.jpg?v=1784619616",
+ "url": "https://designerwallcoverings.com/products/flow-lines-beige-wallcoverings-dwrw-361331"
+ },
+ {
+ "pattern": "Florida",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R20038_interior1_1200x.webp?v=1784619613",
+ "url": "https://designerwallcoverings.com/products/florida-blue-wallcoverings-dwrw-361328"
+ },
+ {
+ "pattern": "Floral Ripple Rust",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R19320_interior1_1200x.jpg?v=1784619613",
+ "url": "https://designerwallcoverings.com/products/floral-ripple-rust-wallcoverings-dwrw-361325"
+ },
+ {
+ "pattern": "Floral Ripple Graphite",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R19322_interior1_1200x.jpg?v=1784533219",
+ "url": "https://designerwallcoverings.com/products/floral-ripple-graphite-wallcoverings-dwrw-361324"
+ }
+ ],
+ "used": false,
+ "used_at": null,
+ "used_where": null
+ },
+ {
+ "id": "sg-2026-07-22-2-1ecfjbc",
+ "date": "2026-07-22",
+ "slot": 2,
+ "created_at": "2026-07-22T13:58:58.056Z",
+ "kind": "new-arrivals-reel",
+ "format": "1:1",
+ "url": "",
+ "title": "Feed grid — Rebel Walls spotlight",
+ "idea": "A 1:1 feed post spotlighting Rebel Walls (15 new this drop). Tight 4–6 product montage, brand-forward. Caption names the house.",
+ "hero_image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R18523_interior1_1200x.jpg?v=1784619621",
+ "refs": [
+ {
+ "pattern": "Flow Mini Deep Blue",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R18523_interior1_1200x.jpg?v=1784619621",
+ "url": "https://designerwallcoverings.com/products/flow-mini-deep-blue-wallcoverings-dwrw-361333"
+ },
+ {
+ "pattern": "Flow Lines",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21820_interior1_1200x.webp?v=1784619618",
+ "url": "https://designerwallcoverings.com/products/flow-lines-gray-wallcoverings-dwrw-361332"
+ },
+ {
+ "pattern": "Flow Lines",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21819_interior1_1200x.jpg?v=1784619616",
+ "url": "https://designerwallcoverings.com/products/flow-lines-beige-wallcoverings-dwrw-361331"
+ },
+ {
+ "pattern": "Florida",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R20038_interior1_1200x.webp?v=1784619613",
+ "url": "https://designerwallcoverings.com/products/florida-blue-wallcoverings-dwrw-361328"
+ },
+ {
+ "pattern": "Floral Ripple Rust",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R19320_interior1_1200x.jpg?v=1784619613",
+ "url": "https://designerwallcoverings.com/products/floral-ripple-rust-wallcoverings-dwrw-361325"
+ },
+ {
+ "pattern": "Floral Ripple Graphite",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R19322_interior1_1200x.jpg?v=1784533219",
+ "url": "https://designerwallcoverings.com/products/floral-ripple-graphite-wallcoverings-dwrw-361324"
+ }
+ ],
+ "used": false,
+ "used_at": null,
+ "used_where": null
+ },
+ {
+ "id": "sg-2026-07-22-3-6a6v8d",
+ "date": "2026-07-22",
+ "slot": 3,
+ "created_at": "2026-07-22T13:58:58.056Z",
+ "kind": "url-reel",
+ "format": "9:16",
+ "url": "https://designerwallcoverings.com/collections/new-arrivals",
+ "title": "Collection tour — New Arrivals page",
+ "idea": "A 9:16 promo tour built from the live New Arrivals collection page (https://designerwallcoverings.com/collections/new-arrivals). Cinematic scroll of the whole drop, DW wordmark open/close.",
+ "hero_image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R18523_interior1_1200x.jpg?v=1784619621",
+ "refs": [
+ {
+ "pattern": "Flow Mini Deep Blue",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R18523_interior1_1200x.jpg?v=1784619621",
+ "url": "https://designerwallcoverings.com/products/flow-mini-deep-blue-wallcoverings-dwrw-361333"
+ },
+ {
+ "pattern": "Flow Lines",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21820_interior1_1200x.webp?v=1784619618",
+ "url": "https://designerwallcoverings.com/products/flow-lines-gray-wallcoverings-dwrw-361332"
+ },
+ {
+ "pattern": "Flow Lines",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21819_interior1_1200x.jpg?v=1784619616",
+ "url": "https://designerwallcoverings.com/products/flow-lines-beige-wallcoverings-dwrw-361331"
+ },
+ {
+ "pattern": "Florida",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R20038_interior1_1200x.webp?v=1784619613",
+ "url": "https://designerwallcoverings.com/products/florida-blue-wallcoverings-dwrw-361328"
+ }
+ ],
+ "used": false,
+ "used_at": null,
+ "used_where": null
+ },
+ {
+ "id": "sg-2026-07-22-4-1r41r7f",
+ "date": "2026-07-22",
+ "slot": 4,
+ "created_at": "2026-07-22T13:58:58.056Z",
+ "kind": "url-reel",
+ "format": "9:16",
+ "url": "https://designerwallcoverings.com/products/flow-mini-deep-blue-wallcoverings-dwrw-361333",
+ "title": "Statement piece — Flow Mini Deep Blue",
+ "idea": "A 9:16 hero spotlight of \"Flow Mini Deep Blue\" by Rebel Walls ($375.00) — the drop's showpiece. Slow push-in on the mural, one-line hook.",
+ "hero_image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R18523_interior1_1200x.jpg?v=1784619621",
+ "refs": [
+ {
+ "pattern": "Flow Mini Deep Blue",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R18523_interior1_1200x.jpg?v=1784619621",
+ "url": "https://designerwallcoverings.com/products/flow-mini-deep-blue-wallcoverings-dwrw-361333"
+ }
+ ],
+ "used": false,
+ "used_at": null,
+ "used_where": null
+ }
+]
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index bbdd8a3..1b02d42 100644
--- a/public/index.html
+++ b/public/index.html
@@ -69,6 +69,23 @@
.postto label { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#8a8272; }
.postto select { font-size:12.5px; padding:5px 8px; }
+ /* ── suggestions ── */
+ .sg-day { margin:18px 0 8px; display:flex; align-items:baseline; gap:10px; }
+ .sg-day h3 { margin:0; font-family:"Didot","Bodoni 72",Georgia,serif; font-weight:400; font-size:18px; }
+ .sg-day .n { font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:#a79f8d; }
+ .sg-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:16px; }
+ .sg { background:#fff; border:1px solid var(--line); border-radius:12px; overflow:hidden; display:flex; flex-direction:column; }
+ .sg.done { border-color:#cfe6d5; }
+ .sg .thumbs { display:flex; gap:2px; background:#eee; aspect-ratio:16/7; overflow:hidden; }
+ .sg .thumbs img { flex:1; min-width:0; object-fit:cover; height:100%; }
+ .sg .b { padding:12px 14px; display:flex; flex-direction:column; gap:7px; flex:1; }
+ .sg .kind { font-size:10.5px; letter-spacing:.16em; text-transform:uppercase; color:var(--gold); }
+ .sg .t { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:17px; line-height:1.25; }
+ .sg .idea { font-size:12.5px; color:#4c463c; }
+ .sg .when { font-size:11px; color:#9a917f; }
+ .sg .used { font-size:11.5px; color:#1f7a3d; background:#e5f3e8; border-radius:8px; padding:5px 9px; }
+ .sg .row { display:flex; gap:8px; align-items:center; margin-top:auto; padding-top:4px; }
+
/* ── modals ── */
.overlay { position:fixed; inset:0; background:rgba(28,26,23,.44); display:none; align-items:center; justify-content:center; z-index:40; padding:20px; }
.overlay.open { display:flex; }
@@ -96,6 +113,7 @@
</div>
<nav>
<div class="grp">Browse</div>
+ <a href="#sec-suggest">🎥 Suggested videos</a>
<a href="#sec-reels">🎞 Reels</a>
<a href="#sec-videos">🎬 Studio videos</a>
<a href="#sec-accts">📷 Instagram accounts</a>
@@ -103,6 +121,7 @@
</nav>
<div class="acts">
<div class="grp" style="margin:0 0 2px 0">Video Studio</div>
+ <button class="gold" id="btnSuggest">✨ Suggest 4 for today</button>
<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>
@@ -118,6 +137,10 @@
<main>
<div class="status" id="status"></div>
+ <h2 id="sec-suggest">Suggested videos <span id="sgInfo" style="font-size:13px;color:#8a8272"></span></h2>
+ <p style="font-size:12.5px;color:#8a8272;margin:0 0 6px">Four fresh video ideas a day, built from the live New Arrivals catalog. Every idea is dated and kept forever; “Use it” queues the $0 local HyperFrames render and date-stamps when & where it was used.</p>
+ <div id="suggest"></div>
+
<h2 id="sec-accts">Instagram accounts <span style="font-size:13px;color:#8a8272">(choose per reel below)</span></h2>
<div class="accts" id="accts"></div>
@@ -399,6 +422,64 @@ $('#studioGo').addEventListener('click', async () => {
if (await queueVideo({ kind:'newsletter-video', eml, format })) closeModal($('#ovStudio')); }
});
+// ── Suggested videos ("hypergen" 4-a-day ideas) ──
+const KINDLBL = { 'new-arrivals-reel':'New Arrivals reel', 'url-reel':'URL → reel', 'newsletter-video':'Newsletter → video' };
+const dayLabel = d => { try { return new Date(d+'T12:00:00').toLocaleDateString(undefined,{weekday:'short',year:'numeric',month:'long',day:'numeric'}); } catch { return d; } };
+let CANBUILD = true;
+async function loadSuggestions() {
+ let list = []; try { list = await (await fetch('api/video/suggestions')).json(); } catch {}
+ const days = [...new Set(list.map(s => s.date))];
+ $('#sgInfo').textContent = list.length ? `(${list.length} ideas · ${days.length} day${days.length>1?'s':''} · ${list.filter(s=>s.used).length} used)` : '';
+ if (!list.length) { $('#suggest').innerHTML = '<p style="color:#8a8272">No suggestions yet — hit “✨ Suggest 4 for today”.</p>'; return; }
+ $('#suggest').innerHTML = days.map(d => {
+ const items = list.filter(s => s.date === d).sort((a,b)=>a.slot-b.slot);
+ const cards = items.map(s => {
+ const thumbs = (s.refs||[]).filter(r=>r.image).slice(0,4)
+ .map(r => `<img loading="lazy" src="${escapeAttr(r.image)}" alt="${escapeAttr(r.pattern)}"/>`).join('')
+ || `<img src="${escapeAttr(s.hero_image||'')}" alt=""/>`;
+ const used = s.used ? `<div class="used" title="${escapeAttr(s.used_at)}">✓ Used ${fmt(s.used_at)} → ${escapeHtml((KINDLBL[s.used_where?.kind]||s.used_where?.kind||''))} · ${escapeHtml(s.used_where?.format||'')} <span style="color:#7a8f80">(${escapeHtml(s.used_where?.jobId||'')})</span></div>` : '';
+ const btn = s.used
+ ? `<button class="ghost tiny use-sg" data-id="${escapeAttr(s.id)}">Queue again</button>`
+ : `<button class="gold tiny use-sg" data-id="${escapeAttr(s.id)}"${CANBUILD?'':' disabled'}>Use it ↗</button>`;
+ return `<div class="sg ${s.used?'done':''}">
+ <div class="thumbs">${thumbs}</div>
+ <div class="b">
+ <div class="kind">${escapeHtml(KINDLBL[s.kind]||s.kind)} · ${escapeHtml(s.format)}</div>
+ <div class="t">${escapeHtml(s.title)}</div>
+ <div class="idea">${escapeHtml(s.idea)}</div>
+ <div class="when" title="${escapeAttr(s.created_at)}">🕓 Suggested ${fmt(s.created_at)}</div>
+ ${used}
+ <div class="row">${btn}
+ ${s.url?`<a href="${escapeAttr(s.url)}" target="_blank" rel="noopener noreferrer" style="font-size:11px;color:var(--gold);text-transform:uppercase;letter-spacing:.06em;text-decoration:none">Source ↗</a>`:''}
+ </div>
+ </div></div>`;
+ }).join('');
+ return `<div class="sg-day"><h3>${dayLabel(d)}</h3><span class="n">${items.length} idea${items.length>1?'s':''}</span></div>
+ <div class="sg-grid">${cards}</div>`;
+ }).join('');
+}
+// Use a suggestion → queue its render + date-stamp it (delegated)
+$('#suggest').addEventListener('click', async e => {
+ const u = e.target.closest('.use-sg'); if (!u) return;
+ u.disabled = true; const o = u.textContent; u.textContent = '⏳ queueing…';
+ const r = await fetch('api/video/suggestions/use', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ id: u.dataset.id }) });
+ const j = await r.json().catch(()=>({}));
+ if (!r.ok) { $('#status').textContent = '✗ ' + (j.error||'failed to queue'); u.disabled=false; u.textContent=o; return; }
+ $('#status').textContent = `✓ queued ${j.queued.id} — renders on the studio host (watch “Studio videos”).`;
+ loadSuggestions(); loadJobs();
+});
+// Sidebar: generate today's 4 on demand
+$('#btnSuggest').addEventListener('click', async () => {
+ $('#status').textContent = '✨ generating today’s suggestions…'; $('#status').dataset.wasBusy='1';
+ await fetch('api/video/suggest', { method:'POST', headers:{'content-type':'application/json'}, body:'{}' });
+ const tick = async () => {
+ const st = await (await fetch('api/status')).json();
+ if (st.building) { setTimeout(tick, 1200); }
+ else { $('#status').dataset.wasBusy=''; $('#status').textContent = '✓ suggestions updated'; loadSuggestions(); }
+ };
+ tick();
+});
+
// ── new arrivals grid (sort + density, persisted) ──
let NA = [];
function renderGrid() {
@@ -449,16 +530,17 @@ $('#btnBuild').addEventListener('click', ()=>job('api/build','▶ generating ree
(async () => {
try {
const cfg = await (await fetch('api/config')).json();
- VAUTO = !!cfg.videoAutorun; loadJobs();
+ VAUTO = !!cfg.videoAutorun; CANBUILD = !!cfg.allowBuild; loadJobs();
if (!cfg.allowBuild) {
- ['#btnBuild','#btnRefresh','#btnPublish','#btnStudioReel','#btnStudioUrl','#btnStudioEml']
+ ['#btnBuild','#btnRefresh','#btnPublish','#btnStudioReel','#btnStudioUrl','#btnStudioEml','#btnSuggest']
.forEach(s => { const el = $(s); if (el) el.style.display = 'none'; });
$('#status').textContent = 'Reels are rendered + published on the studio host.';
+ loadSuggestions(); // re-render so “Use it” buttons show disabled on the read-only deploy
}
} catch {}
})();
-loadAccounts().then(loadReels); loadNA(); loadVideos(); loadJobs();
+loadAccounts().then(loadReels); loadNA(); loadVideos(); loadJobs(); loadSuggestions();
</script>
</body>
</html>
diff --git a/scripts/suggest-videos.mjs b/scripts/suggest-videos.mjs
new file mode 100644
index 0000000..4042b35
--- /dev/null
+++ b/scripts/suggest-videos.mjs
@@ -0,0 +1,120 @@
+#!/usr/bin/env node
+// suggest-videos.mjs — the "hypergen" daily idea engine for the DW Marketing console.
+//
+// Every day this proposes FOUR video ideas (the "4 suggested videos a day") built from
+// the live New Arrivals catalog (data/new-arrivals.json). Each idea maps 1:1 onto a real
+// HyperFrames render job the Video Studio already knows how to run (reels-producer /
+// product-launch-video), so "Use it" in the console just queues that gated job.
+//
+// HARD RULES honoured here:
+// • $0 local — pure heuristics over the catalog, no paid LLM/API call.
+// • Keep ALL history — suggestions are append-only, grouped by day; nothing is deleted.
+// • Idempotent per day — a given local (PT) date is only ever filled to 4 once; re-runs
+// top up a short day but never duplicate or clobber a suggestion already USED.
+//
+// Usage: node scripts/suggest-videos.mjs [--force]
+// --force replace today's UNUSED suggestions and regenerate (used ones are preserved).
+import { readFile, writeFile } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
+const NA_FILE = join(ROOT, 'data', 'new-arrivals.json');
+const OUT_FILE = join(ROOT, 'data', 'video-suggestions.json');
+const NA_URL = 'https://designerwallcoverings.com/collections/new-arrivals';
+const FORCE = process.argv.includes('--force');
+
+// Local (America/Los_Angeles) calendar date, e.g. "2026-07-22" — the day this drop belongs to.
+function ptDate(d = new Date()) {
+ return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Los_Angeles' }).format(d);
+}
+async function readJson(fp, fallback) {
+ return existsSync(fp) ? JSON.parse(await readFile(fp, 'utf8')) : fallback;
+}
+// Stable-ish id without Math.random (harness-friendly): date + slot + short hash of the seed.
+function makeId(date, slot, seed) {
+ let h = 0; for (const c of String(seed)) h = (h * 31 + c.charCodeAt(0)) >>> 0;
+ return `sg-${date}-${slot}-${h.toString(36)}`;
+}
+const ref = it => ({ pattern: it.pattern || it.title || 'Untitled', image: it.image_hi || it.image || '', url: it.url || '' });
+
+// Build the four ideas for `date` from the catalog `items`.
+function buildIdeas(date, items, createdISO) {
+ const pick = (arr, n) => arr.slice(0, n);
+ // vendor with the most fresh arrivals → the "spotlight" of the day
+ const byVendor = {};
+ for (const it of items) { const v = it.vendor || 'Assorted'; (byVendor[v] ||= []).push(it); }
+ const topVendor = Object.entries(byVendor).sort((a, b) => b[1].length - a[1].length)[0] || ['Assorted', items];
+ // highest-priced fresh arrival → the "statement piece"
+ const priced = items.filter(it => Number(it.price) > 0).sort((a, b) => Number(b.price) - Number(a.price));
+ const statement = priced[0] || items[0] || null;
+
+ const ideas = [];
+ const push = (slot, o) => ideas.push({
+ id: makeId(date, slot, o.seed), date, slot, created_at: createdISO,
+ kind: o.kind, format: o.format, url: o.url || '',
+ title: o.title, idea: o.idea, hero_image: o.hero || '', refs: (o.refs || []).map(ref),
+ used: false, used_at: null, used_where: null,
+ });
+
+ // 1 — the flagship weekly drop reel (9:16)
+ if (items.length) push(1, {
+ seed: 'drop:' + date, kind: 'new-arrivals-reel', format: '9:16',
+ title: `New Arrivals reel — the ${date} drop`,
+ idea: `A 9:16 Instagram reel of this week's freshest 6–8 arrivals, varied by color & scale. Lead with the most striking image, jewel-tone first. Ready-to-post DW caption.`,
+ hero: (items[0].image_hi || items[0].image), refs: pick(items, 6),
+ });
+ // 2 — vendor spotlight, square feed grid (1:1)
+ if (topVendor[1].length) push(2, {
+ seed: 'vendor:' + topVendor[0] + ':' + date, kind: 'new-arrivals-reel', format: '1:1',
+ title: `Feed grid — ${topVendor[0]} spotlight`,
+ idea: `A 1:1 feed post spotlighting ${topVendor[0]} (${topVendor[1].length} new this drop). Tight 4–6 product montage, brand-forward. Caption names the house.`,
+ hero: (topVendor[1][0].image_hi || topVendor[1][0].image), refs: pick(topVendor[1], 6),
+ });
+ // 3 — collection tour straight off the live storefront URL (9:16)
+ push(3, {
+ seed: 'tour:' + date, kind: 'url-reel', format: '9:16', url: NA_URL,
+ title: 'Collection tour — New Arrivals page',
+ idea: `A 9:16 promo tour built from the live New Arrivals collection page (${NA_URL}). Cinematic scroll of the whole drop, DW wordmark open/close.`,
+ hero: (items[0] && (items[0].image_hi || items[0].image)) || '', refs: pick(items, 4),
+ });
+ // 4 — statement-piece spotlight from its own product URL (9:16)
+ if (statement) push(4, {
+ seed: 'statement:' + (statement.url || statement.pattern) + ':' + date, kind: 'url-reel', format: '9:16', url: statement.url,
+ title: `Statement piece — ${statement.pattern}`,
+ idea: `A 9:16 hero spotlight of "${statement.pattern}"${statement.vendor ? ' by ' + statement.vendor : ''}${Number(statement.price) ? ` ($${statement.price})` : ''} — the drop's showpiece. Slow push-in on the mural, one-line hook.`,
+ hero: (statement.image_hi || statement.image), refs: [statement],
+ });
+ return ideas;
+}
+
+(async () => {
+ const na = await readJson(NA_FILE, { items: [] });
+ const items = na.items || [];
+ if (!items.length) { console.log('suggest-videos: no New Arrivals in the feed — nothing to suggest.'); return; }
+
+ const all = await readJson(OUT_FILE, []);
+ const date = ptDate();
+ const createdISO = new Date().toISOString();
+ const todays = all.filter(s => s.date === date);
+
+ if (FORCE) {
+ // drop today's UNUSED ideas, keep anything already used, then rebuild
+ const keep = all.filter(s => s.date !== date || s.used);
+ const fresh = buildIdeas(date, items, createdISO).filter(n => !keep.some(k => k.slot === n.slot && k.date === date));
+ const next = [...keep, ...fresh];
+ await writeFile(OUT_FILE, JSON.stringify(next, null, 2));
+ console.log(`suggest-videos: [force] ${date} → ${fresh.length} regenerated, ${next.length} total kept.`);
+ return;
+ }
+ if (todays.length >= 4) { console.log(`suggest-videos: ${date} already has ${todays.length} suggestions — no-op.`); return; }
+
+ // top up today's slots that don't exist yet
+ const have = new Set(todays.map(s => s.slot));
+ const add = buildIdeas(date, items, createdISO).filter(n => !have.has(n.slot));
+ if (!add.length) { console.log(`suggest-videos: ${date} nothing to add.`); return; }
+ const next = [...all, ...add];
+ await writeFile(OUT_FILE, JSON.stringify(next, null, 2));
+ console.log(`suggest-videos: ${date} +${add.length} suggestion(s) → ${next.length} total (all history retained).`);
+})();
diff --git a/server.js b/server.js
index 7518390..1bb0408 100644
--- a/server.js
+++ b/server.js
@@ -55,6 +55,43 @@ async function readJson(fp, fallback) {
return existsSync(fp) ? JSON.parse(await readFile(fp, 'utf8')) : fallback;
}
+// Build + validate a Video Studio job from a request body. Shared by the manual
+// /api/video/queue door AND the "Use it" action on a daily suggestion, so both paths
+// validate identically. Returns { job } on success or { error, status } on rejection.
+const VIDEO_KINDS = ['new-arrivals-reel', 'url-reel', 'newsletter-video'];
+function buildVideoJob(b) {
+ const kind = String(b.kind || '').trim();
+ if (!VIDEO_KINDS.includes(kind)) return { error: 'unknown kind', status: 400 };
+ let url = '', eml = '';
+ if (kind === 'url-reel') {
+ try { const u = new URL(String(b.url || '')); if (u.protocol !== 'https:') throw 0; url = u.href.slice(0, 400); }
+ catch { return { error: 'url must be a valid https:// URL', status: 400 }; }
+ }
+ if (kind === 'newsletter-video') {
+ eml = String(b.eml || '').trim();
+ if (eml.includes('..') || !/^(~|\/)[\w .\/-]{1,380}\.eml$/.test(eml))
+ return { error: 'eml must be an absolute or ~ path ending in .eml', status: 400 };
+ }
+ const job = { id: 'vj-' + Date.now().toString(36), kind, url, eml,
+ format: b.format === '1:1' ? '1:1' : '9:16', status: 'queued', created_at: new Date().toISOString() };
+ return { job };
+}
+// Persist a job to the queue (respecting the pending-jobs cap) and best-effort nudge the
+// runner (which no-ops unless VIDEO_AUTORUN=1). Returns { job } or { error, status }.
+async function enqueueVideoJob(job) {
+ const fp = join(ROOT, 'data', 'video-jobs.json');
+ const jobs = await readJson(fp, []);
+ if (jobs.filter(j => j.status === 'queued' || j.status === 'running').length >= 20)
+ return { error: 'too many pending jobs — clear the queue first', status: 429 };
+ jobs.unshift(job);
+ await writeFile(fp, JSON.stringify(jobs, null, 2));
+ 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 { job };
+}
+
async function serveFile(fp, req, res) {
const st = await stat(fp);
const type = MIME[extname(fp)] || 'application/octet-stream';
@@ -152,35 +189,48 @@ const server = http.createServer(async (req, res) => {
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' }));
// Validate at the door — these strings feed a headless skill prompt when armed.
- let url = '', eml = '';
- if (kind === 'url-reel') {
- try { const u = new URL(String(b.url || '')); if (u.protocol !== 'https:') throw 0; url = u.href.slice(0, 400); }
- catch { return res.writeHead(400).end(JSON.stringify({ error: 'url must be a valid https:// URL' })); }
- }
- if (kind === 'newsletter-video') {
- eml = String(b.eml || '').trim();
- if (eml.includes('..') || !/^(~|\/)[\w .\/-]{1,380}\.eml$/.test(eml))
- return res.writeHead(400).end(JSON.stringify({ error: 'eml must be an absolute or ~ path ending in .eml' }));
- }
- const fp = join(ROOT, 'data', 'video-jobs.json');
- const jobs = await readJson(fp, []);
- if (jobs.filter(j => j.status === 'queued' || j.status === 'running').length >= 20)
- return res.writeHead(429).end(JSON.stringify({ error: 'too many pending jobs — clear the queue first' }));
- const job = { id: 'vj-' + Date.now().toString(36), kind, url, eml,
- 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 }));
+ const built = buildVideoJob(await readBody(req));
+ if (built.error) return res.writeHead(built.status).end(JSON.stringify({ error: built.error }));
+ const q = await enqueueVideoJob(built.job);
+ if (q.error) return res.writeHead(q.status).end(JSON.stringify({ error: q.error }));
+ return res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ queued: q.job }));
+ }
+ // ── Suggestions ("hypergen"): the 4-a-day video ideas, dated + kept forever.
+ // GET → the full dated history (newest day first). Public/read-only deploys see this too.
+ if (p === '/api/video/suggestions') {
+ const fp = join(ROOT, 'data', 'video-suggestions.json');
+ const list = await readJson(fp, []);
+ list.sort((a, b) => (b.date + '#' + b.slot).localeCompare(a.date + '#' + a.slot));
+ return res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(list));
+ }
+ // POST /api/video/suggest → run the daily generator now (fills today to 4). Gated like build.
+ if (p === '/api/video/suggest' && req.method === 'POST') {
+ if (!ALLOW_BUILD) return res.writeHead(400, { 'content-type': 'application/json' })
+ .end(JSON.stringify({ error: 'Suggestions are generated on the studio host.' }));
+ const b = await readBody(req).catch(() => ({}));
+ return run('suggest-videos.mjs', res, b && b.force ? ['--force'] : []);
+ }
+ // POST /api/video/suggestions/use → queue the render for a suggestion AND date-stamp it
+ // (used_at = WHEN, used_where = WHERE: the job it became). Body: { id }.
+ if (p === '/api/video/suggestions/use' && 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.' }));
+ const { id } = await readBody(req);
+ const fp = join(ROOT, 'data', 'video-suggestions.json');
+ const list = await readJson(fp, []);
+ const s = list.find(x => x.id === id);
+ if (!s) return res.writeHead(404).end(JSON.stringify({ error: 'suggestion not found' }));
+ const built = buildVideoJob({ kind: s.kind, format: s.format, url: s.url });
+ if (built.error) return res.writeHead(built.status).end(JSON.stringify({ error: built.error }));
+ const q = await enqueueVideoJob(built.job);
+ if (q.error) return res.writeHead(q.status).end(JSON.stringify({ error: q.error }));
+ // stamp the suggestion: when + where it was used. Kept even if regenerated later.
+ s.used = true;
+ s.used_at = new Date().toISOString();
+ s.used_where = { jobId: q.job.id, kind: q.job.kind, format: q.job.format, surface: 'video-studio-queue' };
+ await writeFile(fp, JSON.stringify(list, null, 2));
+ return res.writeHead(202, { 'content-type': 'application/json' }).end(JSON.stringify({ queued: q.job, suggestion: s }));
}
// list every rendered mp4 (reels/ + videos/**/*.mp4 from the HyperFrames pipelines)
if (p === '/api/videos') {
← aea6ea1 auto-save: 2026-07-21T07:36:20 (2 files) — data/new-arrivals
·
back to Dw Marketing Reels
·
auto-save: 2026-07-22T07:12:00 (3 files) — data/new-arrivals c0278de →