← 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
M index.jsA lib/catalog.jsM lib/plan.jsM lib/render.js
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(/'|'/g, "'").replace(/&/g, '&').replace(/"/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 →