[object Object]

← back to Dw Slideshow Gen

dw-slideshow-gen: DW marketing slideshow engine (plan+render, $0 canvas, Didot/brass brand)

92f0a35ba5172418f756ab7c9aa52377f766cf82 · 2026-06-29 09:45:30 -0700 · Steve Abrams

Files touched

Diff

commit 92f0a35ba5172418f756ab7c9aa52377f766cf82
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 29 09:45:30 2026 -0700

    dw-slideshow-gen: DW marketing slideshow engine (plan+render, $0 canvas, Didot/brass brand)
---
 .gitignore         |   8 ++
 DESIGN.md          |  20 ++++
 README.md          |  45 +++++++++
 index.js           |  90 ++++++++++++++++++
 lib/design.js      |  71 ++++++++++++++
 lib/hooks.js       | 115 +++++++++++++++++++++++
 lib/plan.js        |  34 +++++++
 lib/render.js      | 217 +++++++++++++++++++++++++++++++++++++++++++
 lib/util.js        |  40 ++++++++
 package-lock.json  | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json       |  16 ++++
 samples/queue.json |  31 +++++++
 12 files changed, 954 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b661cdb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+output/
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..4b8eab2
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,20 @@
+# Designer Wallcoverings — Slideshow Brand
+
+The single source of truth for slideshow look + voice. The generator reads these
+fields (tolerant key:value parsing); anything omitted falls back to the DW house
+defaults baked into `lib/design.js`.
+
+- **Brand:** Designer Wallcoverings
+- **URL:** designerwallcoverings.com
+- **Accent:** #C2A45A        <!-- muted brass/gold -->
+- **Ink:** #F5F0E6           <!-- warm cream text -->
+- **BG:** #1C1B19            <!-- warm charcoal, top of gradient -->
+- **BG bottom:** #2A2723
+- **CTA:** Shop the collection
+- **Voice:** editorial, calm, design-forward — speaks to interior designers and discerning homeowners
+- **Hashtags:** designerwallcoverings wallcovering interiordesign wallpaper homedecor
+
+Type: **Didot** display serif (fashion-magazine luxe) over **Avenir** body.
+Slides render at 1080×1920 (TikTok/IG vertical). Slide 1 = hook, 2–5 = body,
+6 = CTA. Backgrounds default to a brass-glow charcoal gradient; pass `--bg DIR`
+to drop in real product photography with an automatic legibility scrim.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2e294f2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,45 @@
+# dw-slideshow-gen
+
+Designer Wallcoverings marketing **slideshow generator** — turn a marketing topic
+or product into a 6-slide, 1080×1920 TikTok/Instagram photo-carousel as PNGs.
+**Zero image-gen cost**: slides are drawn headless with `@napi-rs/canvas` (no
+browser, no OpenAI), deterministic and on-brand.
+
+## Why this shape
+- **6 slides:** slide 1 = hook, 2–5 = substantive body, 6 = CTA — the proven
+  TikTok carousel structure (slide 1 is the whole ballgame).
+- **Brand from `DESIGN.md`:** fonts (Didot display / Avenir body), warm-charcoal
+  ground, brass accent, cream type, hashtags — one source of truth.
+- **`renderSpec()` is server-callable:** the same function a CLI uses here can be
+  called from a marketing endpoint later to serve PNGs. No rewrite.
+
+## Use
+```bash
+node index.js all samples/queue.json          # plan + render (template copy)
+node index.js plan  samples/queue.json         # -> output/plan-<date>.json
+node index.js render output/plan-<date>.json   # -> output/renders/<id>/slide_N.png
+```
+Flags:
+- `--ai` — generate slide copy via headless Claude Code (`claude -p`, reuses the
+  Max-plan login, **no metered API key**). Falls back to the template generator
+  on any failure.
+- `--bg DIR` — use real product photography as slide backgrounds
+  (`slide_N.jpg` per slide or one `bg.jpg`) with an automatic legibility scrim.
+- `--design FILE` — point at a different `DESIGN.md`.
+
+## Queue shape
+```json
+{ "items": [ {
+  "id": "grasscloth-moody-dining",
+  "topic": "Grasscloth wallcoverings",
+  "angle": "a moody dining room",
+  "style": "Organic Modern", "colorway": "Charcoal & Flax", "room": "dining room",
+  "facts": ["Title: one substantive sentence.", "..."],   // one per body slide (≤4)
+  "hashtags": ["#grasscloth", "..."]                        // optional, ≤5
+} ] }
+```
+If `facts` is omitted the generator writes on-brand body slides from the topic.
+
+## Cost
+Render: **$0 (local)** — no image-gen API. Optional `--ai` copy uses the existing
+Claude Code login (no separate billing). `output/` is gitignored.
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..147c26c
--- /dev/null
+++ b/index.js
@@ -0,0 +1,90 @@
+#!/usr/bin/env node
+// dw-slideshow — Designer Wallcoverings marketing slideshow generator.
+//   plan   <queue.json> [--design DESIGN.md] [--ai] [--model M]   -> specs JSON
+//   render <plan.json>  [--design DESIGN.md] [--bg DIR]           -> 6x PNG/spec
+//   all    <queue.json> [--design ...] [--ai] [--bg DIR]          -> plan + render
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { loadDesign } from './lib/design.js'
+import { buildPlan } from './lib/plan.js'
+import { renderSpec } from './lib/render.js'
+import { readJSON, writeJSON, ensureDir, slugify, today } from './lib/util.js'
+
+const ROOT = path.dirname(fileURLToPath(import.meta.url))
+const OUT = path.join(ROOT, 'output')
+
+function parseArgs(argv) {
+  const a = { _: [] }
+  for (let i = 0; i < argv.length; i++) {
+    const t = argv[i]
+    if (t === '--ai') a.ai = true
+    else if (t === '--design') a.design = argv[++i]
+    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 a._.push(t)
+  }
+  return a
+}
+
+function resolveDesign(a) {
+  const candidates = [a.design, path.join(ROOT, 'DESIGN.md')].filter(Boolean)
+  const found = candidates.find(c => fs.existsSync(c)) || null
+  const d = loadDesign(found)
+  if (!d.found) process.stderr.write('  [design] no DESIGN.md found — using DW house defaults\n')
+  else process.stderr.write(`  [design] loaded ${d.source}\n`)
+  return d
+}
+
+const cmd = process.argv[2]
+const a = parseArgs(process.argv.slice(3))
+
+if (!cmd || cmd === 'help' || cmd === '-h') {
+  console.log(`dw-slideshow — DW marketing slideshow carousels (6x 1080x1920 PNG, $0 render)
+
+  plan   <queue.json> [--design F] [--ai] [--model M]   write specs JSON
+  render <plan.json>  [--design F] [--bg DIR]           render PNGs for each spec
+  all    <queue.json> [--design F] [--ai] [--bg DIR]    plan then render
+
+  --ai      generate copy via headless Claude Code (Max-plan login, no API key)
+  --bg DIR  use photos as slide backgrounds (slide_N.jpg | bg.jpg) with a scrim
+  output -> output/plan-<date>.json and output/renders/<id>/slide_N.png`)
+  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) }
+  const design = resolveDesign(a)
+  const queue = readJSON(queuePath)
+  const specs = buildPlan(queue, design, { ai: a.ai, model: a.model })
+  const planPath = a.out || path.join(OUT, `plan-${today()}.json`)
+  writeJSON(planPath, { generated_at: new Date().toISOString(), project: design.brand, specs })
+  console.error(`  [plan] ${specs.length} slideshow(s) -> ${planPath}  (copy: ${specs[0]?.copy_source})`)
+  if (cmd === 'plan') { console.log(planPath); process.exit(0) }
+  // fall through to render
+  await renderAll(specs, design, a)
+} else if (cmd === 'render') {
+  const planPath = a._[0]
+  if (!planPath || !fs.existsSync(planPath)) { console.error('need a plan.json path'); process.exit(1) }
+  const design = resolveDesign(a)
+  const plan = readJSON(planPath)
+  const specs = plan.specs || plan
+  await renderAll(specs, design, a)
+} else {
+  console.error(`unknown command: ${cmd} (try: help)`); process.exit(1)
+}
+
+async function renderAll(specs, design, a) {
+  const base = path.join(OUT, 'renders')
+  ensureDir(base)
+  let total = 0
+  for (const spec of specs) {
+    const dir = path.join(base, `${slugify(spec.id)}-${today()}`)
+    const m = await renderSpec(spec, design, dir, { bg: a.bg })
+    total += m.slides
+    console.error(`  [render] ${spec.id}: ${m.slides} slides -> ${dir}  ($0 local)`)
+  }
+  console.error(`  [render] done — ${total} PNG(s) total. $0 (local, no image-gen API).`)
+}
diff --git a/lib/design.js b/lib/design.js
new file mode 100644
index 0000000..43ecc4c
--- /dev/null
+++ b/lib/design.js
@@ -0,0 +1,71 @@
+// lib/design.js — read a project DESIGN.md into brand tokens, with DW-luxe defaults.
+// Tolerant parser: every field has a default, and `found:false` is returned when no
+// DESIGN.md is present so callers can warn. One source of truth for fonts/colors/logo
+// across every slideshow.
+import fs from 'node:fs'
+import { exists } from './util.js'
+
+// Designer Wallcoverings house defaults — warm-charcoal ground, cream type, brass accent,
+// Didot display serif (fashion-magazine luxe) over Avenir body.
+export const DW_DEFAULTS = {
+  brand: 'Designer Wallcoverings',
+  kicker: 'DESIGNER WALLCOVERINGS',
+  url: 'designerwallcoverings.com',
+  // fonts: family alias -> font file we register at render time
+  fonts: {
+    display: { family: 'Didot', path: '/System/Library/Fonts/Supplemental/Didot.ttc' },
+    body:    { family: 'Avenir', path: '/System/Library/Fonts/Avenir.ttc' },
+  },
+  colors: {
+    bgTop: '#1C1B19',   // warm charcoal
+    bgBot: '#2A2723',
+    ink:   '#F5F0E6',   // warm cream
+    accent:'#C2A45A',   // muted brass/gold
+    muted: '#B9B2A4',   // soft taupe for subtitles
+  },
+  cta: { label: 'Shop the collection', sub: 'designerwallcoverings.com' },
+  voice: 'editorial, calm, design-forward — speaks to interior designers and discerning homeowners',
+  hashtags: ['#designerwallcoverings', '#wallcovering', '#interiordesign', '#wallpaper', '#homedecor'],
+}
+
+// extremely tolerant key:value-ish extractor from a DESIGN.md
+function pull(md, keys) {
+  for (const k of keys) {
+    const re = new RegExp(`(?:^|\\n)\\s*(?:[-*]\\s*)?\\*{0,2}${k}\\*{0,2}\\s*[:=]\\s*(.+)`, 'i')
+    const m = md.match(re)
+    if (m) return m[1]
+      .replace(/<!--.*?-->/g, '')   // strip trailing HTML comments
+      .replace(/\*/g, '')           // strip markdown bold markers
+      .trim()
+      .replace(/^["'`]|["'`]$/g, '')
+  }
+  return null
+}
+
+export function loadDesign(designPath) {
+  const d = structuredClone(DW_DEFAULTS)
+  if (!designPath || !exists(designPath)) return { ...d, found: false, source: null }
+  const md = fs.readFileSync(designPath, 'utf8')
+
+  const brand  = pull(md, ['brand', 'brand name', 'name'])
+  const url    = pull(md, ['url', 'website', 'domain'])
+  const accent = pull(md, ['accent', 'accent color', 'primary'])
+  const ink    = pull(md, ['ink', 'text color', 'foreground'])
+  const bgTop  = pull(md, ['bg', 'background', 'bg top', 'background top'])
+  const bgBot  = pull(md, ['bg bottom', 'background bottom'])
+  const cta    = pull(md, ['cta', 'call to action', 'cta label'])
+  const voice  = pull(md, ['voice', 'tone'])
+  const tags   = pull(md, ['hashtags', 'tags'])
+
+  if (brand) { d.brand = brand; d.kicker = brand.toUpperCase() }
+  if (url)   { d.url = url; d.cta.sub = url }
+  if (accent) d.colors.accent = accent
+  if (ink)    d.colors.ink = ink
+  if (bgTop)  d.colors.bgTop = bgTop
+  if (bgBot)  d.colors.bgBot = bgBot || bgTop
+  if (cta)    d.cta.label = cta
+  if (voice)  d.voice = voice
+  if (tags)   d.hashtags = tags.split(/[,\s]+/).filter(Boolean).map(t => t.startsWith('#') ? t : '#' + t).slice(0, 5)
+
+  return { ...d, found: true, source: designPath }
+}
diff --git a/lib/hooks.js b/lib/hooks.js
new file mode 100644
index 0000000..9991299
--- /dev/null
+++ b/lib/hooks.js
@@ -0,0 +1,115 @@
+// lib/hooks.js — turn a queue item into slide COPY.
+//   Default: deterministic template generator (zero-dep, always works).
+//   Optional: AI mode via headless Claude Code (`claude -p`), reusing the Max-plan
+//             login (no metered API key). Falls back to template on any failure.
+import { execFileSync } from 'node:child_process'
+import { titleCase, cap } from './util.js'
+
+// stable pseudo-random pick keyed by a string, so output is varied but reproducible
+const pick = (arr, key) => {
+  let h = 0
+  for (const c of String(key)) h = (h * 31 + c.charCodeAt(0)) >>> 0
+  return arr[h % arr.length]
+}
+
+// ---- template copy ---------------------------------------------------------
+function templateHook(item) {
+  const topic = titleCase(item.topic || 'This wallcovering')
+  const nFacts = (item.facts || []).length
+  const cands = []
+  if (nFacts >= 3) cands.push(`${nFacts} ${topic} ideas worth saving`)
+  if (item.style)  cands.push(`The ${titleCase(item.style)} wall, reimagined`)
+  if (item.room)   cands.push(`${topic} for the ${item.room}`)
+  if (item.angle)  cands.push(`${topic} for ${item.angle}`)
+  cands.push(
+    `${topic}, done right`,
+    `Why designers reach for ${topic.toLowerCase()}`,
+    `Save this for your next ${item.room || 'project'}`,
+  )
+  return cap(pick(cands, topic + (item.angle || '')), 52)
+}
+
+function templateBody(item) {
+  const topic = (item.topic || 'this wallcovering').toLowerCase()
+  // explicit body wins
+  if (Array.isArray(item.slides) && item.slides.length)
+    return item.slides.slice(0, 4).map((s, i) => ({
+      title: cap(s.title || `Slide ${i + 2}`, 38),
+      subtitle: cap(s.subtitle || '', 130),
+    }))
+  // one fact per body slide
+  if (Array.isArray(item.facts) && item.facts.length) {
+    return item.facts.slice(0, 4).map((f, i) => {
+      const m = String(f).match(/^([^.:—-]{4,38})[.:—-]\s*(.+)$/)
+      return m
+        ? { title: cap(m[1].trim(), 38), subtitle: cap(m[2].trim(), 130) }
+        : { title: cap(`${i + 1} · ${topic}`, 38), subtitle: cap(f, 130) }
+    })
+  }
+  // generic-but-on-brand angles for a wallcovering line
+  const c = item.colorway ? ` in ${item.colorway}` : ''
+  const room = item.room || 'a quiet corner'
+  return [
+    { title: 'The texture',  subtitle: cap(`Up close, ${topic} adds depth a flat paint never can${c}.`, 130) },
+    { title: 'The light',    subtitle: cap(`It shifts through the day — matte in the morning, warm at dusk.`, 130) },
+    { title: 'Where it lives', subtitle: cap(`Designers love it in ${room}, an entry, or behind the bed.`, 130) },
+    { title: 'Style it',     subtitle: cap(`Pair with natural wood, brass, and a single sculptural light.`, 130) },
+  ]
+}
+
+function templateCarousel(item, design) {
+  const topic = titleCase(item.topic || 'Wallcovering')
+  return {
+    hook: templateHook(item),
+    tagline: cap(item.angle ? titleCase(item.angle) : (item.style ? `${titleCase(item.style)} · ${design.brand}` : design.brand), 60),
+    body: templateBody(item),
+    cta: {
+      label: cap(item.cta?.label || design.cta.label, 28),
+      sub: item.cta?.sub || design.cta.sub || design.url,
+    },
+    caption: cap(item.caption || `${topic}${item.colorway ? ` in ${item.colorway}` : ''} — ${design.brand}. ${item.angle || 'Save for your next project.'}`, 200),
+    hashtags: (item.hashtags || design.hashtags).slice(0, 5),
+    copy_source: 'template',
+  }
+}
+
+// ---- optional AI copy via `claude -p` --------------------------------------
+function claudeCarousel(item, design, model) {
+  const prompt = [
+    `You are a senior social copywriter for ${design.brand} (${design.voice}).`,
+    `Write a 6-slide TikTok/Instagram photo-carousel about: "${item.topic}".`,
+    item.angle ? `Angle: ${item.angle}.` : '',
+    (item.facts || []).length ? `Use these facts: ${JSON.stringify(item.facts)}.` : '',
+    item.colorway ? `Colorway: ${item.colorway}.` : '',
+    item.room ? `Room: ${item.room}.` : '',
+    `Slide 1 is the HOOK (punchy, <52 chars, no hashtags). Slides 2-5 are TRUE body slides`,
+    `(each a short title <38 chars + one substantive sentence <130 chars — real specifics, no filler).`,
+    `Slide 6 is the CTA. Also write a caption (<200 chars) and up to 5 hashtags.`,
+    `Return ONLY minified JSON: {"hook":"","tagline":"","body":[{"title":"","subtitle":""}],"cta":{"label":"","sub":""},"caption":"","hashtags":[]}`,
+    `body must have exactly 4 items. No markdown, no prose, JSON only.`,
+  ].filter(Boolean).join(' ')
+
+  // `--` stops the model treating any leading '-' in the prompt as a flag
+  const out = execFileSync('claude', ['-p', '--model', model, '--', prompt], {
+    encoding: 'utf8', timeout: 120000, maxBuffer: 1 << 20,
+  })
+  const j = out.slice(out.indexOf('{'), out.lastIndexOf('}') + 1)
+  const parsed = JSON.parse(j)
+  if (!parsed.hook || !Array.isArray(parsed.body)) throw new Error('bad shape')
+  parsed.body = parsed.body.slice(0, 4)
+  while (parsed.body.length < 4) parsed.body.push({ title: '', subtitle: '' })
+  parsed.hashtags = (parsed.hashtags || design.hashtags).slice(0, 5)
+  parsed.cta = parsed.cta || { label: design.cta.label, sub: design.url }
+  parsed.copy_source = `claude:${model}`
+  return parsed
+}
+
+export function generateCarousel(item, design, opts = {}) {
+  if (opts.ai) {
+    try { return claudeCarousel(item, design, opts.model || 'claude-opus-4-8') }
+    catch (e) {
+      process.stderr.write(`  [hooks] AI copy failed (${e.message}) — falling back to template\n`)
+    }
+  }
+  return templateCarousel(item, design)
+}
diff --git a/lib/plan.js b/lib/plan.js
new file mode 100644
index 0000000..086d9e1
--- /dev/null
+++ b/lib/plan.js
@@ -0,0 +1,34 @@
+// lib/plan.js — assemble queue items into SlideshowSpec[].
+// A spec is the render-ready contract: 6 slides (1 hook + 4 body + 1 CTA),
+// caption + hashtags, plus provenance. Pure data; no canvas here.
+import { slugify, stamp, titleCase } from './util.js'
+import { generateCarousel } from './hooks.js'
+
+export function buildSpec(item, design, opts = {}) {
+  const c = generateCarousel(item, design, opts)
+  const topic = titleCase(item.topic || 'Wallcovering')
+  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 },
+  ]
+  return {
+    id: slugify(item.id || item.topic || 'slideshow'),
+    project: design.brand,
+    source_topic: item.topic || null,
+    angle: item.angle || null,
+    hook: c.hook,
+    slides,
+    caption: c.caption,
+    hashtags: c.hashtags,
+    copy_source: c.copy_source,
+    background: item.background || null, // optional bg image hint per spec
+    generated_at: stamp(),
+  }
+}
+
+export function buildPlan(queue, design, opts = {}) {
+  const items = Array.isArray(queue) ? queue : (queue.items || [])
+  if (!items.length) throw new Error('queue has no items')
+  return items.map(it => buildSpec(it, design, opts))
+}
diff --git a/lib/render.js b/lib/render.js
new file mode 100644
index 0000000..834180d
--- /dev/null
+++ b/lib/render.js
@@ -0,0 +1,217 @@
+// lib/render.js — draw a SlideshowSpec into 6x 1080x1920 PNGs with @napi-rs/canvas.
+// Headless (no browser), deterministic, $0/image. The SAME renderSpec() can be called
+// from a server endpoint later — it only needs (spec, design, outDir).
+import fs from 'node:fs'
+import path from 'node:path'
+import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'
+import { ensureDir, writeJSON, exists } from './util.js'
+
+const W = 1080, H = 1920, M = 96
+
+let _fontsReady = false
+function registerFonts(design) {
+  if (_fontsReady) return
+  for (const f of [design.fonts.display, design.fonts.body]) {
+    try { if (exists(f.path)) GlobalFonts.registerFromPath(f.path, f.family) } catch {}
+  }
+  _fontsReady = true
+}
+
+const hexA = (hex, a) => {
+  const h = hex.replace('#', '')
+  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
+  return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
+}
+
+// wrap `text` into <=maxLines lines that each fit maxW at the given px size
+function wrap(ctx, text, family, size, weight, maxW, maxLines) {
+  ctx.font = `${weight} ${size}px "${family}"`
+  const words = String(text).split(/\s+/).filter(Boolean)
+  const lines = []
+  let line = ''
+  for (const w of words) {
+    const t = line ? line + ' ' + w : w
+    if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w }
+    else line = t
+    if (lines.length >= maxLines) break
+  }
+  if (line && lines.length < maxLines) lines.push(line)
+  return lines
+}
+
+// shrink font until the headline fits width within maxLines
+function fitLines(ctx, text, family, weight, maxW, startPx, minPx, maxLines) {
+  for (let s = startPx; s >= minPx; s -= 4) {
+    const lines = wrap(ctx, text, family, s, weight, maxW, maxLines + 1)
+    const fits = lines.length <= maxLines &&
+      lines.every(l => (ctx.font = `${weight} ${s}px "${family}"`, ctx.measureText(l).width <= maxW))
+    if (fits) return { size: s, lines }
+  }
+  return { size: minPx, lines: wrap(ctx, text, family, minPx, weight, maxW, maxLines) }
+}
+
+function drawBackground(ctx, design, bgImg) {
+  const { colors } = design
+  if (bgImg) {
+    // cover-fit the photo, then a dark scrim for legibility
+    const r = Math.max(W / bgImg.width, H / bgImg.height)
+    const dw = bgImg.width * r, dh = bgImg.height * r
+    ctx.drawImage(bgImg, (W - dw) / 2, (H - dh) / 2, dw, dh)
+    const sc = ctx.createLinearGradient(0, 0, 0, H)
+    sc.addColorStop(0, hexA(colors.bgTop, 0.55))
+    sc.addColorStop(0.5, hexA(colors.bgTop, 0.35))
+    sc.addColorStop(1, hexA(colors.bgBot, 0.85))
+    ctx.fillStyle = sc; ctx.fillRect(0, 0, W, H)
+  } else {
+    const g = ctx.createLinearGradient(0, 0, W * 0.3, H)
+    g.addColorStop(0, colors.bgTop)
+    g.addColorStop(1, colors.bgBot)
+    ctx.fillStyle = g; ctx.fillRect(0, 0, W, H)
+    // soft brass glow, upper area
+    const rg = ctx.createRadialGradient(W * 0.72, H * 0.22, 40, W * 0.72, H * 0.22, W * 0.9)
+    rg.addColorStop(0, hexA(colors.accent, 0.16))
+    rg.addColorStop(1, hexA(colors.accent, 0))
+    ctx.fillStyle = rg; ctx.fillRect(0, 0, W, H)
+  }
+  // gentle vignette
+  const v = ctx.createRadialGradient(W / 2, H / 2, H * 0.35, W / 2, H / 2, H * 0.75)
+  v.addColorStop(0, 'rgba(0,0,0,0)')
+  v.addColorStop(1, 'rgba(0,0,0,0.28)')
+  ctx.fillStyle = v; ctx.fillRect(0, 0, W, H)
+}
+
+function drawChrome(ctx, design, idx, total) {
+  const { colors } = design
+  // kicker, top-left, letterspaced
+  ctx.fillStyle = hexA(colors.ink, 0.82)
+  ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'
+  ctx.font = `600 26px "${design.fonts.body.family}"`
+  const k = design.kicker
+  let x = M
+  for (const ch of k) { ctx.fillText(ch, x, 150); x += ctx.measureText(ch).width + 6 }
+  // counter, top-right
+  ctx.textAlign = 'right'
+  ctx.fillStyle = hexA(colors.accent, 0.95)
+  ctx.font = `600 26px "${design.fonts.body.family}"`
+  ctx.fillText(`${String(idx).padStart(2, '0')} / ${String(total).padStart(2, '0')}`, W - M, 150)
+  // hairline under chrome
+  ctx.strokeStyle = hexA(colors.ink, 0.18); ctx.lineWidth = 1
+  ctx.beginPath(); ctx.moveTo(M, 178); ctx.lineTo(W - M, 178); ctx.stroke()
+}
+
+function drawCenteredBlock(ctx, design, slide) {
+  const { colors } = design
+  const maxW = W - 2 * M
+  const isHook = slide.kind === 'hook'
+  // headline (display serif)
+  const startPx = isHook ? 124 : 108
+  const { size, lines } = fitLines(ctx, slide.title, design.fonts.display.family, '400', maxW, startPx, 52, isHook ? 4 : 3)
+  const lh = size * 1.12
+  const subLines = slide.subtitle
+    ? wrap(ctx, slide.subtitle, design.fonts.body.family, 40, '400', maxW, 4) : []
+  const subLh = 40 * 1.34
+  const blockH = lines.length * lh + (subLines.length ? 40 + subLines.length * subLh : 0)
+  let y = (H - blockH) / 2 + size
+
+  ctx.textAlign = 'center'
+  ctx.fillStyle = colors.ink
+  ctx.font = `400 ${size}px "${design.fonts.display.family}"`
+  for (const l of lines) { ctx.fillText(l, W / 2, y); y += lh }
+
+  if (isHook) {
+    // brass underline accent
+    y += 6
+    ctx.strokeStyle = colors.accent; ctx.lineWidth = 4
+    ctx.beginPath(); ctx.moveTo(W / 2 - 70, y); ctx.lineTo(W / 2 + 70, y); ctx.stroke()
+    y += 30
+  } else if (subLines.length) y += 40
+
+  if (subLines.length) {
+    ctx.fillStyle = isHook ? hexA(colors.accent, 0.95) : hexA(colors.muted, 0.95)
+    ctx.font = `400 40px "${design.fonts.body.family}"`
+    for (const l of subLines) { ctx.fillText(l, W / 2, y); y += subLh }
+  }
+}
+
+function drawCTA(ctx, design, slide, spec) {
+  const { colors } = design
+  ctx.textAlign = 'center'
+  // wordmark
+  ctx.fillStyle = colors.ink
+  const wm = fitLines(ctx, design.brand, design.fonts.display.family, '400', W - 2 * M, 96, 52, 2)
+  let y = H * 0.40
+  ctx.font = `400 ${wm.size}px "${design.fonts.display.family}"`
+  for (const l of wm.lines) { ctx.fillText(l, W / 2, y); y += wm.size * 1.1 }
+  // accent button
+  y += 40
+  const label = slide.title
+  ctx.font = `600 40px "${design.fonts.body.family}"`
+  const bw = Math.min(W - 2 * M, ctx.measureText(label).width + 96)
+  const bh = 96, bx = (W - bw) / 2
+  ctx.fillStyle = colors.accent
+  if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, y, bw, bh, 48); ctx.fill() }
+  else ctx.fillRect(bx, y, bw, bh)
+  ctx.fillStyle = colors.bgTop
+  ctx.textBaseline = 'middle'
+  ctx.fillText(label, W / 2, y + bh / 2 + 2)
+  ctx.textBaseline = 'alphabetic'
+  // url
+  y += bh + 70
+  ctx.fillStyle = hexA(colors.ink, 0.9)
+  ctx.font = `400 38px "${design.fonts.body.family}"`
+  ctx.fillText(slide.subtitle || design.url, W / 2, y)
+  // hashtags footer — auto-fit to the safe width (shrink, then drop trailing tags)
+  if (spec.hashtags?.length) {
+    const maxW = W - 2 * M
+    let tags = spec.hashtags.slice()
+    let size = 30, line = tags.join('  ')
+    const fits = s => (ctx.font = `400 ${s}px "${design.fonts.body.family}"`, ctx.measureText(line).width <= maxW)
+    while (size > 22 && !fits(size)) size -= 2
+    while (tags.length > 1 && !fits(size)) { tags = tags.slice(0, -1); line = tags.join('  ') }
+    ctx.fillStyle = hexA(colors.muted, 0.85)
+    ctx.font = `400 ${size}px "${design.fonts.body.family}"`
+    ctx.fillText(line, W / 2, H - M)
+  }
+}
+
+function resolveBg(spec, slide, bgDir) {
+  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
+  }
+  return null
+}
+
+export async function renderSpec(spec, design, outDir, opts = {}) {
+  registerFonts(design)
+  ensureDir(outDir)
+  const pngs = []
+  for (const slide of spec.slides) {
+    const canvas = createCanvas(W, H)
+    const ctx = canvas.getContext('2d')
+    const bgPath = resolveBg(spec, slide, opts.bg)
+    let bgImg = null
+    if (bgPath) { try { bgImg = await loadImage(bgPath) } catch {} }
+    drawBackground(ctx, design, bgImg)
+    drawChrome(ctx, design, slide.index, spec.slides.length)
+    if (slide.kind === 'cta') drawCTA(ctx, design, slide, spec)
+    else drawCenteredBlock(ctx, design, slide)
+    const file = path.join(outDir, `slide_${slide.index}.png`)
+    fs.writeFileSync(file, canvas.toBuffer('image/png'))
+    pngs.push(file)
+  }
+  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(),
+  }
+  writeJSON(path.join(outDir, 'render-manifest.json'), manifest)
+  return manifest
+}
diff --git a/lib/util.js b/lib/util.js
new file mode 100644
index 0000000..4fd6572
--- /dev/null
+++ b/lib/util.js
@@ -0,0 +1,40 @@
+// lib/util.js — tiny shared helpers (zero deps)
+import fs from 'node:fs'
+import path from 'node:path'
+
+export const slugify = (s = '') =>
+  String(s).toLowerCase().trim()
+    .replace(/['’"]/g, '')
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 60) || 'untitled'
+
+export const titleCase = (s = '') =>
+  String(s).replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1))
+
+// ISO-ish date string used in spec ids / output folders. We can't call Date.now()
+// in some sandboxes, but the CLI runs in a normal shell, so a plain Date is fine here.
+export const today = () => {
+  const d = new Date()
+  const p = n => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
+}
+export const stamp = () => new Date().toISOString()
+
+export const ensureDir = d => { fs.mkdirSync(d, { recursive: true }); return d }
+export const readJSON = p => JSON.parse(fs.readFileSync(p, 'utf8'))
+export const writeJSON = (p, obj) => {
+  ensureDir(path.dirname(p))
+  fs.writeFileSync(p, JSON.stringify(obj, null, 2))
+  return p
+}
+export const exists = p => { try { fs.accessSync(p); return true } catch { return false } }
+
+// cap a string to n chars on a word boundary, with an ellipsis
+export const cap = (s = '', n = 80) => {
+  s = String(s).trim()
+  if (s.length <= n) return s
+  const cut = s.slice(0, n)
+  const sp = cut.lastIndexOf(' ')
+  return (sp > n * 0.6 ? cut.slice(0, sp) : cut).replace(/[,;:.\s]+$/, '') + '…'
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f39dd29
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,267 @@
+{
+  "name": "dw-slideshow-gen",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "dw-slideshow-gen",
+      "version": "0.1.0",
+      "dependencies": {
+        "@napi-rs/canvas": "^0.1.65"
+      },
+      "bin": {
+        "dw-slideshow": "index.js"
+      }
+    },
+    "node_modules/@napi-rs/canvas": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
+      "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
+      "license": "MIT",
+      "workspaces": [
+        "e2e/*"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "optionalDependencies": {
+        "@napi-rs/canvas-android-arm64": "0.1.100",
+        "@napi-rs/canvas-darwin-arm64": "0.1.100",
+        "@napi-rs/canvas-darwin-x64": "0.1.100",
+        "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
+        "@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
+        "@napi-rs/canvas-linux-arm64-musl": "0.1.100",
+        "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
+        "@napi-rs/canvas-linux-x64-gnu": "0.1.100",
+        "@napi-rs/canvas-linux-x64-musl": "0.1.100",
+        "@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
+        "@napi-rs/canvas-win32-x64-msvc": "0.1.100"
+      }
+    },
+    "node_modules/@napi-rs/canvas-android-arm64": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
+      "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-darwin-arm64": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
+      "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-darwin-x64": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
+      "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
+      "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
+      "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
+      "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
+      "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
+      "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-linux-x64-musl": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
+      "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
+      "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    },
+    "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+      "version": "0.1.100",
+      "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
+      "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0c55ffb
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+  "name": "dw-slideshow-gen",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "Designer Wallcoverings marketing slideshow generator — topic -> 6-slide 1080x1920 TikTok carousel PNGs, zero-cost canvas render, on-brand.",
+  "bin": { "dw-slideshow": "index.js" },
+  "scripts": {
+    "plan": "node index.js plan",
+    "render": "node index.js render",
+    "all": "node index.js all"
+  },
+  "dependencies": {
+    "@napi-rs/canvas": "^0.1.65"
+  }
+}
diff --git a/samples/queue.json b/samples/queue.json
new file mode 100644
index 0000000..fdf06f2
--- /dev/null
+++ b/samples/queue.json
@@ -0,0 +1,31 @@
+{
+  "queue_type": "dw-marketing",
+  "note": "Sample DW marketing slideshow queue. Each item -> one 6-slide carousel.",
+  "items": [
+    {
+      "id": "grasscloth-moody-dining",
+      "topic": "Grasscloth wallcoverings",
+      "angle": "a moody dining room",
+      "style": "Organic Modern",
+      "colorway": "Charcoal & Flax",
+      "room": "dining room",
+      "facts": [
+        "Hand-woven texture: Each panel is a natural fiber weave — no two repeats are identical.",
+        "Light that moves: Matte by morning, warm and dimensional at candlelight.",
+        "Where it shines: Dining rooms, entries, and feature walls behind the bed.",
+        "Style it: Pair with walnut, unlacquered brass, and a single sculptural pendant."
+      ],
+      "hashtags": ["#grasscloth", "#designerwallcoverings", "#moodydiningroom", "#interiordesign", "#naturalfibers"]
+    },
+    {
+      "id": "5-grasscloth-looks",
+      "topic": "Grasscloth ideas worth saving",
+      "facts": [
+        "The classic: Tonal sisal in a warm oatmeal — quiet luxury that goes with everything.",
+        "The drama: Deep ink-blue arrowroot behind a four-poster bed.",
+        "The unexpected: Metallic-backed weave in a powder room for a jewel-box glow.",
+        "The trick: Run it to the ceiling line to make a low room feel taller."
+      ]
+    }
+  ]
+}

(oldest)  ·  back to Dw Slideshow Gen  ·  catalog feed: dw_unified -> queue with tag-derived copy + pr ec89b75 →