Commit 1baa27ed by PLN (Algolia)

og: generator — headless sketch frames and typographic cards, 1200x630 under a 300KB budget

scripts/og.mjs renders each non-draft post's card front: hydra posts get a
real frame of their sketch (source resolved through lib/hydra-sketch.js,
booted headless on a bare canvas, five candidates between 4s and 12s, the
most detailed wins), other posts a standalone typographic card in Source
Serif 4 over the site's paper/ink tokens with a seeded tick-field motif.
PNG when it fits the budget, JPEG q~92 otherwise. --video cuts a ping-pong
og.mp4 for hydra posts (forward + reverse is the honest loop for a feedback
sketch). The og image of a hydra sketch is a render of the author's
CC-licensed artwork — attribution stays on the page.
parent d0bb1436
...@@ -18,6 +18,7 @@ ...@@ -18,6 +18,7 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"prose": "node scripts/prose.mjs", "prose": "node scripts/prose.mjs",
"og": "node scripts/og.mjs",
"fmt": "node scripts/fmt.mjs" "fmt": "node scripts/fmt.mjs"
}, },
"engines": { "engines": {
......
/**
* OG image (and exploratory video) generator for blog posts.
*
* yarn og # incremental: skips posts whose og image exists
* yarn og --force # regenerate everything
* yarn og --only slug # one post
* yarn og --video # also cut an og.mp4 for hydra posts (best effort)
* yarn og --demo-card /tmp/card.png # render a sample typographic card
*
* For each non-draft post it writes public/images/blog/<slug>/og.png
* (1200×630, or og.jpg when the PNG would exceed the size budget):
*
* - Hydra posts: a real rendered frame of the sketch. The sketch source is
* resolved through lib/hydra-sketch.js (same path the page uses) and
* booted headless (Playwright chromium, SwiftShader WebGL) on a bare
* canvas. Five candidate frames are captured between 4 s and 12 s — the
* sketch runs for a while before the shot, so the card shows a developed
* state, not frame one; the "most detailed" candidate wins (largest PNG
* payload, a decent detail proxy for same-size same-pipeline shots).
*
* LICENCE NOTE: the og image of a hydra post is a render of the sketch
* author's CC BY-NC-SA (or as declared) artwork — it rides on the page's
* attribution, which carries title, author and licence link. The card is
* a depiction of the work, not a new work by PLN.
*
* - Other posts: a standalone typographic card — title/description/date in
* Source Serif 4 (the site's article face, vendored under
* scripts/og-assets/fonts/, SIL OFL 1.1) over the site's paper/ink
* tokens, with a seeded tick-field motif so no two cards are identical.
*
* --video (exploratory): ~5 s of canvas frames → ping-pong (forward +
* reverse) → h264 mp4 at 960×504. Ping-pong is the honest loop for a
* feedback sketch
* (the image never repeats itself, so nothing loops natively): motion is
* continuous at both ends because the reversed tail lands back on frame 0.
* The reverse pass reads as a subtle rewind — acceptable for a 10 s social
* loop. A sketch-native loop would need the sketch itself to be periodic.
*
* The metadata side lives in lib/og.js: it serves whatever exists on disk,
* so regenerating media never touches page code.
*/
import { createRequire } from 'module';
import { execFile } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
import os from 'os';
import path from 'path';
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const ROOT = path.resolve(import.meta.dirname, '..');
const BLOG_ROOT = path.join(ROOT, 'content', 'blog');
const PUBLIC_ROOT = path.join(ROOT, 'public');
const FONTS_DIR = path.join(ROOT, 'scripts', 'og-assets', 'fonts');
const { chromium } = require('playwright');
const matter = require('gray-matter');
const { resolveHydraSketch } = require(path.join(ROOT, 'lib', 'hydra-sketch.js'));
const WIDTH = 1200;
const HEIGHT = 630;
const SIZE_BUDGET = 300 * 1024; // per the og pass: keep images under 300 KB
const CANDIDATE_TIMES = [4, 6, 8, 10, 12]; // seconds after sketch boot
const args = process.argv.slice(2);
const FORCE = args.includes('--force');
const WITH_VIDEO = args.includes('--video');
const ONLY = args.includes('--only') ? args[args.indexOf('--only') + 1] : null;
const DEMO_CARD = args.includes('--demo-card') ? args[args.indexOf('--demo-card') + 1] : null;
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'og-pln-'));
process.on('exit', () => fs.rmSync(TMP, { recursive: true, force: true }));
/* --------------------------------------------------------------------- *
* Content reading — the same shape lib/blog.js serves, minus the
* markdown chain (the og pass needs frontmatter and hydra slots only).
* --------------------------------------------------------------------- */
const LANGS = ['fr', 'en'];
function blogFolders() {
return fs
.readdirSync(BLOG_ROOT)
.filter((name) => fs.statSync(path.join(BLOG_ROOT, name)).isDirectory())
.filter((name) => {
const dir = path.join(BLOG_ROOT, name);
return (
fs.existsSync(path.join(dir, 'meta.md')) ||
LANGS.some((lang) => fs.existsSync(path.join(dir, `${lang}.md`)))
);
});
}
function frontmatter(file) {
return matter(fs.readFileSync(file, 'utf8')).data;
}
function langFile(slug, lang) {
return path.join(BLOG_ROOT, slug, `${lang}.md`);
}
/**
* The post face the canonical URL renders: default language, falling back
* to whatever language exists. Returns { slug, title, description, date,
* draft, lang, langData }.
*/
function readPost(slug) {
const metaFile = path.join(BLOG_ROOT, slug, 'meta.md');
const meta = fs.existsSync(metaFile) ? frontmatter(metaFile) : {};
const lang = LANGS.find((l) => fs.existsSync(langFile(slug, l)));
const langData = lang ? frontmatter(langFile(slug, lang)) : {};
return {
slug,
lang,
langData,
title: langData.title ?? meta.title,
description: langData.description ?? meta.description,
date: meta.date,
draft: meta.draft === true,
};
}
/** First hydra slot, in the page's own precedence order (language file wins). */
function hydraUrl(post) {
const metaFile = path.join(BLOG_ROOT, post.slug, 'meta.md');
const meta = fs.existsSync(metaFile) ? frontmatter(metaFile) : {};
for (const slot of ['hydra-opening', 'hydra']) {
const url = post.langData[slot] ?? meta[slot];
if (url) return url;
}
return null;
}
/* --------------------------------------------------------------------- *
* hydra-synth bundle — Yarn PnP keeps packages zipped; unzip -p lifts the
* browser bundle out once per run.
* --------------------------------------------------------------------- */
async function extractHydraBundle() {
const resolved = require.resolve('hydra-synth'); // …/<cache>.zip/node_modules/hydra-synth/dist/hydra-synth.js
const zipIndex = resolved.indexOf('.zip/');
if (zipIndex === -1) return resolved; // unplugged checkout — a real file
const zip = resolved.slice(0, zipIndex + 4);
const inner = resolved.slice(zipIndex + 5);
const out = path.join(TMP, 'hydra-synth.js');
const { stdout } = await execFileAsync('unzip', ['-p', zip, inner], { maxBuffer: 16 * 1024 * 1024 });
fs.writeFileSync(out, stdout);
return out;
}
/* --------------------------------------------------------------------- *
* Rendering
* --------------------------------------------------------------------- */
function escapeForInlineScript(source) {
return JSON.stringify(source).replace(/</g, '\\u003c');
}
/** Bare boot page: the sketch, a canvas, nothing else. */
function sketchPageHtml(source, width, height) {
return `<!doctype html><html><head><style>html,body{margin:0;padding:0;background:#000}canvas{display:block}</style></head><body>
<canvas id="c" width="${width}" height="${height}"></canvas>
<script>window.SRC = ${escapeForInlineScript(source)};</script></body></html>`;
}
async function bootSketch(browser, bundlePath, source, width, height) {
const page = await browser.newPage({ viewport: { width: width + 20, height: height + 20 } });
const htmlFile = path.join(TMP, `sketch-${Math.random().toString(36).slice(2)}.html`);
fs.writeFileSync(htmlFile, sketchPageHtml(source, width, height));
await page.goto(`file://${htmlFile}`);
await page.addScriptTag({ path: bundlePath });
await page.evaluate(
({ w, h, src }) => {
const hydra = new Hydra({ canvas: document.getElementById('c'), detectAudio: false, width: w, height: h });
return hydra.eval(src);
},
{ w: width, h: height, src: source },
);
return page;
}
/**
* Representative frame: candidate shots at CANDIDATE_TIMES, the largest
* PNG wins (same dimensions, same pipeline → size tracks detail).
*/
async function captureRepresentativeFrame(page) {
const canvas = page.locator('#c');
const t0 = Date.now();
const shots = [];
for (const t of CANDIDATE_TIMES) {
await page.waitForTimeout(Math.max(0, t * 1000 - (Date.now() - t0)));
const file = path.join(TMP, `cand-${shots.length}.png`);
await canvas.screenshot({ path: file });
shots.push({ file, bytes: fs.statSync(file).size });
process.stdout.write(` frame @${t}s ${(shots.at(-1).bytes / 1024).toFixed(0)}KB\n`);
}
const best = shots.reduce((a, b) => (b.bytes > a.bytes ? b : a));
return best.file;
}
/**
* og.mp4 — ~5 s of frames, ping-ponged into a seamless loop. Returns
* { file, fps, frames } or null when nothing usable came out.
*/
async function captureLoopVideo(page, outDir, slug) {
const canvas = page.locator('#c');
const framesDir = path.join(TMP, `frames-${slug}`);
fs.mkdirSync(framesDir);
const DURATION_MS = 5000;
const frames = [];
const t0 = Date.now();
while (Date.now() - t0 < DURATION_MS) {
const buf = await canvas.screenshot();
frames.push(buf);
}
const elapsed = (Date.now() - t0) / 1000;
const fps = Math.max(4, Math.min(15, Math.round(frames.length / elapsed)));
// Ping-pong: forward + reverse without repeating the end frames.
const loop = frames.concat(frames.slice(1, -1).reverse());
loop.forEach((buf, i) => fs.writeFileSync(path.join(framesDir, `f${String(i).padStart(4, '0')}.png`), buf));
const outFile = path.join(outDir, 'og.mp4');
await execFileAsync('ffmpeg', [
'-y', '-framerate', String(fps), '-i', path.join(framesDir, 'f%04d.png'),
'-vf', 'scale=960:504:flags=lanczos',
'-c:v', 'libx264', '-preset', 'medium', '-crf', '28', '-pix_fmt', 'yuv420p',
'-movflags', '+faststart', outFile,
], { maxBuffer: 16 * 1024 * 1024 });
return { file: outFile, fps, frames: loop.length, captured: frames.length, elapsed };
}
/* --------------------------------------------------------------------- *
* Typographic card (non-hydra posts)
* --------------------------------------------------------------------- */
function mulberry32(seed) {
return function () {
seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashSeed(str) {
let h = 2166136261;
for (const byte of Buffer.from(str)) {
h ^= byte;
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
const MONTHS = {
fr: ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'],
en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
};
function formatDate(iso, lang) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return `${d.getDate()} ${MONTHS[lang]?.[d.getMonth()] ?? d.getMonth() + 1} ${d.getFullYear()}`;
}
/** Seeded tick-field: a quiet row of strokes along the bottom edge. */
function tickField(seed) {
const rand = mulberry32(seed);
const ticks = [];
for (let x = 30; x < WIDTH - 30; x += 6 + rand() * 22) {
const h = 6 + rand() * 34;
ticks.push(
`<rect x="${x.toFixed(1)}" y="${(HEIGHT - 28 - h).toFixed(1)}" width="1.1" height="${h.toFixed(1)}" fill="#1a1a1a" opacity="${(0.18 + rand() * 0.5).toFixed(2)}"/>`,
);
}
return `<svg width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}" style="position:absolute;inset:0">${ticks.join('')}</svg>`;
}
function cardHtml(post) {
const lang = post.lang ?? 'en';
const title = post.title ?? '';
// Tiered sizing so two-word and twelve-word titles both sit well.
const titleSize = title.length > 80 ? 46 : title.length > 44 ? 58 : 74;
const description = post.description
? `<div class="desc">${escapeHtml(post.description)}</div>`
: '';
const date = formatDate(post.date, lang);
return `<!doctype html><html><head><meta charset="utf-8"><style>
@font-face { font-family: 'Source Serif 4'; font-weight: 400; src: url('file://${FONTS_DIR}/source-serif-4-latin-400-normal.woff2') format('woff2'); }
@font-face { font-family: 'Source Serif 4'; font-weight: 600; src: url('file://${FONTS_DIR}/source-serif-4-latin-600-normal.woff2') format('woff2'); }
html, body { margin: 0; }
.card {
width: ${WIDTH}px; height: ${HEIGHT}px; box-sizing: border-box; position: relative;
background: #ffffff; color: #1a1a1a;
font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
padding: 56px 72px; display: flex; flex-direction: column;
}
.mast { display: flex; justify-content: space-between; align-items: baseline;
font-size: 17px; letter-spacing: 0.08em; text-transform: uppercase; color: #555555;
border-bottom: 1px solid #e0e0e0; padding-bottom: 18px; }
.title { margin: auto 0 0; font-size: ${titleSize}px; line-height: 1.14; font-weight: 600;
letter-spacing: -0.01em; max-width: 980px; }
.desc { margin-top: 22px; font-size: 27px; line-height: 1.4; color: #555555; max-width: 860px; }
.host { margin: 26px 0 74px; font-size: 17px; color: #555555; letter-spacing: 0.04em; }
</style></head><body>
<div class="card">
${tickField(hashSeed(post.slug))}
<div class="mast"><span>PLN</span><span>${escapeHtml(date)}</span></div>
<h1 class="title">${escapeHtml(title)}</h1>
${description}
<div class="host">me.nech.pl</div>
</div>
</body></html>`;
}
function escapeHtml(text) {
return String(text ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
async function renderCard(browser, post, outPath) {
const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } });
const htmlFile = path.join(TMP, `card-${post.slug}.html`);
fs.writeFileSync(htmlFile, cardHtml(post));
await page.goto(`file://${htmlFile}`);
await page.waitForTimeout(150); // woff2 settle
await page.screenshot({ path: outPath, clip: { x: 0, y: 0, width: WIDTH, height: HEIGHT } });
await page.close();
}
/* --------------------------------------------------------------------- *
* Size-budget finalization: PNG if it fits, else JPEG q85 (.og.jpg).
* --------------------------------------------------------------------- */
async function finalizeImage(srcPng, outDir, slug) {
const pngPath = path.join(outDir, 'og.png');
fs.copyFileSync(srcPng, pngPath);
if (fs.statSync(pngPath).size <= SIZE_BUDGET) return { file: pngPath, kind: 'png' };
const jpgPath = path.join(outDir, 'og.jpg');
await execFileAsync('ffmpeg', ['-y', '-i', pngPath, '-q:v', '3', jpgPath]);
fs.unlinkSync(pngPath);
if (fs.statSync(jpgPath).size > SIZE_BUDGET) {
process.stdout.write(` ! og.jpg still over budget (${(fs.statSync(jpgPath).size / 1024).toFixed(0)}KB)\n`);
}
return { file: jpgPath, kind: 'jpeg' };
}
/* --------------------------------------------------------------------- *
* Main
* --------------------------------------------------------------------- */
function outDirFor(slug) {
const dir = path.join(PUBLIC_ROOT, 'images', 'blog', slug);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function existingImage(dir) {
for (const name of ['og.png', 'og.jpg']) {
const file = path.join(dir, name);
if (fs.existsSync(file)) return file;
}
return null;
}
async function main() {
// Demo card: a synthetic post to /tmp — verifies the card path without
// minting anything into public/.
if (DEMO_CARD) {
const browser = await chromium.launch();
await renderCard(
browser,
{ slug: 'demo-card', lang: 'fr', title: 'Hello World (here we go again)', description: 'Et nous y revoilà.', date: '2026-11-01' },
DEMO_CARD,
);
await browser.close();
console.log(`demo card → ${DEMO_CARD}`);
return;
}
const slugs = blogFolders().filter((slug) => !ONLY || slug === ONLY);
const posts = slugs.map(readPost).filter((post) => {
if (!post.draft) return true;
console.log(`skip ${post.slug} (draft)`);
return false;
});
if (posts.length === 0) {
console.log('no non-draft posts to process');
return;
}
const hydraPosts = posts.filter((post) => hydraUrl(post));
if (hydraPosts.length > 0) {
var bundlePath = await extractHydraBundle();
}
const browser = await chromium.launch();
try {
for (const post of posts) {
const dir = outDirFor(post.slug);
const existing = existingImage(dir);
if (existing && !FORCE) {
console.log(`skip ${post.slug} (${path.basename(existing)} exists, --force to regenerate)`);
continue;
}
const url = hydraUrl(post);
if (url) {
console.log(`${post.slug}: hydra sketch → rendering`);
const sketch = await resolveHydraSketch(url);
if (!sketch) {
console.log(` ! sketch unresolvable (${url}) — falling back to the typographic card`);
} else {
const page = await bootSketch(browser, bundlePath, sketch.source, WIDTH, HEIGHT);
try {
const frame = await captureRepresentativeFrame(page);
const image = await finalizeImage(frame, dir, post.slug);
console.log(` og image → ${path.relative(ROOT, image.file)} (${(fs.statSync(image.file).size / 1024).toFixed(0)}KB, ${image.kind})`);
if (WITH_VIDEO) {
const video = await captureLoopVideo(page, dir, post.slug);
console.log(
` og video → ${path.relative(ROOT, video.file)} (${(fs.statSync(video.file).size / 1024).toFixed(0)}KB, ` +
`${video.frames} frames @ ${video.fps}fps, ${video.captured} captured in ${video.elapsed.toFixed(1)}s, ping-pong loop)`,
);
}
} finally {
await page.close();
}
continue;
}
}
console.log(`${post.slug}: typographic card (${post.lang ?? 'no lang file'})`);
const tmpCard = path.join(TMP, `card-out-${post.slug}.png`);
await renderCard(browser, post, tmpCard);
const image = await finalizeImage(tmpCard, dir, post.slug);
console.log(` og image → ${path.relative(ROOT, image.file)} (${(fs.statSync(image.file).size / 1024).toFixed(0)}KB, ${image.kind})`);
}
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment