[object Object]

← back to Dw Slideshow Gen

catalog feed: dw_unified -> queue with tag-derived copy + product-photo backgrounds (spotlight/roundup)

ec89b75ede05ff079f1a5885bf7c891567bcf8c5 · 2026-06-29 10:01:51 -0700 · Steve Abrams

Files touched

Diff

commit ec89b75ede05ff079f1a5885bf7c891567bcf8c5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 29 10:01:51 2026 -0700

    catalog feed: dw_unified -> queue with tag-derived copy + product-photo backgrounds (spotlight/roundup)
---
 index.js       |  25 ++++++++++
 lib/catalog.js | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/plan.js    |   7 +--
 lib/render.js  |  10 ++--
 4 files changed, 182 insertions(+), 6 deletions(-)

diff --git a/index.js b/index.js
index 147c26c..432f75a 100644
--- a/index.js
+++ b/index.js
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'
 import { loadDesign } from './lib/design.js'
 import { buildPlan } from './lib/plan.js'
 import { renderSpec } from './lib/render.js'
+import { buildCatalogQueue } from './lib/catalog.js'
 import { readJSON, writeJSON, ensureDir, slugify, today } from './lib/util.js'
 
 const ROOT = path.dirname(fileURLToPath(import.meta.url))
@@ -23,6 +24,12 @@ function parseArgs(argv) {
     else if (t === '--model') a.model = argv[++i]
     else if (t === '--bg') a.bg = argv[++i]
     else if (t === '--out') a.out = argv[++i]
+    else if (t === '--vendor') a.vendor = argv[++i]
+    else if (t === '--type') a.type = argv[++i]
+    else if (t === '--limit') a.limit = argv[++i]
+    else if (t === '--since') a.since = argv[++i]
+    else if (t === '--mode') a.mode = argv[++i]
+    else if (t === '--no-photos') a.photos = false
     else a._.push(t)
   }
   return a
@@ -53,6 +60,24 @@ if (!cmd || cmd === 'help' || cmd === '-h') {
   process.exit(0)
 }
 
+if (cmd === 'catalog') {
+  const design = resolveDesign(a)
+  const bgDir = path.join(OUT, 'bg')
+  console.error(`  [catalog] querying dw_unified — mode=${a.mode || 'spotlight'} vendor=${a.vendor || 'any'} type=${a.type || 'any'} limit=${a.limit || 6}`)
+  const items = await buildCatalogQueue({
+    vendor: a.vendor, type: a.type, limit: a.limit, since: a.since,
+    mode: a.mode || 'spotlight', bgDir, photos: a.photos !== false,
+  })
+  const queuePath = a.out || path.join(OUT, `queue-catalog-${today()}.json`)
+  writeJSON(queuePath, { queue_type: 'dw-marketing', source: 'dw_unified', generated_at: new Date().toISOString(), items })
+  console.error(`  [catalog] ${items.length} item(s) -> ${queuePath}  (photos: $0 download, render: $0 local)`)
+  const specs = buildPlan({ items }, design, { ai: a.ai, model: a.model })
+  const planPath = path.join(OUT, `plan-${today()}.json`)
+  writeJSON(planPath, { generated_at: new Date().toISOString(), project: design.brand, specs })
+  await renderAll(specs, design, a)
+  process.exit(0)
+}
+
 if (cmd === 'plan' || cmd === 'all') {
   const queuePath = a._[0]
   if (!queuePath || !fs.existsSync(queuePath)) { console.error('need a queue.json path'); process.exit(1) }
diff --git a/lib/catalog.js b/lib/catalog.js
new file mode 100644
index 0000000..7cd8849
--- /dev/null
+++ b/lib/catalog.js
@@ -0,0 +1,146 @@
+// lib/catalog.js — build a slideshow queue straight from the live dw_unified catalog.
+// Shells out to the local passwordless `psql -d dw_unified` (no `pg` dependency),
+// parses each product's tags into facets, writes real editorial body copy, and
+// downloads the lead product photo to use as a slide background.
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import { ensureDir, slugify, titleCase, cap } from './util.js'
+
+function psqlJSON(sql) {
+  const out = execFileSync('psql', ['-d', 'dw_unified', '-tAc', sql],
+    { encoding: 'utf8', maxBuffer: 1 << 26 }).trim()
+  return out ? JSON.parse(out) : []
+}
+
+// ---- tag -> facets ---------------------------------------------------------
+const STYLES = ['Chinoiserie', 'Damask', 'Toile', 'Botanical', 'Floral', 'Geometric',
+  'Traditional', 'Modern', 'Contemporary', 'Art Deco', 'Grasscloth', 'Cottagecore',
+  'Animal', 'Stripe', 'Ikat', 'Trellis', 'Abstract', 'Tropical', 'Moroccan']
+const MATERIALS = ['Grasscloth', 'Raffia', 'Linen', 'Silk', 'Cork', 'Vinyl', 'Mylar',
+  'Non Woven', 'Sisal', 'Paperweave', 'Mica', 'Leather', 'Wool', 'Cotton', 'Velvet', 'Suede']
+const ROOMS = ['Bedroom', 'Dining Room', 'Living Room', 'Powder Room', 'Bathroom',
+  'Entry', 'Office', 'Nursery', 'Hallway', 'Kitchen']
+
+const decode = s => String(s || '')
+  .replace(/&#x27;|&#39;/g, "'").replace(/&amp;/g, '&').replace(/&quot;/g, '"').trim()
+
+function facets(tagStr) {
+  const tags = decode(tagStr).split(',').map(t => t.trim()).filter(Boolean)
+  const f = { color: null, style: [], material: null, rooms: [], match: null }
+  for (const t of tags) {
+    const m = t.match(/^colou?r\s*[:=]\s*(.+)/i)
+    if (m && !f.color) { f.color = titleCase(m[1].trim()); continue }
+    if (/(straight|half\s*drop|random|free)\s*match/i.test(t)) f.match = t
+    for (const s of STYLES) if (t.toLowerCase() === s.toLowerCase() && !f.style.includes(s)) f.style.push(s)
+    for (const mat of MATERIALS) if (new RegExp(mat, 'i').test(t) && !f.material) f.material = t
+    for (const r of ROOMS) if (t.toLowerCase() === r.toLowerCase() && !f.rooms.includes(r)) f.rooms.push(r)
+  }
+  return f
+}
+
+function patternName(title) {
+  let t = decode(title).split('|')[0].trim()      // drop "| Vendor"
+  t = t.split(/\s+[-–]\s+/)[0].trim()             // drop "- colorway/desc"
+  t = t.replace(/\b(Wallcovering|Wallpaper|Fabric)\b\s*$/i, '').trim()
+  return t || decode(title)
+}
+
+function factsFromFacets(f, type) {
+  const out = []
+  if (f.material) out.push({ title: 'The texture', subtitle: cap(`${titleCase(f.material)} brings real depth to the wall — texture a flat paint can't touch.`, 130) })
+  if (f.color)    out.push({ title: 'The colorway', subtitle: cap(`Shown in ${f.color} — a tone that reads warm and intentional in any light.`, 130) })
+  if (f.style.length) out.push({ title: 'The look', subtitle: cap(`${titleCase(f.style.slice(0, 2).join(' · '))} — a pattern that anchors a room without shouting.`, 130) })
+  if (f.rooms.length) out.push({ title: 'Where it works', subtitle: cap(`Designers reach for it in the ${f.rooms.slice(0, 2).join(' and ').toLowerCase()}.`, 130) })
+  if (f.match)    out.push({ title: 'Easy install', subtitle: cap(`${titleCase(f.match)} for a clean, professional hang.`, 130) })
+  return out.slice(0, 4)
+}
+
+async function download(url, dest) {
+  const res = await fetch(url, { redirect: 'follow' })
+  if (!res.ok) throw new Error(`HTTP ${res.status}`)
+  const buf = Buffer.from(await res.arrayBuffer())
+  ensureDir(path.dirname(dest)); fs.writeFileSync(dest, buf)
+  return dest
+}
+
+// ---- queue builders --------------------------------------------------------
+export async function buildCatalogQueue(opts = {}) {
+  const { vendor, type, limit = 6, since, mode = 'spotlight', bgDir, photos = true } = opts
+  const where = [
+    "status='active'", 'online_store_published is true',
+    "image_url like 'http%'",
+    'tags is not null', 'length(tags) > 10',  // tags drive the slide copy; mirror lacks body_html
+  ]
+  if (vendor) where.push(`vendor = '${vendor.replace(/'/g, "''")}'`)
+  if (type)   where.push(`product_type = '${type.replace(/'/g, "''")}'`)
+  if (since)  where.push(`created_at_shopify >= '${since.replace(/'/g, "''")}'`)
+  const sql = `select json_agg(t) from (
+    select id, title, vendor, product_type, tags, image_url, dw_sku,
+           created_at_shopify::date as created
+    from shopify_products
+    where ${where.join(' and ')}
+    order by created_at_shopify desc nulls last
+    limit ${Number(limit) || 6}
+  ) t`
+  const rows = psqlJSON(sql) || []
+  if (!rows.length) throw new Error('no matching catalog products')
+
+  if (mode === 'roundup') return [await buildRoundup(rows, opts)]
+  // spotlight: one carousel per product
+  const items = []
+  for (const r of rows) {
+    const f = facets(r.tags)
+    const pat = patternName(r.title)
+    let body = factsFromFacets(f, r.product_type)
+    const backgrounds = {}
+    if (photos) {
+      try {
+        const dest = path.join(bgDir, `${r.id}.jpg`)
+        await download(r.image_url, dest)
+        backgrounds['1'] = dest; backgrounds['6'] = dest // photo on hook + CTA
+      } catch (e) { process.stderr.write(`  [catalog] img ${r.id} failed: ${e.message}\n`) }
+    }
+    items.push({
+      id: slugify(`${pat}-${r.dw_sku || r.id}`),
+      topic: pat,
+      angle: `${r.vendor} · ${r.product_type}`,
+      style: f.style[0] || null,
+      colorway: f.color || null,
+      room: f.rooms[0] || null,
+      slides: body.length >= 2 ? body : undefined, // else hooks.js writes generic body
+      backgrounds,
+      cta: { label: 'Shop this pattern', sub: 'designerwallcoverings.com' },
+      hashtags: dedupeTags(['#designerwallcoverings', tagify(r.vendor), tagify(f.material), tagify(f.style[0]), tagify(r.product_type)]),
+    })
+  }
+  return items
+}
+
+async function buildRoundup(rows, opts) {
+  const { bgDir, photos = true } = opts
+  const vendor = rows[0].vendor
+  const type = rows[0].product_type || 'Wallcovering'
+  const body = []
+  const backgrounds = {}
+  for (let i = 0; i < Math.min(4, rows.length); i++) {
+    const r = rows[i], f = facets(r.tags), pat = patternName(r.title)
+    body.push({ title: cap(pat, 38), subtitle: cap(`${f.color ? f.color + ' · ' : ''}${f.material ? titleCase(f.material) + ' · ' : ''}${f.style[0] || type}.`, 130) })
+    if (photos) {
+      try { const dest = path.join(bgDir, `roundup-${r.id}.jpg`); await download(r.image_url, dest); backgrounds[String(i + 2)] = dest }
+      catch {}
+    }
+  }
+  return {
+    id: slugify(`new-${vendor}-arrivals`),
+    topic: `New ${vendor} arrivals`,
+    angle: `${rows.length} just landed`,
+    slides: body,
+    backgrounds,
+    cta: { label: 'Shop new arrivals', sub: 'designerwallcoverings.com' },
+    hashtags: dedupeTags(['#designerwallcoverings', tagify(vendor), '#newarrivals', tagify(type), '#interiordesign']),
+  }
+}
+
+const tagify = s => s ? '#' + slugify(s).replace(/-/g, '') : null
+const dedupeTags = arr => [...new Set(arr.filter(Boolean))].slice(0, 5)
diff --git a/lib/plan.js b/lib/plan.js
index 086d9e1..fc8be61 100644
--- a/lib/plan.js
+++ b/lib/plan.js
@@ -7,10 +7,11 @@ import { generateCarousel } from './hooks.js'
 export function buildSpec(item, design, opts = {}) {
   const c = generateCarousel(item, design, opts)
   const topic = titleCase(item.topic || 'Wallcovering')
+  const bg = item.backgrounds || {}
   const slides = [
-    { index: 1, kind: 'hook', title: c.hook, subtitle: c.tagline },
-    ...c.body.map((b, i) => ({ index: i + 2, kind: 'body', title: b.title, subtitle: b.subtitle })),
-    { index: 6, kind: 'cta', title: c.cta.label, subtitle: c.cta.sub },
+    { index: 1, kind: 'hook', title: c.hook, subtitle: c.tagline, background: bg['1'] || null },
+    ...c.body.map((b, i) => ({ index: i + 2, kind: 'body', title: b.title, subtitle: b.subtitle, background: bg[String(i + 2)] || null })),
+    { index: 6, kind: 'cta', title: c.cta.label, subtitle: c.cta.sub, background: bg['6'] || null },
   ]
   return {
     id: slugify(item.id || item.topic || 'slideshow'),
diff --git a/lib/render.js b/lib/render.js
index 834180d..8adb185 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -175,13 +175,16 @@ function drawCTA(ctx, design, slide, spec) {
 }
 
 function resolveBg(spec, slide, bgDir) {
+  // 1. explicit per-slide / per-spec background (e.g. catalog-embedded absolute path)
+  const explicit = slide.background || spec.background
+  if (explicit && exists(explicit)) return explicit
+  // 2. directory lookup, only when --bg DIR was given
   if (!bgDir) return null
   const cands = [
-    spec.background, slide.background,
     `slide_${slide.index}.jpg`, `slide_${slide.index}.png`,
     `${slide.index}.jpg`, `${slide.index}.png`,
     'bg.jpg', 'bg.png',
-  ].filter(Boolean)
+  ]
   for (const c of cands) {
     const p = path.isAbsolute(c) ? c : path.join(bgDir, c)
     if (exists(p)) return p
@@ -210,7 +213,8 @@ export async function renderSpec(spec, design, outDir, opts = {}) {
   const manifest = {
     spec_id: spec.id, project: spec.project, slides: pngs.length,
     width: W, height: H, render_mode: opts.bg ? 'photo+scrim' : 'gradient',
-    copy_source: spec.copy_source, png_paths: pngs, rendered_at: new Date().toISOString(),
+    copy_source: spec.copy_source, caption: spec.caption, hashtags: spec.hashtags,
+    png_paths: pngs, rendered_at: new Date().toISOString(),
   }
   writeJSON(path.join(outDir, 'render-manifest.json'), manifest)
   return manifest

← 92f0a35 dw-slideshow-gen: DW marketing slideshow engine (plan+render  ·  back to Dw Slideshow Gen  ·  post leg: fail-closed TikTok draft package (zip+caption+inst d59b3ab →