Files
cruciverb/scripts/generate-daily-image.js
T
2025-07-26 18:21:01 +03:00

186 lines
6.2 KiB
JavaScript

const { createCanvas, loadImage } = (() => { try { return require('canvas') } catch { return {} } })()
const http = require('http')
const fs = require('fs')
const path = require('path')
const W = 2560, H = 1440
const PRESETS = [
{ id: 'glyph', name: 'Glyph', desc: '5x5', tag: 'VERY EASY', icon: 'feather' },
{ id: 'rune', name: 'Rune', desc: '5x5', tag: 'EASY', icon: 'diamond' },
{ id: 'scroll', name: 'Scroll', desc: '9x9', tag: 'MEDIUM', icon: 'compass' },
{ id: 'codex', name: 'Codex', desc: '15x15', tag: 'HARD', icon: 'flame' },
{ id: 'grimoire', name: 'Grimoire', desc: '21x21', tag: 'EXPERT', icon: 'meteor' },
]
function fetchJSON(urlPath) {
return new Promise((resolve) => {
http.get(`http://127.0.0.1:8080${urlPath}`, res => {
if (res.statusCode !== 200) { resolve(null); res.resume(); return }
let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)) } catch { resolve(null) } })
}).on('error', () => resolve(null))
})
}
async function loadIcon(name, size) {
const dataDir = process.env.DATA_DIR || '/app/data'
const svgPath = path.join(dataDir, 'icons', `${name}.svg`)
try {
let svg = fs.readFileSync(svgPath, 'utf8')
// set viewBox and size for rendering at target size
svg = svg.replace(/width="24"/, `width="${size}"`)
svg = svg.replace(/height="24"/, `height="${size}"`)
const buf = Buffer.from(svg)
return await loadImage(buf)
} catch (e) {
console.log(`could not load icon ${name}: ${e.message}`)
return null
}
}
async function main() {
console.log('daily image generator starting')
if (!createCanvas) { console.log('canvas module not available, skipping'); process.exit(0) }
const today = new Date().toISOString().split('T')[0]
console.log('generating for', today)
const dateDisplay = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).toUpperCase()
const grids = await Promise.all(PRESETS.map(async p => {
const res = await fetchJSON(`/api/puzzles/daily/${today}/${p.id}`)
return res ? { width: res.puzzle.width, height: res.puzzle.height, cells: res.puzzle.cells } : null
}))
// preload icons
const iconSize = 56
const icons = await Promise.all(PRESETS.map(p => loadIcon(p.icon, iconSize)))
const canvas = createCanvas(W, H)
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#111111'
ctx.fillRect(0, 0, W, H)
const cardW = 400, cardGap = 32, headerH = 240, gridPad = 24, bottomPad = 24
const gridAreaW = cardW - gridPad * 2, maxGridH = 600
let tallestCard = headerH + bottomPad
for (const grid of grids) {
if (grid) {
const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height)
tallestCard = Math.max(tallestCard, headerH + cellSize * grid.height + bottomPad)
}
}
const totalCardsW = 5 * cardW + 4 * cardGap
const startX = (W - totalCardsW) / 2
const topRuleH = 3, nameH = 190, dateH = 120, bottomRuleH = 3
const gapAfterRule = 80, todaysH = 52, gapBeforeCards = 40
const accentGap = 50, accentH = 3
const totalContentH = topRuleH + nameH + dateH + bottomRuleH + gapAfterRule + todaysH + gapBeforeCards + tallestCard + accentGap + accentH
let y = Math.max(0, (H - totalContentH) / 2)
// top rule
ctx.fillStyle = '#ededed'
ctx.fillRect(140, y, W - 280, topRuleH)
y += topRuleH
// title
ctx.fillStyle = '#ededed'
ctx.font = 'bold 180px serif'
ctx.textAlign = 'center'
ctx.fillText('CRUCIVERB', W / 2, y + nameH)
y += nameH
// date
ctx.fillStyle = '#ababab'
ctx.font = '32px monospace'
ctx.fillText(dateDisplay, W / 2, y + 55)
y += dateH
// bottom rule
ctx.fillStyle = '#ededed'
ctx.fillRect(140, y, W - 280, bottomRuleH)
y += bottomRuleH + gapAfterRule
// subtitle
ctx.fillStyle = '#b5b5b5'
ctx.font = 'italic 52px serif'
ctx.fillText("Today's puzzles", W / 2, y + todaysH * 0.75)
y += todaysH + gapBeforeCards
const cardY = y
let maxCardBottom = cardY
// cards
for (let i = 0; i < 5; i++) {
const p = PRESETS[i], grid = grids[i]
const cx = startX + i * (cardW + cardGap)
let cardH = headerH + bottomPad
let gridDrawH = 0
if (grid) {
const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height)
gridDrawH = cellSize * grid.height
cardH = headerH + gridDrawH + bottomPad
}
maxCardBottom = Math.max(maxCardBottom, cardY + cardH)
// card background
ctx.fillStyle = '#1a1a1a'
ctx.fillRect(cx, cardY, cardW, cardH)
ctx.strokeStyle = '#2a2a2a'
ctx.lineWidth = 1
ctx.strokeRect(cx, cardY, cardW, cardH)
// icon
if (icons[i]) {
ctx.drawImage(icons[i], cx + cardW / 2 - iconSize / 2, cardY + 28, iconSize, iconSize)
}
// preset name
ctx.fillStyle = '#ededed'
ctx.font = 'bold 56px serif'
ctx.textAlign = 'center'
ctx.fillText(p.name, cx + cardW / 2, cardY + 150)
// size and tag
ctx.fillStyle = '#ababab'
ctx.font = '20px monospace'
ctx.fillText(`${p.desc} ${p.tag}`, cx + cardW / 2, cardY + 185)
// grid
if (grid) {
const cellSize = Math.min(gridAreaW / grid.width, maxGridH / grid.height)
const gridW = cellSize * grid.width, gridH = cellSize * grid.height
const ox = cx + gridPad + (gridAreaW - gridW) / 2
const oy = cardY + headerH
for (let r = 0; r < grid.height; r++) {
for (let c = 0; c < grid.width; c++) {
const px = ox + c * cellSize, py = oy + r * cellSize
ctx.fillStyle = grid.cells[r][c] === 'black' ? '#090909' : '#1e1e1e'
ctx.fillRect(px, py, cellSize, cellSize)
ctx.strokeStyle = '#555555'
ctx.lineWidth = 0.5
ctx.strokeRect(px, py, cellSize, cellSize)
}
}
}
}
// accent line
ctx.fillStyle = '#d4432f'
ctx.fillRect(140, maxCardBottom + accentGap, W - 280, accentH)
// save with today's date and as canonical name
const buf = canvas.toBuffer('image/jpeg', { quality: 0.85 })
const dataDir = process.env.DATA_DIR || '/app/data'
fs.writeFileSync(`${dataDir}/daily-image-${today}.jpg`, buf)
fs.writeFileSync(`${dataDir}/daily-image.jpg`, buf)
console.log(`saved daily image for ${today} (${buf.length} bytes)`)
}
main().catch(e => { console.error(e); process.exit(1) })