← back to Dw Slideshow Gen
index.js
202 lines
#!/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 { buildCatalogQueue } from './lib/catalog.js'
import { packageDraft, stageToPostiz } from './lib/post.js'
import { Postiz, pickIntegration } from './lib/postiz.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')
// load .env (zero-dep) so POSTIZ_API_KEY etc. are available without exporting
try {
for (const line of fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/)
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '')
}
} catch {}
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 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 if (t === '--confirm') a.confirm = true
else if (t === '--postiz') a.postiz = true
else if (t === '--restage') a.restage = true
else if (t === '--channel') a.channel = argv[++i]
else if (t === '--schedule') a.schedule = 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 (1080x1920 PNG, $0 render)
catalog [--vendor V] [--type T] [--limit N] [--since DATE] [--mode spotlight|roundup]
build a queue from dw_unified + 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
integrations list connected Postiz channels (needs key)
post [renderDir...] [--postiz] [--channel C] [--schedule ISO] [--confirm]
build local package; --postiz stages a Postiz DRAFT
--ai copy via headless Claude Code (Max-plan login, no API key)
--bg DIR photos as slide backgrounds (slide_N.jpg | bg.jpg) with a scrim
--no-photos catalog mode: skip product-photo download (gradient only)
output -> output/plan-<date>.json and output/renders/<id>/slide_N.png`)
process.exit(0)
}
if (cmd === 'integrations') {
const key = process.env.POSTIZ_API_KEY
if (!key) { console.error(' [integrations] no POSTIZ_API_KEY — get it at Postiz → Settings → Developers → Public API, then route via /secrets'); process.exit(1) }
const list = await new Postiz(key).integrations()
console.error(` [integrations] ${list.length} connected channel(s):`)
for (const i of list) console.error(` ${i.disabled ? '✗' : '✓'} ${i.identifier.padEnd(12)} ${i.name} id=${i.id}`)
process.exit(0)
}
if (cmd === 'post') {
// Build the postable draft package for one render dir (or every dir under output/renders).
let dirs = a._.filter(Boolean)
if (!dirs.length) {
const base = path.join(OUT, 'renders')
dirs = fs.existsSync(base)
? fs.readdirSync(base).map(d => path.join(base, d)).filter(d => fs.existsSync(path.join(d, 'render-manifest.json')))
: []
}
if (!dirs.length) { console.error('no render dirs found — run `render`/`catalog`/`all` first'); process.exit(1) }
const sleep = ms => new Promise(r => setTimeout(r, ms))
let staged = 0, skipped = 0, failed = 0, i = 0
for (const dir of dirs) {
i++
if (!a.postiz) { // local-package mode (no network)
const pkg = packageDraft(dir, { zip: true })
console.error(` [post] ${pkg.spec_id}: local draft package (${pkg.slides} slides) -> ${pkg.zipPath || pkg.pkgDir} ($0)`)
continue
}
const marker = path.join(dir, 'posted.json')
const id = path.basename(dir)
if (!a.restage && fs.existsSync(marker)) { skipped++; continue } // idempotent: already staged
let r
try {
r = await stageToPostiz(dir, process.env, { confirm: a.confirm, channel: a.channel, schedule: a.schedule })
} catch (e) { // never let one item kill the whole batch
failed++; console.error(` [post ${i}/${dirs.length}] FAIL ${id}: ${e.message}`)
continue
}
const chNames = (r.channels || []).map(c => c.identifier).join('+')
if (r.staged) {
staged++
writeJSON(marker, { postIds: r.postIds, channels: (r.channels || []).map(c => c.identifier), type: r.type, at: new Date().toISOString() })
console.error(` [post ${i}/${dirs.length}] STAGED ${id} → ${chNames} (${(r.postIds || []).length} entries)`)
await sleep(a.confirm ? 700 : 0) // pace API to avoid throttling
} else if (r.dryRun) {
console.error(` [post ${i}/${dirs.length}] DRY-RUN ${r.type} → ${chNames}, ${r.images} imgs (add --confirm to stage)`)
} else { failed++; console.error(` [post ${i}/${dirs.length}] ${r.gated ? 'SKIP' : 'FAIL'} ${id}: ${r.reason || r.error}`) }
}
if (a.postiz) console.error(`\n [post] done — staged ${staged}, skipped(already) ${skipped}, failed ${failed} of ${dirs.length}. Review at platform.postiz.com`)
console.error([
'',
' ── GATE ─────────────────────────────────────────────',
' Local draft packages are always built ($0). To stage into Postiz:',
' 1. connect TikTok (+ any channel) in the Postiz dashboard',
' 2. Settings → Developers → Public API → generate key → paste to me',
' (I route POSTIZ_API_KEY via /secrets)',
' 3. preview: post --postiz (dry-run, no network)',
' stage: post --postiz --confirm (creates a Postiz DRAFT)',
' Staged as a DRAFT (reversible) — you review + publish in Postiz; you',
' add the trending sound there. Outward-facing: Steve-gated.',
' ─────────────────────────────────────────────────────',
].join('\n'))
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) }
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).`)
}