Files
Nizz05andClaude Opus 5 074c87ed79 Website djnizz.at: Hero- und About-Sektion mit Docker-Deployment
Astro 7 mit Tailwind 4 und Node-Adapter. Alle Seiten werden vorgerendert,
der Node-Server ist dabei, damit spaeter einzelne Routen (Booking-Formular)
ohne Umbau am Deployment dynamisch werden koennen.

Inhalt:
- Fullscreen-Hero mit freigestelltem Render, animierten Lasern und
  Genre-Laufband; Typografie als HTML statt eingebranntem Bildtext
- About-Sektion mit Genre-Karten und Eckdaten
- Impressum und Datenschutzerklaerung
- Zentrale Inhaltsdatei src/data/site.ts

Technik:
- Selbst gehostete Schriften, keine Cookies, kein Tracking, keine
  externen Requests
- Docker Multi-Stage (238 MB) hinter Caddy mit automatischem HTTPS,
  Security-Headern und IP-maskierten Logs
- scripts/cutout.mjs stellt den Render ueber eine Sobel-Kantenkarte frei
  und entfernt eingeschlossene Hintergrundflaechen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 23:17:13 +02:00

316 lines
10 KiB
JavaScript

/**
* Stellt den DJ-NIZZ-Render frei.
*
* Der Render ist eine Vektor-Illustration: das Motiv ist durchgehend von dunkler
* Linework umschlossen, der Hintergrund ist ein sehr weicher Verlauf. Ein reiner
* Farb-Flood-Fill laeuft durch die hellen Hemd- und Hosenpartien durch (die sind
* farblich fast identisch mit dem Hintergrund). Deshalb wird der Fill hier an einer
* Sobel-Kantenkarte gestoppt: starke Kanten = Wand, alles was vom Bildrand aus ohne
* Wandkontakt erreichbar ist = Hintergrund.
*
* Aufruf: node scripts/cutout.mjs <input> <output.png> [edgeThreshold]
*/
import sharp from 'sharp';
const SRC = process.argv[2];
const OUT = process.argv[3];
const EDGE_T = Number(process.argv[4] ?? 20);
const { data, info } = await sharp(SRC)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const { width: W, height: H, channels: C } = info;
console.log(`source ${W}x${H} channels=${C} edgeThreshold=${EDGE_T}`);
// --- Luminanz -------------------------------------------------------------
const grey = new Float32Array(W * H);
for (let i = 0; i < W * H; i++) {
const p = i * C;
grey[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2];
}
// --- Sobel-Kantenkarte ----------------------------------------------------
const wall = new Uint8Array(W * H);
let wallCount = 0;
for (let y = 1; y < H - 1; y++) {
for (let x = 1; x < W - 1; x++) {
const i = y * W + x;
const tl = grey[i - W - 1], t = grey[i - W], tr = grey[i - W + 1];
const l = grey[i - 1], r = grey[i + 1];
const bl = grey[i + W - 1], b = grey[i + W], br = grey[i + W + 1];
const gx = tl + 2 * l + bl - tr - 2 * r - br;
const gy = tl + 2 * t + tr - bl - 2 * b - br;
if (Math.abs(gx) + Math.abs(gy) > EDGE_T) {
wall[i] = 1;
wallCount++;
}
}
}
console.log(`edge pixels: ${wallCount} (${((wallCount / (W * H)) * 100).toFixed(1)}%)`);
// --- Flood-Fill vom Bildrand, an Kanten blockiert --------------------------
const bg = new Uint8Array(W * H);
const queue = new Int32Array(W * H);
let qh = 0;
let qt = 0;
const push = (i) => {
if (!bg[i] && !wall[i]) {
bg[i] = 1;
queue[qt++] = i;
}
};
for (let x = 0; x < W; x++) {
push(x);
push((H - 1) * W + x);
}
for (let y = 0; y < H; y++) {
push(y * W);
push(y * W + W - 1);
}
while (qh < qt) {
const i = queue[qh++];
const x = i % W;
const y = (i / W) | 0;
if (x > 0) push(i - 1);
if (x < W - 1) push(i + 1);
if (y > 0) push(i - W);
if (y < H - 1) push(i + W);
}
let bgCount = 0;
for (let i = 0; i < bg.length; i++) bgCount += bg[i];
console.log(`vom Rand erreicht: ${((bgCount / bg.length) * 100).toFixed(1)}%`);
// --- Eingeschlossene Hintergrundflaechen ------------------------------------
// Flaechen wie zwischen Arm und Oberkoerper (Hand in der Hosentasche) sind
// ringsum von Linework umschlossen und damit vom Bildrand aus nicht
// erreichbar. Sie werden ueber ihre Farbe erkannt: liegt der Mittelwert einer
// eingeschlossenen Flaeche im Farbbereich des Hintergrunds, ist es Hintergrund.
// Der Hintergrund ist ein Verlauf - ein globaler Mittelwert waere so unscharf,
// dass auch die cremefarbenen Shorts hineinfielen. Deshalb wird pro Bildzeile
// gemittelt und eine Tasche gegen den Hintergrund *auf ihrer eigenen Hoehe*
// geprueft.
const rowSum = [new Float64Array(H), new Float64Array(H), new Float64Array(H)];
const rowCount = new Float64Array(H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const i = y * W + x;
if (!bg[i]) continue;
rowCount[y]++;
for (let c = 0; c < 3; c++) rowSum[c][y] += data[i * C + c];
}
}
const BAND = 40; // +/- Zeilen, ueber die der lokale Hintergrund gemittelt wird
const HOLE_TOL = Number(process.env.CUTOUT_HOLE_TOL ?? 22); // 0 = Taschensuche aus
const MIN_HOLE = 400; // kleiner = Bildrauschen bzw. Details im Motiv
// Obergrenze relativ zur Motivflaeche. Noetig, weil die cremefarbenen Shorts
// dem Hintergrund auf ihrer Hoehe farblich fast exakt entsprechen
// (Abweichung nur 10/1/3) und ueber die Farbe allein nicht zu trennen sind.
// Groessenordnung im Ausgangsbild: Armluecken ~3.000-7.000 px,
// Shorts ~38.000-48.000 px bei ~713.000 px Motivflaeche.
const subjectPixels = W * H - bgCount;
const MAX_HOLE = Math.round(subjectPixels * Number(process.env.CUTOUT_MAX_HOLE ?? 0.03));
console.log(`Taschengroesse zulaessig: ${MIN_HOLE} .. ${MAX_HOLE} px`);
/** Mittlere Hintergrundfarbe rund um Zeile `y`, oder null bei zu wenig Daten. */
const localBackground = (y) => {
const from = Math.max(0, y - BAND);
const to = Math.min(H - 1, y + BAND);
let n = 0;
const sum = [0, 0, 0];
for (let r = from; r <= to; r++) {
n += rowCount[r];
for (let c = 0; c < 3; c++) sum[c] += rowSum[c][r];
}
return n < 500 ? null : sum.map((s) => s / n);
};
const seen = new Uint8Array(W * H);
const pocket = new Int32Array(W * H);
let holePixels = 0;
let holeCount = 0;
for (let seed = 0; seed < W * H; seed++) {
if (bg[seed] || wall[seed] || seen[seed]) continue;
let ph = 0;
let pt = 0;
seen[seed] = 1;
pocket[pt++] = seed;
const sum = [0, 0, 0];
let rowTotal = 0;
let colTotal = 0;
while (ph < pt) {
const i = pocket[ph++];
for (let c = 0; c < 3; c++) sum[c] += data[i * C + c];
rowTotal += (i / W) | 0;
colTotal += i % W;
const x = i % W;
const y = (i / W) | 0;
const visit = (n) => {
if (!bg[n] && !wall[n] && !seen[n]) {
seen[n] = 1;
pocket[pt++] = n;
}
};
if (x > 0) visit(i - 1);
if (x < W - 1) visit(i + 1);
if (y > 0) visit(i - W);
if (y < H - 1) visit(i + W);
}
if (pt < MIN_HOLE) continue;
const reference = localBackground(Math.round(rowTotal / pt));
if (!reference) continue;
const mean = sum.map((s) => s / pt);
const looksLikeBackground =
pt <= MAX_HOLE && mean.every((m, c) => Math.abs(m - reference[c]) <= HOLE_TOL);
if (process.env.CUTOUT_DEBUG) {
const fmt = (v) => v.map((n) => n.toFixed(0).padStart(3)).join(',');
console.log(
` Tasche x=${String(Math.round(colTotal / pt)).padStart(4)} y=${String(Math.round(rowTotal / pt)).padStart(4)} ${String(pt).padStart(6)} px` +
` mean=(${fmt(mean)}) bg=(${fmt(reference)})` +
` d=(${fmt(mean.map((m, c) => Math.abs(m - reference[c])))})` +
` -> ${looksLikeBackground ? 'HINTERGRUND' : 'Motiv'}`,
);
}
if (!looksLikeBackground) continue;
for (let k = 0; k < pt; k++) bg[pocket[k]] = 1;
holePixels += pt;
holeCount++;
}
console.log(`eingeschlossene Hintergrundflaechen: ${holeCount} (${holePixels} px)`);
bgCount = 0;
for (let i = 0; i < bg.length; i++) bgCount += bg[i];
console.log(`background: ${((bgCount / bg.length) * 100).toFixed(1)}% subject: ${(100 - (bgCount / bg.length) * 100).toFixed(1)}%`);
// --- Morphologisches Opening ----------------------------------------------
// Erodieren trennt duenne Anhaengsel (Horizontlinie am Schuh, Schattenreste) vom
// Koerper ab, damit sie der Groesste-Komponente-Filter danach verwirft.
const solid = Buffer.alloc(W * H);
for (let i = 0; i < W * H; i++) solid[i] = bg[i] ? 0 : 255;
const morph = async (buf, blurSigma, threshold) => {
const out = await sharp(buf, { raw: { width: W, height: H, channels: 1 } })
.blur(blurSigma)
// Harte Schwelle bei `threshold` (0..1). linear() rechnet auf 0..255,
// die Verschiebung muss also mit 255*255 skaliert werden.
.linear(255, -threshold * 255 * 255)
.toColourspace('b-w')
.raw()
.toBuffer();
if (out.length !== W * H) throw new Error(`Maske hat ${out.length} Bytes, erwartet ${W * H}`);
return out;
};
const opened = await morph(solid, 2.0, 0.86); // erodiert ~2 px
// --- Nur die groesste zusammenhaengende Motivflaeche behalten ---------------
// Entfernt Kantenrauschen im Hintergrund (z. B. Reste des Bodenschattens).
for (let i = 0; i < W * H; i++) bg[i] = opened[i] > 127 ? 0 : 1;
const label = new Int32Array(W * H).fill(-1);
let best = -1;
let bestSize = 0;
let current = 0;
for (let seed = 0; seed < W * H; seed++) {
if (bg[seed] || label[seed] !== -1) continue;
let size = 0;
qh = 0;
qt = 0;
label[seed] = current;
queue[qt++] = seed;
while (qh < qt) {
const i = queue[qh++];
size++;
const x = i % W;
const y = (i / W) | 0;
const visit = (n) => {
if (!bg[n] && label[n] === -1) {
label[n] = current;
queue[qt++] = n;
}
};
if (x > 0) visit(i - 1);
if (x < W - 1) visit(i + 1);
if (y > 0) visit(i - W);
if (y < H - 1) visit(i + W);
}
if (size > bestSize) {
bestSize = size;
best = current;
}
current++;
}
console.log(`components: ${current}, largest: ${bestSize} px`);
const largest = Buffer.alloc(W * H);
for (let i = 0; i < W * H; i++) largest[i] = label[i] === best ? 255 : 0;
// Opening abschliessen: wieder dilatieren und mit der Originalmaske schneiden,
// damit die Silhouette exakt bleibt, die abgetrennten Anhaengsel aber weg sind.
const dilated = await morph(largest, 2.0, 0.14);
const mask = Buffer.alloc(W * H);
for (let i = 0; i < W * H; i++) mask[i] = dilated[i] > 127 && solid[i] > 127 ? 255 : 0;
// --- Kante glaetten und minimal erodieren ----------------------------------
// Weicher Verlauf statt harter Schwelle -> antialiaste Silhouette ohne hellen Saum.
const alpha = await sharp(mask, { raw: { width: W, height: H, channels: 1 } })
.blur(1.1)
.linear(3, -1.7 * 255)
.toColourspace('b-w')
.raw()
.toBuffer();
if (alpha.length !== W * H) {
throw new Error(`Alpha hat ${alpha.length} Bytes, erwartet ${W * H}`);
}
// --- Bounding Box ----------------------------------------------------------
let minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (alpha[y * W + x] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
console.log(`bbox: x ${minX}..${maxX} y ${minY}..${maxY}`);
const rgba = Buffer.alloc(W * H * 4);
for (let i = 0; i < W * H; i++) {
rgba[i * 4] = data[i * C];
rgba[i * 4 + 1] = data[i * C + 1];
rgba[i * 4 + 2] = data[i * C + 2];
rgba[i * 4 + 3] = alpha[i];
}
const pad = 4;
const left = Math.max(0, minX - pad);
const top = Math.max(0, minY - pad);
await sharp(rgba, { raw: { width: W, height: H, channels: 4 } })
.extract({
left,
top,
width: Math.min(W - 1, maxX + pad) - left,
height: Math.min(H - 1, maxY + pad) - top,
})
.png({ compressionLevel: 9 })
.toFile(OUT);
console.log(`written ${OUT}`);