Files
property-match/scripts/build-basel-panorama.mjs
Benjamin SutterandClaude Opus 5 4349641ee9 fix(startseite): durchgehendes Basel-Panorama, Kreise ohne Stühle
Vier Korrekturen aus der Nachprüfung.

**Naht in der linken Fensterfront.** Sie stammte von zwei getrennten
Fotoflächen — vorne und links. Eine Stossstelle lässt sich nicht wegblenden;
sie verschwindet nur, wenn es keine zwei Bilder mehr gibt. Die Kulisse ist
jetzt eine gekrümmte Fläche mit einer einzigen Panoramadatei: auf einem
durchgehenden Zylinder ist eine Naht geometrisch unmöglich.

Das Panorama entsteht einmalig aus den beiden Referenzfotos
(`scripts/build-basel-panorama.mjs`), jede Quelle genau einmal — kein Kacheln,
kein Spiegeln. Das gute Motiv vom Tischende bleibt in Ausschnitt und Farbe
unverändert und steht weiterhin in der Frontscheibe; die Panoramaaufnahme setzt
es nach links fort, helligkeitsangeglichen und über 320 Pixel überblendet. Aus
der Fortsetzung wird nur der Abschnitt östlich des Münsters verwendet — beide
Vorlagen zeigen es, nebeneinander stünde es zweimal im Bild. Himmel und Wasser
sind aus den Randzeilen gestreckt, damit oben und unten keine Bildgrenze bleibt.

**Stühle und Körper entfernt.** Sitzmöbel, Rümpfe, Schultern, Hälse, Arme und
Hinterköpfe sind ersatzlos weg. Die Agenten bestehen nur noch aus ihrem runden
Porträt mit weissem Ring.

**Positionen neu.** Ferdi und Bruno sind die direkten Nachbarn links und rechts
vorne, die übrigen gestaffelt dahinter, Thomas unverändert vis-à-vis. Die
Kreise liegen deutlich über der Blickachse: lagen sie auf Augenhöhe,
projizierten alle fünf auf fast dieselbe Bildhöhe und die hinteren
verschwanden hinter den vorderen — seitliches Auseinanderziehen half dagegen
nicht, weil ein tieferer Platz in der Perspektive zur Mitte wandert.

Dazu ein echter Fehler, der dabei auffiel: die grosszügigen Trefferquader der
vorderen Kreise fingen die Strahlen der hinteren ab, ein Klick auf Livia landete
bei Ferdi. Die Trefferfläche ist jetzt eine Kugel knapp um den sichtbaren Kreis.

**Bildschärfe.** Die Textur wird auf einen mittigen quadratischen Ausschnitt
begrenzt — ohne das zieht die Kreisgeometrie ein liegendes Foto in die Breite,
was Gesichter verzerrt und Schärfe kostet. Dazu anisotrope Filterung und
Mipmaps. Höher aufgelöste Originale der fünf Agenten waren nicht auffindbar:
die Porträts in `Agenten_Verwaltung_Fotos.html` liegen zwar in 256×256 vor,
gehören aber zu einer anderen Belegschaft — geprüft und verworfen, statt Gesichter
zu vertauschen.

**Ortsangabe** unten rechts entfernt. **Menü** liegt bündig an der rechten
Fensterkante: der Spalt kam vom Anker am eingerückten Menüsymbol, jetzt ist das
Klappmenü an eine Bildschirmstelle gebunden.

Geprüft: 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925). Im
Browser 1280 und 1600 px: sechs Beschriftungen ohne Überlappung, sechs Kreise
ohne Überlappung, alle sechs per Maus auf der richtigen Subpage, Menü bündig
ohne waagrechten Scrollbalken, keine Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 14:32:58 +02:00

191 lines
7.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Erzeugt das Basler Panorama für den 3D-Meetingraum (Runde 6, §2).
*
* Vorher lagen zwei getrennte Fotoflächen vor den Glasfronten — vorne und
* links. An deren Stossstelle war auf der linken Fensterseite eine harte Kante
* sichtbar. Diese Naht lässt sich nicht wegblenden; sie verschwindet nur, wenn
* es gar keine zwei Bilder mehr gibt.
*
* Dieses Skript baut aus den beiden Referenzfotos des Auftrags eine einzige
* durchgehende Panoramadatei. Jede Quelle wird **genau einmal** verwendet —
* kein Kacheln, kein Spiegeln (§2.2). Das gut wirkende Motiv vom Tischende
* («front») bildet die linke Hälfte und bleibt in Ausschnitt und Farbe
* unverändert; die Panoramaaufnahme («left») setzt es nach links fort, in
* Helligkeit angeglichen und über 300 Pixel weich überblendet.
*
* Himmel und Wasser werden anschliessend aus der jeweiligen Randzeile nach oben
* und unten gestreckt. Ohne das stünde über und unter dem Bildstreifen eine
* harte Grenze, die im Raum als Kante sichtbar wäre.
*
* Einmalig auszuführen; das Ergebnis liegt als Asset im Repository:
*
* node scripts/build-basel-panorama.mjs
*
* Es braucht keine Bildbibliothek — die Komposition entsteht auf einem Canvas
* im bereits vorhandenen Chrome, den auch die visuellen Prüfungen nutzen.
*/
import { chromium } from 'playwright-core'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const assets = resolve(here, '../src/assets/staff')
const SRC_FRONT = resolve(assets, 'basel-front.jpg')
const SRC_LEFT = resolve(assets, 'basel-left.jpg')
const OUT = resolve(assets, 'basel-panorama.jpg')
/**
* Zielmasse. Die Breite ergibt sich aus den beiden Quellen: jede einmal,
* abzüglich der Überblendung. Grösser wäre reines Hochskalieren.
*/
const H = 660
/** Wasserlinie im Ziel — beide Quellen werden darauf ausgerichtet. */
const WATER_Y = 400
/** Wasserlinie in den Quellen, als Anteil der Bildhöhe. */
const FRONT_WATER = 0.60
const LEFT_WATER = 0.64
/** Skalierung der beiden Quellen. */
const FRONT_SCALE = 1.3
const LEFT_SCALE = 2.45
/**
* Ausschnitt der Fortsetzung, in Pixeln der Quelle.
*
* Beide Referenzfotos zeigen das Münster. Nebeneinander gesetzt stünde es
* zweimal im Bild — eine Doppelstruktur, die §2.1 ausdrücklich ausschliesst.
* Aus der Panoramaaufnahme wird deshalb nur der Abschnitt östlich des Münsters
* verwendet: die Häuserzeilen am Rhein. Das Münster selbst kommt genau einmal
* vor, nämlich im Mittelmotiv vom Tischende.
*/
const LEFT_CROP = { sx: 330, sw: 370 }
/** Breite der Überblendung zwischen Fortsetzung und Mittelmotiv. */
const FEATHER = 320
const CHROME = [
process.env.CHROME_PATH,
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
].filter(Boolean).find(p => existsSync(p))
if (!CHROME) throw new Error('Kein Chrome gefunden — CHROME_PATH setzen.')
for (const f of [SRC_FRONT, SRC_LEFT]) {
if (!existsSync(f)) throw new Error(`Referenzbild fehlt: ${f}`)
}
const asDataUri = f => `data:image/jpeg;base64,${readFileSync(f).toString('base64')}`
const browser = await chromium.launch({ executablePath: CHROME })
const page = await browser.newPage()
const result = await page.evaluate(async (cfg) => {
const load = src => new Promise((ok, err) => {
const img = new Image()
img.onload = () => ok(img)
img.onerror = err
img.src = src
})
const [fImg, lImg] = await Promise.all([load(cfg.front), load(cfg.left)])
const fW = Math.round(fImg.width * cfg.FRONT_SCALE)
const fH = Math.round(fImg.height * cfg.FRONT_SCALE)
const lW = Math.round(cfg.LEFT_CROP.sw * cfg.LEFT_SCALE)
const lH = Math.round(lImg.height * cfg.LEFT_SCALE)
const W = lW + fW - cfg.FEATHER
const H = cfg.H
/** Mittlere Helligkeit eines Quellbereichs. */
function meanLuma(img, sy, sh) {
const c = document.createElement('canvas')
c.width = 48
c.height = 48
const cg = c.getContext('2d')
cg.drawImage(img, 0, sy, img.width, sh, 0, 0, 48, 48)
const d = cg.getImageData(0, 0, 48, 48).data
let sum = 0
for (let i = 0; i < d.length; i += 4) sum += 0.2126 * d[i] + 0.7152 * d[i + 1] + 0.0722 * d[i + 2]
return sum / (d.length / 4)
}
// Helligkeit der Fortsetzung an das Mittelmotiv angleichen. Ohne diesen
// Abgleich bliebe trotz Überblendung ein Helligkeitssprung sichtbar.
const bandF = meanLuma(fImg, fImg.height * 0.3, fImg.height * 0.45)
const bandL = meanLuma(lImg, lImg.height * 0.3, lImg.height * 0.45)
const korrektur = Math.max(0.8, Math.min(1.25, bandF / bandL))
/**
* Eine Quelle als Schicht über die volle Zielhöhe: das Foto an der
* Wasserlinie ausgerichtet, darüber der Himmel und darunter das Wasser aus der
* jeweiligen Randzeile gestreckt. So deckt jede Schicht die ganze Höhe ab und
* es entsteht keine harte obere oder untere Bildgrenze.
*/
function schicht(img, crop, scale, waterFraction, brightness) {
const sx = crop ? crop.sx : 0
const sw = crop ? crop.sw : img.width
const w = Math.round(sw * scale)
const h = Math.round(img.height * scale)
const y = Math.round(cfg.WATER_Y - img.height * waterFraction * scale)
const c = document.createElement('canvas')
c.width = w
c.height = H
const cg = c.getContext('2d')
cg.imageSmoothingEnabled = true
cg.imageSmoothingQuality = 'high'
cg.save()
if (brightness !== 1) cg.filter = `brightness(${brightness.toFixed(3)})`
cg.drawImage(img, sx, 0, sw, img.height, 0, y, w, h)
cg.restore()
// Himmel nach oben, Wasser nach unten strecken
if (y > 0) cg.drawImage(c, 0, y, w, 2, 0, 0, w, y + 2)
const unten = y + h
if (unten < H) cg.drawImage(c, 0, unten - 2, w, 2, 0, unten - 2, w, H - unten + 2)
return { canvas: c, w }
}
const cv = document.createElement('canvas')
cv.width = W
cv.height = H
const g = cv.getContext('2d')
g.imageSmoothingEnabled = true
g.imageSmoothingQuality = 'high'
// 1) Fortsetzung rechts, einmal verwendet, helligkeitsangeglichen. Der
// Zylinder beginnt beim Mittelmotiv und läuft nach links in den Raum
// weiter; die Bildfolge muss dieser Richtung folgen.
const fort = schicht(lImg, cfg.LEFT_CROP, cfg.LEFT_SCALE, cfg.LEFT_WATER, korrektur)
g.drawImage(fort.canvas, W - fort.w, 0)
// 2) Mittelmotiv links, unverändert, mit weicher rechter Flanke.
const vorne = schicht(fImg, null, cfg.FRONT_SCALE, cfg.FRONT_WATER, 1)
const mid = document.createElement('canvas')
mid.width = vorne.w
mid.height = H
const mg = mid.getContext('2d')
mg.drawImage(vorne.canvas, 0, 0)
mg.globalCompositeOperation = 'destination-in'
const mGrad = mg.createLinearGradient(0, 0, vorne.w, 0)
mGrad.addColorStop(0, 'rgba(0,0,0,1)')
mGrad.addColorStop(1 - cfg.FEATHER / vorne.w, 'rgba(0,0,0,1)')
mGrad.addColorStop(1, 'rgba(0,0,0,0)')
mg.fillStyle = mGrad
mg.fillRect(0, 0, vorne.w, H)
g.drawImage(mid, 0, 0)
return { dataUri: cv.toDataURL('image/jpeg', 0.84), W, H, korrektur }
}, {
front: asDataUri(SRC_FRONT),
left: asDataUri(SRC_LEFT),
H, WATER_Y, FRONT_WATER, LEFT_WATER, FRONT_SCALE, LEFT_SCALE, FEATHER, LEFT_CROP,
})
await browser.close()
writeFileSync(OUT, Buffer.from(result.dataUri.split(',')[1], 'base64'))
const kb = (readFileSync(OUT).length / 1024).toFixed(0)
console.log(`Panorama erzeugt: ${OUT}`)
console.log(` ${result.W}x${result.H}, ${kb} KB, Helligkeitsangleichung ×${result.korrektur.toFixed(3)}`)