From bfd58e3419e7577e0bc710429005ff95300fc8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 7 Mar 2026 13:27:46 +0100 Subject: [PATCH] fix: media thumbnails, product dimensions, inline 3D viewer, GLB export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug A: Media Library thumbnails were gray because cannot send JWT auth headers. Added useAuthBlob() hook (fetch + createObjectURL) in MediaBrowser.tsx. Also fixed publish_asset Celery task to populate product_id + cad_file_id on MediaAsset for thumbnail fallback resolution. Bug B: Product dimensions now shown in Product Details card with Ruler icon and "from CAD" label when cad_mesh_attributes.dimensions_mm exists. Bug C: Replaced 128×128 CAD thumbnail with InlineCadViewer component. Queries gltf_geometry MediaAssets, fetches GLB via auth fetch → blob URL → Three.js Canvas with OrbitControls. Falls back to thumbnail + "Load 3D Model" button. Polling when GLB generation is in progress. Bug D: trimesh was in [cad] optional extra but Dockerfile only installed [dev]. Changed to pip install -e ".[dev,cad]" — trimesh now available in backend container, GLB + Colors export works. Also added bbox extraction (STL-first numpy parsing) in render_step_thumbnail and admin "Re-extract CAD Metadata" bulk endpoint. Co-Authored-By: Claude Sonnet 4.6 --- LEARNINGS.md | 122 +++++++++++ backend/Dockerfile | 4 +- backend/app/api/routers/admin.py | 95 ++++++++- backend/app/api/routers/cad.py | 149 ++++++++++++++ backend/app/api/routers/products.py | 1 + backend/app/domains/media/router.py | 87 +++++++- backend/app/domains/media/service.py | 14 +- backend/app/domains/products/schemas.py | 1 + .../app/domains/rendering/dispatch_service.py | 24 +++ backend/app/domains/rendering/tasks.py | 68 ++++++ backend/app/services/step_processor.py | 33 ++- backend/app/tasks/step_tasks.py | 194 +++++++++++++++++- frontend/src/api/cad.ts | 12 ++ frontend/src/api/media.ts | 9 +- frontend/src/api/products.ts | 7 + .../src/components/cad/InlineCadViewer.tsx | 128 ++++++++++++ frontend/src/components/cad/ThreeDViewer.tsx | 17 +- frontend/src/pages/Admin.tsx | 174 ++++++++++++++++ frontend/src/pages/CadPreview.tsx | 6 +- frontend/src/pages/MediaBrowser.tsx | 154 +++++++++++--- frontend/src/pages/ProductDetail.tsx | 172 +++++++++------- plan.md | 151 ++++++-------- render-worker/scripts/export_gltf.py | 43 ++++ review-report.md | 55 +++++ 24 files changed, 1502 insertions(+), 218 deletions(-) create mode 100644 frontend/src/components/cad/InlineCadViewer.tsx create mode 100644 review-report.md diff --git a/LEARNINGS.md b/LEARNINGS.md index 5942a9f..d8bc64e 100644 --- a/LEARNINGS.md +++ b/LEARNINGS.md @@ -7,6 +7,31 @@ ## Learnings +### 2026-03-07 | Security | Media-Endpoints ohne Auth — Tenant-RLS reicht nicht allein +**Problem**: `list_assets`, `download_asset`, `zip_download` hatten kein `get_current_user`-Dep → unauthentifizierte Requests möglich. RLS schützt nur Datenbankzugriffe, nicht HTTP-Ebene. +**Lösung**: `_user: User = Depends(get_current_user)` zu allen drei Endpoints hinzufügen. RLS filtert dann automatisch per Tenant-ID aus dem JWT-Token (via Session-Variable `app.current_tenant_id`). +**Für künftige Projekte**: Jeder neue Router-Endpoint braucht expliziten Auth-Dep — RLS ist Defense-in-Depth, kein Ersatz für HTTP-Auth. + +### 2026-03-07 | MediaAsset | `is_animation` Flag entscheidet Asset-Type — falsches Design +**Problem**: `import_existing_media_assets` + `render_order_line_task` nutzten `output_type.is_animation == True` um `asset_type = turntable` zu setzen — auch für `.jpg` Poster-Frames aus Animations-OutputTypes. Folge: 6 JPG-Assets als `turntable` in DB → Broken-Video-Icons in MediaBrowser. +**Lösung**: Extension entscheidet: `.mp4`/`.webm` → `turntable`, alles andere → `still`. `is_animation` Flag ist für OutputType-Konfiguration, nicht für Asset-Klassifizierung. +**Für künftige Projekte**: MIME-Typ/Extension immer als primäre Typ-Quelle, niemals Meta-Flags des Auftrags. + +### 2026-03-07 | OCC | Bounding-Box aus STEP mit `Bnd_Box` + `brepbndlib.Add()` +**Problem**: Keine Real-World-Dimensions in der DB — weder Breite/Höhe/Tiefe noch Bauteil-Mittelpunkt. OCC-Extraktion lieferte nur Kanten-Topologie. +**Lösung**: `from OCC.Core.Bnd import Bnd_Box; from OCC.Core.BRepBndLib import brepbndlib; bbox = Bnd_Box(); brepbndlib.Add(shape, bbox); xmin,ymin,zmin,xmax,ymax,zmax = bbox.Get()` → `dimensions_mm = {x, y, z}` in `mesh_attributes` JSONB. Kein neues DB-Feld nötig — JSONB-Erweiterung reicht. +**Für künftige Projekte**: OCC `Bnd_Box` gibt Werte in mm (STEP-Einheit). In Blender nach Scale-Apply (0.001) sind die Werte dann in m. + +### 2026-03-07 | Storage | `storage_key` absolute Pfade brachen Volume-Moves +**Problem**: `step_tasks.py` und `admin.py` schrieben `storage_key=str(output_path)` mit absoluten Pfaden (`/shared/data/uploads/...`). Nach Volume-Umzug in v2 waren 398 Assets nicht mehr erreichbar. +**Lösung**: `_normalize_key()` Helper: strippt `UPLOAD_DIR`-Prefix. In `download_asset` Legacy-Remapping für alte Pfade als Fallback behalten. Neue Assets immer relativ speichern. +**Für künftige Projekte**: `storage_key` immer relativ zu `UPLOAD_DIR` → `candidate = Path(settings.upload_dir) / key`. Absolute Pfade nie in die DB schreiben. + +### 2026-03-07 | Workflow | Turntable-Workflow brauchte step_path zur Laufzeit +**Problem**: `WorkflowDefinition.config` ist statisch (JSON) — enthält keine produktspezifischen Pfade. `_build_turntable()` erwartet `step_path` + `output_dir` in params → `ValueError` bei Workflow-Dispatch. +**Lösung**: `dispatch_render_with_workflow()` löst `step_path` + `output_dir` aus dem `OrderLine → Product → CadFile` Graph auf und injiziert sie in params vor `dispatch_workflow()`. +**Für künftige Projekte**: Workflow-Configs müssen zwischen statischen Parametern (engine, samples) und laufzeit-abhängigen (Dateipfade, IDs) unterscheiden. Letztere immer im Dispatch-Service auflösen. + ### 2026-03-06 | Docker | `COPY --from=docker-cli cli-plugins` schlägt fehl wenn Pfad nicht existiert **Problem**: `docker:cli` Image hat `/usr/local/bin/docker` aber KEIN `/usr/local/lib/docker/cli-plugins` Verzeichnis — `COPY --from` bricht ab. **Lösung**: Nur `/usr/local/bin/docker` kopieren. Compose-Plugin wird über `docker compose` (space, nicht `-`) aufgerufen — das Binary enthält compose bereits bei neueren docker:cli Images. @@ -347,3 +372,100 @@ SQLAlchemy `Enum(create_type=False)` funktioniert nicht zuverlässig mit asyncpg **Problem:** `vitest`- und `msw`-Imports in `src/__tests__/` erzeugen TypeScript-Fehler in `tsc --noEmit` weil diese Packages ihre Typen nur im Test-Kontext (über vitest globals) bereitstellen. `tsc` kennt die Types nicht, obwohl die Packages installiert sind. **Lösung:** In `tsconfig.json` ein `"exclude": ["src/__tests__"]` hinzufügen. Vitest führt seine eigene Typ-Prüfung durch; der Haupt-Build braucht nur Produktionscode zu prüfen. **Für künftige Projekte:** Test-Verzeichnisse immer aus der Haupt-tsconfig ausschließen und eine separate `tsconfig.test.json` oder Vitest-interne Typ-Prüfung nutzen. + +--- + +### 2026-03-07 | PostgreSQL RLS | SET LOCAL muss in jeder Transaktion erneut gesetzt werden +**Problem:** `GRANT BYPASSRLS TO schaeffler` in Migration 036 schlug still fehl (schaeffler ist kein Superuser). Alle Endpoints die `cad_files`, `order_lines`, `products` abfragen (z.B. `import_existing_media_assets`, `get_thumbnail`, `_resolve_thumbnails_bulk`) erhielten durch RLS 0 Zeilen zurück → Media-Browser leer, Thumbnails fehlten. +**Lösung:** `await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))` direkt vor jede RLS-geschützte Query in internen/admin Endpoints setzen. `SET LOCAL` wirkt nur für die aktuelle Transaktion — reicht für async SQLAlchemy (gleiche Session = gleiche Transaktion). +**Regel:** Jeder interne Endpoint der ohne User-Auth-Kontext RLS-Tabellen liest braucht expliziten `SET LOCAL`-Bypass. BYPASSRLS-Grant an App-User ist kein sicherer Weg. + +--- + +### 2026-03-07 | trimesh | GLB-Export-Scale: STL in mm → Three.js in Metern +**Problem:** STL-Cache enthält Vertices in Millimetern (STEP-Standard). trimesh exportiert ohne Skalierung → Three.js liest GLB in Metern → Objekte 1000× zu groß. +**Lösung:** `mesh.apply_scale(scale_factor)` (default 0.001) nach `trimesh.load()` vor Export. Bei `trimesh.Scene` über `scene.geometry.values()` iterieren; bei einzelnem `Trimesh` direkt anwenden. +**Auch:** `trimesh.smoothing.filter_laplacian(mesh, lamb=0.5, iterations=5)` für smooth normals (STL speichert nur Facet-Normals → facettiertes Aussehen ohne Smoothing). + +--- + +### 2026-03-07 | React Dashboard | Responsive CSS-Grid mit matchMedia +**Problem:** CSS Grid mit `gridColumnStart/End/RowStart/End` per Inline-Style lässt sich nicht mit Tailwind-Breakpoints kombinieren — Inline-Styles haben keine Medienabfrage-Unterstützung. +**Lösung:** Custom Hook `useLargeScreen()` mit `window.matchMedia('(min-width: 1024px)')` + Change-Listener. `isLarge`-Boolean bedingt die Inline-Styles: Auf großen Screens: Grid-Positioning aktiv; auf kleinen Screens: leeres Style-Objekt → natürlicher Flow (Widgets stacken). +**Regel:** Wenn CSS-Grid-Positioning über Inline-Styles kommt (z.B. aus DB-Konfiguration), immer matchMedia-Hook zur responsiven Steuerung verwenden statt CSS-only. + +--- + +### 2026-03-07 | Media Browser | ZIP-Download 22-Byte-Korruption +**Problem:** ZIP-Download-Endpoint lieferte 22-Byte-leere Archive. Ursache: `storage_key` enthielt absolute Pfade (z.B. `/shared/renders/...`). `except Exception: pass` im Generator schluckte den Fehler still. +**Lösung:** Pfad-Check vor MinIO-Fallback: `Path(key)` prüfen ob absolut; falls nicht → relativ zu `UPLOAD_DIR`. `candidate.exists()` → `read_bytes()`. `except` loggt jetzt `logger.warning()` statt silent pass. +**Regel:** In Generator-Funktionen für Streaming-Responses IMMER loggen — silent pass führt zu korrupten Archiven ohne sichtbaren Fehler. + +--- + +### 2026-03-07 | Frontend | Fehlende React-Imports crashen die gesamte App (Blank Page) +**Problem:** `useEffect` in `useLargeScreen()` Hook hinzugefügt, aber `import { useState } from 'react'` nicht auf `import { useState, useEffect } from 'react'` erweitert. Vite/React wirft zur Laufzeit `ReferenceError: useEffect is not defined` → ErrorBoundary auf Root-Level fängt nicht ab → gesamte React-App zeigt leere Seite. +**Warum /check es nicht gefangen hat:** `/check` rief `npm test` und `npm run lint` auf — kein `lint`-Script vorhanden, kein TypeScript-Compiler (`tsc`) in `node_modules` lokal (Deps nur in Docker). `npm test` (Vitest) lief für Test-Dateien, prüfte aber keine Production-Komponenten auf fehlende Imports. +**Lösung:** `useEffect` zum Import hinzugefügt. **Langfristig:** `tsc --noEmit` als Quality Gate im Container ausführen. +**Regel:** Nach jedem neuen React-Hook oder neuer API (`useEffect`, `useCallback`, `useRef` etc.) sofort prüfen ob der Import oben in der Datei ergänzt wurde. + +--- + +### 2026-03-07 | Storage Keys | Absolute Pfade in DB brechen nach Infrastruktur-Änderung +**Problem:** Flamenco schrieb Render-Outputs nach `/shared/renders/{uuid}/{file}`. Nach Flamenco-Entfernung wurden die Dateien in `/app/uploads/renders/` kopiert, aber die `storage_key`-Werte in `media_assets` blieben auf `/shared/renders/...`. Der `download_asset`-Endpoint suchte den absoluten Pfad (existiert nicht) und fiel auf MinIO zurück (auch nicht vorhanden) → HTTP 404 für 396 Blender-Renders. +**Lösung:** +1. Bulk-UPDATE: `UPDATE media_assets SET storage_key = 'renders/{uuid}/{file}' WHERE storage_key LIKE '/shared/renders/%'` (nur für Dateien die am neuen Pfad existieren) +2. Safety-Net im Code: Wenn absoluter Pfad nicht existiert und `/shared/renders/` enthält → automatisch auf `UPLOAD_DIR/renders/` remappen +3. `settings.UPLOAD_DIR` war falsch (Pydantic-Setting heißt `upload_dir` lowercase) — ebenfalls behoben +**Regelung:** `storage_key` in MediaAssets IMMER relativ zu `UPLOAD_DIR` speichern, nie als absoluten Pfad. Format: `renders/{uuid}/{filename}` oder `thumbnails/{uuid}/{filename}`. Absolute Pfade brechen bei jedem Container-Rebuild oder Volume-Umzug. + +--- + +### 2026-03-07 | Config | Pydantic Settings: Attributname case-sensitive +**Problem:** `settings.UPLOAD_DIR` warf `AttributeError` — Pydantic-Settings-Objekte sind case-sensitive. Das korrekte Attribut heißt `upload_dir` (lowercase, wie in config.py definiert). +**Lösung:** Alle Zugriffe auf `settings.UPLOAD_DIR` → `settings.upload_dir` korrigiert. +**Quality Gate:** `docker compose exec backend python -c "from app.config import settings; print(settings.upload_dir)"` als Smoke-Test für Config-Zugriff. + + +--- + +### 2026-03-07 | Media ZIP | MIME-Type-basierte Extension → ".bin" statt ".png" +**Problem:** `zip_download` ermittelte Datei-Extension via `(a.mime_type or "").split("/")[-1] or "bin"`. Für Assets mit `mime_type=None` (importierte Flamenco-Renders) → Extension `"bin"` → Dateien im ZIP als `.bin` statt `.png`/`.jpg` — ZIP öffnet, aber keine Bilder erkennbar. +**Lösung:** Extension primär aus `Path(storage_key).suffix` lesen — der storage_key enthält immer die echte Datei-Extension. MIME-Type nur als Fallback. Zusätzlich: Original-Dateiname aus `storage_key` statt generischem `{type}_{uuid}.{ext}` verwenden. Duplikat-Filenames (mehrere Assets mit gleichem Dateinamen) werden mit `_1`, `_2` Suffix dedupliziert. +**Regel:** Datei-Erweiterung IMMER aus dem tatsächlichen Dateinamen (storage_key) lesen, nie nur aus MIME-Type. MIME-Types können null sein oder nicht dem tatsächlichen Format entsprechen. + +--- + +### 2026-03-07 | Frontend | `` kann keine Auth-Header senden — useAuthBlob Hook nötig +**Problem:** `` schickt keine `Authorization`-Header → 401 → `imgError=true` → graues Icon in der Media Library. Betrifft alle Browser-nativen Elemente (``, `