[object Object]

← back to Dw Slideshow Gen

post leg: fail-closed TikTok draft package (zip+caption+instructions) + gated Postiz SELF_ONLY path

d59b3abd9a2d5387db843884ab5f7f2a30ecf842 · 2026-06-29 10:04:08 -0700 · Steve Abrams

Files touched

Diff

commit d59b3abd9a2d5387db843884ab5f7f2a30ecf842
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 29 10:04:08 2026 -0700

    post leg: fail-closed TikTok draft package (zip+caption+instructions) + gated Postiz SELF_ONLY path
---
 README.md   | 22 +++++++++++++--
 index.js    | 49 ++++++++++++++++++++++++++++-----
 lib/post.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 153 insertions(+), 8 deletions(-)

diff --git a/README.md b/README.md
index 2e294f2..27695eb 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,18 @@ browser, no OpenAI), deterministic and on-brand.
 
 ## Use
 ```bash
+# straight from the live catalog (queries dw_unified, downloads product photos, renders):
+node index.js catalog --limit 6                       # 6 newest -> one carousel each
+node index.js catalog --vendor "Anna French" --mode roundup   # "New Anna French arrivals"
+node index.js catalog --type Wallcovering --since 2026-06-01
+
+# from a hand-authored queue:
 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
+
+# assemble the postable draft package (fail-closed — never posts without creds+--confirm):
+node index.js post                             # all render dirs -> zip + caption + instructions
 ```
 Flags:
 - `--ai` — generate slide copy via headless Claude Code (`claude -p`, reuses the
@@ -40,6 +49,15 @@ Flags:
 ```
 If `facts` is omitted the generator writes on-brand body slides from the topic.
 
+## Posting (Postiz) — fail-closed
+`post` always builds a local draft package (6 PNGs + `caption.txt` +
+`POST-INSTRUCTIONS.md`, zipped). A **live** TikTok draft is only attempted when
+`POSTIZ_API_KEY` + `POSTIZ_INTEGRATION_ID` are set **and** you pass `--confirm` —
+and even then it creates a **SELF_ONLY (private) DRAFT**. You add the trending
+sound and publish in the TikTok app (the Content API can't add music — and that
+human step drives the algorithm). Going live is outward-facing → Steve-gated.
+
 ## Cost
-Render: **$0 (local)** — no image-gen API. Optional `--ai` copy uses the existing
-Claude Code login (no separate billing). `output/` is gitignored.
+Render: **$0 (local)** — no image-gen API. Catalog photo download: **$0**.
+Optional `--ai` copy uses the existing Claude Code login (no separate billing).
+`output/` is gitignored.
diff --git a/index.js b/index.js
index 432f75a..82d06a1 100644
--- a/index.js
+++ b/index.js
@@ -10,6 +10,7 @@ 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, postToPostiz } from './lib/post.js'
 import { readJSON, writeJSON, ensureDir, slugify, today } from './lib/util.js'
 
 const ROOT = path.dirname(fileURLToPath(import.meta.url))
@@ -30,6 +31,7 @@ function parseArgs(argv) {
     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 a._.push(t)
   }
   return a
@@ -48,18 +50,53 @@ 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)
+  console.log(`dw-slideshow — DW marketing slideshow carousels (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
+  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
+  post    [renderDir...] [--confirm]            build TikTok draft package (fail-closed)
 
-  --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
+  --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 === '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) }
+  for (const dir of dirs) {
+    const pkg = packageDraft(dir, { zip: true })
+    console.error(`  [post] ${pkg.spec_id}: draft package (${pkg.slides} slides) -> ${pkg.zipPath || pkg.pkgDir}  ($0, no network)`)
+    const live = await postToPostiz(dir, process.env, { confirm: a.confirm })
+    if (live.gated) console.error(`  [post] ${pkg.spec_id}: NOT posted — ${live.reason}`)
+    else if (live.posted) console.error(`  [post] ${pkg.spec_id}: Postiz DRAFT created (${live.privacy}) id=${live.postiz_id}`)
+  }
+  console.error([
+    '',
+    '  ── GATE ─────────────────────────────────────────────',
+    '  Draft packages are ready locally. Going live needs (all of):',
+    '    1. a Postiz account + connected TikTok integration',
+    '    2. POSTIZ_API_KEY + POSTIZ_INTEGRATION_ID in env (route via /secrets)',
+    '    3. re-run with --confirm',
+    '  Even then it creates a SELF_ONLY (private) DRAFT — you add the',
+    '  trending sound and publish in the TikTok app. Outward-facing: Steve-gated.',
+    '  ─────────────────────────────────────────────────────',
+  ].join('\n'))
+  process.exit(0)
+}
+
 if (cmd === 'catalog') {
   const design = resolveDesign(a)
   const bgDir = path.join(OUT, 'bg')
diff --git a/lib/post.js b/lib/post.js
new file mode 100644
index 0000000..4f1c13c
--- /dev/null
+++ b/lib/post.js
@@ -0,0 +1,90 @@
+// lib/post.js — the delivery leg. FAIL-CLOSED by design.
+//
+//   Default (no creds / no --confirm): assemble a local "draft package" — the 6 PNGs
+//   + caption.txt + POST-INSTRUCTIONS.md, zipped. NO network call. $0.
+//
+//   Live Postiz draft: only attempted when ALL of (POSTIZ_API_KEY, POSTIZ_INTEGRATION_ID,
+//   --confirm) are present. Even then it creates a TikTok carousel DRAFT with
+//   privacy SELF_ONLY — never a public post, never auto-publish. Music + publish stay
+//   manual in the TikTok app (a platform limit: the Content API can't add a trending
+//   sound — and that human step is exactly where the algorithm is won).
+import fs from 'node:fs'
+import path from 'node:path'
+import { execFileSync } from 'node:child_process'
+import { readJSON, ensureDir, exists } from './util.js'
+
+function loadManifest(renderDir) {
+  const mp = path.join(renderDir, 'render-manifest.json')
+  if (!exists(mp)) throw new Error(`no render-manifest.json in ${renderDir}`)
+  const m = readJSON(mp)
+  const pngs = (m.png_paths || []).filter(exists)
+  if (!pngs.length) throw new Error('manifest lists no existing PNGs')
+  return { ...m, png_paths: pngs }
+}
+
+export function packageDraft(renderDir, opts = {}) {
+  const m = loadManifest(renderDir)
+  const pkgDir = path.join(renderDir, 'post-package')
+  ensureDir(pkgDir)
+  // 1..N images, zero-padded for stable carousel order
+  m.png_paths.forEach((p, i) => fs.copyFileSync(p, path.join(pkgDir, `slide_${String(i + 1).padStart(2, '0')}.png`)))
+  const caption = `${m.caption || ''}\n\n${(m.hashtags || []).join(' ')}`.trim()
+  fs.writeFileSync(path.join(pkgDir, 'caption.txt'), caption + '\n')
+  fs.writeFileSync(path.join(pkgDir, 'POST-INSTRUCTIONS.md'), [
+    `# Post: ${m.spec_id}`,
+    ``,
+    `**Platform:** TikTok (photo carousel) — also works for Instagram.`,
+    `**Slides:** ${m.png_paths.length} × ${m.width}×${m.height}, in slide_01…order.`,
+    ``,
+    `## Steps (manual, by design)`,
+    `1. Upload the slides as a **photo carousel**, in order.`,
+    `2. Paste the caption from \`caption.txt\`.`,
+    `3. **Add a trending sound** in the TikTok app — the Content API cannot, and`,
+    `   the sound is what drives the algorithm. Keep a human on this step.`,
+    `4. Post as a **draft (private / SELF_ONLY)** first; review; then publish.`,
+    ``,
+    `_Generated by dw-slideshow-gen. Render mode: ${m.render_mode}. $0._`,
+  ].join('\n') + '\n')
+
+  let zipPath = null
+  if (opts.zip !== false) {
+    try {
+      zipPath = path.join(renderDir, `${m.spec_id}-draft.zip`)
+      if (exists(zipPath)) fs.rmSync(zipPath)
+      execFileSync('zip', ['-rjq', zipPath, pkgDir], { stdio: 'ignore' })
+    } catch (e) { zipPath = null; process.stderr.write(`  [post] zip skipped: ${e.message}\n`) }
+  }
+  return { spec_id: m.spec_id, pkgDir, zipPath, slides: m.png_paths.length, caption }
+}
+
+// Live Postiz draft — only runs when fully credentialed AND explicitly confirmed.
+export async function postToPostiz(renderDir, env, opts = {}) {
+  const apiKey = env.POSTIZ_API_KEY
+  const integrationId = env.POSTIZ_INTEGRATION_ID
+  const base = (env.POSTIZ_URL || 'https://api.postiz.com').replace(/\/$/, '')
+  if (!apiKey || !integrationId || !opts.confirm) {
+    return { posted: false, gated: true,
+      reason: 'fail-closed: needs POSTIZ_API_KEY + POSTIZ_INTEGRATION_ID + --confirm' }
+  }
+  // NOTE: Postiz public-API shape — VERIFY against the live account before first use.
+  const m = loadManifest(renderDir)
+  const media = []
+  for (const p of m.png_paths) {
+    const fd = new FormData()
+    fd.append('file', new Blob([fs.readFileSync(p)], { type: 'image/png' }), path.basename(p))
+    const up = await fetch(`${base}/public/v1/upload`, { method: 'POST', headers: { Authorization: apiKey }, body: fd })
+    if (!up.ok) throw new Error(`upload ${up.status}`)
+    media.push((await up.json()).id)
+  }
+  const body = {
+    type: 'draft',
+    content: [{ integration: { id: integrationId }, value: `${m.caption}\n\n${(m.hashtags || []).join(' ')}`, image: media }],
+    settings: { tiktok: { privacy_level: 'SELF_ONLY', content_disclosure: false } },
+  }
+  const res = await fetch(`${base}/public/v1/posts`, {
+    method: 'POST', headers: { Authorization: apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify(body),
+  })
+  if (!res.ok) throw new Error(`posts ${res.status}: ${await res.text()}`)
+  const j = await res.json()
+  return { posted: true, draft: true, privacy: 'SELF_ONLY', postiz_id: j.id || j.postId || null }
+}

← ec89b75 catalog feed: dw_unified -> queue with tag-derived copy + pr  ·  back to Dw Slideshow Gen  ·  Postiz integration: verified public-API client (integrations 05c2f1d →