# 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 ```bash 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. ```bash # 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.sh` zeigt alle 6 Patches als aktiv > - [ ] RAM-Messung dokumentiert (Baseline) > - [ ] Testbilder existieren auf orange-desktop unter `/tmp/test-*.png` > > **Erst wenn alle 3 Checks grün sind → weiter zu Phase 1.** --- ## Phase 1: CORS + WebDAV-Zugriff ### 1.1 Whiteboard-Assets-Ordner anlegen ```bash 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: ```nginx # 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 ```bash 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 === 500` > > **Erst 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 ```javascript // 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 ```javascript // 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 ```javascript // 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) > ```javascript > // 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 === true` > - [ ] `checks.hasThumbUrl === true` > - [ ] `checks.noDataUrl === true` > - [ ] `checks.ramDelta < 5 * 1024 * 1024` (5 MB) > - [ ] `checks.noErrors === true` > - [ ] Board-JSON via curl: enthält `"url"`, NICHT `"dataURL"` > - [ ] `verify-whiteboard-patches.sh` zeigt alle 6 Patches weiterhin aktiv > > **Erst 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`: - `url` als neues Feld im File-Objekt akzeptieren - `dataURL` als optional/Fallback behandeln ### 3.2 Lazy-Loading im Renderer ```javascript // 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 ```javascript 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) > ```javascript > // 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.sh` weiterhin grün > > **Erst wenn alle 6 Checks grün sind → weiter zu Phase 4.** --- ## Phase 4: Migration + Stress-Test ### 4.1 Bestehende Boards migrieren ```javascript // 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 ```javascript // 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`, keine `dataURL` > - [ ] 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.sh` weiterhin 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` |