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