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