perf: optimize homepage and logo images to webp

This commit is contained in:
2026-07-26 10:18:43 +02:00
parent fabc63edf5
commit 789a200595
50 changed files with 126 additions and 29 deletions
+97
View File
@@ -0,0 +1,97 @@
import os
from PIL import Image
from pathlib import Path
BASE_DIR = Path("/home/andre/coden/Orchestrator/privat/CV/andreknie.de")
DIRS_TO_PROCESS = [
BASE_DIR / "public/images",
BASE_DIR / "public/logos"
]
def optimize_images():
for img_dir in DIRS_TO_PROCESS:
if not img_dir.exists():
continue
# Process files directly in this directory (not recursive to avoid processing kniepunkt again)
for img_path in img_dir.iterdir():
if img_path.is_file() and img_path.suffix.lower() in [".png", ".jpg", ".jpeg"]:
if img_path.name == "favicon.png":
continue
try:
with Image.open(img_path) as img:
# Convert to RGB if necessary
if img.mode in ("RGBA", "P"):
img = img.convert("RGBA")
elif img.mode != "RGB":
img = img.convert("RGB")
# Resize if overly large
if img.width > 2000:
ratio = 2000 / img.width
new_size = (2000, int(img.height * ratio))
img = img.resize(new_size, Image.Resampling.LANCZOS)
webp_path = img_path.with_suffix(".webp")
img.save(webp_path, "WEBP", quality=80)
img_path.unlink()
print(f"Optimized: {img_path.name} -> {webp_path.name}")
except Exception as e:
print(f"Error processing {img_path}: {e}")
def update_references():
import glob
# Search all jsx, css, yaml, md files
extensions = ["jsx", "css", "yaml", "yml", "md"]
search_dirs = [
BASE_DIR / "src",
BASE_DIR / "content"
]
# Also update index.html if needed (though favicon.png is excluded from conversion)
for d in search_dirs:
for root, _, files in os.walk(d):
for file in files:
ext = file.split('.')[-1].lower()
if ext in extensions:
filepath = Path(root) / file
try:
content = filepath.read_text(encoding="utf-8")
# Replace .png, .jpg, .jpeg with .webp
# But be careful not to replace favicon.png if it's there
if "favicon.png" not in content:
new_content = content.replace(".png", ".webp").replace(".jpg", ".webp").replace(".jpeg", ".webp")
if new_content != content:
filepath.write_text(new_content, encoding="utf-8")
print(f"Updated references in: {filepath.name}")
else:
# Do a safe replace (not touching favicon.png)
lines = content.split('\n')
new_lines = []
changed = False
for line in lines:
if "favicon.png" not in line:
nl = line.replace(".png", ".webp").replace(".jpg", ".webp").replace(".jpeg", ".webp")
if nl != line:
changed = True
new_lines.append(nl)
else:
new_lines.append(line)
if changed:
filepath.write_text('\n'.join(new_lines), encoding="utf-8")
print(f"Updated references in: {filepath.name} (safe mode)")
except Exception as e:
pass
if __name__ == "__main__":
optimize_images()
update_references()
print("Optimization complete!")
+11 -11
View File
@@ -6,13 +6,13 @@ light:
- name: "Universität Kassel" - name: "Universität Kassel"
src: "/logos/uni-kassel.svg" src: "/logos/uni-kassel.svg"
- name: "Universität zu Köln" - name: "Universität zu Köln"
src: "/logos/uni-koeln.jpg" src: "/logos/uni-koeln.webp"
- name: "Hessian.AI" - name: "Hessian.AI"
src: "/logos/hessian-ai.svg" src: "/logos/hessian-ai.svg"
- name: "Science Park Kassel" - name: "Science Park Kassel"
src: "/logos/science-park-kassel.png" src: "/logos/science-park-kassel.webp"
- name: "Hübner Group" - name: "Hübner Group"
src: "/logos/huebner-group.jpg" src: "/logos/huebner-group.webp"
- name: "Deutsche Bahn" - name: "Deutsche Bahn"
src: "/logos/deutsche-bahn.svg" src: "/logos/deutsche-bahn.svg"
- name: "eoda" - name: "eoda"
@@ -24,17 +24,17 @@ light:
- name: "Hochschule Fresenius" - name: "Hochschule Fresenius"
src: "/logos/hochschule-fresenius.svg" src: "/logos/hochschule-fresenius.svg"
- name: "Studierendenwerk Kassel" - name: "Studierendenwerk Kassel"
src: "/logos/studierendenwerk-kassel.png" src: "/logos/studierendenwerk-kassel.webp"
- name: "ÖkoVision" - name: "ÖkoVision"
src: "/logos/oekovision.jpg" src: "/logos/oekovision.webp"
- name: "Arvos" - name: "Arvos"
src: "/logos/arvos.svg" src: "/logos/arvos.svg"
- name: "MTO" - name: "MTO"
src: "/logos/mto.png" src: "/logos/mto.webp"
dark: dark:
- name: "Auteba" - name: "Auteba"
src: "/logos/auteba.png" src: "/logos/auteba.webp"
- name: "Esterer" - name: "Esterer"
src: "/logos/esterer.svg" src: "/logos/esterer.svg"
- name: "VSB" - name: "VSB"
@@ -42,12 +42,12 @@ dark:
- name: "Schlachthof Kassel" - name: "Schlachthof Kassel"
src: "/logos/schlachthof-kassel.svg" src: "/logos/schlachthof-kassel.svg"
- name: "Veli" - name: "Veli"
src: "/logos/veli.png" src: "/logos/veli.webp"
- name: "Hexagon Purus" - name: "Hexagon Purus"
src: "/logos/hexagon-purus.svg" src: "/logos/hexagon-purus.svg"
- name: "Paul Beier" - name: "Paul Beier"
src: "/logos/paul-beier.png" src: "/logos/paul-beier.webp"
- name: "Sika" - name: "Sika"
src: "/logos/sika.png" src: "/logos/sika.webp"
- name: "KI Bundesverband" - name: "KI Bundesverband"
src: "/logos/ki-bundesverband.png" src: "/logos/ki-bundesverband.webp"
@@ -6,7 +6,7 @@ roles:
- "Content-Autor" - "Content-Autor"
- "Geschäftsführer, Data Hive Cassel" - "Geschäftsführer, Data Hive Cassel"
summary: "Technologie begeistert mich. Menschen noch mehr. Ich verbinde Welten, die selten zusammenkommen: Grundlagenforschung und Shopfloor. Konzernsteuerung und Startup-Garage. Algorithmen und Ängste." summary: "Technologie begeistert mich. Menschen noch mehr. Ich verbinde Welten, die selten zusammenkommen: Grundlagenforschung und Shopfloor. Konzernsteuerung und Startup-Garage. Algorithmen und Ängste."
photo: "/images/andre-knie.png" photo: "/images/andre-knie.webp"
positioning: positioning:
sovereignty: "Unabhängig von US-Hyperscalern. KI-Lösungen aus und für Deutschland." sovereignty: "Unabhängig von US-Hyperscalern. KI-Lösungen aus und für Deutschland."
data_sovereignty: "100% DSGVO- und AI-Act-konform, EU-Hosting." data_sovereignty: "100% DSGVO- und AI-Act-konform, EU-Hosting."
@@ -17,7 +17,7 @@ style:
sections: sections:
- format: keynote - format: keynote
heading: "Keynotes" heading: "Keynotes"
image: "/images/keynote-1.jpg" image: "/images/keynote-1.webp"
topics: topics:
- "KI im Mittelstand — Chancen ohne Hype" - "KI im Mittelstand — Chancen ohne Hype"
- "Digitale Souveränität: Pilot statt Passagier" - "Digitale Souveränität: Pilot statt Passagier"
@@ -26,7 +26,7 @@ sections:
- format: workshop - format: workshop
heading: "Workshops" heading: "Workshops"
image: "/images/workshop-1.jpg" image: "/images/workshop-1.webp"
topics: topics:
- "KI-Rollout in Organisationen: vom Pilot zur Einführung" - "KI-Rollout in Organisationen: vom Pilot zur Einführung"
- "KI in HR und Verwaltung: Use Cases entwickeln" - "KI in HR und Verwaltung: Use Cases entwickeln"
@@ -35,7 +35,7 @@ sections:
- format: panel - format: panel
heading: "Panels & Diskussionen" heading: "Panels & Diskussionen"
image: "/images/panel-1.jpg" image: "/images/panel-1.webp"
topics: topics:
- "KI-Regulierung und Datensouveränität" - "KI-Regulierung und Datensouveränität"
- "KI in Gesellschaft, Bildung und öffentlichem Sektor" - "KI in Gesellschaft, Bildung und öffentlichem Sektor"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -6,7 +6,7 @@ import React, { useRef, useEffect } from 'react'
* - Samples dark pixels → places dots * - Samples dark pixels → places dots
* - Dots are STATIC until mouse hovers, then they scatter and spring back * - Dots are STATIC until mouse hovers, then they scatter and spring back
* *
* For germany: uses /images/germany-outline.png * For germany: uses /images/germany-outline.webp
* For shield/tools: draws shape on offscreen canvas then samples * For shield/tools: draws shape on offscreen canvas then samples
*/ */
export default function DotCloudIcon({ shape = 'germany', size = 140 }) { export default function DotCloudIcon({ shape = 'germany', size = 140 }) {
@@ -181,7 +181,7 @@ function sampleGermany(size) {
resolve(points) resolve(points)
} }
img.onerror = () => { console.error('[DotCloudIcon] Failed to load germany image'); resolve([]) } img.onerror = () => { console.error('[DotCloudIcon] Failed to load germany image'); resolve([]) }
img.src = '/images/germany-outline.png' img.src = '/images/germany-outline.webp'
}) })
} }
@@ -320,6 +320,6 @@ function sampleToolsRightHalf(size) {
resolve(points) resolve(points)
} }
img.onerror = () => resolve([]) img.onerror = () => resolve([])
img.src = '/images/tools.jpg' img.src = '/images/tools.webp'
}) })
} }
@@ -16,7 +16,7 @@ describe('PhotoBand', () => {
// The CSS animation translates the track by -50%. If the sequence is not // The CSS animation translates the track by -50%. If the sequence is not
// rendered exactly twice, the loop "jumps" instead of being seamless. // rendered exactly twice, the loop "jumps" instead of being seamless.
it('renders the image sequence exactly twice for a seamless loop', () => { it('renders the image sequence exactly twice for a seamless loop', () => {
const images = ['/a.jpg', '/b.jpg', '/c.jpg'] const images = ['/a.webp', '/b.webp', '/c.webp']
const { container } = render(<PhotoBand images={images} />) const { container } = render(<PhotoBand images={images} />)
const rendered = [...container.querySelectorAll('.photo-band-track img')].map(img => const rendered = [...container.querySelectorAll('.photo-band-track img')].map(img =>
img.getAttribute('src') img.getAttribute('src')
@@ -30,7 +30,7 @@ describe('PhotoBand', () => {
it('keeps the duplication invariant for any non-empty image list', () => { it('keeps the duplication invariant for any non-empty image list', () => {
for (const n of [1, 2, 4, 7]) { for (const n of [1, 2, 4, 7]) {
const images = Array.from({ length: n }, (_, i) => `/img-${i}.jpg`) const images = Array.from({ length: n }, (_, i) => `/img-${i}.webp`)
const { container } = render(<PhotoBand images={images} />) const { container } = render(<PhotoBand images={images} />)
const count = container.querySelectorAll('.photo-band-track img').length const count = container.querySelectorAll('.photo-band-track img').length
expect(count).toBe(n * 2) expect(count).toBe(n * 2)
@@ -8,7 +8,7 @@ export default function SEOHead({ title, description, url, image, type = 'websit
const siteTitle = title ? `${title} | Dr. André Knie` : defaultTitle const siteTitle = title ? `${title} | Dr. André Knie` : defaultTitle
const siteDesc = description || defaultDescription const siteDesc = description || defaultDescription
const siteUrl = url ? `https://andreknie.de${url}` : "https://andreknie.de" const siteUrl = url ? `https://andreknie.de${url}` : "https://andreknie.de"
const siteImage = image || "https://andreknie.de/images/og-image.jpg" const siteImage = image || "https://andreknie.de/images/og-image.webp"
return ( return (
<Helmet> <Helmet>
+1 -1
View File
@@ -19,7 +19,7 @@ export default function About() {
{/* Profile Card */} {/* Profile Card */}
<div className="glass-panel" style={{ padding: '48px', marginBottom: '40px', display: 'flex', gap: '32px', alignItems: 'flex-start', flexWrap: 'wrap' }}> <div className="glass-panel" style={{ padding: '48px', marginBottom: '40px', display: 'flex', gap: '32px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<img src="/images/andre-knie.png" alt="Dr. André Knie" style={{ width: '140px', height: '140px', borderRadius: '50%', objectFit: 'cover', border: '3px solid rgba(var(--accent-color-rgb), 0.3)' }} /> <img src="/images/andre-knie.webp" alt="Dr. André Knie" style={{ width: '140px', height: '140px', borderRadius: '50%', objectFit: 'cover', border: '3px solid rgba(var(--accent-color-rgb), 0.3)' }} />
<div style={{ flex: 1, minWidth: '250px' }}> <div style={{ flex: 1, minWidth: '250px' }}>
<h2 style={{ marginBottom: '8px' }}>{profile?.name || 'Dr. André Knie'}</h2> <h2 style={{ marginBottom: '8px' }}>{profile?.name || 'Dr. André Knie'}</h2>
<p style={{ color: 'var(--accent-color)', marginBottom: '16px', fontSize: '0.95rem' }}> <p style={{ color: 'var(--accent-color)', marginBottom: '16px', fontSize: '0.95rem' }}>
+6 -6
View File
@@ -9,11 +9,11 @@ import PhotoBand from '../components/PhotoBand'
import './Home.css' import './Home.css'
const PHOTO_BAND_IMAGES = [ const PHOTO_BAND_IMAGES = [
'/images/workshop-1.jpg', '/images/workshop-1.webp',
'/images/keynote-1.jpg', '/images/keynote-1.webp',
'/images/workshop-2.jpg', '/images/workshop-2.webp',
'/images/panel-1.jpg', '/images/panel-1.webp',
'/images/workshop-3.jpg', '/images/workshop-3.webp',
] ]
export default function Home() { export default function Home() {
@@ -24,7 +24,7 @@ export default function Home() {
{/* Hero */} {/* Hero */}
<section className="hero"> <section className="hero">
<div className="app-container hero-content"> <div className="app-container hero-content">
<img src="/images/andre-knie.png" alt="Dr. André Knie" className="hero-photo" /> <img src="/images/andre-knie.webp" alt="Dr. André Knie" className="hero-photo" />
<h1> <h1>
{profile?.name || 'Dr. André Knie'} {profile?.name || 'Dr. André Knie'}
</h1> </h1>
@@ -18,7 +18,7 @@ export default function Speaking() {
{/* Speaker Bio */} {/* Speaker Bio */}
{speaking?.bio && ( {speaking?.bio && (
<div className="glass-panel" style={{ padding: '32px', maxWidth: '900px', margin: '0 auto 48px', display: 'flex', gap: '28px', alignItems: 'flex-start', flexWrap: 'wrap' }}> <div className="glass-panel" style={{ padding: '32px', maxWidth: '900px', margin: '0 auto 48px', display: 'flex', gap: '28px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<img src="/images/andre-knie.png" alt="Dr. André Knie" style={{ width: '100px', height: '100px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(var(--accent-color-rgb), 0.3)', flexShrink: 0 }} /> <img src="/images/andre-knie.webp" alt="Dr. André Knie" style={{ width: '100px', height: '100px', borderRadius: '50%', objectFit: 'cover', border: '2px solid rgba(var(--accent-color-rgb), 0.3)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: '250px' }}> <div style={{ flex: 1, minWidth: '250px' }}>
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, margin: '0 0 16px' }}>{speaking.bio}</p> <p style={{ color: 'var(--text-secondary)', lineHeight: 1.7, margin: '0 0 16px' }}>{speaking.bio}</p>
{speaking.style && ( {speaking.style && (