diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index af9c2be..ca79905 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -1,5 +1,6 @@ """Asset management router.""" +import json import os from pathlib import Path from typing import Optional @@ -13,6 +14,7 @@ from app.database import get_db from app.dependencies import CurrentUser from app.models.asset import Asset, AssetType from app.models.world import World +from app.services import gltf as gltf_service from pydantic import BaseModel, Field router = APIRouter() @@ -64,6 +66,14 @@ def _sanitize_filename(filename: str) -> str: return safe or "file" +def _detect_asset_type(filename: Optional[str], content: bytes) -> AssetType: + """Detect the asset type from filename and content.""" + if filename and gltf_service.looks_like_gltf(content, filename): + return AssetType.MODEL_3D + # Fallback: let the caller decide based on mime_type or default. + return AssetType.OTHER + + # --- Endpoints --- @router.post("/", response_model=AssetResponse, status_code=201) @@ -183,10 +193,16 @@ async def upload_asset_file( # Sanitize filename to prevent path traversal. safe_filename = _sanitize_filename(file.filename or f"asset_{asset_id}") - # Validate file size against configured maximum. - max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + # Read the file content. content = await file.read() file_size = len(content) + + # Determine which size limit to apply. + if gltf_service.looks_like_gltf(content, file.filename): + max_bytes = settings.GLTF_MAX_FILE_SIZE_MB * 1024 * 1024 + else: + max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024 + if file_size > max_bytes: raise HTTPException( status_code=413, @@ -200,10 +216,28 @@ async def upload_asset_file( file_path = upload_dir / stored_name file_path.write_bytes(content) + # Detect asset type from content/filename. + detected_type = _detect_asset_type(file.filename, content) + + # Update asset record. asset.file_path = str(file_path) asset.mime_type = file.content_type asset.file_size = file_size + # If this is a glTF file, extract and store metadata. + if detected_type == AssetType.MODEL_3D: + try: + metadata = gltf_service.extract_gltf_metadata(content) + asset.metadata_json = json.dumps(metadata) + # Also set the asset type to model_3d if it was auto-detected. + if asset.asset_type == AssetType.OTHER: + asset.asset_type = AssetType.MODEL_3D + except ValueError as exc: + raise HTTPException( + status_code=400, + detail=f"Invalid glTF file: {exc}", + ) + await db.commit() await db.refresh(asset) return asset diff --git a/backend/tests/test_gltf_metadata.py b/backend/tests/test_gltf_metadata.py new file mode 100644 index 0000000..61d29b7 --- /dev/null +++ b/backend/tests/test_gltf_metadata.py @@ -0,0 +1,220 @@ +"""Tests for the lightweight glTF metadata extraction service.""" + +import json +import struct +import pytest + +from app.services.gltf import ( + extract_gltf_metadata, + looks_like_gltf, + _parse_glb, + _parse_gltf_json, +) + + +# --------------------------------------------------------------------------- +# Test data helpers +# --------------------------------------------------------------------------- + +def _make_gltf_json( + meshes: int = 1, + vertices_per_mesh: int = 24, + has_animations: bool = False, + images: list[dict] | None = None, +) -> dict: + """Build a minimal glTF JSON structure.""" + accessors = [] + meshes_data = [] + for i in range(meshes): + acc_index = len(accessors) + accessors.append({ + "bufferView": 0, + "componentType": 5126, # FLOAT + "count": vertices_per_mesh, + "type": "VEC3", + "max": [1.0, 1.0, 1.0], + "min": [-1.0, -1.0, -1.0], + }) + meshes_data.append({ + "primitives": [{ + "attributes": {"POSITION": acc_index}, + }], + }) + gltf = { + "asset": {"version": "2.0"}, + "accessors": accessors, + "meshes": meshes_data, + } + if has_animations: + gltf["animations"] = [{"name": "anim1", "channels": [], "samplers": []}] + if images: + gltf["images"] = images + return gltf + + +def _make_glb(gltf_json: dict) -> bytes: + """Wrap *gltf_json* in a minimal GLB binary container.""" + json_bytes = json.dumps(gltf_json).encode("utf-8") + json_length = len(json_bytes) + # Pad JSON chunk to 4-byte boundary. + json_padding = (4 - json_length % 4) % 4 + json_chunk = json_bytes + b"\x00" * json_padding + json_chunk_type = 0x4E4F534A # "JSON" + # No BIN chunk. + bin_length = 0 + bin_chunk_type = 0x004E4942 + total_length = 12 + 8 + len(json_chunk) + 8 + bin_length + header = struct.pack(" 0: + bin_header = struct.pack("