← 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).`)
}