← 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
A .gitignoreA DESIGN.mdA README.mdA index.jsA lib/design.jsA lib/hooks.jsA lib/plan.jsA lib/render.jsA lib/util.jsA package-lock.jsonA package.jsonA samples/queue.json
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 →