← back to Dw Slideshow Gen

lib/render.js

222 lines

// lib/render.js — draw a SlideshowSpec into 6x 1080x1920 PNGs with @napi-rs/canvas.
// Headless (no browser), deterministic, $0/image. The SAME renderSpec() can be called
// from a server endpoint later — it only needs (spec, design, outDir).
import fs from 'node:fs'
import path from 'node:path'
import { createCanvas, GlobalFonts, loadImage } from '@napi-rs/canvas'
import { ensureDir, writeJSON, exists } from './util.js'

const W = 1080, H = 1920, M = 96

let _fontsReady = false
function registerFonts(design) {
  if (_fontsReady) return
  for (const f of [design.fonts.display, design.fonts.body]) {
    try { if (exists(f.path)) GlobalFonts.registerFromPath(f.path, f.family) } catch {}
  }
  _fontsReady = true
}

const hexA = (hex, a) => {
  const h = hex.replace('#', '')
  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
  return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`
}

// wrap `text` into <=maxLines lines that each fit maxW at the given px size
function wrap(ctx, text, family, size, weight, maxW, maxLines) {
  ctx.font = `${weight} ${size}px "${family}"`
  const words = String(text).split(/\s+/).filter(Boolean)
  const lines = []
  let line = ''
  for (const w of words) {
    const t = line ? line + ' ' + w : w
    if (ctx.measureText(t).width > maxW && line) { lines.push(line); line = w }
    else line = t
    if (lines.length >= maxLines) break
  }
  if (line && lines.length < maxLines) lines.push(line)
  return lines
}

// shrink font until the headline fits width within maxLines
function fitLines(ctx, text, family, weight, maxW, startPx, minPx, maxLines) {
  for (let s = startPx; s >= minPx; s -= 4) {
    const lines = wrap(ctx, text, family, s, weight, maxW, maxLines + 1)
    const fits = lines.length <= maxLines &&
      lines.every(l => (ctx.font = `${weight} ${s}px "${family}"`, ctx.measureText(l).width <= maxW))
    if (fits) return { size: s, lines }
  }
  return { size: minPx, lines: wrap(ctx, text, family, minPx, weight, maxW, maxLines) }
}

function drawBackground(ctx, design, bgImg) {
  const { colors } = design
  if (bgImg) {
    // cover-fit the photo, then a dark scrim for legibility
    const r = Math.max(W / bgImg.width, H / bgImg.height)
    const dw = bgImg.width * r, dh = bgImg.height * r
    ctx.drawImage(bgImg, (W - dw) / 2, (H - dh) / 2, dw, dh)
    const sc = ctx.createLinearGradient(0, 0, 0, H)
    sc.addColorStop(0, hexA(colors.bgTop, 0.55))
    sc.addColorStop(0.5, hexA(colors.bgTop, 0.35))
    sc.addColorStop(1, hexA(colors.bgBot, 0.85))
    ctx.fillStyle = sc; ctx.fillRect(0, 0, W, H)
  } else {
    const g = ctx.createLinearGradient(0, 0, W * 0.3, H)
    g.addColorStop(0, colors.bgTop)
    g.addColorStop(1, colors.bgBot)
    ctx.fillStyle = g; ctx.fillRect(0, 0, W, H)
    // soft brass glow, upper area
    const rg = ctx.createRadialGradient(W * 0.72, H * 0.22, 40, W * 0.72, H * 0.22, W * 0.9)
    rg.addColorStop(0, hexA(colors.accent, 0.16))
    rg.addColorStop(1, hexA(colors.accent, 0))
    ctx.fillStyle = rg; ctx.fillRect(0, 0, W, H)
  }
  // gentle vignette
  const v = ctx.createRadialGradient(W / 2, H / 2, H * 0.35, W / 2, H / 2, H * 0.75)
  v.addColorStop(0, 'rgba(0,0,0,0)')
  v.addColorStop(1, 'rgba(0,0,0,0.28)')
  ctx.fillStyle = v; ctx.fillRect(0, 0, W, H)
}

function drawChrome(ctx, design, idx, total) {
  const { colors } = design
  // kicker, top-left, letterspaced
  ctx.fillStyle = hexA(colors.ink, 0.82)
  ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'
  ctx.font = `600 26px "${design.fonts.body.family}"`
  const k = design.kicker
  let x = M
  for (const ch of k) { ctx.fillText(ch, x, 150); x += ctx.measureText(ch).width + 6 }
  // counter, top-right
  ctx.textAlign = 'right'
  ctx.fillStyle = hexA(colors.accent, 0.95)
  ctx.font = `600 26px "${design.fonts.body.family}"`
  ctx.fillText(`${String(idx).padStart(2, '0')} / ${String(total).padStart(2, '0')}`, W - M, 150)
  // hairline under chrome
  ctx.strokeStyle = hexA(colors.ink, 0.18); ctx.lineWidth = 1
  ctx.beginPath(); ctx.moveTo(M, 178); ctx.lineTo(W - M, 178); ctx.stroke()
}

function drawCenteredBlock(ctx, design, slide) {
  const { colors } = design
  const maxW = W - 2 * M
  const isHook = slide.kind === 'hook'
  // headline (display serif)
  const startPx = isHook ? 124 : 108
  const { size, lines } = fitLines(ctx, slide.title, design.fonts.display.family, '400', maxW, startPx, 52, isHook ? 4 : 3)
  const lh = size * 1.12
  const subLines = slide.subtitle
    ? wrap(ctx, slide.subtitle, design.fonts.body.family, 40, '400', maxW, 4) : []
  const subLh = 40 * 1.34
  const blockH = lines.length * lh + (subLines.length ? 40 + subLines.length * subLh : 0)
  let y = (H - blockH) / 2 + size

  ctx.textAlign = 'center'
  ctx.fillStyle = colors.ink
  ctx.font = `400 ${size}px "${design.fonts.display.family}"`
  for (const l of lines) { ctx.fillText(l, W / 2, y); y += lh }

  if (isHook) {
    // brass underline accent
    y += 6
    ctx.strokeStyle = colors.accent; ctx.lineWidth = 4
    ctx.beginPath(); ctx.moveTo(W / 2 - 70, y); ctx.lineTo(W / 2 + 70, y); ctx.stroke()
    y += 30
  } else if (subLines.length) y += 40

  if (subLines.length) {
    ctx.fillStyle = isHook ? hexA(colors.accent, 0.95) : hexA(colors.muted, 0.95)
    ctx.font = `400 40px "${design.fonts.body.family}"`
    for (const l of subLines) { ctx.fillText(l, W / 2, y); y += subLh }
  }
}

function drawCTA(ctx, design, slide, spec) {
  const { colors } = design
  ctx.textAlign = 'center'
  // wordmark
  ctx.fillStyle = colors.ink
  const wm = fitLines(ctx, design.brand, design.fonts.display.family, '400', W - 2 * M, 96, 52, 2)
  let y = H * 0.40
  ctx.font = `400 ${wm.size}px "${design.fonts.display.family}"`
  for (const l of wm.lines) { ctx.fillText(l, W / 2, y); y += wm.size * 1.1 }
  // accent button
  y += 40
  const label = slide.title
  ctx.font = `600 40px "${design.fonts.body.family}"`
  const bw = Math.min(W - 2 * M, ctx.measureText(label).width + 96)
  const bh = 96, bx = (W - bw) / 2
  ctx.fillStyle = colors.accent
  if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(bx, y, bw, bh, 48); ctx.fill() }
  else ctx.fillRect(bx, y, bw, bh)
  ctx.fillStyle = colors.bgTop
  ctx.textBaseline = 'middle'
  ctx.fillText(label, W / 2, y + bh / 2 + 2)
  ctx.textBaseline = 'alphabetic'
  // url
  y += bh + 70
  ctx.fillStyle = hexA(colors.ink, 0.9)
  ctx.font = `400 38px "${design.fonts.body.family}"`
  ctx.fillText(slide.subtitle || design.url, W / 2, y)
  // hashtags footer — auto-fit to the safe width (shrink, then drop trailing tags)
  if (spec.hashtags?.length) {
    const maxW = W - 2 * M
    let tags = spec.hashtags.slice()
    let size = 30, line = tags.join('  ')
    const fits = s => (ctx.font = `400 ${s}px "${design.fonts.body.family}"`, ctx.measureText(line).width <= maxW)
    while (size > 22 && !fits(size)) size -= 2
    while (tags.length > 1 && !fits(size)) { tags = tags.slice(0, -1); line = tags.join('  ') }
    ctx.fillStyle = hexA(colors.muted, 0.85)
    ctx.font = `400 ${size}px "${design.fonts.body.family}"`
    ctx.fillText(line, W / 2, H - M)
  }
}

function resolveBg(spec, slide, bgDir) {
  // 1. explicit per-slide / per-spec background (e.g. catalog-embedded absolute path)
  const explicit = slide.background || spec.background
  if (explicit && exists(explicit)) return explicit
  // 2. directory lookup, only when --bg DIR was given
  if (!bgDir) return null
  const cands = [
    `slide_${slide.index}.jpg`, `slide_${slide.index}.png`,
    `${slide.index}.jpg`, `${slide.index}.png`,
    'bg.jpg', 'bg.png',
  ]
  for (const c of cands) {
    const p = path.isAbsolute(c) ? c : path.join(bgDir, c)
    if (exists(p)) return p
  }
  return null
}

export async function renderSpec(spec, design, outDir, opts = {}) {
  registerFonts(design)
  ensureDir(outDir)
  const pngs = []
  for (const slide of spec.slides) {
    const canvas = createCanvas(W, H)
    const ctx = canvas.getContext('2d')
    const bgPath = resolveBg(spec, slide, opts.bg)
    let bgImg = null
    if (bgPath) { try { bgImg = await loadImage(bgPath) } catch {} }
    drawBackground(ctx, design, bgImg)
    drawChrome(ctx, design, slide.index, spec.slides.length)
    if (slide.kind === 'cta') drawCTA(ctx, design, slide, spec)
    else drawCenteredBlock(ctx, design, slide)
    const file = path.join(outDir, `slide_${slide.index}.png`)
    fs.writeFileSync(file, canvas.toBuffer('image/png'))
    pngs.push(file)
  }
  const manifest = {
    spec_id: spec.id, project: spec.project, slides: pngs.length,
    width: W, height: H, render_mode: opts.bg ? 'photo+scrim' : 'gradient',
    copy_source: spec.copy_source, caption: spec.caption, hashtags: spec.hashtags,
    png_paths: pngs, rendered_at: new Date().toISOString(),
  }
  writeJSON(path.join(outDir, 'render-manifest.json'), manifest)
  return manifest
}