Drei handfeste Fehler, alle im Screenshot zu sehen gewesen. 1. ABGESCHNITTEN. background-size stand auf "138% auto". Auf einem 2540 px breiten Schirm wurde das Bild damit 3500 px breit und knapp 2000 px hoch -- in einem 1300 px hohen Fenster fehlten 700 px, und zwar oben. Genau deshalb waren die Figuren riesig und ihre Koepfe weg. Jetzt cover mit Verankerung auf 50% 42%: Die Flaeche wird immer gefuellt, so wenig wie noetig skaliert, und wenn etwas beschnitten werden muss, dann Fussboden und Decke -- nicht die Gesichter. 2. DIE VIGNETTE LAG FALSCH HERUM. Abgedunkelt wurde die MITTE, also genau der Teil, in dem die Szene steht. Man sah die Raender des Bildes und in der Mitte einen grauen Fleck. Jetzt laufen die RAENDER ins Dunkle und die Mitte bleibt klar -- so wie in der Fotografie. 3. ZWEI SCHICHTEN DUNKELHEIT. Zusaetzlich zur Vignette legte der Schleier .86/.58/.44 Schwarz ueber dasselbe Bild. Jetzt nur noch oben (Kopfleiste) und unten (Seitenende) ein Streifen. Dazu die Bilder selbst: Helligkeit 0.66 -> 0.88, mehr Kontrast und Farbe. Sie sind ein BILD, kein Nebel. DIE LESESPUR ist der eigentliche Kniff. In allen neun Szenen stehen HasiDog und DogFather AUSSEN, die Mitte ist frei -- danach wurden sie ausgesucht. Diese Aufteilung wird jetzt benutzt statt bekaempft: aussen bleibt das Bild hell und scharf, in der Mitte (wo der Text steht) wird gedaempft. Der klare Rand ist in jedem Fenster mindestens 400 px breit. Auf dem Handy gibt es daneben keinen Platz, dort deckt die Spur alles. Was frei stand und keine Karte hatte, bekommt eine Lesezone mit backdrop-filter: Der Hintergrund bleibt in Farbe und Form sichtbar, wird an dieser Stelle aber weichgezeichnet. Die billige Loesung waere gewesen, das Bild wieder abzudunkeln -- damit waere man dort, wo man angefangen hat. ZWEITER FUND AN DER EIGENEN KONTRASTMESSUNG. Sie tastete ausschliesslich NEBEN dem Element ab. Traegt ein Text seine Flaeche aber selbst (eine Beschriftung mit Hintergrund und Polsterung), liegt jeder Punkt daneben schon auf dem Bild -- gemeldet wurden 1,89:1, obwohl der Text auf deckender Flaeche steht und tadellos lesbar ist. Jetzt wird zuerst in der eigenen Polsterung gemessen, also auf dem, worauf der Text WIRKLICH liegt. (Der erste Fund an derselben Stelle war gestern: Sie mass Text gegen Nachbartext.) Nach dem Aufhellen einmal komplett durchgemessen: von sieben roten Stellen auf null. Schlechtester Wert jetzt 4,70:1 bei 4,5:1 Norm. Die Bilder sind groesser geworden (580 KB -> 1,8 MB fuer alle achtzehn), weil weniger Dunkelheit weniger komprimierbar ist. Geladen wird pro Seite genau eine: 42 bis 132 KB. Co-Authored-By: Claude Opus 5 <[email protected]>
286 lines
12 KiB
JavaScript
286 lines
12 KiB
JavaScript
/* ===================================================================
|
|
Baut den Hintergrund des Workspace aus den Marken-Bildern.
|
|
|
|
ZWEITER ANLAUF (01.09.2026). Der erste war schlecht, und Filipe hatte
|
|
recht: Ich hatte einen fremden Neon-Husky aus dem alten Bilderordner
|
|
genommen -- nicht DogFather. Ausserdem klebten zwei Bilder in den
|
|
Ecken, ohne dass daraus ein Bild geworden waere.
|
|
|
|
Jetzt richtig, aus den echten Marken-Bildern:
|
|
|
|
1. Eine BUEHNE ueber die ganze Flaeche: das Studio, in dem HasiDog
|
|
und DogFather stehen. Warum genau dieses von den fuenf: Die beiden
|
|
stehen weit aussen, die MITTE ist dunkel -- und dort steht der
|
|
Inhalt. Ein Bild mit Figuren in der Mitte waere hinter dem Text
|
|
gelandet.
|
|
|
|
2. Die beiden Figuren zusaetzlich freigestellt in den unteren Ecken,
|
|
aus den Einzelbildern. Sie stehen VOR der Buehne und sind das,
|
|
was man wiedererkennt.
|
|
|
|
Behandlung in beiden Faellen:
|
|
* stark abgedunkelt -- eine Buehne, kein Poster
|
|
* Mitte zusaetzlich verdunkelt (Vignette), dort steht der Text
|
|
* weiche Raender eingerechnet statt als Maske im Browser: eine
|
|
Maske ueber ein grosses Bild kostet bei jedem Bildaufbau Zeit
|
|
* WebP statt PNG -- aus 1,9 MB werden rund 60 KB
|
|
|
|
Gerechnet mit dem Browser, der ohnehin fuer die Pruefungen da ist.
|
|
Kein Bildprogramm, keine neue Abhaengigkeit, 0 EUR.
|
|
|
|
Aufrufen mit: node tools/buehne-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");
|
|
if (!existsSync(ZIEL)) mkdirSync(ZIEL, { recursive: true });
|
|
|
|
/* ---------------------------------------------------------------------
|
|
NEUN SZENEN, NICHT EINE.
|
|
|
|
Bis zum 01.09.2026 wurde aus den Bildern, die Filipe geschickt hat,
|
|
genau eines benutzt (das Studio) -- die uebrigen acht lagen unbenutzt
|
|
im Ordner. Zu Recht beanstandet: Er hatte sie ausdruecklich geschickt,
|
|
damit es VERSCHIEDENE Hintergruende gibt.
|
|
|
|
Jetzt wird aus jeder Szene eine Buehne, breit und schmal, und jede
|
|
Seite bekommt die, die zu ihr passt (Zuordnung in bereiche.js):
|
|
|
|
studio Startseite -- der neutrale Ort, alles beginnt hier
|
|
showbuehne Dashboard -- die grosse Buehne, alles im Blick
|
|
garage Aufgaben -- Werkstatt, hier wird gearbeitet
|
|
skyline Kalender -- Nacht ueber der Stadt, Zeit
|
|
lounge Calls -- Sitzecke, hier wird geredet
|
|
arena Dateien/Wissen -- Archiv hinter dem Portal
|
|
halle Profil/Personen -- die Halle, in der jemand steht
|
|
wald Start-Check/Scouting -- der Weg, den man erst sucht
|
|
portal Content/Reports -- Durchgang, hier entsteht etwas
|
|
|
|
Alle bekommen dieselbe Behandlung: abgedunkelt, Vignette in der Mitte
|
|
(dort steht der Text), oben ausgeblendet (dort sitzt die Kopfleiste).
|
|
Dadurch sehen sie verschieden aus und verhalten sich doch gleich --
|
|
was der eigentliche Punkt ist. Eine Buehne, die je Seite anders hell
|
|
ist, waere Unruhe statt Vielfalt.
|
|
--------------------------------------------------------------------- */
|
|
|
|
const SZENEN = [
|
|
/* [Name, Ausschnitt breit, Ausschnitt schmal]
|
|
Der schmale Ausschnitt ist je Szene ein anderer: Ein auf 9:16
|
|
gequetschtes Breitbild zeigt nur noch Wand. Gewaehlt wird jeweils
|
|
der Bereich, in dem die Figuren stehen. */
|
|
["studio", [0, 0, 1, 1], [0.17, 0, 0.66, 1]],
|
|
["showbuehne", [0, 0, 1, 1], [0.20, 0, 0.62, 1]],
|
|
["garage", [0, 0, 1, 1], [0.02, 0, 0.60, 1]],
|
|
["skyline", [0, 0, 1, 1], [0.00, 0, 0.58, 1]],
|
|
["lounge", [0, 0, 1, 1], [0.15, 0, 0.68, 1]],
|
|
["arena", [0, 0, 1, 1], [0.20, 0, 0.62, 1]],
|
|
["halle", [0, 0, 1, 1], [0.24, 0, 0.60, 1]],
|
|
["wald", [0, 0, 1, 1], [0.30, 0, 0.62, 1]],
|
|
["portal", [0, 0, 1, 1], [0.20, 0, 0.62, 1]],
|
|
];
|
|
|
|
const AUFTRAEGE = [
|
|
...SZENEN.flatMap(([name, breitA, schmalA]) => [
|
|
{
|
|
quelle: `szene-${name}.png`,
|
|
ziel: `buehne-${name}-breit.webp`,
|
|
breite: 1800, hoehe: 1013,
|
|
ausschnitt: breitA,
|
|
/* 01.09.2026 deutlich aufgehellt: von 0.42 auf 0.66, Farbe von
|
|
0.95 auf 1.08. Vorher war von den Bildern kaum etwas zu sehen --
|
|
"zu dunkel und sieht ganz komisch aus" traf es genau: Man sah
|
|
Schemen, nicht die Szene. Jetzt kommt sie durch.
|
|
|
|
Moeglich wird das nur, weil die Kacheln und Karten im selben
|
|
Zug DICHTER geworden sind (siehe --glas in gate.css). Der Text
|
|
steht dadurch auf eigener Flaeche statt direkt auf dem Bild --
|
|
das Bild darf hell sein, ohne die Lesbarkeit anzugreifen. */
|
|
/* Dritter Anlauf am 01.09.2026, und diesmal deutlich: 0.42 -> 0.66
|
|
war noch zu wenig. Jetzt 0.88 mit mehr Kontrast und Farbe -- die
|
|
Szene ist ein BILD, kein Nebel. Tragen kann sie das, weil aller
|
|
Text auf eigenen, deckenden Flaechen steht (--flaeche). */
|
|
helligkeit: 0.88, saettigung: 1.16, kontrast: 1.1,
|
|
/* Kein dunkler Fleck mehr in der Mitte -- stattdessen laufen die
|
|
RAENDER weich ins Dunkle. Siehe die Begruendung weiter unten. */
|
|
randvignette: { beginn: 0.5, staerke: 0.78 },
|
|
/* Oben ausblenden: dort sitzt die Kopfleiste. */
|
|
verlauf: [{ von: [0, 0], nach: [0, 0.14], stopps: [[0, 0.55], [1, 0]] }],
|
|
guete: 0.72,
|
|
},
|
|
{
|
|
quelle: `szene-${name}.png`,
|
|
ziel: `buehne-${name}-schmal.webp`,
|
|
breite: 800, hoehe: 1400,
|
|
ausschnitt: schmalA,
|
|
/* Auf dem Handy etwas zurueckhaltender als am Rechner: Der Text
|
|
nimmt dort fast die ganze Flaeche ein, das Bild traegt weniger. */
|
|
helligkeit: 0.74, saettigung: 1.1, kontrast: 1.06,
|
|
randvignette: { beginn: 0.42, staerke: 0.8 },
|
|
verlauf: [{ von: [0, 0], nach: [0, 0.12], stopps: [[0, 0.55], [1, 0]] }],
|
|
guete: 0.68,
|
|
},
|
|
]),
|
|
{
|
|
/* DogFather selbst -- der RICHTIGE Husky, im blauen Hoodie. Steht
|
|
unten rechts. */
|
|
quelle: "dogfather-husky.png",
|
|
ziel: "figur-dogfather.webp",
|
|
breite: 620, hoehe: 1120,
|
|
ausschnitt: [0.04, 0.06, 0.92, 0.92],
|
|
helligkeit: 0.72, saettigung: 1.0, kontrast: 1.03,
|
|
/* Nach unten und zur Seite offen lassen, oben und innen ausblenden --
|
|
so waechst er aus der Ecke heraus, statt als Ausschnitt zu kleben. */
|
|
verlauf: [
|
|
{ von: [0, 0], nach: [0, 0.3], stopps: [[0, 1], [1, 0]] },
|
|
{ von: [0.55, 0], nach: [0, 0], stopps: [[0, 0], [1, 0.9]] },
|
|
],
|
|
guete: 0.78,
|
|
},
|
|
{
|
|
/* HasiDog. Unten links. */
|
|
quelle: "hasidog-hase.png",
|
|
ziel: "figur-hasidog.webp",
|
|
breite: 560, hoehe: 1120,
|
|
ausschnitt: [0.05, 0.05, 0.9, 0.93],
|
|
helligkeit: 0.66, saettigung: 0.95, kontrast: 1.03,
|
|
verlauf: [
|
|
{ von: [0, 0], nach: [0, 0.3], stopps: [[0, 1], [1, 0]] },
|
|
{ von: [0.45, 0], nach: [1, 0], stopps: [[0, 0], [1, 0.9]] },
|
|
],
|
|
guete: 0.78,
|
|
},
|
|
{
|
|
/* Der Kopf aus dem DogFather-Logo -- als MASKE, nicht als Bild.
|
|
Die Vorlage liegt schwarz auf weiss; hier wird die Dunkelheit zur
|
|
Deckkraft. Ergebnis: eine Form, die sich im Stil beliebig
|
|
einfaerben laesst (mask-image + background). Ein fertig
|
|
eingefaerbtes Bild muesste man fuer jede Farbe neu bauen. */
|
|
quelle: "logo.png",
|
|
ziel: "marke-husky.webp",
|
|
breite: 96, hoehe: 96,
|
|
ausschnitt: [0.14, 0.03, 0.72, 0.68],
|
|
alsMaske: true,
|
|
guete: 0.9,
|
|
},
|
|
];
|
|
|
|
const { chromium } = await import(
|
|
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
|
const browser = await chromium.launch();
|
|
const seite = await browser.newPage();
|
|
await seite.setContent("<html><body></body></html>");
|
|
|
|
for (const a of AUFTRAEGE) {
|
|
const roh = readFileSync(join(QUELLE, a.quelle));
|
|
const daten = `data:image/png;base64,${roh.toString("base64")}`;
|
|
|
|
const webp = await seite.evaluate(async ({ daten, a }) => {
|
|
const bild = new Image();
|
|
bild.src = daten;
|
|
await bild.decode();
|
|
|
|
const c = document.createElement("canvas");
|
|
c.width = a.breite; c.height = a.hoehe;
|
|
const g = c.getContext("2d");
|
|
|
|
const [ax, ay, aw, ah] = a.ausschnitt;
|
|
|
|
/* Als Maske: schwarz auf weiss wird zu Form auf durchsichtig. Fuer
|
|
jeden Bildpunkt wird die Helligkeit zur Durchsichtigkeit -- weiss
|
|
verschwindet, schwarz bleibt stehen. */
|
|
if (a.alsMaske) {
|
|
g.drawImage(bild,
|
|
bild.naturalWidth * ax, bild.naturalHeight * ay,
|
|
bild.naturalWidth * aw, bild.naturalHeight * ah,
|
|
0, 0, a.breite, a.hoehe);
|
|
const d2 = g.getImageData(0, 0, a.breite, a.hoehe);
|
|
for (let i = 0; i < d2.data.length; i += 4) {
|
|
const hell = (d2.data[i] * 0.299 + d2.data[i + 1] * 0.587 + d2.data[i + 2] * 0.114) / 255;
|
|
d2.data[i] = d2.data[i + 1] = d2.data[i + 2] = 255;
|
|
d2.data[i + 3] = Math.round((1 - hell) * 255);
|
|
}
|
|
g.putImageData(d2, 0, 0);
|
|
return c.toDataURL("image/webp", a.guete);
|
|
}
|
|
|
|
g.filter = `brightness(${a.helligkeit}) saturate(${a.saettigung}) contrast(${a.kontrast})`;
|
|
g.drawImage(bild,
|
|
bild.naturalWidth * ax, bild.naturalHeight * ay,
|
|
bild.naturalWidth * aw, bild.naturalHeight * ah,
|
|
0, 0, a.breite, a.hoehe);
|
|
g.filter = "none";
|
|
|
|
/* Vignette: dunkler Fleck in der Mitte. Er wird DRAUFGEMALT (nicht
|
|
ausradiert), damit dort, wo der Text steht, wirklich Ruhe ist. */
|
|
/* DIE VIGNETTE LIEGT AUSSEN, NICHT IN DER MITTE.
|
|
|
|
Bis zum 01.09.2026 wurde die MITTE abgedunkelt -- also genau der
|
|
Teil, in dem die Szene steht. Ergebnis: Man sah nur die Raender
|
|
des Bildes, und die Mitte war ein grauer Fleck. Genau das meinte
|
|
Filipe mit "sieht ganz komisch aus".
|
|
|
|
Richtig herum ist es andersherum, so wie in der Fotografie: Die
|
|
Raender laufen weich ins Dunkle, die MITTE bleibt hell und klar.
|
|
Das Bild bekommt dadurch Tiefe statt Nebel. Moeglich ist das,
|
|
seit der Text auf eigenen Flaechen steht (--flaeche) und nicht
|
|
mehr direkt auf dem Bild liegt. */
|
|
if (a.randvignette) {
|
|
const v = a.randvignette;
|
|
g.save();
|
|
g.translate(0.5 * a.breite, 0.5 * a.hoehe);
|
|
/* Ueber die Diagonale skaliert, damit die Ecken wirklich erfasst
|
|
werden -- bei einem Kreis ueber die Breite blieben sie hell. */
|
|
g.scale(a.breite * 0.78, a.hoehe * 0.78);
|
|
const gr = g.createRadialGradient(0, 0, 0, 0, 0, 1);
|
|
gr.addColorStop(0, "rgba(5,7,13,0)");
|
|
gr.addColorStop(v.beginn ?? 0.55, "rgba(5,7,13,0)");
|
|
gr.addColorStop(1, `rgba(5,7,13,${v.staerke})`);
|
|
g.fillStyle = gr;
|
|
g.fillRect(-1.4, -1.4, 2.8, 2.8);
|
|
g.restore();
|
|
}
|
|
|
|
if (a.vignette) {
|
|
const v = a.vignette;
|
|
g.save();
|
|
g.translate(v.x * a.breite, v.y * a.hoehe);
|
|
g.scale(v.rx * a.breite, v.ry * a.hoehe);
|
|
const gr = g.createRadialGradient(0, 0, 0, 0, 0, 1);
|
|
gr.addColorStop(0, `rgba(5,7,13,${v.staerke})`);
|
|
gr.addColorStop(0.62, `rgba(5,7,13,${v.staerke * 0.72})`);
|
|
gr.addColorStop(1, "rgba(5,7,13,0)");
|
|
g.fillStyle = gr;
|
|
g.fillRect(-1.6, -1.6, 3.2, 3.2);
|
|
g.restore();
|
|
}
|
|
|
|
/* Weiche Raender: destination-out radiert dort, wo der Verlauf
|
|
deckend ist. */
|
|
g.globalCompositeOperation = "destination-out";
|
|
for (const v of (a.verlauf || [])) {
|
|
const gr = g.createLinearGradient(
|
|
v.von[0] * a.breite, v.von[1] * a.hoehe,
|
|
v.nach[0] * a.breite, v.nach[1] * a.hoehe);
|
|
for (const [pos, deckung] of v.stopps) gr.addColorStop(pos, `rgba(0,0,0,${deckung})`);
|
|
g.fillStyle = gr;
|
|
g.fillRect(0, 0, a.breite, a.hoehe);
|
|
}
|
|
g.globalCompositeOperation = "source-over";
|
|
|
|
return c.toDataURL("image/webp", a.guete);
|
|
}, { daten, a });
|
|
|
|
const puffer = Buffer.from(webp.split(",")[1], "base64");
|
|
writeFileSync(join(ZIEL, a.ziel), puffer);
|
|
console.log(`${a.ziel.padEnd(24)} ${String(Math.round(roh.length / 1024)).padStart(5)} KB`
|
|
+ ` -> ${String(Math.round(puffer.length / 1024)).padStart(4)} KB`);
|
|
}
|
|
|
|
await browser.close();
|
|
console.log("\nFertig. Die Dateien liegen in workspace/assets/img/.");
|