ee6eb34b4c
- GPU: fix Cycles device activation order — set compute_device_type BEFORE engine init, re-set AFTER open_mainfile wipes preferences - GPU: remove _mark_sharp_and_seams edit-mode loop (redundant with Blender 5.0 shade_smooth_by_angle), saves ~200s/render on 175 parts - Material: fix _AFN suffix mismatch — build AF-stripped mat_map keys and add prefix fallback in _apply_material_library (blender_render.py) - Material: production GLB now uses get_material_library_path() which checks active AssetLibrary instead of empty legacy system setting - Admin: RenderTemplateTable multi-select output types (M2M frontend) - Admin: MaterialLibraryPanel replaced with link to Asset Libraries - UX: move Toaster to top-left to avoid dispatch button overlap - SQLAlchemy: add .unique() to all RenderTemplate M2M collection queries - Logging: flush=True on all Blender progress prints, stdout reconfigure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
401 lines
14 KiB
Python
401 lines
14 KiB
Python
"""Render Templates API — CRUD + .blend file upload/download + material library."""
|
|
import uuid
|
|
import shutil
|
|
from datetime import datetime
|
|
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, update as sql_update, delete as sql_delete
|
|
from pydantic import BaseModel
|
|
|
|
from app.database import get_db
|
|
from app.config import settings as app_settings
|
|
from app.models.user import User
|
|
from app.models.render_template import RenderTemplate
|
|
from app.models.output_type import OutputType
|
|
from app.models.system_setting import SystemSetting
|
|
from app.utils.auth import require_admin_or_pm
|
|
|
|
router = APIRouter(tags=["render-templates"])
|
|
|
|
BLEND_DIR = "blend-templates"
|
|
|
|
|
|
def _blend_dir() -> Path:
|
|
d = Path(app_settings.upload_dir) / BLEND_DIR
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
# ── Schemas ──────────────────────────────────────────────────────────────────
|
|
|
|
class RenderTemplateOut(BaseModel):
|
|
id: str
|
|
name: str
|
|
category_key: str | None
|
|
output_type_id: str | None # legacy single FK
|
|
output_type_name: str | None # legacy
|
|
output_type_ids: list[str] # M2M
|
|
output_type_names: list[str] # M2M display names
|
|
blend_file_path: str
|
|
original_filename: str
|
|
target_collection: str
|
|
material_replace_enabled: bool
|
|
lighting_only: bool
|
|
shadow_catcher_enabled: bool
|
|
camera_orbit: bool
|
|
is_active: bool
|
|
created_at: str
|
|
updated_at: str
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class RenderTemplateUpdate(BaseModel):
|
|
name: str | None = None
|
|
category_key: str | None = None
|
|
output_type_ids: list[str] | None = None # replaces output_type_id
|
|
target_collection: str | None = None
|
|
material_replace_enabled: bool | None = None
|
|
lighting_only: bool | None = None
|
|
shadow_catcher_enabled: bool | None = None
|
|
camera_orbit: bool | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
class MaterialLibraryInfo(BaseModel):
|
|
exists: bool
|
|
filename: str | None = None
|
|
size_bytes: int | None = None
|
|
path: str | None = None
|
|
|
|
|
|
def _to_out(t: RenderTemplate) -> dict:
|
|
ot_name = None
|
|
if t.output_type:
|
|
ot_name = t.output_type.name
|
|
# M2M output types
|
|
ot_ids = [str(ot.id) for ot in t.output_types] if t.output_types else []
|
|
ot_names = [ot.name for ot in t.output_types] if t.output_types else []
|
|
return {
|
|
"id": str(t.id),
|
|
"name": t.name,
|
|
"category_key": t.category_key,
|
|
"output_type_id": str(t.output_type_id) if t.output_type_id else None,
|
|
"output_type_name": ot_name,
|
|
"output_type_ids": ot_ids,
|
|
"output_type_names": ot_names,
|
|
"blend_file_path": t.blend_file_path,
|
|
"original_filename": t.original_filename,
|
|
"target_collection": t.target_collection,
|
|
"material_replace_enabled": t.material_replace_enabled,
|
|
"lighting_only": t.lighting_only,
|
|
"shadow_catcher_enabled": t.shadow_catcher_enabled,
|
|
"camera_orbit": t.camera_orbit,
|
|
"is_active": t.is_active,
|
|
"created_at": t.created_at.isoformat() if t.created_at else "",
|
|
"updated_at": t.updated_at.isoformat() if t.updated_at else "",
|
|
}
|
|
|
|
|
|
# ── CRUD Endpoints ───────────────────────────────────────────────────────────
|
|
|
|
@router.get("/render-templates", response_model=list[RenderTemplateOut])
|
|
async def list_render_templates(
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(RenderTemplate).order_by(RenderTemplate.created_at.desc())
|
|
)
|
|
return [_to_out(t) for t in result.unique().scalars().all()]
|
|
|
|
|
|
@router.post("/render-templates", response_model=RenderTemplateOut, status_code=status.HTTP_201_CREATED)
|
|
async def create_render_template(
|
|
name: str = Form(...),
|
|
file: UploadFile = File(...),
|
|
category_key: str | None = Form(None),
|
|
output_type_id: str | None = Form(None),
|
|
target_collection: str = Form("Product"),
|
|
material_replace_enabled: bool = Form(False),
|
|
lighting_only: bool = Form(False),
|
|
shadow_catcher_enabled: bool = Form(False),
|
|
camera_orbit: bool = Form(True),
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
if not file.filename or not file.filename.endswith(".blend"):
|
|
raise HTTPException(400, detail="File must be a .blend file")
|
|
|
|
# Normalise empty strings from form data to None
|
|
if category_key == "" or category_key == "null":
|
|
category_key = None
|
|
if output_type_id == "" or output_type_id == "null":
|
|
output_type_id = None
|
|
|
|
template_id = uuid.uuid4()
|
|
blend_path = _blend_dir() / f"{template_id}.blend"
|
|
|
|
with open(blend_path, "wb") as f:
|
|
shutil.copyfileobj(file.file, f)
|
|
|
|
ot_uuid = uuid.UUID(output_type_id) if output_type_id else None
|
|
|
|
tmpl = RenderTemplate(
|
|
id=template_id,
|
|
name=name,
|
|
category_key=category_key,
|
|
output_type_id=ot_uuid,
|
|
blend_file_path=str(blend_path),
|
|
original_filename=file.filename,
|
|
target_collection=target_collection,
|
|
material_replace_enabled=material_replace_enabled,
|
|
lighting_only=lighting_only,
|
|
shadow_catcher_enabled=shadow_catcher_enabled,
|
|
camera_orbit=camera_orbit,
|
|
)
|
|
db.add(tmpl)
|
|
await db.flush()
|
|
|
|
# Sync M2M from initial output_type_id
|
|
if ot_uuid:
|
|
from app.domains.rendering.models import render_template_output_types
|
|
await db.execute(
|
|
render_template_output_types.insert().values(
|
|
template_id=template_id, output_type_id=ot_uuid,
|
|
)
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(tmpl)
|
|
|
|
# Eagerly load output_type for response
|
|
if ot_uuid:
|
|
ot = await db.get(OutputType, ot_uuid)
|
|
tmpl.output_type = ot
|
|
|
|
return _to_out(tmpl)
|
|
|
|
|
|
@router.patch("/render-templates/{template_id}", response_model=RenderTemplateOut)
|
|
async def update_render_template(
|
|
template_id: uuid.UUID,
|
|
body: RenderTemplateUpdate,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
|
|
tmpl = result.unique().scalar_one_or_none()
|
|
if not tmpl:
|
|
raise HTTPException(404, detail="Render template not found")
|
|
|
|
updates = body.model_dump(exclude_unset=True)
|
|
|
|
# Normalise empty strings to None for nullable fields
|
|
if "category_key" in updates and updates["category_key"] in ("", "null"):
|
|
updates["category_key"] = None
|
|
|
|
# Handle M2M output_type_ids
|
|
new_ot_ids: list[str] | None = updates.pop("output_type_ids", None)
|
|
|
|
if updates:
|
|
updates["updated_at"] = datetime.utcnow()
|
|
await db.execute(
|
|
sql_update(RenderTemplate)
|
|
.where(RenderTemplate.id == template_id)
|
|
.values(**updates)
|
|
)
|
|
|
|
# Sync M2M relationship
|
|
if new_ot_ids is not None:
|
|
from app.domains.rendering.models import render_template_output_types
|
|
# Delete existing links
|
|
await db.execute(
|
|
sql_delete(render_template_output_types).where(
|
|
render_template_output_types.c.template_id == template_id
|
|
)
|
|
)
|
|
# Insert new links
|
|
for ot_id in new_ot_ids:
|
|
await db.execute(
|
|
render_template_output_types.insert().values(
|
|
template_id=template_id,
|
|
output_type_id=uuid.UUID(ot_id),
|
|
)
|
|
)
|
|
# Also update legacy FK to first OT (for backward compat)
|
|
legacy_ot = uuid.UUID(new_ot_ids[0]) if new_ot_ids else None
|
|
await db.execute(
|
|
sql_update(RenderTemplate)
|
|
.where(RenderTemplate.id == template_id)
|
|
.values(output_type_id=legacy_ot, updated_at=datetime.utcnow())
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(tmpl)
|
|
return _to_out(tmpl)
|
|
|
|
|
|
@router.delete("/render-templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_render_template(
|
|
template_id: uuid.UUID,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
|
|
tmpl = result.unique().scalar_one_or_none()
|
|
if not tmpl:
|
|
raise HTTPException(404, detail="Render template not found")
|
|
|
|
# Delete .blend file
|
|
blend_path = Path(tmpl.blend_file_path)
|
|
if blend_path.exists():
|
|
blend_path.unlink(missing_ok=True)
|
|
|
|
await db.execute(sql_delete(RenderTemplate).where(RenderTemplate.id == template_id))
|
|
await db.commit()
|
|
|
|
|
|
@router.post("/render-templates/{template_id}/upload", response_model=RenderTemplateOut)
|
|
async def upload_blend_file(
|
|
template_id: uuid.UUID,
|
|
file: UploadFile = File(...),
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Re-upload a .blend file for an existing template."""
|
|
if not file.filename or not file.filename.endswith(".blend"):
|
|
raise HTTPException(400, detail="File must be a .blend file")
|
|
|
|
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
|
|
tmpl = result.unique().scalar_one_or_none()
|
|
if not tmpl:
|
|
raise HTTPException(404, detail="Render template not found")
|
|
|
|
blend_path = _blend_dir() / f"{template_id}.blend"
|
|
|
|
# Remove old file if path changed
|
|
old_path = Path(tmpl.blend_file_path)
|
|
if old_path.exists() and old_path != blend_path:
|
|
old_path.unlink(missing_ok=True)
|
|
|
|
with open(blend_path, "wb") as f:
|
|
shutil.copyfileobj(file.file, f)
|
|
|
|
await db.execute(
|
|
sql_update(RenderTemplate)
|
|
.where(RenderTemplate.id == template_id)
|
|
.values(
|
|
blend_file_path=str(blend_path),
|
|
original_filename=file.filename,
|
|
updated_at=datetime.utcnow(),
|
|
)
|
|
)
|
|
await db.commit()
|
|
await db.refresh(tmpl)
|
|
return _to_out(tmpl)
|
|
|
|
|
|
@router.get("/render-templates/{template_id}/download")
|
|
async def download_blend_file(
|
|
template_id: uuid.UUID,
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(RenderTemplate).where(RenderTemplate.id == template_id))
|
|
tmpl = result.unique().scalar_one_or_none()
|
|
if not tmpl:
|
|
raise HTTPException(404, detail="Render template not found")
|
|
|
|
blend_path = Path(tmpl.blend_file_path)
|
|
if not blend_path.exists():
|
|
raise HTTPException(404, detail=".blend file not found on disk")
|
|
|
|
return FileResponse(
|
|
path=str(blend_path),
|
|
filename=tmpl.original_filename,
|
|
media_type="application/octet-stream",
|
|
)
|
|
|
|
|
|
# ── Material Library ─────────────────────────────────────────────────────────
|
|
|
|
MATERIAL_LIBRARY_FILENAME = "material_library.blend"
|
|
|
|
|
|
async def _save_setting(db: AsyncSession, key: str, value: str) -> None:
|
|
result = await db.execute(
|
|
sql_update(SystemSetting)
|
|
.where(SystemSetting.key == key)
|
|
.values(value=value, updated_at=datetime.utcnow())
|
|
)
|
|
if result.rowcount == 0:
|
|
db.add(SystemSetting(key=key, value=value, updated_at=datetime.utcnow()))
|
|
|
|
|
|
@router.post("/admin/settings/material-library", response_model=MaterialLibraryInfo)
|
|
async def upload_material_library(
|
|
file: UploadFile = File(...),
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
if not file.filename or not file.filename.endswith(".blend"):
|
|
raise HTTPException(400, detail="File must be a .blend file")
|
|
|
|
lib_path = _blend_dir() / MATERIAL_LIBRARY_FILENAME
|
|
with open(lib_path, "wb") as f:
|
|
shutil.copyfileobj(file.file, f)
|
|
|
|
await _save_setting(db, "material_library_path", str(lib_path))
|
|
await db.commit()
|
|
|
|
return MaterialLibraryInfo(
|
|
exists=True,
|
|
filename=file.filename,
|
|
size_bytes=lib_path.stat().st_size,
|
|
path=str(lib_path),
|
|
)
|
|
|
|
|
|
@router.get("/admin/settings/material-library", response_model=MaterialLibraryInfo)
|
|
async def get_material_library(
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(SystemSetting).where(SystemSetting.key == "material_library_path")
|
|
)
|
|
row = result.scalar_one_or_none()
|
|
path_str = row.value if row else ""
|
|
|
|
if path_str and Path(path_str).exists():
|
|
p = Path(path_str)
|
|
return MaterialLibraryInfo(
|
|
exists=True,
|
|
filename=p.name,
|
|
size_bytes=p.stat().st_size,
|
|
path=path_str,
|
|
)
|
|
return MaterialLibraryInfo(exists=False)
|
|
|
|
|
|
@router.delete("/admin/settings/material-library", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_material_library(
|
|
user: User = Depends(require_admin_or_pm),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(
|
|
select(SystemSetting).where(SystemSetting.key == "material_library_path")
|
|
)
|
|
row = result.scalar_one_or_none()
|
|
if row and row.value:
|
|
p = Path(row.value)
|
|
if p.exists():
|
|
p.unlink(missing_ok=True)
|
|
|
|
await _save_setting(db, "material_library_path", "")
|
|
await db.commit()
|