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>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Leitet die abgeleiteten Bild-Assets aus den Quelldateien ab.
|
||||
* Einmalig ausfuehren, wenn sich Banner oder Logo aendern:
|
||||
* node scripts/assets.mjs
|
||||
*/
|
||||
import sharp from 'sharp';
|
||||
import { statSync } from 'node:fs';
|
||||
|
||||
const BANNER = 'src/assets/hero-banner.jpg';
|
||||
const LOGO = 'src/assets/logo-white.png';
|
||||
const BRAND_BG = { r: 11, g: 16, b: 32, alpha: 1 };
|
||||
|
||||
// 1) Club-/Stage-Ausschnitt des Banners: alles links des eingebrannten
|
||||
// "DJ NIZZ"-Schriftzugs (dessen linke Kante liegt bei x = 909) und ohne
|
||||
// die Figur. Dient als Hintergrundebene im Hero, Typo und Figur kommen
|
||||
// als HTML darueber. Breiter schneiden lassen die Geisterbuchstaben
|
||||
// des Schriftzugs im Hintergrund auftauchen.
|
||||
await sharp(BANNER)
|
||||
.extract({ left: 0, top: 0, width: 880, height: 1536 })
|
||||
.jpeg({ quality: 86, mozjpeg: true })
|
||||
.toFile('src/assets/hero-club.jpg');
|
||||
|
||||
// 2) Open-Graph-Bild 1200x630 aus dem kompletten Banner
|
||||
await sharp(BANNER)
|
||||
.extract({ left: 0, top: 45, width: 2752, height: 1445 })
|
||||
.resize(1200, 630)
|
||||
.jpeg({ quality: 88, mozjpeg: true })
|
||||
.toFile('public/og-image.jpg');
|
||||
|
||||
// 3) Favicons: das Logo ist weiss auf transparent und waere im hellen
|
||||
// Browser-Tab unsichtbar -> auf Markenblau setzen.
|
||||
const trimmed = await sharp(LOGO)
|
||||
.trim()
|
||||
.resize(880, 880, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// sharp wendet resize() intern vor composite() an, unabhaengig von der
|
||||
// Aufrufreihenfolge. Daher erst die 1024er Kachel bauen, dann separat skalieren.
|
||||
const icon = await sharp({ create: { width: 1024, height: 1024, channels: 4, background: BRAND_BG } })
|
||||
.composite([{ input: trimmed, gravity: 'center' }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
for (const size of [32, 180, 192, 512]) {
|
||||
const out = size === 180 ? 'public/apple-touch-icon.png' : `public/favicon-${size}.png`;
|
||||
await sharp(icon).resize(size, size).png({ compressionLevel: 9 }).toFile(out);
|
||||
}
|
||||
|
||||
const files = [
|
||||
'src/assets/hero-banner.jpg',
|
||||
'src/assets/hero-club.jpg',
|
||||
'src/assets/nizz-cutout.png',
|
||||
'src/assets/logo-white.png',
|
||||
'public/og-image.jpg',
|
||||
'public/favicon-32.png',
|
||||
'public/favicon-192.png',
|
||||
'public/favicon-512.png',
|
||||
'public/apple-touch-icon.png',
|
||||
];
|
||||
for (const f of files) {
|
||||
const m = await sharp(f).metadata();
|
||||
console.log(f.padEnd(32), `${m.width}x${m.height}`.padEnd(12), `${(statSync(f).size / 1024).toFixed(0)} KB`);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* 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}`);
|
||||
Reference in New Issue
Block a user