← 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
M README.mdM index.jsA lib/post.js
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 →