diff --git a/backend/app/config.py b/backend/app/config.py index af23b68..19d89e4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -30,6 +30,7 @@ class Settings(BaseSettings): # File uploads UPLOAD_DIR: str = "uploads" MAX_UPLOAD_SIZE_MB: int = 100 + GLTF_MAX_FILE_SIZE_MB: int = 200 model_config = SettingsConfigDict( env_file=".env", 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/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/gltf.py b/backend/app/services/gltf.py new file mode 100644 index 0000000..fab388d --- /dev/null +++ b/backend/app/services/gltf.py @@ -0,0 +1,197 @@ +"""Lightweight glTF / GLB metadata extraction. + +This module intentionally avoids heavyweight dependencies such as trimesh or +pygltflib. For the metadata fields the asset upload pipeline needs (mesh and +vertex counts, animation presence, texture references, and the bounding box) +a small amount of glTF-specific parsing is sufficient and keeps the backend +dependency-free. +""" + +from __future__ import annotations + +import json +import struct +from typing import Any, Optional + +# GLB binary header magic bytes ("glTF"). +_GLB_MAGIC = b"glTF" +_GLB_MAGIC_INT = 0x46546C67 # b"glTF" interpreted as little-endian uint32 +_GLB_JSON_CHUNK_TYPE = 0x4E4F534A # "JSON" +_GLB_BIN_CHUNK_TYPE = 0x004E4942 # "BIN\0" + +# Whitespace allowed around a GLB JSON chunk. The spec pads with spaces (0x20), +# but be lenient with null bytes and other whitespace that real exporters emit. +_JSON_CHUNK_TRIM = b" \t\r\n\x00" + + +def looks_like_gltf(content: bytes, filename: Optional[str] = None) -> bool: + """Return True when the bytes or filename indicate a glTF/GLB asset.""" + if content.startswith(_GLB_MAGIC): + return True + + if filename: + lowered = filename.lower() + if lowered.endswith(".gltf") or lowered.endswith(".glb"): + return True + + # A bare JSON buffer (e.g. uploaded without an extension) that has the + # glTF asset marker is also a glTF file. + stripped = content.lstrip() + if stripped.startswith(b"{"): + try: + payload = json.loads(stripped.decode("utf-8")) + except (ValueError, UnicodeDecodeError): + return False + return isinstance(payload, dict) and "asset" in payload and "version" in payload.get("asset", {}) + + return False + + +def _parse_glb(content: bytes) -> dict[str, Any]: + """Parse the JSON chunk of a GLB binary container.""" + if len(content) < 12: + raise ValueError("GLB file is too short") + + magic, _version, _length = struct.unpack_from(" len(content): + raise ValueError("GLB chunk overruns file boundary") + + chunk_data = content[offset:chunk_end] + if chunk_type == _GLB_JSON_CHUNK_TYPE: + try: + json_chunk = json.loads(chunk_data.strip(_JSON_CHUNK_TRIM).decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise ValueError(f"invalid GLB JSON chunk: {exc}") from exc + + offset = chunk_end + + if json_chunk is None: + raise ValueError("GLB file does not contain a JSON chunk") + + return json_chunk + + +def _parse_gltf_json(content: bytes) -> dict[str, Any]: + """Parse a plain .gltf JSON file.""" + stripped = content.lstrip() + try: + payload = json.loads(stripped.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise ValueError(f"invalid glTF JSON: {exc}") from exc + + if not isinstance(payload, dict): + raise ValueError("glTF JSON root must be an object") + return payload + + +def _position_accessor_counts(gltf: dict[str, Any]) -> list[int]: + """Return POSITION accessor counts for every mesh primitive.""" + counts: list[int] = [] + accessors = gltf.get("accessors", []) + for mesh in gltf.get("meshes", []): + for primitive in mesh.get("primitives", []): + attributes = primitive.get("attributes", {}) + position_index = attributes.get("POSITION") + if position_index is None: + continue + try: + accessor = accessors[int(position_index)] + except (IndexError, TypeError, ValueError): + continue + count = accessor.get("count") + if isinstance(count, int): + counts.append(count) + return counts + + +def _position_bounds(gltf: dict[str, Any]) -> list[list[float]]: + """Return min/max bounds for every POSITION accessor that declares them.""" + bounds: list[list[float]] = [] + accessors = gltf.get("accessors", []) + for mesh in gltf.get("meshes", []): + for primitive in mesh.get("primitives", []): + attributes = primitive.get("attributes", {}) + position_index = attributes.get("POSITION") + if position_index is None: + continue + try: + accessor = accessors[int(position_index)] + except (IndexError, TypeError, ValueError): + continue + minimum = accessor.get("min") + maximum = accessor.get("max") + if isinstance(minimum, list) and isinstance(maximum, list): + bounds.append([float(x) for x in minimum]) + bounds.append([float(x) for x in maximum]) + return bounds + + +def _texture_refs(gltf: dict[str, Any]) -> list[str]: + """Collect external texture source references from the image array.""" + refs: list[str] = [] + for index, image in enumerate(gltf.get("images", [])): + uri = image.get("uri") + if isinstance(uri, str) and not uri.startswith("data:"): + refs.append(uri) + continue + + name = image.get("name") + if isinstance(name, str) and name: + refs.append(name) + continue + + if isinstance(uri, str): + # Embedded data URI; keep a stable, non-huge reference. + refs.append(f"data:{index}") + continue + + buffer_view = image.get("bufferView") + if buffer_view is not None: + refs.append(f"bufferView:{buffer_view}") + continue + + refs.append(f"image:{index}") + + return refs + + +def extract_gltf_metadata(content: bytes) -> dict[str, Any]: + """Extract upload-relevant metadata from glTF/GLB bytes. + + Raises ValueError for malformed glTF content. The returned dict contains: + mesh_count, vertex_count, has_animation, texture_refs, + bounding_box_min, bounding_box_max. + """ + if content.startswith(_GLB_MAGIC): + gltf = _parse_glb(content) + else: + gltf = _parse_gltf_json(content) + + position_counts = _position_accessor_counts(gltf) + bounds = _position_bounds(gltf) + + if bounds: + bounding_box_min = [min(axis) for axis in zip(*bounds[::2])] + bounding_box_max = [max(axis) for axis in zip(*bounds[1::2])] + else: + bounding_box_min = [0.0, 0.0, 0.0] + bounding_box_max = [0.0, 0.0, 0.0] + + return { + "mesh_count": len(gltf.get("meshes", [])), + "vertex_count": sum(position_counts), + "has_animation": bool(gltf.get("animations")), + "texture_refs": _texture_refs(gltf), + "bounding_box_min": bounding_box_min, + "bounding_box_max": bounding_box_max, + } diff --git a/backend/tests/test_gltf_metadata.py b/backend/tests/test_gltf_metadata.py new file mode 100644 index 0000000..f73540f --- /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("