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,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)
|
||||
Reference in New Issue
Block a user