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>
No files matched your search
@@ -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()
|
||||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 32 KiB |