15 KiB
Whiteboard URL-Referenzen — Implementierungsplan
Status: Planungsphase · Ziel: Base64-Bilder durch URL-Referenzen ersetzen · Erwartete Wirkung: 50+ Bilder ohne OOM
Ziel
Statt jedes Bild als 50MB Base64-String im Excalidraw-JSON zu speichern, soll nur eine URL gespeichert werden. Der Browser lädt das Bild nur beim Hinscrollen in den Viewport.
// VORHER (Status Quo):
"files": {
"abc123": {
"dataURL": "data:image/png;base64,iVBORw0KGgoAAAAA...", // ← 50 MB
"mimeType": "image/png"
}
}
// NACHHER (Ziel):
"files": {
"abc123": {
"url": "/remote.php/dav/files/Hermes/WhiteboardAssets/abc123.png",
"thumbUrl": "data:image/png;base64,...", // ← 5 KB Thumbnail (256px)
"mimeType": "image/png",
"width": 4000,
"height": 3000
}
}
RAM-Effekt:
- Vorher: 50 Bilder × 50 MB = 2,5 GB 💥
- Nachher: 50 Bilder × 5 KB (Thumbnail) + 3 geladene Vollbilder × 50 MB = ~150 MB ✅
Architektur: 3 Eingriffspunkte
┌─────────────────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ 1. IMPORT-HANDLER │
│ Whiteboard JS (NcSelect) │
│ ┌──────────────────────────────────┐ │
│ │ File dropped by user │ │
│ │ ├─ Upload full image to NC │ ← NEU: WebDAV PUT │
│ │ ├─ Generate thumbnail (256px) │ ← NEU: Canvas │
│ │ └─ Store URL in file object │ ← NEU │
│ └──────────────────────────────────┘ │
│ │
│ 2. JSON-SPEICHERUNG │
│ Excalidraw data model │
│ ┌──────────────────────────────────┐ │
│ │ fileObject.url = "/dav/..." │ ← NEU │
│ │ fileObject.thumbUrl = "data:..." │ ← NEU (256px) │
│ │ fileObject.dataURL = DEPRECATED │ │
│ └──────────────────────────────────┘ │
│ │
│ 3. RENDERING │
│ Excalidraw renderer │
│ ┌──────────────────────────────────┐ │
│ │ Viewport check → load from url? │ ← NEU: Lazy Load │
│ │ Not visible → show thumbnail │ ← NEU: LOD │
│ │ Zoom > 150% → load full image │ ← NEU: Progressive │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ WebDAV / Files API
▼
┌─────────────────────────────────────────────────────────────┐
│ NEXTCLOUD SERVER │
│ ┌──────────────────────────────────┐ │
│ │ /WhiteboardAssets/ │ ← NEU: Ordner │
│ │ ├─ abc123.png │ │
│ │ ├─ def456.jpg │ │
│ │ └─ ... │ │
│ │ CORS: Allow-Origin: * │ ← NEU: Config │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Vorbereitung: Test-Toolkit
| Tool | Zweck |
|---|---|
curl |
Server-seitige Verifikation (CORS-Header, Datei-Upload, JSON-Inhalt) |
| Playwright | Browser-Automation (Drag-Drop, Canvas-Screenshot, Console-Logs, RAM-Messung) |
| Chrome DevTools → Performance Monitor | Live RAM/Task-Manager während manuellem Test |
verify-whiteboard-patches.sh |
Regression: bestehende 6 JS-Patches noch aktiv? |
| Testbilder-Generator | PNGs mit exakten Dimensionen (500×400 bis 8000×6000) |
Testbilder generieren
ssh orange@niklashmotion.art "python3 -c \"
import struct, zlib
def create_png(w, h):
def chunk(ctype, data):
c = ctype + data
crc = struct.pack('>I', zlib.crc32(c) & 0xffffffff)
return struct.pack('>I', len(data)) + c + crc
sig = b'\\\\x89PNG\\\\r\\\\n\\\\x1a\\\\n'
ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
raw = b''
for y in range(h):
raw += b'\\\\x00' + bytes([(x+y) % 256 for x in range(w*3)])
idat = chunk(b'IDAT', zlib.compress(raw))
iend = chunk(b'IEND', b'')
return sig + ihdr + idat + iend
sizes = [(500, 400, 'small'), (2000, 1500, 'medium'), (5000, 3750, 'large'), (8000, 6000, 'xl')]
for w, h, label in sizes:
with open(f'/tmp/test-{label}-{w}x{h}.png', 'wb') as f:
f.write(create_png(w, h))
print(f'Created test-{label}-{w}x{h}.png')
\""
Phase 0: Baseline messen
Ziel: Aktuellen RAM-Verbrauch und Bildgrößen dokumentieren, um Verbesserung zu quantifizieren.
# 0.1 Bestehende Patches verifizieren
ssh orange@niklashmotion.art \
"bash /opt/data/skills/devops/whiteboard-patches/scripts/verify-whiteboard-patches.sh"
# 0.2 RAM vor/nach Board-Öffnung (Chrome Task-Manager Shift+Esc)
# 0.3 Board-JSON-Größe: 1 Bild 2000×1500px → dataURL-Länge messen
Erwartet: 2000×1500px → ~12 MB Base64 · 5000×3750px → ~70 MB Base64 · 3 große Bilder → ~200 MB RAM → OOM-Risiko
🚦 GATE Phase 0
verify-whiteboard-patches.shzeigt alle 6 Patches als aktiv- RAM-Messung dokumentiert (Baseline)
- Testbilder existieren auf orange-desktop unter
/tmp/test-*.pngErst wenn alle 3 Checks grün sind → weiter zu Phase 1.
Phase 1: CORS + WebDAV-Zugriff
1.1 Whiteboard-Assets-Ordner anlegen
source /opt/data/home/.hermes/nextcloud.env
curl -s -u "$NEXTCLOUD_USER:$NEXTCLOUD_TOKEN" -X MKCOL \
"$NEXTCLOUD_URL/remote.php/dav/files/$NEXTCLOUD_USER/WhiteboardAssets"
1.2 CORS für Bild-URLs aktivieren
Damit der Browser Bilder cross-origin laden kann:
# In /etc/nginx/sites-available/nextcloud.conf
location ~ \.(png|jpg|jpeg|gif|webp|svg)$ {
add_header Access-Control-Allow-Origin "*";
}
Nginx reload: sudo nginx -s reload
1.3 Upload-Test
source /opt/data/home/.hermes/nextcloud.env
curl -s -u "$NEXTCLOUD_USER:$NEXTCLOUD_TOKEN" -X PUT \
--data-binary @/tmp/test-small-500x400.png \
"$NEXTCLOUD_URL/remote.php/dav/files/$NEXTCLOUD_USER/WhiteboardAssets/test.png"
🚦 GATE Phase 1
curl -sI "$URL" | grep "Access-Control-Allow-Origin"→*- Upload per curl erfolgreich (HTTP 201)
- Download per Browser möglich (kein CORS-Error in Console)
- Playwright:
new Image()mit crossOrigin lädt korrekt,naturalWidth === 500Erst wenn alle 4 Checks grün sind → weiter zu Phase 2.
Phase 2: Import-Handler patchen
2.1 File-Drop-Handler in NcSelect finden
Im NcSelect-CknHatsl.chunk.mjs den Callback identifizieren, der aktuell reader.readAsDataURL() aufruft.
2.2 Durch WebDAV-Upload ersetzen
// STATT: reader.readAsDataURL(file) → dataURL
// NEU:
async function uploadToNextcloud(file, fileId) {
const ext = file.name.split('.').pop();
const url = `/remote.php/dav/files/${userId}/WhiteboardAssets/${fileId}.${ext}`;
await fetch(url, { method: 'PUT', body: file, credentials: 'include' });
return `/remote.php/dav/files/${userId}/WhiteboardAssets/${fileId}.${ext}`;
}
2.3 Thumbnail parallel generieren
// 256px Thumbnail, ~5 KB
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const scale = Math.min(256 / img.width, 256 / img.height);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const thumbDataURL = canvas.toDataURL('image/jpeg', 0.7);
2.4 File-Objekt anpassen
// fileObject speichert URL statt dataURL
fileObject.url = uploadedUrl;
fileObject.thumbUrl = thumbDataURL;
fileObject.width = img.naturalWidth;
fileObject.height = img.naturalHeight;
// fileObject.dataURL = undefined (nicht mehr setzen!)
🚦 GATE Phase 2 (Playwright-Test)
// test-phase2.js — muss alle Checks bestehen: const checks = { hasUrl: !!file?.url, // ← Bild hat URL hasThumbUrl: !!file?.thumbUrl, // ← Thumbnail generiert noDataUrl: !file?.dataURL, // ← KEIN 50MB Base64 mehr ramDelta: ramAfter - ramBefore, // ← < 5 MB (statt 50 MB) noErrors: errors.length === 0, // ← Kein fileTooBig, kein CORS }; // ALLE müssen true sein.
checks.hasUrl === truechecks.hasThumbUrl === truechecks.noDataUrl === truechecks.ramDelta < 5 * 1024 * 1024(5 MB)checks.noErrors === true- Board-JSON via curl: enthält
"url", NICHT"dataURL"verify-whiteboard-patches.shzeigt alle 6 Patches weiterhin aktivErst wenn alle 7 Checks grün sind → weiter zu Phase 3.
Phase 3: Excalidraw-Rendering patchen (LOD)
3.1 File-Objekt-Schema erweitern
Im percentages-BXMCSKIN-D6x-nnv3.chunk.mjs:
urlals neues Feld im File-Objekt akzeptierendataURLals optional/Fallback behandeln
3.2 Lazy-Loading im Renderer
// PSEUDO-CODE
if (file.url && !imageLoaded) {
// Thumbnail sofort rendern
ctx.drawImage(thumbnailImg, x, y, w, h);
// Zoom > 1.5 → Vollbild nachladen
if (zoom > 1.5 && !fullImageLoading) {
fullImageLoading = true;
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = file.url;
img.onload = () => {
file._fullImageLoaded = true;
fullImageLoading = false;
// Re-render mit Vollbild
};
}
}
3.3 Viewport-Culling
function isInViewport(element, scrollX, scrollY, zoom, viewportW, viewportH) {
const elX = element.x + scrollX;
const elY = element.y + scrollY;
return elX + element.width * zoom > -viewportW/2
&& elX < viewportW + viewportW/2
&& elY + element.height * zoom > -viewportH/2
&& elY < viewportH + viewportH/2;
}
🚦 GATE Phase 3 (Playwright-Test)
// test-phase3.js — Board mit 10 Bildern: const state = { ramAllThumbnails: performance.memory.usedJSHeapSize, // ← < 100 MB fullResCount: countWhere(files, f => f._fullImageLoaded), // ← 0 (nur Thumbnails) }; // Reinzoomen auf Bild #1 (zoom = 2.0): const afterZoom = { fullResCount: countWhere(files, f => f._fullImageLoaded), // ← 1–3 (sichtbare) thumbnailCount: countWhere(files, f => !f._fullImageLoaded), // ← 7–9 }; // Rauszoomen (zoom = 0.5): const afterUnload = { ramAfter: performance.memory.usedJSHeapSize, // ← nahe ramAllThumbnails };
- RAM 10 Thumbnails < 100 MB
- Vor Zoom: 0 Vollbilder geladen
- Nach Zoom 2.0×: 1–3 Vollbilder geladen, Rest Thumbnails
- Nach Zoom 0.5×: RAM sinkt (Vollbilder entladen)
- Kein visuelles Flackern bei Thumbnail→Vollbild-Wechsel
verify-whiteboard-patches.shweiterhin grünErst wenn alle 6 Checks grün sind → weiter zu Phase 4.
Phase 4: Migration + Stress-Test
4.1 Bestehende Boards migrieren
// Beim Öffnen: dataURL → Blob → WebDAV-Upload → url setzen
for (const [fileId, file] of Object.entries(files)) {
if (file.dataURL && !file.url) {
const blob = dataURLtoBlob(file.dataURL);
const url = await uploadToNextcloud(blob, fileId);
file.url = url;
file.thumbUrl = await generateThumbnail(blob);
delete file.dataURL;
}
}
4.2 50-Bilder-Stress-Test
// Playwright: 50 Bilder nacheinander importieren
for (let i = 0; i < 50; i++) {
await page.dropFile('canvas', '/tmp/test-medium-2000x1500.png');
await page.waitForTimeout(500);
}
const ram = await page.evaluate(() => performance.memory.usedJSHeapSize);
// Speichern + neu laden + Prüfen: kein Datenverlust
🚦 GATE Phase 4
- Bestehendes Board mit dataURL-Bildern öffnet OHNE Fehler
- Nach Migration: Board-JSON via curl → alle Bilder haben
url, keinedataURL- 50 Bilder importiert → RAM < 200 MB
- Board speichern + neu laden → alle 50 Bilder sichtbar (Thumbnails)
- Netzwerk-Fehler-Simulation: Bild lädt auch ohne WebDAV (Thumbnail-Fallback)
verify-whiteboard-patches.shweiterhin grün
Entscheidungen (vor Implementierung zu klären)
| Frage | Optionen |
|---|---|
| Wo Bilder speichern? | A) /WhiteboardAssets/ (eigener Ordner) · B) gleicher Ordner wie Whiteboard-Datei |
| Thumbnail-Größe? | 256px (5 KB) vs. 512px (12 KB) |
| Lazy-Load-Zoom-Schwelle? | 1.0× (immer lazy) vs. 1.5× vs. 2.0× |
| Auth-Methode im JS? | A) Session-Cookie · B) App-Passwort hardcoded |
| Migration automatisieren? | Ja (beim Öffnen) vs. Nein (nur neue Bilder) |
Aufwandsschätzung
| Phase | Aufwand | Tests |
|---|---|---|
| Phase 1: CORS + WebDAV | 30 min | 4 Checks |
| Phase 2: Import-Handler | 2–4 h | 7 Checks |
| Phase 3: Rendering/LOD | 4–8 h | 6 Checks |
| Phase 4: Migration + Stress | 1–2 h | 6 Checks |
| Total | 1–2 Tage | 23 Gates |
Failure Modes (worauf achten)
| Symptom | Ursache | Prüfung |
|---|---|---|
hasUrl = false nach Import |
Import-Patch nicht aktiv | Console-Log auf Fehler |
| CORS-Error in Console | Nginx-CORS-Header fehlt | curl -sI | grep Access-Control |
| Bild nicht sichtbar | crossOrigin nicht gesetzt |
img.crossOrigin = 'anonymous' |
| RAM steigt trotz URLs | Vollbild sofort geladen | Viewport-Check debuggen |
| Board crasht beim Öffnen | Migration schlägt fehl | try/catch + Fallback auf dataURL |
| Browser-Cache zeigt alte JS | Cachebuster nicht erhöht | curl -sI | grep cachebuster |
| Bestehende Patches kaputt | Seiteneffekte in Bundle | verify-whiteboard-patches.sh |