From bfcf638e2074a05da1a83b60edb5bdec0b3f201b Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 26 Jul 2025 18:21:01 +0300 Subject: [PATCH] daily image rendering script --- scripts/generate-daily-image.js | 185 ++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 scripts/generate-daily-image.js diff --git a/scripts/generate-daily-image.js b/scripts/generate-daily-image.js new file mode 100644 index 0000000..826d300 --- /dev/null +++ b/scripts/generate-daily-image.js @@ -0,0 +1,185 @@ +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) })