app
Hero Studio
Public Made by Adomby adom
A live playground for the hero image v2 definition: the lower third of every hero is a true-text overlay (type pill, trending score, stars, title, author, wiki slug) rendered by the surface, never bak
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
// Hero Studio server: state, SSE live channel, console relay, toast hook, generate API, static UI.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn, execFile } = require('child_process');
const PORT = 8846;
const ROOT = path.join(__dirname, 'public');
const PAGES = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pages.json'), 'utf8'));
const COMPONENTS = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'components.json'), 'utf8'));
let state = {
mode: 'app', // app | component
page: 'adom/adom-tts',
comp: { id: 'sm02b-srss-tb', imgSrc: 'photo', layout: 'photo-right', mpn: 'under-title' },
layout: 'bleed-bottom', // bleed-bottom | bleed-right | bleed-top | bleed-corner-br | bleed-corner-tr | contained | mirror
theme: 'midnight', // midnight | pcb | onion | pill | gradient
show: { logo: true, title: true, subtitle: true, description: false },
titleMode: 'overlay-only', // overlay-only | image-only | both
overlay: { pill: true, scores: true, author: true, slug: true },
gradAngle: 155,
guides: true,
};
const consoleBuf = [];
const clients = new Set();
// seq counters seed from the clock so a server restart never replays or masks events
let toastSeq = Date.now();
let lastToast = null;
let genSeq = Date.now();
let lastGen = null;
let generating = false;
function broadcast(event, data) {
const msg = 'event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n';
for (const res of clients) res.write(msg);
}
function logLine(level, text) {
consoleBuf.push({ t: new Date().toISOString(), level, text });
if (consoleBuf.length > 500) consoleBuf.shift();
}
function merge(dst, src) {
for (const k of Object.keys(src)) {
if (src[k] && typeof src[k] === 'object' && !Array.isArray(src[k]) && dst[k] && typeof dst[k] === 'object') {
merge(dst[k], src[k]);
} else {
dst[k] = src[k];
}
}
}
function readBody(req, cb) {
let b = '';
req.on('data', (c) => { b += c; });
req.on('end', () => {
try { cb(null, b ? JSON.parse(b) : {}); } catch (e) { cb(e); }
});
}
const MIME = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
'.woff2': 'font/woff2',
};
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const p = url.pathname;
if (p === '/state' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify(Object.assign({}, state, { _toast: lastToast, _gen: lastGen })));
}
if (p === '/state' && req.method === 'POST') {
return readBody(req, (err, body) => {
if (err) { res.writeHead(200); return res.end(JSON.stringify({ ok: false, error: 'bad json' })); }
merge(state, body);
logLine('info', 'state <- ' + JSON.stringify(body));
broadcast('state', state);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, state }));
});
}
if (p === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write(':' + ' '.repeat(2048) + '\n\n'); // flush proxy buffers
res.write('event: state\ndata: ' + JSON.stringify(state) + '\n\n');
clients.add(res);
const hb = setInterval(() => res.write(': hb\n\n'), 15000);
req.on('close', () => { clearInterval(hb); clients.delete(res); });
return;
}
if (p === '/ui/toast' && req.method === 'POST') {
return readBody(req, (err, body) => {
toastSeq += 1;
const t = { text: (body && body.text) || '...', kind: (body && body.kind) || 'info', seq: toastSeq };
lastToast = t;
logLine('toast', t.text);
broadcast('toast', t);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
if (p === '/console' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify(consoleBuf));
}
if (p === '/console' && req.method === 'POST') {
return readBody(req, (err, body) => {
if (!err && body) logLine(body.level || 'log', String(body.text || ''));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
}
if (p === '/api/pages') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify(PAGES));
}
if (p === '/api/components') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify(COMPONENTS));
}
if (p === '/generate' && req.method === 'POST') {
if (generating) {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'a generate is already running', hint: 'wait for it to finish, then retry' }));
}
generating = true;
const slug = state.mode === 'component'
? ((COMPONENTS.find(c => c.id === state.comp.id) || COMPONENTS[0]).slug)
: (state.page.split('/')[1] || 'hero');
const ts = Date.now();
const file = 'hero-' + slug + '-' + ts + '.png';
const outDir = path.join(__dirname, 'generated');
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, file);
logLine('info', 'generate -> ' + file);
const child = spawn(process.execPath, [path.join(__dirname, 'render-hero.mjs'), 'http://localhost:' + PORT + '/?render=1', outFile], {
env: process.env, stdio: ['ignore', 'pipe', 'pipe'],
});
let errBuf = '';
child.stderr.on('data', (c) => { errBuf += c; });
const finish = (payload) => {
generating = false;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
const timer = setTimeout(() => { child.kill(); }, 60000);
child.on('close', (code) => {
clearTimeout(timer);
if (code !== 0 || !fs.existsSync(outFile)) {
logLine('error', 'generate failed: ' + errBuf.slice(-400));
return finish({ ok: false, error: errBuf.trim().split('\n').pop() || 'render exited ' + code, hint: 'set CHROME and PUPPETEER_CORE env vars for the server process' });
}
// downscale to the hero standard 2000x1250 and stamp provenance metadata
const meta = JSON.stringify({ generator: 'hero-studio', version: '0.3.0', ts, state: { mode: state.mode, page: state.page, comp: state.comp, theme: state.theme, layout: state.layout, gradAngle: state.gradAngle } });
const py = 'import sys\n' +
'from PIL import Image\n' +
'from PIL.PngImagePlugin import PngInfo\n' +
'f, meta = sys.argv[1], sys.argv[2]\n' +
'im = Image.open(f)\n' +
'im = im.resize((2000, round(2000 * im.size[1] / im.size[0])), Image.LANCZOS)\n' +
'info = PngInfo(); info.add_text("comment", meta)\n' +
'im.save(f, pnginfo=info)\n';
execFile('python3', ['-c', py, outFile, meta], (err) => {
if (err) return execFile('convert', [outFile, '-resize', '2000x', '-set', 'comment', meta, outFile], (err2) => {
if (err2) logLine('warn', 'no python3/PIL or convert; keeping full-size PNG without metadata');
afterFinalize();
});
afterFinalize();
});
function afterFinalize() {
try { fs.copyFileSync(outFile, path.join(outDir, 'latest.png')); } catch {}
genSeq += 1;
lastGen = { seq: genSeq, ok: true, file, url: 'generated/' + file, latest: 'generated/latest.png', ts, slug };
logLine('info', 'generated ' + file);
broadcast('generated', lastGen);
broadcast('toast', { text: 'Hero generated: ' + file, kind: 'success', seq: ++toastSeq });
lastToast = { text: 'Hero generated: ' + file, kind: 'success', seq: toastSeq };
finish(lastGen);
}
});
return;
}
if (p.startsWith('/generated/')) {
const gfile = path.normalize(p.slice('/generated/'.length)).replace(/^(\.\.[/\\])+/, '');
const gfull = path.join(__dirname, 'generated', gfile);
if (!gfull.startsWith(path.join(__dirname, 'generated'))) { res.writeHead(404); return res.end('not found'); }
return fs.readFile(gfull, (err, buf) => {
if (err) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-cache' });
res.end(buf);
});
}
// static
let file = p === '/' ? '/index.html' : p;
file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');
const full = path.join(ROOT, file);
if (!full.startsWith(ROOT)) { res.writeHead(404); return res.end('not found'); }
fs.readFile(full, (err, buf) => {
if (err) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'Content-Type': MIME[path.extname(full)] || 'application/octet-stream' });
res.end(buf);
});
});
server.listen(PORT, () => console.log('hero-studio on ' + PORT));