feat(K): Blender Asset Library + production exports (GLB + .blend)
- feat(migration): 045_asset_libraries — new asset_libraries table (blend_file_path, catalog JSONB) - feat(model): AssetLibrary SQLAlchemy model in domains/materials/models.py - feat(api): POST/GET/PATCH/DELETE /api/asset-libraries + /upload-blend + /refresh-catalog endpoints - feat(celery): refresh_asset_library_catalog task on thumbnail_rendering queue — runs Blender headless - feat(blender): catalog_assets.py — extracts asset-marked materials + node_groups from .blend - feat(blender): asset_library.py — apply_asset_library_materials + apply_asset_library_node_groups helpers - feat(blender): export_gltf.py — STEP→STL→GLB production export with optional asset library - feat(blender): export_blend.py — STEP→STL→.blend production export with pack_all() - feat(frontend): api/assetLibraries.ts — full CRUD API client - feat(frontend): AssetLibraryPanel in Admin.tsx — upload, refresh, expand catalog view - docs: Blender asset_data marking requirement learning in LEARNINGS.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""asset_libraries table
|
||||
|
||||
Revision ID: 045
|
||||
Revises: 044
|
||||
Create Date: 2026-03-06
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision = "045"
|
||||
down_revision = "044"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"asset_libraries",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("tenant_id", UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("blend_file_path", sa.Text, nullable=True),
|
||||
sa.Column("original_filename", sa.String(500), nullable=True),
|
||||
sa.Column("catalog", JSONB, nullable=False, server_default='{"materials": [], "node_groups": []}'),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
||||
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime, nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("asset_libraries")
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Asset Libraries API — CRUD + .blend upload + catalog refresh."""
|
||||
import uuid
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.config import settings
|
||||
from app.domains.materials.models import AssetLibrary
|
||||
from app.utils.auth import require_admin_or_pm
|
||||
|
||||
router = APIRouter(prefix="/asset-libraries", tags=["asset-libraries"])
|
||||
|
||||
ASSET_LIB_DIR = "asset-libraries"
|
||||
|
||||
|
||||
def _asset_lib_dir() -> Path:
|
||||
d = Path(settings.upload_dir) / ASSET_LIB_DIR
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class AssetLibraryOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
original_filename: str | None
|
||||
catalog: dict
|
||||
is_active: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AssetLibraryUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
def _to_out(lib: AssetLibrary) -> dict:
|
||||
return {
|
||||
"id": str(lib.id),
|
||||
"name": lib.name,
|
||||
"description": lib.description,
|
||||
"original_filename": lib.original_filename,
|
||||
"catalog": lib.catalog or {"materials": [], "node_groups": []},
|
||||
"is_active": lib.is_active,
|
||||
"created_at": lib.created_at.isoformat(),
|
||||
"updated_at": lib.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=list[AssetLibraryOut])
|
||||
async def list_asset_libraries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_admin_or_pm),
|
||||
):
|
||||
result = await db.execute(select(AssetLibrary).order_by(AssetLibrary.name))
|
||||
return [_to_out(lib) for lib in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("", response_model=AssetLibraryOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_asset_library(
|
||||
name: str = Form(...),
|
||||
description: str | None = Form(None),
|
||||
blend_file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_admin_or_pm),
|
||||
):
|
||||
lib = AssetLibrary(
|
||||
name=name,
|
||||
description=description,
|
||||
original_filename=blend_file.filename,
|
||||
catalog={"materials": [], "node_groups": []},
|
||||
)
|
||||
db.add(lib)
|
||||
await db.flush() # get the id
|
||||
|
||||
# Save .blend file
|
||||
dest = _asset_lib_dir() / f"{lib.id}.blend"
|
||||
with dest.open("wb") as f:
|
||||
shutil.copyfileobj(blend_file.file, f)
|
||||
lib.blend_file_path = str(dest)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(lib)
|
||||
|
||||
# Queue catalog refresh
|
||||
try:
|
||||
from app.domains.materials.tasks import refresh_asset_library_catalog
|
||||
refresh_asset_library_catalog.delay(str(lib.id))
|
||||
except Exception:
|
||||
pass # task queuing failure is non-blocking
|
||||
|
||||
return _to_out(lib)
|
||||
|
||||
|
||||
@router.get("/{lib_id}", response_model=AssetLibraryOut)
|
||||
async def get_asset_library(
|
||||
lib_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_admin_or_pm),
|
||||
):
|
||||
lib = await db.get(AssetLibrary, lib_id)
|
||||
if not lib:
|
||||
raise HTTPException(status_code=404, detail="Asset library not found")
|
||||
return _to_out(lib)
|
||||
|
||||
|
||||
@router.patch("/{lib_id}", response_model=AssetLibraryOut)
|
||||
async def update_asset_library(
|
||||
lib_id: uuid.UUID,
|
||||
body: AssetLibraryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_admin_or_pm),
|
||||
):
|
||||
lib = await db.get(AssetLibrary, lib_id)
|
||||
if not lib:
|
||||
raise HTTPException(status_code=404, detail="Asset library not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(lib, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(lib)
|
||||
return _to_out(lib)
|
||||
|
||||
|
||||
@router.post("/{lib_id}/upload-blend", response_model=AssetLibraryOut)
|
||||
async def upload_blend(
|
||||
lib_id: uuid.UUID,
|
||||
blend_file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_admin_or_pm),
|
||||
):
|
||||
lib = await db.get(AssetLibrary, lib_id)
|
||||
if not lib:
|
||||
raise HTTPException(status_code=404, detail="Asset library not found")
|
||||
|
||||
dest = _asset_lib_dir() / f"{lib.id}.blend"
|
||||
with dest.open("wb") as f:
|
||||
shutil.copyfileobj(blend_file.file, f)
|
||||
lib.blend_file_path = str(dest)
|
||||
lib.original_filename = blend_file.filename
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(lib)
|
||||
|
||||
try:
|
||||
from app.domains.materials.tasks import refresh_asset_library_catalog
|
||||
refresh_asset_library_catalog.delay(str(lib.id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _to_out(lib)
|
||||
|
||||
|
||||
@router.post("/{lib_id}/refresh-catalog", response_model=AssetLibraryOut)
|
||||
async def refresh_catalog(
|
||||
lib_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_admin_or_pm),
|
||||
):
|
||||
lib = await db.get(AssetLibrary, lib_id)
|
||||
if not lib:
|
||||
raise HTTPException(status_code=404, detail="Asset library not found")
|
||||
if not lib.blend_file_path:
|
||||
raise HTTPException(status_code=400, detail="No .blend file uploaded yet")
|
||||
|
||||
from app.domains.materials.tasks import refresh_asset_library_catalog
|
||||
refresh_asset_library_catalog.delay(str(lib.id))
|
||||
return _to_out(lib)
|
||||
|
||||
|
||||
@router.delete("/{lib_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_asset_library(
|
||||
lib_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user=Depends(require_admin_or_pm),
|
||||
):
|
||||
lib = await db.get(AssetLibrary, lib_id)
|
||||
if not lib:
|
||||
raise HTTPException(status_code=404, detail="Asset library not found")
|
||||
|
||||
# Remove .blend file from disk
|
||||
if lib.blend_file_path:
|
||||
p = Path(lib.blend_file_path)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
await db.delete(lib)
|
||||
await db.commit()
|
||||
@@ -1,8 +1,8 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Text, ForeignKey, Integer
|
||||
from sqlalchemy import String, DateTime, Text, ForeignKey, Integer, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.database import Base
|
||||
# TYPE_CHECKING import to avoid circular references
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -45,3 +45,20 @@ class MaterialAlias(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
material = relationship("Material", back_populates="aliases")
|
||||
|
||||
|
||||
class AssetLibrary(Base):
|
||||
__tablename__ = "asset_libraries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
blend_file_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
original_filename: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
catalog: Mapped[dict] = mapped_column(JSONB, nullable=False, default=lambda: {"materials": [], "node_groups": []})
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Celery tasks for asset library management."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CATALOG_SCRIPT = Path(__file__).parent.parent.parent.parent.parent / "render-worker" / "scripts" / "catalog_assets.py"
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="app.domains.materials.tasks.refresh_asset_library_catalog",
|
||||
queue="thumbnail_rendering",
|
||||
bind=True,
|
||||
max_retries=2,
|
||||
default_retry_delay=30,
|
||||
)
|
||||
def refresh_asset_library_catalog(self, asset_library_id: str) -> None:
|
||||
"""Run Blender headless to extract catalog from a .blend asset library.
|
||||
|
||||
Updates the `catalog` JSONB column of the AssetLibrary record.
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from app.domains.materials.models import AssetLibrary
|
||||
from app.config import settings
|
||||
|
||||
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql://")
|
||||
|
||||
try:
|
||||
engine = create_engine(sync_url)
|
||||
with Session(engine) as db:
|
||||
lib = db.get(AssetLibrary, uuid.UUID(asset_library_id))
|
||||
if not lib:
|
||||
logger.warning("AssetLibrary %s not found", asset_library_id)
|
||||
return
|
||||
blend_path = lib.blend_file_path
|
||||
engine.dispose()
|
||||
|
||||
if not blend_path or not Path(blend_path).exists():
|
||||
logger.warning("AssetLibrary %s: blend file not found at %s", asset_library_id, blend_path)
|
||||
return
|
||||
|
||||
# Determine Blender binary
|
||||
import os
|
||||
blender_bin = os.environ.get("BLENDER_BIN", "blender")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
blender_bin,
|
||||
"--background",
|
||||
"--python", str(CATALOG_SCRIPT),
|
||||
"--", blend_path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("catalog_assets.py failed (exit %d):\n%s", result.returncode, result.stderr)
|
||||
return
|
||||
|
||||
# Parse catalog JSON from stdout (last line that starts with '{')
|
||||
catalog = None
|
||||
for line in reversed(result.stdout.splitlines()):
|
||||
line = line.strip()
|
||||
if line.startswith("{"):
|
||||
try:
|
||||
catalog = json.loads(line)
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if catalog is None:
|
||||
logger.error("catalog_assets.py: no JSON found in output:\n%s", result.stdout)
|
||||
return
|
||||
|
||||
# Persist catalog
|
||||
engine2 = create_engine(sync_url)
|
||||
with Session(engine2) as db:
|
||||
lib = db.get(AssetLibrary, uuid.UUID(asset_library_id))
|
||||
if lib:
|
||||
lib.catalog = catalog
|
||||
db.commit()
|
||||
logger.info(
|
||||
"AssetLibrary %s catalog updated: %d materials, %d node_groups",
|
||||
asset_library_id,
|
||||
len(catalog.get("materials", [])),
|
||||
len(catalog.get("node_groups", [])),
|
||||
)
|
||||
engine2.dispose()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("catalog_assets.py timed out for library %s", asset_library_id)
|
||||
raise self.retry(countdown=60)
|
||||
except Exception as exc:
|
||||
logger.exception("refresh_asset_library_catalog failed: %s", exc)
|
||||
raise self.retry(exc=exc)
|
||||
@@ -22,6 +22,7 @@ from app.domains.billing.router import pricing_router, invoice_router
|
||||
from app.domains.tenants.router import router as tenants_router
|
||||
from app.domains.rendering.workflow_router import router as workflows_router
|
||||
from app.domains.media.router import router as media_router
|
||||
from app.api.routers.asset_libraries import router as asset_libraries_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -86,6 +87,7 @@ app.include_router(notifications_router, prefix="/api")
|
||||
app.include_router(tenants_router, prefix="/api")
|
||||
app.include_router(workflows_router)
|
||||
app.include_router(media_router)
|
||||
app.include_router(asset_libraries_router, prefix="/api")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -13,6 +13,7 @@ celery_app = Celery(
|
||||
"app.domains.rendering.tasks",
|
||||
"app.domains.products.tasks",
|
||||
"app.domains.imports.tasks",
|
||||
"app.domains.materials.tasks",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user