Performance-Optimierung: 9.9 MB → 124 KB (-98.7%)

- /media/<filename> Endpunkt mit Cache-Control (max-age=86400, ETag)
- Thumbnail-Generator für Bilder (300px via PIL, on-the-fly)
- Frontend-Karten: URLs statt Base64 + Thumbnails + loading=lazy
- Admin-Detailseite + öffentliche Detailseite ebenfalls auf URLs
- Video-Thumbnails als separate Dateien via /media/ statt Base64
This commit is contained in:
Hermes Agent
2026-06-17 20:02:35 +02:00
parent eba3757572
commit a4a40fd2e7
+66 -29
View File
@@ -1074,24 +1074,15 @@ def submission_detail(sub_id: int):
_, ext = os.path.splitext(path) _, ext = os.path.splitext(path)
mime = mime_map.get(ext.lower(), "application/octet-stream") mime = mime_map.get(ext.lower(), "application/octet-stream")
try: try:
with open(path, "rb") as f: basename = os.path.basename(path)
b64 = base64.b64encode(f.read()).decode() src = f"/media/{basename}"
src = f"data:{mime};base64,{b64}"
media_html += f'<div class="media-item" style="margin:0.5rem 0;background:var(--surface);border-radius:var(--radius-sm);overflow:hidden;">' media_html += f'<div class="media-item" style="margin:0.5rem 0;background:var(--surface);border-radius:var(--radius-sm);overflow:hidden;">'
# Media element # Media element
if mime.startswith("image/"): if mime.startswith("image/"):
media_html += f'<img src="{src}" style="width:100%;max-height:400px;object-fit:contain;display:block;background:#000;">' media_html += f'<img src="{src}" style="width:100%;max-height:400px;object-fit:contain;display:block;background:#000;" loading="lazy">'
elif mime.startswith("video/"): elif mime.startswith("video/"):
# Generate thumbnail for poster
thumb = generate_video_thumbnail(path) thumb = generate_video_thumbnail(path)
poster_attr = "" poster_attr = f' poster="/media/{os.path.basename(thumb)}"' if thumb else ""
if thumb:
try:
with open(thumb, "rb") as tf:
thumb_b64 = base64.b64encode(tf.read()).decode()
poster_attr = f' poster="data:image/jpeg;base64,{thumb_b64}"'
except Exception:
pass
media_html += f'<video controls preload="metadata"{poster_attr} style="width:100%;max-height:400px;background:#000;"><source src="{src}" type="{mime}"></video>' media_html += f'<video controls preload="metadata"{poster_attr} style="width:100%;max-height:400px;background:#000;"><source src="{src}" type="{mime}"></video>'
elif mime.startswith("audio/"): elif mime.startswith("audio/"):
media_html += f'<audio controls style="width:100%;padding:0.8rem;"><source src="{src}" type="{mime}"></audio>' media_html += f'<audio controls style="width:100%;padding:0.8rem;"><source src="{src}" type="{mime}"></audio>'
@@ -1711,17 +1702,21 @@ def public_frontend():
continue continue
if not os.path.exists(path): if not os.path.exists(path):
continue continue
basename = os.path.basename(path)
_, ext = os.path.splitext(path) _, ext = os.path.splitext(path)
mime = mime_map.get(ext.lower(), "application/octet-stream") mime = mime_map.get(ext.lower(), "application/octet-stream")
try: try:
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
src = "data:" + mime + ";base64," + b64
if mime.startswith("image/"): if mime.startswith("image/"):
# Use thumbnail for frontend cards
thumb = generate_image_thumbnail(path)
if thumb:
img_src = f"/media/{os.path.basename(thumb)}"
else:
img_src = f"/media/{basename}"
img_tag = ( img_tag = (
'<a href="/beitrag/' + str(r["id"]) + '" class="media-preview-link">' '<a href="/beitrag/' + str(r["id"]) + '" class="media-preview-link">'
'<div class="media-crop" style="width:100%;height:160px;overflow:hidden;border-radius:8px;">' '<div class="media-crop" style="width:100%;height:160px;overflow:hidden;border-radius:8px;">'
'<img src="' + src + '" alt="Beitragsbild" loading="lazy" style="width:100%;height:100%;object-fit:cover;">' '<img src="' + img_src + '" alt="Beitragsbild" loading="lazy" style="width:100%;height:100%;object-fit:cover;">'
'</div></a>' '</div></a>'
) )
if i == 0 and not media_first: if i == 0 and not media_first:
@@ -1730,16 +1725,11 @@ def public_frontend():
media_html += img_tag media_html += img_tag
elif mime.startswith("video/"): elif mime.startswith("video/"):
thumb = generate_video_thumbnail(path) thumb = generate_video_thumbnail(path)
if thumb: thumb_src = f"/media/{os.path.basename(thumb)}" if thumb else ""
with open(thumb, "rb") as f:
thumb_b64 = base64.b64encode(f.read()).decode()
thumb_src = "data:image/jpeg;base64," + thumb_b64
else:
thumb_src = ""
vid_tag = ( vid_tag = (
'<a href="/beitrag/' + str(r["id"]) + '" class="video-thumb-link">' '<a href="/beitrag/' + str(r["id"]) + '" class="video-thumb-link">'
'<div class="video-thumb" style="position:relative;background:#000;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;overflow:hidden;">' '<div class="video-thumb" style="position:relative;background:#000;aspect-ratio:2/3;display:flex;align-items:center;justify-content:center;overflow:hidden;">'
+ (f'<img src="{thumb_src}" alt="Video-Vorschau" style="width:100%;height:100%;object-fit:cover;opacity:0.7;">' if thumb_src else '<div style="color:#555;font-size:3rem;">🎬</div>') + (f'<img src="{thumb_src}" alt="Video-Vorschau" loading="lazy" style="width:100%;height:100%;object-fit:cover;opacity:0.7;">' if thumb_src else '<div style="color:#555;font-size:3rem;">🎬</div>')
+ '<div class="play-btn" style="position:absolute;width:60px;height:60px;background:rgba(255,255,255,0.9);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(0,0,0,0.4);">' + '<div class="play-btn" style="position:absolute;width:60px;height:60px;background:rgba(255,255,255,0.9);border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px rgba(0,0,0,0.4);">'
+ '<svg width="24" height="28" viewBox="0 0 24 28"><path d="M0 0v28l24-14z" fill="#20228a"/></svg>' + '<svg width="24" height="28" viewBox="0 0 24 28"><path d="M0 0v28l24-14z" fill="#20228a"/></svg>'
+ '</div></div></a>' + '</div></div></a>'
@@ -2059,7 +2049,7 @@ def public_frontend():
<div class="chat-body" id="chatBody"> <div class="chat-body" id="chatBody">
<div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;"> <div style="display:flex;align-items:flex-end;gap:0.3rem;align-self:flex-start;">
<div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div> <div class="chat-msg assistant">Hey! 👋 Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!</div>
<button onclick="(function(t){var u=new SpeechSynthesisUtterance(t);u.lang='de-DE';u.rate=0.95;var v=speechSynthesis.getVoices();var th=v.find(function(x){return x.name==='Thorsten'});if(th)u.voice=th;else{var d=v.find(function(x){return x.lang.startsWith('de')});if(d)u.voice=d}speechSynthesis.cancel();speechSynthesis.speak(u)})('Hey! Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!')" title="Vorlesen" style="background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;" onmouseenter="this.style.background='rgba(32,34,138,0.18)';this.style.borderColor='rgba(32,34,138,0.4)'" onmouseleave="this.style.background='rgba(32,34,138,0.08)';this.style.borderColor='rgba(32,34,138,0.2)'">🔊</button> <button id="greetingSpeakBtn" title="Vorlesen" style="background:rgba(32,34,138,0.08);border:1px solid rgba(32,34,138,0.2);border-radius:8px;cursor:pointer;font-size:0.9rem;padding:0.15rem 0.4rem;transition:all 0.15s;flex-shrink:0;" onmouseenter="this.style.background='rgba(32,34,138,0.18)';this.style.borderColor='rgba(32,34,138,0.4)'" onmouseleave="this.style.background='rgba(32,34,138,0.08)';this.style.borderColor='rgba(32,34,138,0.2)'">🔊</button>
</div> </div>
</div> </div>
<div class="chat-footer"> <div class="chat-footer">
@@ -2261,6 +2251,14 @@ def public_frontend():
bubble.addEventListener('click', toggle); bubble.addEventListener('click', toggle);
closeBtn.addEventListener('click', () => { open = false; overlay.classList.remove('open'); }); closeBtn.addEventListener('click', () => { open = false; overlay.classList.remove('open'); });
// Wire up greeting speak button
const greetBtn = document.getElementById('greetingSpeakBtn');
if (greetBtn) {
greetBtn.addEventListener('click', () => {
speak('Hey! Ich bin der BSN Community Assistant. Frag mich nach Spielen, Ständen oder was auf der SPIEL Essen los ist!');
});
}
function addMsg(role, text) { function addMsg(role, text) {
const wrapper = document.createElement('div'); const wrapper = document.createElement('div');
wrapper.style.display = 'flex'; wrapper.style.display = 'flex';
@@ -2374,6 +2372,44 @@ def public_frontend():
def generate_image_thumbnail(image_path: str, width: int = 300) -> str | None:
"""Generate a thumbnail for an image. Returns path to .thumb.jpg or None."""
thumb_path = image_path + ".thumb.jpg"
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 100:
return thumb_path
try:
from PIL import Image
img = Image.open(image_path)
img.thumbnail((width, width * 3), Image.LANCZOS)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
img.save(thumb_path, 'JPEG', quality=75)
return thumb_path
except Exception:
return None
@app.route('/media/<path:filename>')
def serve_media(filename):
"""Serve media files with aggressive caching headers."""
safe = os.path.normpath(filename)
if '..' in safe or safe.startswith('/'):
return '', 404
filepath = MEDIA_DIR / safe
if not filepath.is_file():
# Try with .thumb.jpg suffix
filepath_thumb = MEDIA_DIR / (safe + '.thumb.jpg')
if filepath_thumb.is_file():
filepath = filepath_thumb
else:
return '', 404
from flask import send_file
response = send_file(str(filepath))
response.headers['Cache-Control'] = 'public, max-age=86400, immutable'
response.headers['ETag'] = f'"{filepath.stat().st_mtime}-{filepath.stat().st_size}"'
return response
@app.route('/api/query') @app.route('/api/query')
def api_query(): def api_query():
'''Instant lookup: find submissions by hall, keyword, or sender.''' '''Instant lookup: find submissions by hall, keyword, or sender.'''
@@ -2682,14 +2718,15 @@ def public_detail(sub_id: int):
_, ext = os.path.splitext(path) _, ext = os.path.splitext(path)
mime = mime_map.get(ext.lower(), "application/octet-stream") mime = mime_map.get(ext.lower(), "application/octet-stream")
try: try:
with open(path, "rb") as f: basename = os.path.basename(path)
b64 = base64.b64encode(f.read()).decode() src = f"/media/{basename}"
src = "data:" + mime + ";base64," + b64
if mime.startswith("image/"): if mime.startswith("image/"):
media_html += f'<img src="{src}" alt="Bild" style="width:100%;height:auto;border-radius:12px;margin:0.5rem 0;">' media_html += f'<img src="{src}" alt="Bild" style="width:100%;height:auto;border-radius:12px;margin:0.5rem 0;">'
elif mime.startswith("video/"): elif mime.startswith("video/"):
thumb = generate_video_thumbnail(path)
poster = f' poster="/media/{os.path.basename(thumb)}"' if thumb else ""
media_html += ( media_html += (
f'<video controls playsinline style="width:100%;max-height:80vh;border-radius:12px;margin:0.5rem 0;background:#000;">' f'<video controls playsinline{poster} style="width:100%;max-height:80vh;border-radius:12px;margin:0.5rem 0;background:#000;">'
f'<source src="{src}" type="{mime}"></video>' f'<source src="{src}" type="{mime}"></video>'
) )
elif mime.startswith("audio/"): elif mime.startswith("audio/"):