/* ===================================================================== gate-bauen.mjs — Anmeldebild und Startbild aus den Markenbildern. Zwei Dinge, ein Werkzeug, weil beide dieselbe Behandlung brauchen: verkleinern, abdunkeln, nach WebP. 1. DIE ANMELDESEITE (gate-*.webp) Aus dem Spicy-Media-x-DogFather-Bild. Es hat rechts eine LEERE TAFEL mit Neonrahmen -- genau dort soll die Anmeldekarte sitzen. 2. DIE STARTSEITE (buehne-dogfather-*.webp) Aus dem Tresorbild. --------------------------------------------------------------------- DIE TAFEL WIRD GEMESSEN, NICHT GESCHAETZT Die Karte an einer im Bild GEMALTEN Tafel auszurichten, geht nur, wenn man weiss, wo die Tafel wirklich sitzt. Ich haette Prozentwerte abschaetzen koennen -- und laege dann um ein paar Pixel daneben, was man bei einem Neonrahmen sofort sieht. Deshalb sucht dieses Werkzeug den Rahmen selbst: Es geht Zeile fuer Zeile und Spalte fuer Spalte durch die rechte Bildhaelfte und sucht die hellen, gesaettigten Bildpunkte des Neonrandes. Heraus kommen vier Prozentwerte, die genau so in gate.css stehen. Gerechnet wird im Browser, der ohnehin fuer die Pruefungen da ist -- kein Bildprogramm, keine neue Abhaengigkeit, 0 EUR. Derselbe Weg wie in tools/buehne-bauen.mjs. Aufrufen mit: node tools/gate-bauen.mjs ===================================================================== */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const WURZEL = join(dirname(fileURLToPath(import.meta.url)), ".."); const QUELLE = join(WURZEL, "assets", "img", "marke"); const ZIEL = join(WURZEL, "workspace", "assets", "img"); const ZIEL_BUEHNE = join(ZIEL, "buehne"); for (const o of [ZIEL, ZIEL_BUEHNE]) if (!existsSync(o)) mkdirSync(o, { recursive: true }); const alsDatenAdresse = (pfad) => { const b = readFileSync(pfad); const art = pfad.endsWith(".png") ? "image/png" : "image/jpeg"; return `data:${art};base64,${b.toString("base64")}`; }; const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const seite = await (await browser.newContext()).newPage(); await seite.setContent(""); /* --------------------------------------------------------------------- 1. Wo sitzt die Tafel? --------------------------------------------------------------------- */ const tafel = await seite.evaluate(async (daten) => { const i = new Image(); i.src = daten; await i.decode(); const c = document.createElement("canvas"); c.width = i.width; c.height = i.height; const g = c.getContext("2d"); g.drawImage(i, 0, 0); const d = g.getImageData(0, 0, c.width, c.height).data; /* ZWEI ANLAEUFE WAREN FALSCH, und beide haben es selbst verraten. (1) "Suche den LEUCHTENDEN Rahmen" fand den Mond, die roten Blueten und jede Spiegelung im Wasser -- Ergebnis: die ganze rechte Bildhaelfte. Eine Messung, die das Offensichtliche zurueckgibt, hat nichts gemessen. (2) "Suche die groesste DUNKLE Flaeche" fand die Breite richtig, aber 94 % Hoehe: Ueber und unter der Tafel ist die Szene genauso schwarz. Dunkelheit unterscheidet sie nicht. Was sie WIRKLICH unterscheidet, ist der Rahmen -- aber nur, wenn man ihn dort sucht, wo nichts anderes leuchtet: INNERHALB der Tafel. Deshalb wird jetzt vom Mittelpunkt aus nach aussen gelaufen, bis es hell wird. Auf diesem Weg liegt garantiert nichts anderes, denn die Tafelflaeche ist leer. Der Startpunkt ist dabei nicht die Bildmitte, sondern ein Punkt sicher INNERHALB der Tafel -- gefunden ueber die dunkle Strecke aus Anlauf (2), die waagerecht ja richtig lag. */ /* HELL ODER FARBIG. Nur nach Helligkeit zu suchen, hat die OBERE Kante nicht gefunden: Sie ist ein duenner, dunkelroter Strich (der rote Teil des Rahmens laeuft dort aus), waehrend die untere Kante hell blau leuchtet. Der Rahmen ist aber an JEDER Stelle deutlich FARBIG -- die Tafelflaeche daneben ist neutralgrau. Genau das unterscheidet ihn, und zwar rundum. */ const hell = (x, y) => { const p = (y * c.width + x) * 4; const r = d[p], g2 = d[p + 1], b = d[p + 2]; const max = Math.max(r, g2, b), min = Math.min(r, g2, b); return max > 118 || (max > 52 && max - min > 26); }; const dunkel = (x, y) => { const p = (y * c.width + x) * 4; return Math.max(d[p], d[p + 1], d[p + 2]) < 62; }; /* Grobe Waagerechte: die laengste dunkle Strecke auf halber Hoehe. Sie liegt sicher in der Tafel -- daraus kommt der Startpunkt. */ function laengsteStrecke(von, bis, punkt) { let bA = von, bB = von - 1, a = -1, luecke = 0; for (let n = von; n <= bis; n++) { if (punkt(n)) { if (a < 0) a = n; luecke = 0; } else if (a >= 0 && ++luecke > 6) { const b = n - luecke; if (b - a > bB - bA) { bA = a; bB = b; } a = -1; luecke = 0; } } if (a >= 0 && bis - a > bB - bA) { bA = a; bB = bis; } return [bA, bB]; } const yMitte = Math.round(c.height * 0.45); const [gLinks, gRechts] = laengsteStrecke( Math.round(c.width * 0.45), c.width - 1, (x) => dunkel(x, yMitte)); const xStart = Math.round((gLinks + gRechts) / 2); /** Vom Startpunkt aus in eine Richtung laufen, bis es hell wird. * Verlangt werden ZWEI helle Punkte hintereinander -- ein einzelner * waere Bildrauschen. */ function bisZumRahmen(x0, y0, dx, dy, grenze) { let x = x0, y = y0, hintereinander = 0; while (x >= 0 && y >= 0 && x < c.width && y < c.height) { if (hell(x, y)) { if (++hintereinander >= 2) return dx ? x : y; } else hintereinander = 0; x += dx; y += dy; if (dx && (dx > 0 ? x > grenze : x < grenze)) break; if (dy && (dy > 0 ? y > grenze : y < grenze)) break; } return dx ? (dx > 0 ? grenze : grenze) : (dy > 0 ? grenze : grenze); } /* Auf mehreren Linien, damit ein Staubkorn nicht das Ergebnis bestimmt -- genommen wird jeweils der mittlere Wert. */ const mitte = (w) => w.slice().sort((a, b) => a - b)[Math.floor(w.length / 2)]; const yProben = [0.35, 0.42, 0.5, 0.58, 0.65].map((f) => Math.round(c.height * f)); const links = mitte(yProben.map((y) => bisZumRahmen(xStart, y, -1, 0, Math.round(c.width * 0.42)))); const rechts = mitte(yProben.map((y) => bisZumRahmen(xStart, y, +1, 0, c.width - 1))); const xProben = [0.3, 0.42, 0.5, 0.58, 0.7].map((f) => Math.round(links + (rechts - links) * f)); const oben = mitte(xProben.map((x) => bisZumRahmen(x, yMitte, 0, -1, 0))); const unten = mitte(xProben.map((x) => bisZumRahmen(x, yMitte, 0, +1, c.height - 1))); return { breite: c.width, hoehe: c.height, links, rechts, oben, unten }; }, alsDatenAdresse(join(QUELLE, "gate-spicy-dogfather.jpeg"))); const p = (v, g) => Math.round((v / g) * 10000) / 100; /* DAS ERGEBNIS MUSS PLAUSIBEL SEIN. Findet die Suche "die ganze rechte Haelfte", hat sie nichts gefunden -- genau das ist beim ersten Anlauf passiert, und es waere fast unbemerkt in die CSS gewandert. Lieber ein Abbruch mit Ansage als vier Prozentwerte, die nach Messung aussehen und geraten sind. */ { const bAnteil = (tafel.rechts - tafel.links) / tafel.breite; const hAnteil = (tafel.unten - tafel.oben) / tafel.hoehe; const gut = bAnteil > 0.25 && bAnteil < 0.62 && hAnteil > 0.35 && hAnteil < 0.92 && tafel.links / tafel.breite > 0.45; if (!gut) { console.error(` ABBRUCH: Die gefundene Tafel ist nicht plausibel ` + `(${p(bAnteil * 100, 100)} % breit, ${p(hAnteil * 100, 100)} % hoch, ` + `links bei ${p(tafel.links, tafel.breite)} %).`); console.error(`Erwartet wird ein Feld zwischen 25 und 62 Prozent Breite in der ` + `rechten Bildhaelfte. Wahrscheinlich passt die Helligkeitsschwelle nicht ` + `zum Bild -- in gate-bauen.mjs bei "dunkel()" nachsehen.`); await browser.close(); process.exit(2); } } console.log(`Quellbild: ${tafel.breite}x${tafel.hoehe}`); console.log("Die Tafel im Bild:"); console.log(` links ${p(tafel.links, tafel.breite)} % rechts ${p(tafel.rechts, tafel.breite)} %`); console.log(` oben ${p(tafel.oben, tafel.hoehe)} % unten ${p(tafel.unten, tafel.hoehe)} %`); console.log(` Breite ${p(tafel.rechts - tafel.links, tafel.breite)} % ` + `Hoehe ${p(tafel.unten - tafel.oben, tafel.hoehe)} %`); console.log("\nFuer gate.css:"); console.log(` --tafel-links: ${p(tafel.links, tafel.breite)}%;`); console.log(` --tafel-oben: ${p(tafel.oben, tafel.hoehe)}%;`); console.log(` --tafel-breite: ${p(tafel.rechts - tafel.links, tafel.breite)}%;`); console.log(` --tafel-hoehe: ${p(tafel.unten - tafel.oben, tafel.hoehe)}%;`); console.log(` --gate-seiten: ${tafel.breite} / ${tafel.hoehe};`); /* --------------------------------------------------------------------- 2. Die Bilder bauen --------------------------------------------------------------------- */ /** Verkleinert, dunkelt ab und gibt WebP zurueck. * * `ausschnitt` erlaubt einen Bildausschnitt in Anteilen (0..1) -- fuer * die Hochkant-Fassung, die nur den rechten Teil mit der Tafel zeigt. * * `dunkel` legt einen schwarzen Schleier darueber. Auf der Anmeldeseite * bleibt er MASSVOLL: Das Bild ist der Grund, warum die Seite gut * aussieht, und die Karte bringt ihren eigenen dunklen Untergrund mit. * Auf der Startseite darf er kraeftig sein -- dort liegt Text direkt * darauf. */ async function bauen({ quelle, ziel, breite, ausschnitt = null, dunkel = 0, guete = 0.82, seitenverhaeltnis = null }) { const webp = await seite.evaluate(async (a) => { const i = new Image(); i.src = a.daten; await i.decode(); const qx = a.ausschnitt ? a.ausschnitt[0] * i.width : 0; const qy = a.ausschnitt ? a.ausschnitt[1] * i.height : 0; const qb = a.ausschnitt ? a.ausschnitt[2] * i.width : i.width; const qh = a.ausschnitt ? a.ausschnitt[3] * i.height : i.height; const zb = a.breite; const zh = Math.round(a.seitenverhaeltnis ? zb / a.seitenverhaeltnis : zb * (qh / qb)); const c = document.createElement("canvas"); c.width = zb; c.height = zh; const g = c.getContext("2d"); g.imageSmoothingQuality = "high"; g.drawImage(i, qx, qy, qb, qh, 0, 0, zb, zh); if (a.dunkel > 0) { g.fillStyle = `rgba(0,0,0,${a.dunkel})`; g.fillRect(0, 0, zb, zh); } return c.toDataURL("image/webp", a.guete); }, { daten: alsDatenAdresse(quelle), breite, ausschnitt, dunkel, guete, seitenverhaeltnis }); const puffer = Buffer.from(webp.split(",")[1], "base64"); writeFileSync(ziel, puffer); return puffer.length; } const GATE = join(QUELLE, "gate-spicy-dogfather.jpeg"); const TRESOR = join(QUELLE, "szene-tresor.png"); console.log("\n--- Anmeldeseite ---"); for (const b of [960, 1280, 1600]) { const n = await bauen({ quelle: GATE, ziel: join(ZIEL_BUEHNE, `gate-${b}.webp`), breite: b, /* Nur ein Hauch. Der Neonrahmen der Tafel ist das, was die Karte traegt -- wer ihn wegdunkelt, hat ein huebsches Bild und eine Karte, die im Nichts schwebt. */ dunkel: 0.14, guete: 0.84, }); console.log(` gate-${b}.webp ${Math.round(n / 1024)} KB`); } /* HOCHKANT: der Ausschnitt um das LOGO, nicht um die Tafel. * * Erster Anlauf war falsch, und das Bildschirmfoto hat es sofort * gezeigt: Ich hatte den Bereich um die Tafel genommen -- die ist aber * LEER, und auf dem Handy liegt die Anmeldekarte ohnehin davor. Uebrig * blieb ein schwarzes Rechteck mit ein paar Saeulen. Kein Logo, kein * DogFather, nichts, woran man die Seite erkennt. * * Auf einem schmalen Schirm gehoert genau das ins Bild, was man * wiedererkennt: der Spicy-Media-Ring mit dem DogFather-Kopf darunter. * Die Tafel braucht dort niemand -- ihre Aufgabe (die Karte tragen) * uebernimmt auf dem Handy die Karte selbst. */ for (const b of [440, 580]) { const n = await bauen({ quelle: GATE, ziel: join(ZIEL_BUEHNE, `gate-hoch-${b}.webp`), breite: b, /* Auch SENKRECHT beschnitten. Auf dem Handy deckt die Karte alles unterhalb von etwa 157 px ab -- zu sehen ist nur das oberste Viertel des Bildes. Ohne diesen Schnitt landet dort die Spitze der Chili und sonst nichts. Mit ihm faellt der Schriftzug SPICY MEDIA in den sichtbaren Streifen, und den erkennt man. Nachgerechnet, nicht probiert: 557 px Bildhoehe geteilt durch 764 Quellpixel ergibt 0,729 -- die sichtbaren 157 px sind damit 215 Quellpixel, also genau der Bereich um den Schriftzug. */ ausschnitt: [0, 0.20, 0.52, 0.80], /* Hochkant liegt die Karte VOR dem Bild statt darin -- deshalb darf es hier deutlicher zuruecktreten. */ dunkel: 0.30, guete: 0.82, }); console.log(` gate-hoch-${b}.webp ${Math.round(n / 1024)} KB`); } console.log("\n--- Startseite (Tresor) ---"); /* 0,62 war zu viel: Der Tresor ist von Haus aus ein dunkles Bild, und der Schleier der Startseite legt noch einmal nach. Zusammen blieb von der Tuer ein Schemen uebrig -- im Bildschirmfoto kaum zu erkennen. Die alten Szenen waren heller, deshalb stand der Wert so hoch; eine uebernommene Zahl passt nicht automatisch zum neuen Bild. */ for (const [name, breite, dunkel] of [["breit", 1600, 0.34], ["schmal", 760, 0.40]]) { const n = await bauen({ quelle: TRESOR, ziel: join(ZIEL, `buehne-dogfather-${name}.webp`), breite, dunkel, guete: 0.8, }); console.log(` buehne-dogfather-${name}.webp ${Math.round(n / 1024)} KB`); } await browser.close(); console.log("\nFertig.");