[object Object]

← 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

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