← back to Dw Slideshow Gen
lib/hooks.js
116 lines
// 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)
}