fix: media thumbnails, product dimensions, inline 3D viewer, GLB export
Bug A: Media Library thumbnails were gray because <img src> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -9,9 +9,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.domains.auth.models import User
|
||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||
from app.domains.media.schemas import MediaAssetOut
|
||||
from app.domains.media import service
|
||||
from app.utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/media", tags=["media"], redirect_slashes=False)
|
||||
|
||||
@@ -44,6 +46,9 @@ async def _resolve_thumbnails_bulk(db: AsyncSession, assets: list) -> None:
|
||||
|
||||
# 2. Fallback: product's cad_file_id → CAD thumbnail endpoint
|
||||
from app.domains.products.models import Product
|
||||
from sqlalchemy import text
|
||||
# products has RLS — bypass for this internal read-only lookup
|
||||
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
|
||||
prod_rows = await db.execute(
|
||||
select(Product.id, Product.cad_file_id).where(Product.id.in_(product_ids))
|
||||
)
|
||||
@@ -69,6 +74,9 @@ async def list_assets(
|
||||
asset_types: list[MediaAssetType] = Query(default=[]),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
sort_by: str = Query("created_at"),
|
||||
sort_dir: str = Query("desc"),
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
assets = await service.list_media_assets(
|
||||
@@ -80,6 +88,8 @@ async def list_assets(
|
||||
asset_types=asset_types if asset_types else None,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
)
|
||||
for a in assets:
|
||||
a.download_url = service.get_download_url(a)
|
||||
@@ -100,7 +110,11 @@ async def get_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.api_route("/{asset_id}/download", methods=["GET", "HEAD"])
|
||||
async def download_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
async def download_asset(
|
||||
asset_id: uuid.UUID,
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Proxy file content directly — avoids internal MinIO hostname issues."""
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from pathlib import Path
|
||||
@@ -112,14 +126,28 @@ async def download_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
mime = asset.mime_type or "application/octet-stream"
|
||||
|
||||
# Local file path (absolute or relative to UPLOAD_DIR)
|
||||
from app.config import settings
|
||||
candidate = Path(key)
|
||||
if not candidate.is_absolute():
|
||||
from app.config import settings
|
||||
candidate = Path(settings.UPLOAD_DIR) / key
|
||||
candidate = Path(settings.upload_dir) / key
|
||||
# Legacy path remapping: /shared/renders/{uuid}/{file} → UPLOAD_DIR/renders/{uuid}/{file}
|
||||
if not candidate.exists() and "/shared/renders/" in key:
|
||||
import logging
|
||||
parts = key.split("/")
|
||||
if len(parts) >= 2:
|
||||
remapped = Path(settings.upload_dir) / "renders" / parts[-2] / parts[-1]
|
||||
if remapped.exists():
|
||||
logging.getLogger(__name__).warning(
|
||||
"Remapped legacy path %s → %s", key, remapped
|
||||
)
|
||||
candidate = remapped
|
||||
if candidate.exists():
|
||||
ext = candidate.suffix.lstrip(".")
|
||||
fname = f"{asset.asset_type.value}_{asset_id}.{ext or 'bin'}"
|
||||
return FileResponse(str(candidate), media_type=mime, filename=fname)
|
||||
return FileResponse(
|
||||
str(candidate), media_type=mime, filename=fname,
|
||||
headers={"Cache-Control": "max-age=3600, public"},
|
||||
)
|
||||
|
||||
# Fall back to MinIO
|
||||
try:
|
||||
@@ -130,7 +158,10 @@ async def download_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=mime,
|
||||
headers={"Content-Disposition": f"attachment; filename={fname}"},
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={fname}",
|
||||
"Cache-Control": "max-age=3600, public",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
raise HTTPException(404, "File not available")
|
||||
@@ -139,6 +170,7 @@ async def download_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)
|
||||
@router.post("/zip")
|
||||
async def zip_download(
|
||||
asset_ids: list[uuid.UUID],
|
||||
_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
assets = []
|
||||
@@ -150,18 +182,42 @@ async def zip_download(
|
||||
raise HTTPException(404, "No assets found")
|
||||
|
||||
def generate():
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from app.core.storage import get_storage
|
||||
logger = logging.getLogger(__name__)
|
||||
buf = io.BytesIO()
|
||||
seen_names: dict[str, int] = {}
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
from app.core.storage import get_storage
|
||||
storage = get_storage()
|
||||
for a in assets:
|
||||
ext = (a.mime_type or "").split("/")[-1] or "bin"
|
||||
fname = f"{a.asset_type.value}_{a.id}.{ext}"
|
||||
key = a.storage_key
|
||||
# Use filename from storage_key (always has correct extension)
|
||||
original_name = Path(key).name
|
||||
ext = Path(key).suffix.lstrip(".") or (a.mime_type or "").split("/")[-1] or "bin"
|
||||
base = original_name if original_name else f"{a.asset_type.value}_{a.id}.{ext}"
|
||||
# Deduplicate filenames within the ZIP
|
||||
if base in seen_names:
|
||||
seen_names[base] += 1
|
||||
stem = Path(base).stem
|
||||
suffix = Path(base).suffix
|
||||
fname = f"{stem}_{seen_names[base]}{suffix}"
|
||||
else:
|
||||
seen_names[base] = 0
|
||||
fname = base
|
||||
try:
|
||||
data = storage.download_bytes(a.storage_key)
|
||||
# Check absolute path first (local filesystem)
|
||||
candidate = Path(key)
|
||||
if not candidate.is_absolute():
|
||||
from app.config import settings
|
||||
candidate = Path(settings.upload_dir) / key
|
||||
if candidate.exists():
|
||||
data = candidate.read_bytes()
|
||||
else:
|
||||
data = storage.download_bytes(key)
|
||||
zf.writestr(fname, data)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("ZIP: skipping asset %s — %s", a.id, exc)
|
||||
yield buf.getvalue()
|
||||
|
||||
return StreamingResponse(
|
||||
@@ -177,3 +233,12 @@ async def archive_asset(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db))
|
||||
if not asset:
|
||||
raise HTTPException(404, "Asset not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{asset_id}/permanent")
|
||||
async def delete_asset_permanent(asset_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
"""Permanently remove a MediaAsset record from the database."""
|
||||
deleted = await service.delete_media_asset(db, asset_id)
|
||||
if not deleted:
|
||||
raise HTTPException(404, "Asset not found")
|
||||
return {"ok": True}
|
||||
|
||||
Reference in New Issue
Block a user