Files
dogfather-universe/server/helfer-kachel-echtfarbe.mjs
T
DogFatherGit 077925ab25 Dreizehn rote Pruefungen, eine Ursache: openssl steht nicht im PATH
DER BEFUND

Im naechtlichen Lauf standen dreizehn Pruefungen mit derselben Zeile
rot:

    [uncaughtException] Error: spawnSync openssl ENOENT

Kein Programmfehler. Die Pruefungen brauchen einen https-Vorbau (der
Sitzungskeks des Hauses ist `secure` und kommt ueber http nicht an),
und dafuer erzeugen sie ein Wegwerf-Zertifikat mit openssl.

GEMESSEN, NICHT VERMUTET

  openssl liegt hier:   C:\Program Files\Git\mingw64\bin\openssl.exe
                        C:\Program Files\Git\usr\bin\openssl.exe
  im System-PATH steht: C:\Program Files\Git\cmd   (nur git.exe)
  im Benutzer-PATH:     nichts mit Git

Wer aus Git Bash startet, erbt die mingw-Pfade und merkt nie etwas.
Die Aufgabenplanung startet `node.exe` DIREKT, ohne Shell -- und
bekommt sie nicht. Bei mir gruen, nachts rot, und der Grund steht
nicht im Code.

Das Schlimmste daran ist nicht der Ausfall: Dreizehn dauerhaft rote
Zeilen in einer Notiz, die Filipe morgens liest, gewoehnen einem das
Hinsehen ab -- und decken dabei die echten Befunde zu.

DIE REPARATUR

`server/helfer-openssl.mjs` sucht openssl erst im PATH (ohne `where`
oder `which` -- beides sind selbst Programme und koennen genauso
fehlen), danach an den Orten, an denen es auf einem Windows-Rechner
mit Git wirklich liegt.

Dazu `zertifikatBauen(schluessel, zertifikat, host)`. Die acht
Aufrufformen im Haus waren identisch bis auf die Variablennamen
(schl/zert, schluesselDatei/zertDatei, zKey/zCrt, sk/zt, key/crt) --
eine Stelle traegt alle neunundzwanzig. Zwei Tage zuvor war eine
davon um ein `-addext` aermer als die anderen; gemerkt hat es
niemand, weil der Browser den fehlenden Alternativnamen erst bei
einer Weiterleitung anmahnt. Eine Stelle kann nicht von sich selbst
abweichen.

KEIN EINTRAG IN DEN SYSTEM-PATH. `mingw64\bin` enthaelt rund hundert
Programme mit Unix-Namen (`find`, `sort`, `link`), die gleichnamige
Windows-Befehle verdecken. Das fuer eine Pruefung zu aendern haette
an ganz anderen Stellen Fehler verursacht, die niemand hierher
zurueckverfolgt.

GEGENPROBE IN BEIDE RICHTUNGEN, mit dem PATH des Nachtlaufs:

  vorher (direkter Aufruf):  ABSTURZ: spawnSync openssl ENOENT
  nachher (ueber den Helfer): Zertifikat gebaut, 1236 Bytes

Und eine echte Pruefung unter denselben Bedingungen:

  PATH ohne mingw64 -> pruef-chat-neu-stelle
  63 Pruefungen, 0 Fehler, 0 Abstuerze

Genau diese Datei stand im Nachtlauf mit ENOENT rot.

EINE WACHE DAGEGEN

pruef-struktur prueft ab jetzt, dass niemand openssl wieder direkt
ruft -- der naechste merkt es dort und nicht erst in einem
Nachtlauf, den niemand liest. Drei Gegenproben.

Beim ersten Anlauf schlug sie auf ihre EIGENEN Probetexte an: Sie
liest alle Serverdateien, und dazu gehoert sie selbst. Die Texte
werden jetzt zusammengesetzt. Derselbe Selbsttreffer ist mir heute
schon zweimal passiert.

DER DRITTE AUSGANG BLEIBT, WO ER HINGEHOERT

`helfer-kachel-echtfarbe.mjs` faengt den Fehler weiterhin ab und
meldet `moeglich: false` mit Grund, statt abzubrechen. Eine
Sicherung abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags.

NEBENBEI: pruef-community-sicht

Sie war rot mit "ZU VIEL: reaktion.html" -- mein eigener Rueckstand
vom 28.09. Die Reaction steht als Kachel im Community-Bereich; die
Liste war nicht nachgezogen. Jetzt 10 / 0.

Die Liste bleibt bewusst von Hand gepflegt: Sie ist eine ABSICHT,
keine Ableitung. Aus rechte.js gelesen verglichen sich zwei Kopien
derselben Quelle -- immer gruen, nie ein Beweis.

GEPRUEFT
  pruef-struktur         ALLES IN ORDNUNG (mit der neuen Wache)
  pruef-community-sicht  10 / 0
  pruef-chat-neu-stelle  63 / 0  (mit dem PATH des Nachtlaufs)
  pruef-notizen, -teamlage-karten, -werdegang: EXIT 0, keine Abstuerze
  mess-quer              EXIT 0
  29 Dateien: node --check auf allen, kein direkter Aufruf mehr
2026-09-29 16:14:13 +02:00

311 lines
14 KiB
JavaScript

/* =====================================================================
helfer-kachel-echtfarbe.mjs — welche Farbe eine Kachel WIRKLICH hat
---------------------------------------------------------------------
Filipe am 22.09.2026: „ich seh immer wieder sehr viele die einfach
komplett die gleiche farbe haben ich will dass sie wirklich alle
alle alle komplett unterschiedlich sind."
Er hatte recht, und `pruef-kachelfarben` war trotzdem grün. Der
Grund war ein Messfehler: Sie las `--ton` aus `start.css` — die
ROHFARBE. Die lag zwischen allen 31 Kacheln weit auseinander
(0,0978 in OKLab). Nur steht sie so nie auf dem Bildschirm: Die
Kachel zeichnet sie als Verlauf unter einem Sternenfeld, einer
Vignette und (bis zu diesem Tag) einer fest blauen Wolke.
Gemessen kamen 14,4 Prozent an. Acht Paare waren für ein Auge
nicht mehr zu unterscheiden.
WARUM DER DURCHSCHNITT ÜBER DIE GANZE KACHEL
Eine Einzelmessung an einem Punkt träfe vielleicht einen Stern,
einen Rand oder das Zeichen. Gemittelt wird über alle Bildpunkte —
das ist ungefähr das, was ein Auge aus zwei Metern Entfernung
sieht, und genau darum geht es.
WARUM DAS HIER STEHT UND NICHT ZWEIMAL
`tools/kachel-echtfarbe.mjs` zeigt die Liste zum Ansehen,
`server/pruef-kachelfarben.mjs` prüft sie. Zwei Fassungen derselben
Messung altern unterschiedlich — dann sagt die eine „gleich" und
die andere „verschieden", und man glaubt der bequemeren.
===================================================================== */
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn, execFileSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { createServer } from "node:https";
import { request as httpAnfrage } from "node:http";
import { zertifikatBauen } from "./helfer-openssl.mjs";
const HIER = dirname(fileURLToPath(import.meta.url));
const CREW = "crew.dogfather-universe.com";
/* ---- Farbrechnung (OKLab, wie in den Farbwerkzeugen) --------------- */
const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
export function oklab([R, G, B]) {
const [r, g, b] = [R / 255, G / 255, B / 255].map(linear);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
];
}
const abstand = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
const ausHex = (hex) => (/^#[0-9a-f]{6}$/i.test(hex)
? [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) : null);
export const alsHex = (c) => "#" + c.map((x) => x.toString(16).padStart(2, "0")).join("");
/* UNTER 0,02 SIEHT EIN AUGE KEINEN UNTERSCHIED MEHR.
In OKLab entspricht 1 Einheit ungefähr dem ganzen Helligkeitsbereich;
0,02 ist damit die Grenze, ab der zwei Flächen nebeneinander noch
als „zwei Farben" gelesen werden. Darunter ist es eine. */
export const NICHT_UNTERSCHEIDBAR = 0.02;
/**
* Fotografiert die Kachelwand und liest die echten Bildpunkte aus.
*
* Gibt IMMER ein Ergebnis zurück — auch wenn es nicht messen konnte.
* `moeglich: false` mit Grund ist der dritte Ausgang: nicht „in
* Ordnung", nicht „kaputt", sondern „konnte nicht nachsehen".
*/
export async function pruefeEchteFarben({ port = 4369, breite = 1500 } = {}) {
let chromium;
try {
({ chromium } = await import("playwright-core"));
} catch {
return { moeglich: false, grund: "playwright-core ist nicht da" };
}
const ordner = mkdtempSync(join(tmpdir(), "ws-echtfarbe-"));
const DB = join(ordner, "workspace.db");
let kind = null, vorbau = null, browser = null;
try {
kind = spawn(process.execPath, [join(HIER, "index.js")], {
env: { ...process.env, WORKSPACE_DB: DB, PORT: String(port),
SITE_ACCESS_SECRET: "lokaler-test",
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
stdio: ["ignore", "pipe", "pipe"] });
let oben = false;
for (let i = 0; i < 80; i++) {
await new Promise((r) => setTimeout(r, 250));
if (kind.exitCode !== null) break;
try {
const a = await fetch(`http://127.0.0.1:${port}/workspace/`, { redirect: "manual" });
if (a.status < 500) { oben = true; break; }
} catch { /* noch nicht oben */ }
}
if (!oben) return { moeglich: false, grund: `der Server kam auf Port ${port} nicht hoch` };
const d = new DatabaseSync(DB);
const salz = randomBytes(16).toString("hex");
const hash = scryptSync("CODE-DOGI-0001", salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name,rolle,code_hash,code_salt,code_n,aktiv,erstellt)"
+ " VALUES (?,?,?,?,?,1,?)")
.run("Filipe", "admin", hash, salz, 32768, new Date().toISOString());
d.close();
const keks = await new Promise((fertig, schief) => {
const daten = JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" });
const a = httpAnfrage({ host: "127.0.0.1", port, path: "/workspace/api/anmelden",
method: "POST",
headers: { "content-type": "application/json", Host: CREW,
"content-length": Buffer.byteLength(daten) } }, (antwort) => {
let t = ""; antwort.on("data", (x) => { t += x; });
antwort.on("end", () => fertig((antwort.headers["set-cookie"] || [])
.join("; ").split(";")[0]));
});
a.on("error", schief); a.end(daten);
});
if (!keks) return { moeglich: false, grund: "die Anmeldung hat keinen Keks geliefert" };
/* Chromium zwingt crew.… per eingebauter HSTS-Liste auf https --
deshalb derselbe selbst unterschriebene Vorbau wie überall. */
const zKey = join(ordner, "k.pem"), zCrt = join(ordner, "z.pem");
/* HIER BLEIBT DAS try/catch -- anders als an den 28 anderen
Stellen. Diese Datei hat einen dritten Ausgang: Sie sagt
„moeglich: false" samt Grund, statt den ganzen Lauf
abzubrechen. Ein fehlender https-Vorbau ist hier kein Befund
ueber die Farben, sondern „konnte nicht nachsehen".
Seit dem 29.09.2026 sucht `zertifikatBauen` openssl auch
ausserhalb des PATH -- der Fall sollte damit nicht mehr
eintreten. Der Ausgang bleibt trotzdem stehen: Eine Sicherung
abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags. */
try {
zertifikatBauen(zKey, zCrt, CREW);
} catch (f) {
return { moeglich: false, grund: `openssl: ${f.message.split("\n")[0]}` };
}
const HTTPS_PORT = port + 1;
vorbau = createServer({ key: readFileSync(zKey), cert: readFileSync(zCrt) },
(an, ab) => {
const weiter = httpAnfrage({
host: "127.0.0.1", port, path: an.url, method: an.method,
headers: { ...an.headers, host: CREW },
}, (antwort) => { ab.writeHead(antwort.statusCode, antwort.headers); antwort.pipe(ab); });
weiter.on("error", () => { try { ab.destroy(); } catch { /* Zugabe */ } });
an.on("error", () => {});
an.pipe(weiter);
});
await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r));
browser = await chromium.launch({
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1:${HTTPS_PORT}`],
ignoreHTTPSErrors: true,
});
const ctx = await browser.newContext({
viewport: { width: breite, height: 1000 }, ignoreHTTPSErrors: true });
const [n, w] = keks.split("=");
await ctx.addCookies([{ name: n, value: w, domain: CREW, path: "/" }]);
const seite = await ctx.newPage();
await seite.goto(`https://${CREW}/workspace/start.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(1200);
const beschriftung = await seite.evaluate(() => [...document.querySelectorAll(".kachel")]
.map((k) => ({
name: (k.querySelector(".kachel__name") || k).textContent.trim().split("\n")[0],
ton: getComputedStyle(k).getPropertyValue("--ton").trim(),
})));
/* WO DER TEXT STEHT -- fuer den Lesbarkeitsteil weiter unten. */
const textlagen = await seite.evaluate(() => [...document.querySelectorAll(".kachel")]
.map((k) => {
const t = k.querySelector(".kachel__unter") || k.querySelector(".kachel__name");
if (!t) return null;
const r = t.getBoundingClientRect(), kr = k.getBoundingClientRect();
return {
x: Math.round(r.x - kr.x), y: Math.round(r.y - kr.y),
b: Math.round(r.width), h: Math.round(r.height),
farbe: getComputedStyle(t).color,
};
}));
const zeiger = await seite.$$(".kachel");
const liste = [];
for (let i = 0; i < zeiger.length; i++) {
const png = (await zeiger[i].screenshot({ type: "png" })).toString("base64");
/* Der Browser zählt selbst -- ein PNG hier von Hand zu lesen
wäre ein eigenes Werkzeug, und eines mehr, das altert. */
const mittel = await seite.evaluate(async (daten) => {
const bild = new Image();
await new Promise((f) => { bild.onload = f; bild.src = "data:image/png;base64," + daten; });
const c = document.createElement("canvas");
c.width = bild.width; c.height = bild.height;
const g = c.getContext("2d");
g.drawImage(bild, 0, 0);
const p = g.getImageData(0, 0, c.width, c.height).data;
let r = 0, gr = 0, bl = 0, z = 0;
for (let k = 0; k < p.length; k += 4) { r += p[k]; gr += p[k + 1]; bl += p[k + 2]; z++; }
return [Math.round(r / z), Math.round(gr / z), Math.round(bl / z)];
}, png);
/* WIE LESBAR IST DER TEXT DARAUF?
============================================================
Das ist die Frage, die am 22.09.2026 beinahe untergegangen
waere. Die Farben kraeftiger zu machen war richtig -- gemessen
stieg der Anteil, der ankommt, von 14,4 auf 51,1 Prozent.
Nur waren damit 27 von 31 Kacheltexten unter 4,5:1; vorher
war es EINER. Ein Fehler gegen einen anderen getauscht, und
zwar genau der, den Filipe schon zweimal gemeldet hat ("man
bekommt fast nichts gelesen").
Gemessen wird der HAEUFIGSTE Farbwert hinter dem Text, nicht
der hellste: Der hellste Punkt IST der Text (ein erster Anlauf
meldete darum bei allen 31 einen Kontrast von 1,00 -- also
einen unmoeglichen Wert). Der Text deckt nur einen Bruchteil
der Flaeche; der haeufigste Wert ist der Grund. */
const l = textlagen[i];
let kontrast = null;
if (l && l.b > 4 && l.h > 4) {
const grund = await seite.evaluate(async ([daten, lage]) => {
const bild = new Image();
await new Promise((f) => { bild.onload = f; bild.src = "data:image/png;base64," + daten; });
const c = document.createElement("canvas");
c.width = bild.width; c.height = bild.height;
const g = c.getContext("2d");
g.drawImage(bild, 0, 0);
const x = Math.max(0, lage.x), y = Math.max(0, lage.y);
const bb = Math.min(lage.b, c.width - x), hh = Math.min(lage.h, c.height - y);
if (bb <= 0 || hh <= 0) return null;
const p = g.getImageData(x, y, bb, hh).data;
const zaehler = new Map();
for (let k = 0; k < p.length; k += 4) {
const sch = ((p[k] >> 3) << 10) | ((p[k + 1] >> 3) << 5) | (p[k + 2] >> 3);
zaehler.set(sch, (zaehler.get(sch) || 0) + 1);
}
let haeufig = 0, wie = -1;
for (const [sch, n2] of zaehler) if (n2 > wie) { wie = n2; haeufig = sch; }
return [((haeufig >> 10) & 31) << 3, ((haeufig >> 5) & 31) << 3, (haeufig & 31) << 3];
}, [png, l]);
if (grund) {
const tf = (l.farbe.match(/\d+/g) || [255, 255, 255]).slice(0, 3).map(Number);
const hell = (c) => {
const [r, g2, b] = c.map((x) => {
const v = x / 255;
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g2 + 0.0722 * b;
};
const [a1, a2] = hell(tf) > hell(grund) ? [hell(tf), hell(grund)] : [hell(grund), hell(tf)];
kontrast = (a1 + 0.05) / (a2 + 0.05);
}
}
liste.push({ ...beschriftung[i], echt: mittel, kontrast });
}
/* ---- Auswerten ---------------------------------------------- */
let engstes = { d: Infinity, a: "", b: "" };
let engstesRoh = Infinity;
let ununterscheidbar = 0, paare = 0;
for (let i = 0; i < liste.length; i++) {
for (let j = i + 1; j < liste.length; j++) {
paare++;
const dE = abstand(oklab(liste[i].echt), oklab(liste[j].echt));
if (dE < engstes.d) engstes = { d: dE, a: liste[i].name, b: liste[j].name };
if (dE < NICHT_UNTERSCHEIDBAR) ununterscheidbar++;
const ra = ausHex(liste[i].ton), rb = ausHex(liste[j].ton);
if (ra && rb) engstesRoh = Math.min(engstesRoh, abstand(oklab(ra), oklab(rb)));
}
}
const mitKontrast = liste.filter((k) => k.kontrast !== null);
const zuDunkel = mitKontrast.filter((k) => k.kontrast < 4.5);
return {
moeglich: true,
anzahl: liste.length,
/* Wie viele Kacheltexte nicht lesbar sind -- und welche. */
textGemessen: mitKontrast.length,
textZuWenig: zuDunkel.length,
textSchlechteste: mitKontrast.length
? mitKontrast.reduce((a, b) => (a.kontrast < b.kontrast ? a : b))
: null,
textNamen: zuDunkel.slice(0, 5).map((k) => `${k.name} (${k.kontrast.toFixed(2)}:1)`),
liste,
engstes,
engstesRoh,
ununterscheidbar,
paare,
/* Wie viel von der Rohfarbe auf dem Bildschirm ankommt. Faellt
das unter ein Drittel, nuetzt die schoenste Rohfarbe nichts. */
anteil: engstesRoh > 0 ? (engstes.d / engstesRoh) * 100 : 0,
};
} catch (f) {
return { moeglich: false, grund: f?.message || String(f) };
} finally {
try { if (browser) await browser.close(); } catch { /* Zugabe */ }
try { if (vorbau) await new Promise((r) => vorbau.close(r)); } catch { /* Zugabe */ }
try { if (kind) kind.kill(); } catch { /* Zugabe */ }
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ }
}
}