feat(agenten): Porträts in 512×512 aus den Originalen der Präsentation

Die Porträts lagen bisher nur als kleine, liegende Zuschnitte aus einem
HTML-Konzept vor — Ferdi etwa 215×160 Bildpunkte bei 6 KB. Im 3D-Meetingraum
waren sie damit sichtbar unscharf, und Hochskalieren hilft dagegen nicht.

Die Originale liegen in `PropertyOn_Engeli_v5.pptx` als quadratische Bilder mit
1200×1200 Bildpunkten. Sie zeigen dieselben fünf Personen: Kleidung, Frisur und
Ausschnitt stimmen mit den bisherigen Zuschnitten überein, und die Präsentation
nennt Name und Funktion direkt neben jedem Bild. Jede Zuordnung wurde einzeln
gegen den alten Zuschnitt geprüft — eine frühere Quelle trug dieselben Vornamen
für eine andere Belegschaft, und ein vertauschtes Gesicht wäre schlimmer als ein
unscharfes.

`scripts/build-agent-portraits.mjs` beschneidet den Folienrand, skaliert auf
512×512 und schreibt die Dateien. Das reicht für jede Darstellung im Programm —
im Raum sind die Kreise höchstens rund 100 Bildpunkte gross — und hält das
Bündel klein: rund 30 KB je Porträt statt 250 KB im Original.

Die Verarbeitung läuft ohne Bildbibliothek auf einem Canvas im vorhandenen
Chrome, wie schon beim Basel-Panorama. Keine neue Abhängigkeit.

Geprüft: 422 Tests, Typecheck, Build, ESLint, Token-Schwelle (884/925), Szene
im Browser ohne Konsolenfehler.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin SutterandClaude Opus 5 committed 2026-08-17 15:27:20 +02:00
1 parent 4349641ee9
commit 9e825c04bf
6 files changed
+131

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
/**
* Erzeugt die Agentenporträts aus den hochauflösenden Originalen.
*
* Die Porträts im Repository stammten bis Runde 6 aus einem HTML-Konzept und
* lagen dort nur als kleine, liegende Zuschnitte vor — Ferdi etwa 215 × 160
* Bildpunkte bei 6 KB. Im 3D-Meetingraum sind sie damit sichtbar unscharf, und
* hochskalieren hilft nicht.
*
* Die Originale liegen in der Präsentation `PropertyOn_Engeli_v5.pptx` als
* quadratische Bilder mit 1200 × 1200 Bildpunkten. Sie zeigen dieselben fünf
* Personen — Kleidung, Frisur und Ausschnitt stimmen mit den bisherigen
* Zuschnitten überein, es werden also keine Gesichter getauscht.
*
* Das Skript beschneidet den Rand (die Bilder tragen oben und an den Seiten
* Reste des Folienrahmens), skaliert auf eine für den Browser sinnvolle Grösse
* und schreibt sie nach `src/assets/team/`.
*
* Einmalig auszuführen; die Ergebnisse liegen als Assets im Repository:
*
* node scripts/build-agent-portraits.mjs
*
* Es braucht keine Bildbibliothek — die Verarbeitung läuft 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 { inflateRawSync } from 'node:zlib'
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
const here = dirname(fileURLToPath(import.meta.url))
const ziel = resolve(here, '../src/assets/team')
/** Quellpräsentation. Nur zum Erzeugen nötig, nicht Teil des Repositorys. */
const QUELLE = process.env.PORTRAIT_SOURCE
?? 'C:/Users/beni_/Downloads/PropertyOn_Engeli_v5.pptx'
/**
* Zuordnung Bild → Agent.
*
* Die Reihenfolge entspricht der Titelfolie der Präsentation: Ferdi, Bruno,
* Livia, Nora, Sina. Jede Zuordnung wurde einzeln gegen den bisherigen
* Zuschnitt geprüft — eine frühere Quelle trug dieselben Vornamen für eine
* andere Belegschaft, und ein vertauschtes Gesicht wäre schlimmer als ein
* unscharfes.
*/
const ZUORDNUNG = {
'image1.jpg': 'ferdi',
'image2.jpg': 'bruno',
'image3.jpg': 'livia',
'image4.jpg': 'nora',
'image5.jpg': 'sina',
}
/** Kantenbeschnitt je Seite, als Anteil der Bildbreite. */
const BESCHNITT = 0.03
/** Zielgrösse. Reicht für jede Darstellung im Programm, bleibt klein im Bündel. */
const GROESSE = 512
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.')
if (!existsSync(QUELLE)) throw new Error(`Quelldatei fehlt: ${QUELLE}`)
/** Bilder aus dem ZIP-Container der Präsentation lesen, ohne Fremdbibliothek. */
function medienAusPptx(pfad, gesucht) {
const b = readFileSync(pfad)
const gefunden = {}
let i = 0
while (i < b.length - 4) {
if (b.readUInt32LE(i) !== 0x04034b50) { i++; continue }
const method = b.readUInt16LE(i + 8)
const csize = b.readUInt32LE(i + 18)
const nameLen = b.readUInt16LE(i + 26)
const extraLen = b.readUInt16LE(i + 28)
const name = b.slice(i + 30, i + 30 + nameLen).toString()
const start = i + 30 + nameLen + extraLen
const datei = name.split('/').pop()
if (name.startsWith('ppt/media/') && gesucht.includes(datei)) {
try {
gefunden[datei] = method === 8
? inflateRawSync(b.slice(start, start + csize))
: b.slice(start, start + csize)
} catch { /* beschädigter Eintrag — überspringen */ }
}
i = start + csize
}
return gefunden
}
const roh = medienAusPptx(QUELLE, Object.keys(ZUORDNUNG))
const fehlend = Object.keys(ZUORDNUNG).filter(f => !roh[f])
if (fehlend.length) throw new Error(`Nicht gefunden: ${fehlend.join(', ')}`)
const browser = await chromium.launch({ executablePath: CHROME })
const page = await browser.newPage()
for (const [datei, agent] of Object.entries(ZUORDNUNG)) {
const dataUri = `data:image/jpeg;base64,${roh[datei].toString('base64')}`
const ergebnis = await page.evaluate(async ({ src, beschnitt, groesse }) => {
const img = await new Promise((ok, err) => {
const el = new Image()
el.onload = () => ok(el)
el.onerror = err
el.src = src
})
const rand = Math.round(Math.min(img.width, img.height) * beschnitt)
const sw = img.width - 2 * rand
const sh = img.height - 2 * rand
const c = document.createElement('canvas')
c.width = groesse
c.height = groesse
const g = c.getContext('2d')
g.imageSmoothingEnabled = true
g.imageSmoothingQuality = 'high'
g.drawImage(img, rand, rand, sw, sh, 0, 0, groesse, groesse)
return { dataUri: c.toDataURL('image/jpeg', 0.86), quelle: `${img.width}x${img.height}` }
}, { src: dataUri, beschnitt: BESCHNITT, groesse: GROESSE })
const aus = resolve(ziel, `${agent}.jpg`)
writeFileSync(aus, Buffer.from(ergebnis.dataUri.split(',')[1], 'base64'))
const kb = (readFileSync(aus).length / 1024).toFixed(0)
console.log(`${agent.padEnd(7)} ${ergebnis.quelle} → ${GROESSE}x${GROESSE}, ${kb} KB`)
}
await browser.close()