diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 423f20c..8a349fc 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 @@ -46,6 +47,18 @@ class AssetResponse(BaseModel): model_config = {"from_attributes": True} + @classmethod + def model_validate(cls, obj, **kwargs): + """Override to handle metadata_json stored as JSON string.""" + if isinstance(obj, dict): + obj = dict(obj) + if "metadata_json" in obj and isinstance(obj["metadata_json"], str): + try: + obj["metadata_json"] = json.loads(obj["metadata_json"]) + except (json.JSONDecodeError, TypeError): + obj["metadata_json"] = None + return super().model_validate(obj, **kwargs) + class AssetUpdate(BaseModel): """Schema for updating an asset.""" @@ -70,61 +83,130 @@ def _extract_gltf_metadata(content: bytes) -> Optional[dict]: Returns a dict with mesh_count, vertex_count, has_animation, texture_refs if the file is a valid glTF/GLB, or None otherwise. + + Uses only the standard library (struct, json) so no extra dependencies + are required. """ + if not content: + return None + + # --- GLB detection --- + # GLB magic: 0x676C5442 ("glTF") followed by version 2 and payload length. + if len(content) >= 12: + magic = int.from_bytes(content[0:4], "little") + if magic == 0x46546C67: # "glTF" + version = int.from_bytes(content[4:8], "little") + if version == 2: + return _parse_glb(content) + + # --- .gltf JSON detection --- + # Try parsing as JSON; if it has a top-level "scene" or "scenes" key, + # it's likely a .gltf file. try: - import gltflib - except ImportError: + data = json.loads(content) + except (json.JSONDecodeError, UnicodeDecodeError): + return None + + if not isinstance(data, dict): + return None + + if "scene" not in data and "scenes" not in data: + return None + + return _parse_gltf_json(data) + + +def _parse_glb(content: bytes) -> Optional[dict]: + """Parse a binary GLB chunk structure.""" + # Header: magic(4) + version(4) + length(4) = 12 bytes + # Then chunks: length(4) + type(4) + data... + offset = 12 + json_start = None + json_length = 0 + binary_start = None + binary_length = 0 + + while offset < len(content): + if offset + 8 > len(content): + break + chunk_length = int.from_bytes(content[offset:offset + 4], "little") + chunk_type = int.from_bytes(content[offset + 4:offset + 8], "little") + chunk_data_start = offset + 8 + + if chunk_type == 0x4E4F534A: # JSON chunk + json_start = chunk_data_start + json_length = chunk_length + elif chunk_type == 0x00424946: # BIN chunk + binary_start = chunk_data_start + binary_length = chunk_length + + offset = chunk_data_start + chunk_length + + if json_start is None: return None try: - model = gltflib.GLFModel.from_json(content) - except Exception: + json_bytes = content[json_start:json_start + json_length] + data = json.loads(json_bytes) + except (json.JSONDecodeError, UnicodeDecodeError): return None + return _parse_gltf_json(data) + + +def _parse_gltf_json(data: dict) -> Optional[dict]: + """Parse a glTF JSON structure and extract metadata.""" + meshes = data.get("meshes") or [] + animations = data.get("animations") or [] + textures = data.get("textures") or [] + images = data.get("images") or [] + accessors = data.get("accessors") or [] + # Count meshes - meshes = model.meshes or [] mesh_count = len(meshes) # Count total vertices across all primitives vertex_count = 0 for mesh in meshes: - primitives = mesh.primitives or [] + primitives = mesh.get("primitives") or [] for primitive in primitives: - indices = primitive.indices + indices = primitive.get("indices") if indices is not None: - # Use index count - accessor = model.accessors[indices] if indices < len(model.accessors or []) else None - if accessor and accessor.count is not None: - vertex_count += accessor.count + # Use index count from accessor + if indices < len(accessors) and accessors[indices]: + vertex_count += accessors[indices].get("count", 1) else: - vertex_count += 1 # fallback + vertex_count += 1 else: # No indices — count from POSITION attribute - if primitive.attributes and primitive.attributes.POSITION is not None: - accessor = model.accessors[primitive.attributes.POSITION] if primitive.attributes.POSITION < len(model.accessors or []) else None - if accessor and accessor.count is not None: - vertex_count += accessor.count + attributes = primitive.get("attributes") or {} + pos_accessor = attributes.get("POSITION") + if pos_accessor is not None: + if pos_accessor < len(accessors) and accessors[pos_accessor]: + vertex_count += accessors[pos_accessor].get("count", 1) else: - vertex_count += 1 # fallback + vertex_count += 1 + else: + vertex_count += 1 # fallback # Check for animations - animations = model.animations or [] has_animation = len(animations) > 0 # Collect texture references texture_refs = [] - textures = model.textures or [] for texture in textures: - if texture.source is not None and texture.source < len(model.images or []): - image = model.images[texture.source] - ref = {} - if image.name: - ref["name"] = image.name - if image.uri: - ref["uri"] = image.uri - if image.mimeType: - ref["mime_type"] = image.mimeType - texture_refs.append(ref) + source_index = texture.get("source") + ref = {} + if source_index is not None and source_index < len(images): + image = images[source_index] + if isinstance(image, dict): + if image.get("name"): + ref["name"] = image["name"] + if image.get("uri"): + ref["uri"] = image["uri"] + if image.get("mimeType"): + ref["mime_type"] = image["mimeType"] + texture_refs.append(ref) return { "mesh_count": mesh_count, @@ -279,7 +361,7 @@ async def upload_asset_file( if filename_lower.endswith(".glb") or filename_lower.endswith(".gltf"): metadata = _extract_gltf_metadata(content) if metadata is not None: - asset.metadata_json = str(metadata) + asset.metadata_json = json.dumps(metadata) await db.commit() await db.refresh(asset)