/** * 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)}`)