linkedin-studio/render/build-figur.mjs
Kjell Tore Guttormsen e0f964ad60 fix(linkedin-studio): N18 Del 1 — opprydding velter aldri et vellykket figur-render [skip-docs]
Funnet av N18-roundtrip-røyken: render-suiten var grønn 1 av 4 kjøringer, ikke
"grønn siden N13" som STATE påsto. Rotårsaken er produksjonskode, ikke en test-flake.

runChromeScreenshot.finish() sender SIGKILL til forelder-Chrome og resolver
umiddelbart uten å vente på at prosessen dør. SIGKILL på forelderen dreper ikke
dens renderer/GPU-barn, som fortsetter å skrive inn i --user-data-dir under
workDir mens finally-blokken traverserer samme katalog. rmSync tømmer den, nye
filer dukker opp, og fjerning av selve katalogen feiler med ENOTEMPTY. Ren
timing-race — derav de ~50 %.

Brukerkonsekvensen var den verste sorten: PNG-en skrives til outPath UTENFOR
workDir og er allerede ferdig når dette skjer, så oppryddingen veltet et render
som faktisk lyktes.

Fix: cleanupWorkDir() — eksplisitt retry-løkke (testbar med injisert rm) pluss
fs sine egne maxRetries/retryDelay, og den kaster aldri. En temp-katalog som blir
liggende er en bagatell mot å miste et ferdig bilde; OS rydder tmpdir.

TDD: rødt bevist først (manglende eksport), så 3 tester på kontrakten — svelger
ENOTEMPTY og forsøker på nytt, fjerner katalogen i normaltilfellet, gir seg stille
når den allerede er borte.

render 60/0 -> 63/0, grønn 5 av 5 kjøringer (var 1 av 4). test-runner 270/0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QxvWAjte7vPcF79QeSRvRJ
2026-07-26 17:19:01 +02:00

384 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// build-figur.mjs — render en kodet figur (SVG/HTML) til PNG via headless Chrome.
// Bruk: node build-figur.mjs <figur.svg|figur.html> [--target article|carousel|single]
// [--out <fil.png>] [--width N] [--height N]
// Tre render-mål: article (~1200px bred, innholdsstyrt høyde) · carousel (1080×1350)
// · single (1200×1200). Design-tokens leses fra brukerens data-dir
// (${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/brand-tokens.json)
// og injiseres som CSS-variabler (--figur-*); uten fil brukes nøytrale defaults —
// brukerens merkevare er DATA, aldri hardkodet her. Designregler håndheves som
// warn-validering (aldri hard fail). Ingen nettverkstilgang under render (lokal
// fil-input; Chrome kjøres med nettverksdempende flagg). CLI + importerbar modul
// (samme dobbeltrolle som hooks/scripts/ical-generator.mjs). Ingen npm-avhengigheter.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync, spawn } from "node:child_process";
// ---------------------------------------------------------------------------
// Render-mål. height:null = innholdsstyrt (avledes av kildens aspekt, ellers
// fallback). Overstyrbare via --width/--height.
// ---------------------------------------------------------------------------
export const TARGETS = {
article: { width: 1200, height: null },
carousel: { width: 1080, height: 1350 },
single: { width: 1200, height: 1200 },
};
const ARTICLE_FALLBACK_ASPECT = 16 / 9;
export function deriveDimensions(target, aspect, overrides = {}) {
const t = TARGETS[target];
if (!t) {
throw new Error(`Ukjent mål «${target}». Gyldige mål: ${Object.keys(TARGETS).join(", ")}`);
}
const warnings = [];
const width = overrides.width ?? t.width;
let height = overrides.height ?? t.height;
if (height == null) {
if (aspect) {
height = Math.round(width / aspect);
} else {
height = Math.round(width / ARTICLE_FALLBACK_ASPECT);
warnings.push(
`Kilden oppgir ingen dimensjoner — bruker fallback-høyde ${height}px (overstyr med --height).`
);
}
}
return { width, height, warnings };
}
// ---------------------------------------------------------------------------
// Design-tokens: BRUKERDATA fra data-dir, nøytrale defaults ellers.
// ---------------------------------------------------------------------------
export const DEFAULT_TOKENS = {
background: "#FFFFFF",
ink: "#1A1A1A",
muted: "#5B5B5B",
accent: "#2F6F9F",
rule: "#D9D9D9",
fontFamily: "system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif",
};
function defaultDataDir() {
return process.env.LINKEDIN_STUDIO_DATA || path.join(os.homedir(), ".claude", "linkedin-studio");
}
export function loadBrandTokens(dataDir = defaultDataDir()) {
const file = path.join(dataDir, "profile", "brand-tokens.json");
const warnings = [];
if (!fs.existsSync(file)) {
return { tokens: { ...DEFAULT_TOKENS }, source: "default", warnings };
}
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("forventet et JSON-objekt");
}
return { tokens: { ...DEFAULT_TOKENS, ...parsed }, source: "user", warnings };
} catch (err) {
warnings.push(`Kunne ikke lese brand-tokens.json (${err.message}) — bruker nøytrale defaults.`);
return { tokens: { ...DEFAULT_TOKENS }, source: "default", warnings };
}
}
export function tokensToCss(tokens) {
const vars = Object.entries(tokens)
.map(([key, value]) => `--figur-${key}:${value};`)
.join("");
return `:root{${vars}}`;
}
// ---------------------------------------------------------------------------
// Kilde → render-klar HTML. SVG pakkes i en side med eksakt mål-flate;
// HTML får token-variablene injisert (etter <head>, ellers prependet).
// ---------------------------------------------------------------------------
export function wrapSvg(svgContent, { width, height }, tokens) {
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><style>
${tokensToCss(tokens)}
*{margin:0;padding:0;box-sizing:border-box;}
html,body{width: ${width}px;height: ${height}px;background:var(--figur-background);overflow:hidden;}
svg{display:block;width:100%;height:100%;}
</style></head>
<body>
${svgContent}
</body>
</html>
`;
}
export function injectTokens(html, tokens) {
const style = `<style>${tokensToCss(tokens)}</style>`;
if (/<head[^>]*>/i.test(html)) {
return html.replace(/<head[^>]*>/i, (m) => `${m}${style}`);
}
return `${style}\n${html}`;
}
// Aspekt (bredde/høyde) fra SVG-kilden: viewBox først, ellers width/height-attributter.
export function parseSvgMeta(svgContent) {
const vb = svgContent.match(/viewBox\s*=\s*["']\s*[\d.+-]+[\s,]+[\d.+-]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/);
if (vb) {
const w = parseFloat(vb[1]);
const h = parseFloat(vb[2]);
if (w > 0 && h > 0) return w / h;
}
const wm = svgContent.match(/<svg[^>]*\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/);
const hm = svgContent.match(/<svg[^>]*\bheight\s*=\s*["']([\d.]+)(?:px)?["']/);
if (wm && hm) {
const w = parseFloat(wm[1]);
const h = parseFloat(hm[1]);
if (w > 0 && h > 0) return w / h;
}
return null;
}
// ---------------------------------------------------------------------------
// Warn-validering av designregler (aldri hard fail):
// - minst 24pt/px effektiv tekststørrelse
// - maks 1 primær + 1 sekundærfarge utover nøytraler (gråtoner/hvit/sort)
// (≥40 % whitespace er redaksjonell regel — ikke maskinelt målbar her.)
// ---------------------------------------------------------------------------
const MIN_FONT_SIZE = 24;
const MAX_NON_NEUTRAL_COLORS = 2;
function isNeutralHex(hex6) {
const r = parseInt(hex6.slice(0, 2), 16);
const g = parseInt(hex6.slice(2, 4), 16);
const b = parseInt(hex6.slice(4, 6), 16);
const spread = Math.max(r, g, b) - Math.min(r, g, b);
return spread <= 18;
}
export function validateFigure(source) {
const warnings = [];
const smallSizes = [];
const sizeRe = /font-size\s*[:=]\s*["']?\s*([\d.]+)\s*(px|pt)?/gi;
let m;
while ((m = sizeRe.exec(source)) !== null) {
const size = parseFloat(m[1]);
if (size < MIN_FONT_SIZE) smallSizes.push(size);
}
if (smallSizes.length) {
warnings.push(
`Tekststørrelse under ${MIN_FONT_SIZE}pt funnet (${smallSizes.join(", ")}) — hev for lesbarhet på mobil.`
);
}
const nonNeutral = new Set();
const colorRe = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
while ((m = colorRe.exec(source)) !== null) {
let hex = m[1].toLowerCase();
if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
if (!isNeutralHex(hex)) nonNeutral.add(hex);
}
if (nonNeutral.size > MAX_NON_NEUTRAL_COLORS) {
warnings.push(
`Fargebudsjett: ${nonNeutral.size} ikke-nøytrale farger (maks 1 primær + 1 sekundær utover nøytraler).`
);
}
return warnings;
}
// ---------------------------------------------------------------------------
// Robust Chrome-oppdagelse: app-bundle-stier → PATH → hint. Prober er
// injiserbare for test.
// ---------------------------------------------------------------------------
const CHROME_APP_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
"/Applications/Chromium.app/Contents/MacOS/Chromium",
];
const CHROME_PATH_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"];
const CHROME_HINT =
"Fant ingen Chrome/Chromium-binær — headless-render krever en.\n" +
` Lette etter: ${CHROME_APP_CANDIDATES.join(" · ")} + PATH (${CHROME_PATH_NAMES.join(", ")})\n` +
" Installer Google Chrome (https://www.google.com/chrome/) eller `brew install chromium`.";
function defaultWhich(cmd) {
try {
const out = execFileSync("/usr/bin/which", [cmd], { encoding: "utf8" }).trim();
return out || null;
} catch {
return null;
}
}
export function resolveChrome(probes = {}) {
const exists = probes.exists || fs.existsSync;
const which = probes.which || defaultWhich;
for (const candidate of CHROME_APP_CANDIDATES) {
if (exists(candidate)) return { available: true, path: candidate };
}
for (const name of CHROME_PATH_NAMES) {
const found = which(name);
if (found) return { available: true, path: found };
}
return { available: false, hint: CHROME_HINT };
}
// ---------------------------------------------------------------------------
// Render: kilde → wrapper-HTML i tmp → headless Chrome-screenshot → PNG.
// Kjent macOS-quirk (verifisert lokalt mot Chrome 150): headless skriver PNG-en
// og melder «N bytes written to file …» på stderr, men prosessen avslutter
// aldri. Watchdog: vi lytter på stderr-kvitteringen (evt. ren exit på andre
// plattformer), dreper prosessen og verifiserer at PNG-filen faktisk finnes.
// ---------------------------------------------------------------------------
const RENDER_TIMEOUT_MS = 30000;
function runChromeScreenshot(chromePath, args, timeoutMs = RENDER_TIMEOUT_MS) {
return new Promise((resolve) => {
const child = spawn(chromePath, args, { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
let settled = false;
const finish = (reason) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.kill("SIGKILL");
resolve({ reason, stderr });
};
const timer = setTimeout(() => finish("timeout"), timeoutMs);
child.stderr.on("data", (chunk) => {
stderr += chunk;
if (stderr.includes("written to file")) finish("written");
});
child.on("exit", () => finish("exit"));
child.on("error", () => finish("spawn-error"));
});
}
// Chrome kan ha gjenlevende barneprosesser (renderer/GPU) som fortsatt skriver til
// --user-data-dir når vi rydder: SIGKILL på forelderen dreper ikke barna, så
// katalogen kan fylles på nytt midt i traverseringen og gi ENOTEMPTY. PNG-en ligger
// utenfor workDir og er allerede skrevet på dette punktet, så en temp-katalog som
// blir liggende er en bagatell mot å velte et vellykket render.
const CLEANUP_ATTEMPTS = 3;
export function cleanupWorkDir(dir, rm = fs.rmSync) {
for (let attempt = 0; attempt < CLEANUP_ATTEMPTS; attempt += 1) {
try {
rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
return;
} catch {
// Kan være rester fra Chrome; prøv igjen.
}
}
// Ga opp — OS rydder tmpdir. Denne funksjonen kaster aldri.
}
export async function renderFigure(opts) {
const { input, target = "article", out = null, dataDir, chrome = null } = opts;
const inPath = path.isAbsolute(input) ? input : path.join(process.cwd(), input);
if (!fs.existsSync(inPath)) throw new Error(`Fant ikke input-fil: ${inPath}`);
const ext = path.extname(inPath).toLowerCase();
if (![".svg", ".html", ".htm"].includes(ext)) {
throw new Error(`Ustøttet input-type «${ext}» — bruk .svg eller .html.`);
}
const source = fs.readFileSync(inPath, "utf8");
const { tokens, source: tokensSource, warnings } = loadBrandTokens(dataDir);
warnings.push(...validateFigure(source));
const aspect = ext === ".svg" ? parseSvgMeta(source) : null;
const dims = deriveDimensions(target, aspect, { width: opts.width ?? null, height: opts.height ?? null });
warnings.push(...dims.warnings);
const pageHtml = ext === ".svg" ? wrapSvg(source, dims, tokens) : injectTokens(source, tokens);
const resolved = chrome || resolveChrome();
if (!resolved.available) throw new Error(resolved.hint);
const outPath = out
? (path.isAbsolute(out) ? out : path.join(process.cwd(), out))
: path.join(path.dirname(inPath), `${path.basename(inPath, ext)}-${target}.png`);
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "build-figur-"));
try {
const htmlPath = path.join(workDir, "figur.html");
fs.writeFileSync(htmlPath, pageHtml, "utf8");
const run = await runChromeScreenshot(resolved.path, [
"--headless",
"--disable-gpu",
"--hide-scrollbars",
"--force-device-scale-factor=1",
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
"--disable-sync",
"--disable-background-networking",
"--disable-component-update",
"--use-mock-keychain",
"--password-store=basic",
`--user-data-dir=${path.join(workDir, "profile")}`,
`--window-size=${dims.width},${dims.height}`,
`--screenshot=${outPath}`,
`file://${htmlPath}`,
]);
if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) {
const tail = run.stderr.split("\n").filter(Boolean).slice(-5).join("\n");
throw new Error(`Chrome skrev ikke ${outPath} (${run.reason}).\n${tail}`);
}
} finally {
cleanupWorkDir(workDir);
}
return { out: outPath, width: dims.width, height: dims.height, warnings, tokensSource };
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
const USAGE =
"Bruk: node build-figur.mjs <figur.svg|figur.html> [--target article|carousel|single] [--out <fil.png>] [--width N] [--height N]";
export function parseArgs(argv) {
const cfg = { input: null, target: "article", out: null, width: null, height: null };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--target") cfg.target = argv[++i];
else if (arg === "--out") cfg.out = argv[++i];
else if (arg === "--width") cfg.width = parseInt(argv[++i], 10);
else if (arg === "--height") cfg.height = parseInt(argv[++i], 10);
else if (arg.startsWith("--")) return { error: `Ukjent flagg «${arg}». ${USAGE}` };
else if (!cfg.input) cfg.input = arg;
else return { error: `Uventet argument «${arg}». ${USAGE}` };
}
if (!cfg.input) return { error: USAGE };
if (!TARGETS[cfg.target]) {
return { error: `Ukjent mål «${cfg.target}». Gyldige mål: ${Object.keys(TARGETS).join(", ")}` };
}
if ((cfg.width !== null && !Number.isFinite(cfg.width)) || (cfg.height !== null && !Number.isFinite(cfg.height))) {
return { error: `--width/--height må være heltall. ${USAGE}` };
}
return cfg;
}
async function main() {
const cfg = parseArgs(process.argv.slice(2));
if (cfg.error) {
console.error(cfg.error);
process.exit(1);
}
try {
const result = await renderFigure(cfg);
for (const w of result.warnings) console.warn(`${w}`);
console.log(
`Figur: ${result.out} (${result.width}×${result.height}, mål: ${cfg.target}, tokens: ${result.tokensSource})`
);
} catch (err) {
console.error(err.message);
process.exit(1);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}