Spaces:
Running
Running
Nikkon
commited on
Commit
·
c840ad0
1
Parent(s):
8d96b4e
Deploy PromptAR backend to HF Spaces
Browse files- .dockerignore +47 -0
- Dockerfile +34 -0
- README.md +149 -7
- app/__init__.py +2 -0
- app/app.py +115 -0
- config.py +73 -0
- main.py +48 -0
- middleware/__init__.py +2 -0
- middleware/request_logging.py +80 -0
- requirements.txt +26 -0
- routers/.DS_Store +0 -0
- routers/__init__.py +4 -0
- routers/__pycache__/__init__.cpython-311.pyc +0 -0
- routers/__pycache__/models.cpython-311.pyc +0 -0
- routers/__pycache__/root.cpython-311.pyc +0 -0
- routers/models.py +424 -0
- routers/root.py +26 -0
- schemas/__init__.py +3 -0
- schemas/__pycache__/__init__.cpython-311.pyc +0 -0
- schemas/__pycache__/models.cpython-311.pyc +0 -0
- schemas/models.py +33 -0
- services/__init__.py +3 -0
- services/__pycache__/__init__.cpython-311.pyc +0 -0
- services/__pycache__/ar_material_service.cpython-311.pyc +0 -0
- services/__pycache__/huggingface_service.cpython-311.pyc +0 -0
- services/__pycache__/storage_service.cpython-311.pyc +0 -0
- services/ar_material_service.py +175 -0
- services/database_service.py +196 -0
- services/huggingface_service.py +807 -0
- services/storage_service.py +45 -0
- utils/__init__.py +2 -0
- utils/logging_config.py +67 -0
.dockerignore
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
venv/
|
| 8 |
+
env/
|
| 9 |
+
ENV/
|
| 10 |
+
|
| 11 |
+
# IDEs
|
| 12 |
+
.vscode/
|
| 13 |
+
.idea/
|
| 14 |
+
*.swp
|
| 15 |
+
*.swo
|
| 16 |
+
|
| 17 |
+
# OS
|
| 18 |
+
.DS_Store
|
| 19 |
+
Thumbs.db
|
| 20 |
+
|
| 21 |
+
# Git
|
| 22 |
+
.git/
|
| 23 |
+
.gitignore
|
| 24 |
+
|
| 25 |
+
# Documentation and scripts
|
| 26 |
+
*.md
|
| 27 |
+
!README.md
|
| 28 |
+
*.sh
|
| 29 |
+
|
| 30 |
+
# Local development files
|
| 31 |
+
.env
|
| 32 |
+
*.db
|
| 33 |
+
|
| 34 |
+
# Model files (will be generated on the fly)
|
| 35 |
+
models/*.glb
|
| 36 |
+
|
| 37 |
+
# Temporary files
|
| 38 |
+
temp/
|
| 39 |
+
tmp/
|
| 40 |
+
|
| 41 |
+
# Logs
|
| 42 |
+
*.log
|
| 43 |
+
|
| 44 |
+
# Testing
|
| 45 |
+
test/
|
| 46 |
+
tests/
|
| 47 |
+
*.pytest_cache/
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use Python 3.11 slim image for smaller size
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
build-essential \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements file
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
|
| 15 |
+
# Install Python dependencies
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy application code
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Create models directory and data directory for persistent storage
|
| 22 |
+
RUN mkdir -p /app/models /data
|
| 23 |
+
|
| 24 |
+
# Set environment variables
|
| 25 |
+
ENV PYTHONUNBUFFERED=1
|
| 26 |
+
ENV HOST=0.0.0.0
|
| 27 |
+
ENV PORT=7860
|
| 28 |
+
ENV MODEL_STORAGE_PATH=/app/models
|
| 29 |
+
|
| 30 |
+
# Expose port 7860 (HF Spaces default)
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
# Run the application
|
| 34 |
+
CMD ["python", "main.py"]
|
README.md
CHANGED
|
@@ -1,12 +1,154 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
license:
|
| 9 |
-
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PromptAR Backend API
|
| 3 |
+
emoji: 🎨
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
+
app_port: 7860
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# PromptAR Backend API
|
| 13 |
+
|
| 14 |
+
FastAPI backend for generating 3D models from text prompts using AI, optimized for AR applications.
|
| 15 |
+
|
| 16 |
+
## Features
|
| 17 |
+
|
| 18 |
+
- 🎨 **Text-to-3D Generation**: Generate 3D models from text prompts using TRELLIS and Shap-E
|
| 19 |
+
- 🚀 **Two Generation Modes**:
|
| 20 |
+
- **Advanced Mode** (TRELLIS): High-quality textured models
|
| 21 |
+
- **Basic Mode** (Shap-E): Fast generation with basic geometry
|
| 22 |
+
- 📦 **GLB Format**: Direct export to GLB format optimized for AR
|
| 23 |
+
- 🔧 **AR-Optimized**: Automatic brightness normalization for better AR visibility
|
| 24 |
+
- 📊 **Request Logging**: Built-in database for tracking API requests
|
| 25 |
+
- 🌐 **CORS Enabled**: Ready for cross-origin requests from mobile and web apps
|
| 26 |
+
|
| 27 |
+
## API Endpoints
|
| 28 |
+
|
| 29 |
+
### 🏠 Root Endpoints
|
| 30 |
+
|
| 31 |
+
- **GET `/`** - API information and status
|
| 32 |
+
- **GET `/health`** - Health check endpoint
|
| 33 |
+
|
| 34 |
+
### 🎨 Model Generation
|
| 35 |
+
|
| 36 |
+
- **POST `/api/models/generate`** - Generate a 3D model from text
|
| 37 |
+
```json
|
| 38 |
+
{
|
| 39 |
+
"prompt": "wooden chair",
|
| 40 |
+
"mode": "advanced"
|
| 41 |
+
}
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
- **GET `/api/models/download/{model_id}`** - Download generated model
|
| 45 |
+
|
| 46 |
+
## Usage
|
| 47 |
+
|
| 48 |
+
### API Documentation
|
| 49 |
+
|
| 50 |
+
Once deployed, visit:
|
| 51 |
+
- Interactive docs: `https://your-space-url.hf.space/docs`
|
| 52 |
+
- Alternative docs: `https://your-space-url.hf.space/redoc`
|
| 53 |
+
|
| 54 |
+
### Example Request
|
| 55 |
+
|
| 56 |
+
```bash
|
| 57 |
+
curl -X POST "https://your-space-url.hf.space/api/models/generate" \
|
| 58 |
+
-H "Content-Type: application/json" \
|
| 59 |
+
-d '{"prompt": "a red sports car", "mode": "advanced"}'
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
### Response
|
| 63 |
+
|
| 64 |
+
```json
|
| 65 |
+
{
|
| 66 |
+
"status": "success",
|
| 67 |
+
"message": "3D model generated successfully using advanced mode",
|
| 68 |
+
"model_id": "abc123-456def-789ghi",
|
| 69 |
+
"download_url": "/api/models/download/abc123-456def-789ghi"
|
| 70 |
+
}
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
## Configuration
|
| 74 |
+
|
| 75 |
+
The backend uses environment variables for configuration. In Hugging Face Spaces, set these in the **Settings > Repository Secrets**:
|
| 76 |
+
|
| 77 |
+
### Required
|
| 78 |
+
- `HF_TOKEN` - Your Hugging Face API token (get it from [Settings](https://huggingface.co/settings/tokens))
|
| 79 |
+
|
| 80 |
+
### Optional
|
| 81 |
+
- `ALLOWED_ORIGINS` - CORS allowed origins (default: "*")
|
| 82 |
+
- `MODEL_STORAGE_PATH` - Path for storing models (default: "./models")
|
| 83 |
+
|
| 84 |
+
## Architecture
|
| 85 |
+
|
| 86 |
+
The backend is built with:
|
| 87 |
+
- **FastAPI**: Modern Python web framework
|
| 88 |
+
- **Gradio Client**: Integration with HF Spaces (TRELLIS, Shap-E)
|
| 89 |
+
- **Pydantic**: Data validation
|
| 90 |
+
- **SQLite**: Request logging database
|
| 91 |
+
- **pygltflib**: 3D model processing
|
| 92 |
+
|
| 93 |
+
### Project Structure
|
| 94 |
+
|
| 95 |
+
```
|
| 96 |
+
backend/
|
| 97 |
+
├── app/ # Application factory
|
| 98 |
+
│ └── app.py # FastAPI app creation
|
| 99 |
+
├── routers/ # API route handlers
|
| 100 |
+
│ ├── root.py # Root endpoints
|
| 101 |
+
│ └── models.py # Model generation endpoints
|
| 102 |
+
├── services/ # Business logic
|
| 103 |
+
│ ├── huggingface_service.py # AI model integration
|
| 104 |
+
│ ├── storage_service.py # Model storage
|
| 105 |
+
│ ├── ar_material_service.py # AR optimization
|
| 106 |
+
│ └── database_service.py # Request logging
|
| 107 |
+
├── schemas/ # Request/response models
|
| 108 |
+
├── middleware/ # Custom middleware
|
| 109 |
+
├── utils/ # Utilities
|
| 110 |
+
├── config.py # Configuration
|
| 111 |
+
└── main.py # Entry point
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
## 3D Model Generation
|
| 115 |
+
|
| 116 |
+
### Advanced Mode (TRELLIS)
|
| 117 |
+
- High-quality textured 3D models
|
| 118 |
+
- ~10-30 seconds generation time
|
| 119 |
+
- Uses Microsoft's TRELLIS model
|
| 120 |
+
- Optimized for AR applications
|
| 121 |
+
|
| 122 |
+
### Basic Mode (Shap-E)
|
| 123 |
+
- Fast generation
|
| 124 |
+
- ~5-10 seconds generation time
|
| 125 |
+
- Uses OpenAI's Shap-E model
|
| 126 |
+
- Basic geometry without textures
|
| 127 |
+
|
| 128 |
+
## Development
|
| 129 |
+
|
| 130 |
+
To run locally:
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
# Install dependencies
|
| 134 |
+
pip install -r requirements.txt
|
| 135 |
+
|
| 136 |
+
# Set environment variables
|
| 137 |
+
export HF_TOKEN=your_token_here
|
| 138 |
+
|
| 139 |
+
# Run the server
|
| 140 |
+
python main.py
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
Visit `http://localhost:8000/docs` for API documentation.
|
| 144 |
+
|
| 145 |
+
## License
|
| 146 |
+
|
| 147 |
+
MIT License - See LICENSE file for details
|
| 148 |
+
|
| 149 |
+
## Links
|
| 150 |
+
|
| 151 |
+
- 🏠 [Project Repository](https://github.com/yourusername/prompt_ar)
|
| 152 |
+
- 📱 [Flutter Frontend](https://github.com/yourusername/prompt_ar/tree/main/frontend_prompt_ar)
|
| 153 |
+
- 📖 [Full Documentation](https://github.com/yourusername/prompt_ar/blob/main/README.md)
|
| 154 |
+
|
app/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application package for FastAPI app configuration."""
|
| 2 |
+
|
app/app.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application factory for creating and configuring the FastAPI application."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
|
| 7 |
+
from config import (
|
| 8 |
+
API_TITLE,
|
| 9 |
+
API_DESCRIPTION,
|
| 10 |
+
API_VERSION,
|
| 11 |
+
ALLOWED_ORIGINS,
|
| 12 |
+
DB_PATH,
|
| 13 |
+
)
|
| 14 |
+
from routers import models_router, root_router
|
| 15 |
+
from services.database_service import DatabaseService
|
| 16 |
+
from middleware.request_logging import RequestLoggingMiddleware
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# Global service instances
|
| 21 |
+
hf_service = None
|
| 22 |
+
database_service = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def create_app() -> FastAPI:
|
| 26 |
+
"""Create and configure the FastAPI application.
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
Configured FastAPI application instance
|
| 30 |
+
"""
|
| 31 |
+
# Create FastAPI app
|
| 32 |
+
app = FastAPI(
|
| 33 |
+
title=API_TITLE,
|
| 34 |
+
description=API_DESCRIPTION,
|
| 35 |
+
version=API_VERSION,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
# Initialize database service
|
| 39 |
+
_initialize_database_service()
|
| 40 |
+
|
| 41 |
+
# Add middleware
|
| 42 |
+
_setup_middleware(app)
|
| 43 |
+
|
| 44 |
+
# Include routers
|
| 45 |
+
_setup_routers(app)
|
| 46 |
+
|
| 47 |
+
return app
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _initialize_database_service():
|
| 51 |
+
"""Initialize the database service for request logging."""
|
| 52 |
+
global database_service
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
logger.info(f"Initializing database service for request logging (path: {DB_PATH})")
|
| 56 |
+
database_service = DatabaseService(db_path=DB_PATH)
|
| 57 |
+
logger.info(f"✓ Database service initialized successfully at {DB_PATH}")
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"❌ Failed to initialize database service: {e}")
|
| 60 |
+
database_service = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _setup_middleware(app: FastAPI):
|
| 64 |
+
"""Configure middleware for the application.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
app: FastAPI application instance
|
| 68 |
+
"""
|
| 69 |
+
# Add request logging middleware (must be before CORS middleware to capture all requests)
|
| 70 |
+
if database_service:
|
| 71 |
+
app.add_middleware(RequestLoggingMiddleware, database_service=database_service)
|
| 72 |
+
logger.info("✓ Request logging middleware added")
|
| 73 |
+
else:
|
| 74 |
+
logger.warning(
|
| 75 |
+
"⚠️ Request logging middleware not added - database service not initialized"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Configure CORS (needed for Flutter web and mobile apps)
|
| 79 |
+
app.add_middleware(
|
| 80 |
+
CORSMiddleware,
|
| 81 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 82 |
+
allow_credentials=True,
|
| 83 |
+
allow_methods=["*"],
|
| 84 |
+
allow_headers=["*"],
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _setup_routers(app: FastAPI):
|
| 89 |
+
"""Register routers with the application.
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
app: FastAPI application instance
|
| 93 |
+
"""
|
| 94 |
+
app.include_router(root_router)
|
| 95 |
+
app.include_router(models_router)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def get_hf_service():
|
| 99 |
+
"""Get the global HuggingFaceService instance.
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
HuggingFaceService instance or None if not initialized
|
| 103 |
+
"""
|
| 104 |
+
return hf_service
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def set_hf_service(service):
|
| 108 |
+
"""Set the global HuggingFaceService instance.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
service: HuggingFaceService instance
|
| 112 |
+
"""
|
| 113 |
+
global hf_service
|
| 114 |
+
hf_service = service
|
| 115 |
+
|
config.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration.
|
| 2 |
+
|
| 3 |
+
Loads configuration from .env file.
|
| 4 |
+
Environment variables take precedence over .env file values.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import List
|
| 10 |
+
|
| 11 |
+
from dotenv import dotenv_values
|
| 12 |
+
|
| 13 |
+
# Load environment variables from .env file
|
| 14 |
+
# Look for .env file in the backend directory
|
| 15 |
+
env_path = Path(__file__).parent / ".env"
|
| 16 |
+
if env_path.exists():
|
| 17 |
+
env_vars = dotenv_values(dotenv_path=env_path)
|
| 18 |
+
print(f"Loaded configuration from {env_path}")
|
| 19 |
+
else:
|
| 20 |
+
# Try loading from current directory as fallback
|
| 21 |
+
env_vars = dotenv_values()
|
| 22 |
+
print("Loaded configuration from .env file in current directory")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Helper function to get config value (env vars override .env file)
|
| 26 |
+
def get_config(key: str, default: str = "") -> str:
|
| 27 |
+
"""Get configuration value from environment variable or .env file.
|
| 28 |
+
|
| 29 |
+
Environment variables take precedence over .env file values.
|
| 30 |
+
"""
|
| 31 |
+
return os.getenv(key, env_vars.get(key, default))
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# API Configuration
|
| 35 |
+
API_TITLE = "PromptAR Backend API"
|
| 36 |
+
API_VERSION = "1.0.0"
|
| 37 |
+
API_DESCRIPTION = "API for generating 3D models from text prompts and serving them for AR visualization"
|
| 38 |
+
|
| 39 |
+
# Server Configuration
|
| 40 |
+
HOST = get_config("HOST", "0.0.0.0")
|
| 41 |
+
PORT = int(get_config("PORT", "8000"))
|
| 42 |
+
|
| 43 |
+
# CORS Configuration
|
| 44 |
+
allowed_origins_value = get_config("ALLOWED_ORIGINS", "*")
|
| 45 |
+
ALLOWED_ORIGINS: List[str] = (
|
| 46 |
+
allowed_origins_value.split(",") if allowed_origins_value != "*" else ["*"]
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Model Configuration
|
| 50 |
+
MODEL_STORAGE_PATH = get_config("MODEL_STORAGE_PATH", "./models")
|
| 51 |
+
|
| 52 |
+
# Database Configuration
|
| 53 |
+
# Use persistent storage on Hugging Face Spaces (/data), otherwise use local path
|
| 54 |
+
# /data is persistent storage on Hugging Face Spaces that survives restarts
|
| 55 |
+
_default_db_path = (
|
| 56 |
+
"/data/api_requests.db" if Path("/data").exists() else "./api_requests.db"
|
| 57 |
+
)
|
| 58 |
+
DB_PATH = get_config("DB_PATH", _default_db_path)
|
| 59 |
+
|
| 60 |
+
# Hugging Face Configuration (Required)
|
| 61 |
+
HF_TOKEN = get_config("HF_TOKEN", "")
|
| 62 |
+
if not HF_TOKEN:
|
| 63 |
+
import warnings
|
| 64 |
+
|
| 65 |
+
warnings.warn(
|
| 66 |
+
"HF_TOKEN is not configured. Set HF_TOKEN in .env file or environment variable to enable model generation.",
|
| 67 |
+
UserWarning,
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# 3D Model Generation Configuration
|
| 71 |
+
# Using TRELLIS: https://huggingface.co/spaces/dkatz2391/TRELLIS_TextTo3D_Try2
|
| 72 |
+
# Generates textured GLB files directly from text prompts
|
| 73 |
+
# Uses texture_size parameter to ensure textures are embedded in GLB files
|
main.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main application entry point for PromptAR Backend API."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from utils.logging_config import setup_colored_logging
|
| 6 |
+
from app.app import create_app, set_hf_service
|
| 7 |
+
from config import HOST, PORT, HF_TOKEN
|
| 8 |
+
|
| 9 |
+
# Setup colored logging before creating app
|
| 10 |
+
setup_colored_logging()
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# Create FastAPI application
|
| 14 |
+
app = create_app()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def startup_event():
|
| 18 |
+
"""Handle application startup events."""
|
| 19 |
+
# Initialize HuggingFaceService (works with or without HF_TOKEN)
|
| 20 |
+
try:
|
| 21 |
+
from services.huggingface_service import HuggingFaceService
|
| 22 |
+
|
| 23 |
+
logger.info("Initializing HuggingFaceService")
|
| 24 |
+
hf_service = HuggingFaceService()
|
| 25 |
+
set_hf_service(hf_service)
|
| 26 |
+
logger.info("✓ HuggingFaceService initialized successfully")
|
| 27 |
+
except Exception as e:
|
| 28 |
+
logger.error(f"❌ Failed to initialize HuggingFaceService: {e}")
|
| 29 |
+
set_hf_service(None)
|
| 30 |
+
|
| 31 |
+
if not HF_TOKEN:
|
| 32 |
+
logger.warning(
|
| 33 |
+
"⚠️ HF_TOKEN not configured - Some Spaces may require authentication"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
logger.info(
|
| 37 |
+
"API server started. Visit http://localhost:8000/docs for API documentation"
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# Register startup event
|
| 42 |
+
app.on_event("startup")(startup_event)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
if __name__ == "__main__":
|
| 46 |
+
import uvicorn
|
| 47 |
+
|
| 48 |
+
uvicorn.run(app, host=HOST, port=PORT)
|
middleware/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Middleware package for request logging."""
|
| 2 |
+
|
middleware/request_logging.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Middleware for logging all API requests to SQLite database."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from fastapi import Request
|
| 5 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
| 11 |
+
"""Middleware to log all API requests to SQLite database immediately on receipt."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, app, database_service):
|
| 14 |
+
"""Initialize the middleware.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
app: FastAPI application instance
|
| 18 |
+
database_service: DatabaseService instance for logging requests
|
| 19 |
+
"""
|
| 20 |
+
super().__init__(app)
|
| 21 |
+
self.database_service = database_service
|
| 22 |
+
|
| 23 |
+
async def dispatch(self, request: Request, call_next):
|
| 24 |
+
"""Log the request immediately when received, then process it.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
request: FastAPI request object
|
| 28 |
+
call_next: Next middleware/handler in the chain
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Response object
|
| 32 |
+
"""
|
| 33 |
+
# Get client IP address
|
| 34 |
+
client_ip = self._get_client_ip(request)
|
| 35 |
+
|
| 36 |
+
# Get user agent
|
| 37 |
+
user_agent = request.headers.get("user-agent")
|
| 38 |
+
|
| 39 |
+
# Log the request immediately when received (before processing)
|
| 40 |
+
try:
|
| 41 |
+
self.database_service.log_request(
|
| 42 |
+
method=request.method,
|
| 43 |
+
path=request.url.path,
|
| 44 |
+
client_ip=client_ip,
|
| 45 |
+
user_agent=user_agent,
|
| 46 |
+
)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
# Don't fail the request if logging fails
|
| 49 |
+
logger.error(f"Failed to log request: {e}")
|
| 50 |
+
|
| 51 |
+
# Process the request
|
| 52 |
+
response = await call_next(request)
|
| 53 |
+
return response
|
| 54 |
+
|
| 55 |
+
def _get_client_ip(self, request: Request) -> str:
|
| 56 |
+
"""Extract client IP address from request.
|
| 57 |
+
|
| 58 |
+
Checks various headers for the real client IP, accounting for proxies.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
request: FastAPI request object
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
Client IP address as string
|
| 65 |
+
"""
|
| 66 |
+
# Check for forwarded IP headers (common in proxy/load balancer setups)
|
| 67 |
+
forwarded_for = request.headers.get("x-forwarded-for")
|
| 68 |
+
if forwarded_for:
|
| 69 |
+
# X-Forwarded-For can contain multiple IPs, take the first one
|
| 70 |
+
return forwarded_for.split(",")[0].strip()
|
| 71 |
+
|
| 72 |
+
real_ip = request.headers.get("x-real-ip")
|
| 73 |
+
if real_ip:
|
| 74 |
+
return real_ip.strip()
|
| 75 |
+
|
| 76 |
+
# Fallback to direct client IP
|
| 77 |
+
if request.client:
|
| 78 |
+
return request.client.host
|
| 79 |
+
|
| 80 |
+
return "unknown"
|
requirements.txt
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core web framework - using versions compatible with Python 3.12 and Android/ARM
|
| 2 |
+
# FastAPI 0.104.1 works with Pydantic v1 (no Rust compilation needed)
|
| 3 |
+
fastapi==0.104.1
|
| 4 |
+
uvicorn[standard]==0.24.0
|
| 5 |
+
python-multipart==0.0.6
|
| 6 |
+
|
| 7 |
+
# HTTP clients
|
| 8 |
+
requests==2.31.0
|
| 9 |
+
httpx==0.25.2
|
| 10 |
+
|
| 11 |
+
# Configuration
|
| 12 |
+
python-dotenv==1.0.0
|
| 13 |
+
|
| 14 |
+
# Hugging Face integration
|
| 15 |
+
gradio_client==1.13.3
|
| 16 |
+
huggingface_hub>=0.19.3
|
| 17 |
+
|
| 18 |
+
# Image processing
|
| 19 |
+
Pillow==10.2.0
|
| 20 |
+
|
| 21 |
+
# 3D model processing
|
| 22 |
+
pygltflib==1.16.0
|
| 23 |
+
|
| 24 |
+
# Data validation - Pydantic v1 (no Rust compilation required, works on Android)
|
| 25 |
+
pydantic==1.10.13
|
| 26 |
+
|
routers/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
routers/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from routers.models import router as models_router
|
| 2 |
+
from routers.root import router as root_router
|
| 3 |
+
|
| 4 |
+
__all__ = ["models_router", "root_router"]
|
routers/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (341 Bytes). View file
|
|
|
routers/__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (19 kB). View file
|
|
|
routers/__pycache__/root.cpython-311.pyc
ADDED
|
Binary file (1.12 kB). View file
|
|
|
routers/models.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Router for model generation and download endpoints."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import asyncio
|
| 5 |
+
import uuid
|
| 6 |
+
import tempfile
|
| 7 |
+
import shutil
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from fastapi import APIRouter, HTTPException, BackgroundTasks, File, UploadFile
|
| 10 |
+
from fastapi.responses import Response
|
| 11 |
+
|
| 12 |
+
from schemas.models import PromptRequest, GenerationResponse
|
| 13 |
+
from services.storage_service import StorageService
|
| 14 |
+
from services.ar_material_service import normalize_materials_for_ar
|
| 15 |
+
from config import MODEL_STORAGE_PATH
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
router = APIRouter(prefix="/api/models", tags=["Models"])
|
| 19 |
+
|
| 20 |
+
# Initialize storage service
|
| 21 |
+
storage_service = StorageService()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def sanitize_error_message(error: Exception) -> str:
|
| 25 |
+
"""Sanitize error messages for frontend - hide technical details.
|
| 26 |
+
|
| 27 |
+
Returns user-friendly error messages instead of exposing:
|
| 28 |
+
- GPU quota details
|
| 29 |
+
- Internal error messages
|
| 30 |
+
- Technical stack traces
|
| 31 |
+
"""
|
| 32 |
+
error_str = str(error).lower()
|
| 33 |
+
|
| 34 |
+
# GPU quota errors
|
| 35 |
+
if "gpu quota" in error_str or "exceeded" in error_str:
|
| 36 |
+
return "The AI service is currently busy. Please try again in a few minutes."
|
| 37 |
+
|
| 38 |
+
# Timeout errors
|
| 39 |
+
if "timeout" in error_str or "timed out" in error_str:
|
| 40 |
+
return "The request took too long. Please try again with a simpler prompt."
|
| 41 |
+
|
| 42 |
+
# Space/service unavailable
|
| 43 |
+
if "space" in error_str and ("sleeping" in error_str or "unavailable" in error_str):
|
| 44 |
+
return "The AI service is temporarily unavailable. Please try again later."
|
| 45 |
+
|
| 46 |
+
# Queue/busy errors
|
| 47 |
+
if "queue" in error_str or "busy" in error_str:
|
| 48 |
+
return "The service is busy. Please try again in a moment."
|
| 49 |
+
|
| 50 |
+
# Network errors
|
| 51 |
+
if "network" in error_str or "connection" in error_str:
|
| 52 |
+
return "Network error occurred. Please check your connection and try again."
|
| 53 |
+
|
| 54 |
+
# Generic fallback for other errors
|
| 55 |
+
return "Model generation failed. Please try again or use a different prompt."
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def get_hf_service():
|
| 59 |
+
"""Get HuggingFaceService instance from app."""
|
| 60 |
+
from app.app import get_hf_service as _get_hf_service
|
| 61 |
+
|
| 62 |
+
hf_service = _get_hf_service()
|
| 63 |
+
|
| 64 |
+
if not hf_service:
|
| 65 |
+
raise HTTPException(
|
| 66 |
+
status_code=503,
|
| 67 |
+
detail="HuggingFaceService not initialized. Check HF_TOKEN configuration.",
|
| 68 |
+
)
|
| 69 |
+
return hf_service
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def cleanup_model_file(glb_path: Path):
|
| 73 |
+
"""Cleanup function to delete model file after download.
|
| 74 |
+
|
| 75 |
+
This runs as a background task after the response is sent to the client.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
glb_path: Path to the GLB file to delete
|
| 79 |
+
"""
|
| 80 |
+
try:
|
| 81 |
+
if glb_path.exists():
|
| 82 |
+
glb_path.unlink()
|
| 83 |
+
logger.info(f"✓ Deleted GLB file after download: {glb_path}")
|
| 84 |
+
else:
|
| 85 |
+
logger.warning(f"GLB file not found for deletion: {glb_path}")
|
| 86 |
+
except Exception as delete_error:
|
| 87 |
+
logger.warning(f"Failed to delete GLB file after download: {delete_error}")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.post("/generate", response_model=GenerationResponse)
|
| 91 |
+
async def generate_model(request: PromptRequest):
|
| 92 |
+
"""Generate a 3D model from a text prompt.
|
| 93 |
+
|
| 94 |
+
Mode options:
|
| 95 |
+
- "basic": Uses Shap-E for 3D model generation
|
| 96 |
+
- "advanced": Uses TRELLIS for 3D model generation with textures
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
request: PromptRequest with prompt and mode ("basic" or "advanced")
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
GenerationResponse with model_id and download_url
|
| 103 |
+
"""
|
| 104 |
+
hf_service = get_hf_service()
|
| 105 |
+
|
| 106 |
+
# Validate mode parameter
|
| 107 |
+
mode = request.mode.lower() if request.mode else None
|
| 108 |
+
if not mode or mode not in ["basic", "advanced"]:
|
| 109 |
+
raise HTTPException(status_code=400, detail=f"Invalid mode: '{request.mode}'.")
|
| 110 |
+
|
| 111 |
+
# Create model record
|
| 112 |
+
model_id = storage_service.create_model_record(request.prompt)
|
| 113 |
+
|
| 114 |
+
logger.info(
|
| 115 |
+
f"Generating 3D model for prompt: '{request.prompt[:50]}...' "
|
| 116 |
+
f"(ID: {model_id}, Mode: {mode})"
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
# Generate 3D model based on mode
|
| 121 |
+
# Combine logic for Shap-E and TRELLIS generation
|
| 122 |
+
hf_clients = {
|
| 123 |
+
"basic": ("shap_e_client", hf_service.text_to_3d_shap_e, "Shap-E"),
|
| 124 |
+
"advanced": ("trellis_client", hf_service.text_to_3d, "TRELLIS"),
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
client_attr, gen_func, mode_name = hf_clients[mode]
|
| 128 |
+
|
| 129 |
+
if not getattr(hf_service, client_attr):
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=503,
|
| 132 |
+
detail=f"{mode_name} client not initialized. {mode_name} features are not available.",
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
logger.info(f"Using {mode_name} ({mode} mode) for generation...")
|
| 136 |
+
glb_path = await asyncio.to_thread(gen_func, request.prompt, model_id)
|
| 137 |
+
|
| 138 |
+
logger.info(f"3D model generated: {glb_path}")
|
| 139 |
+
|
| 140 |
+
# Update storage with model file
|
| 141 |
+
storage_service.set_model_file(model_id, glb_path, fmt="glb")
|
| 142 |
+
storage_service.update_model_status(model_id, "completed")
|
| 143 |
+
|
| 144 |
+
logger.info(f"✓ 3D model generation completed for {model_id} (mode: {mode})")
|
| 145 |
+
|
| 146 |
+
resp = GenerationResponse(
|
| 147 |
+
status="success",
|
| 148 |
+
message=f"3D model generated successfully using {mode} mode",
|
| 149 |
+
model_id=model_id,
|
| 150 |
+
download_url=f"/api/models/download/{model_id}",
|
| 151 |
+
)
|
| 152 |
+
logger.info(f"Generation response: {resp}")
|
| 153 |
+
return resp
|
| 154 |
+
|
| 155 |
+
except HTTPException:
|
| 156 |
+
# Re-raise HTTP exceptions as-is
|
| 157 |
+
raise
|
| 158 |
+
except RuntimeError as e:
|
| 159 |
+
error_msg = str(e)
|
| 160 |
+
logger.error(f"Generation failed: {error_msg}")
|
| 161 |
+
storage_service.update_model_status(model_id, "failed")
|
| 162 |
+
# Return sanitized error message to frontend
|
| 163 |
+
user_friendly_msg = sanitize_error_message(e)
|
| 164 |
+
raise HTTPException(status_code=503, detail=user_friendly_msg)
|
| 165 |
+
except Exception as e:
|
| 166 |
+
error_msg = str(e)
|
| 167 |
+
logger.error(f"Unexpected error: {error_msg}", exc_info=True)
|
| 168 |
+
storage_service.update_model_status(model_id, "failed")
|
| 169 |
+
# Return sanitized error message to frontend
|
| 170 |
+
user_friendly_msg = sanitize_error_message(e)
|
| 171 |
+
raise HTTPException(status_code=500, detail=user_friendly_msg)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.get("/download/{model_id}")
|
| 175 |
+
async def download_model(model_id: str, background_tasks: BackgroundTasks):
|
| 176 |
+
"""Download GLB model file with brightness normalization applied on download.
|
| 177 |
+
|
| 178 |
+
This endpoint:
|
| 179 |
+
1. Loads the GLB file
|
| 180 |
+
2. Applies brightness normalization for AR visibility
|
| 181 |
+
3. Returns the normalized GLB file as a single binary file
|
| 182 |
+
4. Deletes the file and clears memory after client downloads
|
| 183 |
+
|
| 184 |
+
Benefits:
|
| 185 |
+
- Single file download (faster, simpler)
|
| 186 |
+
- Smaller size (no zip overhead)
|
| 187 |
+
- Brightness normalization applied on-demand (always uses latest settings)
|
| 188 |
+
- Works directly with AR plugins (NodeType.fileSystemAppFolderGLB)
|
| 189 |
+
- Automatic cleanup after download (saves storage space)
|
| 190 |
+
"""
|
| 191 |
+
hf_service = get_hf_service()
|
| 192 |
+
|
| 193 |
+
logger.info(f"Preparing GLB file for download (model ID: {model_id})")
|
| 194 |
+
glb_path = Path(MODEL_STORAGE_PATH) / f"{model_id}.glb"
|
| 195 |
+
|
| 196 |
+
# Check if GLB file exists
|
| 197 |
+
if not glb_path.exists():
|
| 198 |
+
logger.error(f"GLB file not found: {glb_path}")
|
| 199 |
+
raise HTTPException(status_code=404, detail="Model file not found")
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
# Apply brightness normalization to GLB file before serving
|
| 203 |
+
logger.info("Applying brightness normalization to GLB file...")
|
| 204 |
+
# normalize_materials_for_ar(glb_path)
|
| 205 |
+
logger.info("✓ Brightness normalization applied to GLB")
|
| 206 |
+
|
| 207 |
+
# Read normalized GLB file content
|
| 208 |
+
with open(glb_path, "rb") as f:
|
| 209 |
+
glb_content = f.read()
|
| 210 |
+
|
| 211 |
+
logger.info(
|
| 212 |
+
f"Serving normalized GLB file: {glb_path} ({len(glb_content)} bytes)"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# Schedule cleanup task to run after response is sent
|
| 216 |
+
# background_tasks.add_task(cleanup_model_file, glb_path)
|
| 217 |
+
|
| 218 |
+
# Create response
|
| 219 |
+
response = Response(
|
| 220 |
+
content=glb_content,
|
| 221 |
+
media_type="model/gltf-binary",
|
| 222 |
+
headers={
|
| 223 |
+
"Content-Type": "model/gltf-binary",
|
| 224 |
+
"Content-Disposition": f'attachment; filename="{model_id}.glb"',
|
| 225 |
+
"Access-Control-Allow-Origin": "*",
|
| 226 |
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
| 227 |
+
"Access-Control-Allow-Headers": "*",
|
| 228 |
+
"Cache-Control": "public, max-age=3600",
|
| 229 |
+
},
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
# Clear memory reference (will be garbage collected after response is sent)
|
| 233 |
+
del glb_content
|
| 234 |
+
|
| 235 |
+
return response
|
| 236 |
+
except Exception as e:
|
| 237 |
+
logger.error(f"Failed to prepare/serve GLB file: {e}")
|
| 238 |
+
raise HTTPException(
|
| 239 |
+
status_code=500, detail=f"Failed to prepare/serve GLB file: {str(e)}"
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
@router.post("/generate-from-image", response_model=GenerationResponse)
|
| 244 |
+
async def generate_model_from_image(image: UploadFile = File(...)):
|
| 245 |
+
"""Generate a 3D model from an uploaded image using Hunyuan3D.
|
| 246 |
+
|
| 247 |
+
This endpoint:
|
| 248 |
+
1. Accepts an image file upload
|
| 249 |
+
2. Uses Hunyuan3D to convert the image to a 3D GLB model with textures and colors
|
| 250 |
+
3. Returns a model_id and download_url for the generated model
|
| 251 |
+
|
| 252 |
+
Args:
|
| 253 |
+
image: Uploaded image file (JPEG, PNG, etc.)
|
| 254 |
+
|
| 255 |
+
Returns:
|
| 256 |
+
GenerationResponse with model_id and download_url
|
| 257 |
+
"""
|
| 258 |
+
hf_service = get_hf_service()
|
| 259 |
+
|
| 260 |
+
if not hf_service.hunyuan_client:
|
| 261 |
+
raise HTTPException(
|
| 262 |
+
status_code=503,
|
| 263 |
+
detail="Hunyuan3D-2 client not initialized. Hunyuan3D-2 is required for image-to-3D features.",
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# Create model record with descriptive prompt
|
| 267 |
+
model_id = storage_service.create_model_record(f"image_to_3d_{image.filename}")
|
| 268 |
+
|
| 269 |
+
logger.info(f"Generating 3D model from image: {image.filename} (ID: {model_id})")
|
| 270 |
+
|
| 271 |
+
# Create temporary directory for uploaded image
|
| 272 |
+
temp_dir = Path(tempfile.gettempdir()) / "prompt_ar_uploads"
|
| 273 |
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
| 274 |
+
|
| 275 |
+
# Save uploaded image to temporary file
|
| 276 |
+
temp_image_path = temp_dir / f"{model_id}_{image.filename}"
|
| 277 |
+
|
| 278 |
+
try:
|
| 279 |
+
# Save uploaded file
|
| 280 |
+
with open(temp_image_path, "wb") as buffer:
|
| 281 |
+
shutil.copyfileobj(image.file, buffer)
|
| 282 |
+
|
| 283 |
+
logger.info(f"Saved uploaded image to: {temp_image_path}")
|
| 284 |
+
|
| 285 |
+
# Generate 3D model from image
|
| 286 |
+
logger.info("Using Hunyuan3D-2 for image-to-3D generation...")
|
| 287 |
+
glb_path = await asyncio.to_thread(
|
| 288 |
+
hf_service.image_to_3d_hunyuan, str(temp_image_path), model_id
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
logger.info(f"3D model generated: {glb_path}")
|
| 292 |
+
|
| 293 |
+
# Update storage with model file
|
| 294 |
+
storage_service.set_model_file(model_id, glb_path, fmt="glb")
|
| 295 |
+
storage_service.update_model_status(model_id, "completed")
|
| 296 |
+
|
| 297 |
+
logger.info(f"✓ 3D model generation from image completed for {model_id}")
|
| 298 |
+
|
| 299 |
+
resp = GenerationResponse(
|
| 300 |
+
status="success",
|
| 301 |
+
message="3D model generated successfully from image using Hunyuan3D-2",
|
| 302 |
+
model_id=model_id,
|
| 303 |
+
download_url=f"/api/models/download/{model_id}",
|
| 304 |
+
)
|
| 305 |
+
logger.info(f"Generation response: {resp}")
|
| 306 |
+
return resp
|
| 307 |
+
|
| 308 |
+
except HTTPException:
|
| 309 |
+
# Re-raise HTTP exceptions as-is
|
| 310 |
+
raise
|
| 311 |
+
except RuntimeError as e:
|
| 312 |
+
error_msg = str(e)
|
| 313 |
+
logger.error(f"Generation from image failed: {error_msg}")
|
| 314 |
+
storage_service.update_model_status(model_id, "failed")
|
| 315 |
+
# Return sanitized error message to frontend
|
| 316 |
+
user_friendly_msg = sanitize_error_message(e)
|
| 317 |
+
raise HTTPException(status_code=503, detail=user_friendly_msg)
|
| 318 |
+
except Exception as e:
|
| 319 |
+
error_msg = str(e)
|
| 320 |
+
logger.error(f"Unexpected error: {error_msg}", exc_info=True)
|
| 321 |
+
storage_service.update_model_status(model_id, "failed")
|
| 322 |
+
# Return sanitized error message to frontend
|
| 323 |
+
user_friendly_msg = sanitize_error_message(e)
|
| 324 |
+
raise HTTPException(status_code=500, detail=user_friendly_msg)
|
| 325 |
+
finally:
|
| 326 |
+
# Clean up temporary image file
|
| 327 |
+
try:
|
| 328 |
+
if temp_image_path.exists():
|
| 329 |
+
temp_image_path.unlink()
|
| 330 |
+
logger.debug(f"Cleaned up temporary image file: {temp_image_path}")
|
| 331 |
+
except Exception as cleanup_error:
|
| 332 |
+
logger.warning(f"Failed to cleanup temporary image file: {cleanup_error}")
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
@router.post("/generate-from-image2", response_model=GenerationResponse)
|
| 336 |
+
async def generate_model_from_image2(image: UploadFile = File(...)):
|
| 337 |
+
"""Generate a 3D model from an uploaded image using TRELLIS.
|
| 338 |
+
|
| 339 |
+
This endpoint:
|
| 340 |
+
1. Accepts an image file upload
|
| 341 |
+
2. Uses TRELLIS 2 to convert the image to a 3D GLB model with textures and colors
|
| 342 |
+
3. Returns a model_id and download_url for the generated model
|
| 343 |
+
|
| 344 |
+
Args:
|
| 345 |
+
image: Uploaded image file (JPEG, PNG, etc.)
|
| 346 |
+
|
| 347 |
+
Returns:
|
| 348 |
+
GenerationResponse with model_id and download_url
|
| 349 |
+
"""
|
| 350 |
+
hf_service = get_hf_service()
|
| 351 |
+
|
| 352 |
+
if not hf_service.trellis_client2:
|
| 353 |
+
raise HTTPException(
|
| 354 |
+
status_code=503,
|
| 355 |
+
detail="TRELLIS 2 client not initialized. TRELLIS 2 is required for image-to-3D features.",
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
# Create model record with descriptive prompt
|
| 359 |
+
model_id = storage_service.create_model_record(f"image_to_3d_{image.filename}")
|
| 360 |
+
|
| 361 |
+
logger.info(f"Generating 3D model from image: {image.filename} (ID: {model_id})")
|
| 362 |
+
|
| 363 |
+
# Create temporary directory for uploaded image
|
| 364 |
+
temp_dir = Path(tempfile.gettempdir()) / "prompt_ar_uploads"
|
| 365 |
+
temp_dir.mkdir(parents=True, exist_ok=True)
|
| 366 |
+
|
| 367 |
+
# Save uploaded image to temporary file
|
| 368 |
+
temp_image_path = temp_dir / f"{model_id}_{image.filename}"
|
| 369 |
+
|
| 370 |
+
try:
|
| 371 |
+
# Save uploaded file
|
| 372 |
+
with open(temp_image_path, "wb") as buffer:
|
| 373 |
+
shutil.copyfileobj(image.file, buffer)
|
| 374 |
+
|
| 375 |
+
logger.info(f"Saved uploaded image to: {temp_image_path}")
|
| 376 |
+
|
| 377 |
+
# Generate 3D model from image
|
| 378 |
+
logger.info("Using TRELLIS 2 for image-to-3D generation...")
|
| 379 |
+
glb_path = await asyncio.to_thread(
|
| 380 |
+
hf_service.image_to_3d_TRELLIS, str(temp_image_path), model_id
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
logger.info(f"3D model generated: {glb_path}")
|
| 384 |
+
|
| 385 |
+
# Update storage with model file
|
| 386 |
+
storage_service.set_model_file(model_id, glb_path, fmt="glb")
|
| 387 |
+
storage_service.update_model_status(model_id, "completed")
|
| 388 |
+
|
| 389 |
+
logger.info(f"✓ 3D model generation from image completed for {model_id}")
|
| 390 |
+
|
| 391 |
+
resp = GenerationResponse(
|
| 392 |
+
status="success",
|
| 393 |
+
message="3D model generated successfully from image using TRELLIS 2",
|
| 394 |
+
model_id=model_id,
|
| 395 |
+
download_url=f"/api/models/download/{model_id}",
|
| 396 |
+
)
|
| 397 |
+
logger.info(f"Generation response: {resp}")
|
| 398 |
+
return resp
|
| 399 |
+
|
| 400 |
+
except HTTPException:
|
| 401 |
+
# Re-raise HTTP exceptions as-is
|
| 402 |
+
raise
|
| 403 |
+
except RuntimeError as e:
|
| 404 |
+
error_msg = str(e)
|
| 405 |
+
logger.error(f"Generation from image failed: {error_msg}")
|
| 406 |
+
storage_service.update_model_status(model_id, "failed")
|
| 407 |
+
# Return sanitized error message to frontend
|
| 408 |
+
user_friendly_msg = sanitize_error_message(e)
|
| 409 |
+
raise HTTPException(status_code=503, detail=user_friendly_msg)
|
| 410 |
+
except Exception as e:
|
| 411 |
+
error_msg = str(e)
|
| 412 |
+
logger.error(f"Unexpected error: {error_msg}", exc_info=True)
|
| 413 |
+
storage_service.update_model_status(model_id, "failed")
|
| 414 |
+
# Return sanitized error message to frontend
|
| 415 |
+
user_friendly_msg = sanitize_error_message(e)
|
| 416 |
+
raise HTTPException(status_code=500, detail=user_friendly_msg)
|
| 417 |
+
finally:
|
| 418 |
+
# Clean up temporary image file
|
| 419 |
+
try:
|
| 420 |
+
if temp_image_path.exists():
|
| 421 |
+
temp_image_path.unlink()
|
| 422 |
+
logger.debug(f"Cleaned up temporary image file: {temp_image_path}")
|
| 423 |
+
except Exception as cleanup_error:
|
| 424 |
+
logger.warning(f"Failed to cleanup temporary image file: {cleanup_error}")
|
routers/root.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Root router for API information and health checks."""
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
|
| 4 |
+
router = APIRouter(tags=["Root"])
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@router.get("/")
|
| 8 |
+
async def root():
|
| 9 |
+
"""Root endpoint providing API information."""
|
| 10 |
+
return {
|
| 11 |
+
"message": "PromptAR Backend API",
|
| 12 |
+
"version": "1.0.0",
|
| 13 |
+
"endpoints": {
|
| 14 |
+
"generate": "POST /api/models/generate",
|
| 15 |
+
"download": "GET /api/models/download/{model_id}"
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("/health")
|
| 21 |
+
async def health_check():
|
| 22 |
+
"""Health check endpoint."""
|
| 23 |
+
return {
|
| 24 |
+
"status": "healthy",
|
| 25 |
+
"service": "PromptAR Backend"
|
| 26 |
+
}
|
schemas/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .models import PromptRequest, GenerationResponse
|
| 2 |
+
|
| 3 |
+
__all__ = ["PromptRequest", "GenerationResponse"]
|
schemas/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (304 Bytes). View file
|
|
|
schemas/__pycache__/models.cpython-311.pyc
ADDED
|
Binary file (1.69 kB). View file
|
|
|
schemas/models.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class PromptRequest(BaseModel):
|
| 5 |
+
"""Request schema for model generation."""
|
| 6 |
+
prompt: str
|
| 7 |
+
mode: str # "basic" for Shap-E, "advanced" for TRELLIS
|
| 8 |
+
|
| 9 |
+
class Config:
|
| 10 |
+
json_schema_extra = {
|
| 11 |
+
"example": {
|
| 12 |
+
"prompt": "wooden chair",
|
| 13 |
+
"mode": "basic"
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class GenerationResponse(BaseModel):
|
| 19 |
+
"""Response schema for model generation."""
|
| 20 |
+
status: str
|
| 21 |
+
message: str
|
| 22 |
+
model_id: str
|
| 23 |
+
download_url: str
|
| 24 |
+
|
| 25 |
+
class Config:
|
| 26 |
+
json_schema_extra = {
|
| 27 |
+
"example": {
|
| 28 |
+
"status": "success",
|
| 29 |
+
"message": "3D model generated successfully",
|
| 30 |
+
"model_id": "abc123-456def-789ghi",
|
| 31 |
+
"download_url": "/api/models/download/abc123-456def-789ghi"
|
| 32 |
+
}
|
| 33 |
+
}
|
services/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from services.storage_service import StorageService
|
| 2 |
+
|
| 3 |
+
__all__ = ["StorageService"]
|
services/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (279 Bytes). View file
|
|
|
services/__pycache__/ar_material_service.cpython-311.pyc
ADDED
|
Binary file (6.92 kB). View file
|
|
|
services/__pycache__/huggingface_service.cpython-311.pyc
ADDED
|
Binary file (34.1 kB). View file
|
|
|
services/__pycache__/storage_service.cpython-311.pyc
ADDED
|
Binary file (2.95 kB). View file
|
|
|
services/ar_material_service.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AR Material Processing Service for normalizing 3D model materials for AR visibility."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import TYPE_CHECKING
|
| 6 |
+
|
| 7 |
+
if TYPE_CHECKING:
|
| 8 |
+
from pygltflib import GLTF2
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# ============================================================================
|
| 13 |
+
# Configuration
|
| 14 |
+
# ============================================================================
|
| 15 |
+
|
| 16 |
+
# Material brightness configuration for AR visibility
|
| 17 |
+
# Adjust these values to control model brightness:
|
| 18 |
+
# - Higher emissive = brighter glow (0.0-1.0, typical: 0.2-0.5)
|
| 19 |
+
# - Higher baseColor boost = brighter colors (1.0 = no change, 1.5 = 50% brighter)
|
| 20 |
+
# - Lower metallic threshold = more materials become non-metallic (0.0-1.0)
|
| 21 |
+
# - Higher roughness min = more matte surface (0.0-1.0, typical: 0.7-1.0)
|
| 22 |
+
BRIGHTNESS_CONFIG = {
|
| 23 |
+
"metallic_threshold": 0.1, # Any metallicFactor > this becomes 0.0
|
| 24 |
+
"metallic_target": 0.0, # Target metallicFactor for bright materials
|
| 25 |
+
"roughness_min": 0.9, # Minimum roughnessFactor (higher = more matte)
|
| 26 |
+
"base_color_boost": 1.5, # Multiplier for baseColorFactor (1.5 = 50% brighter)
|
| 27 |
+
"emissive_base": 0.3, # Base emissive glow (0.0-1.0)
|
| 28 |
+
"emissive_max": 0.5, # Maximum emissive glow (0.0-1.0)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
# ============================================================================
|
| 32 |
+
# Core Material Normalization Functions
|
| 33 |
+
# ============================================================================
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def normalize_gltf_materials(gltf: "GLTF2") -> None:
|
| 37 |
+
"""Normalize materials in a GLTF object (in-memory) for AR visibility.
|
| 38 |
+
|
| 39 |
+
This function works on a GLTF object that's already loaded in memory.
|
| 40 |
+
It adjusts material properties to ensure models appear bright in AR.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
gltf: GLTF2 object to normalize
|
| 44 |
+
"""
|
| 45 |
+
# Normalize materials to ensure they render correctly in AR
|
| 46 |
+
# High metallicFactor can cause models to appear dark/black
|
| 47 |
+
# BoxTextured works because it has metallicFactor=0.0 (non-metallic)
|
| 48 |
+
if gltf.materials:
|
| 49 |
+
for i, material in enumerate(gltf.materials):
|
| 50 |
+
if (
|
| 51 |
+
hasattr(material, "pbrMetallicRoughness")
|
| 52 |
+
and material.pbrMetallicRoughness
|
| 53 |
+
):
|
| 54 |
+
pbr = material.pbrMetallicRoughness
|
| 55 |
+
|
| 56 |
+
# If metallicFactor is too high, reduce it to target value
|
| 57 |
+
# High metallic = mirrors environment, needs strong lighting
|
| 58 |
+
# Non-metallic (0.0) = uses base color/texture, works better in AR
|
| 59 |
+
if hasattr(pbr, "metallicFactor") and pbr.metallicFactor is not None:
|
| 60 |
+
original_metallic = pbr.metallicFactor
|
| 61 |
+
if original_metallic > BRIGHTNESS_CONFIG["metallic_threshold"]:
|
| 62 |
+
pbr.metallicFactor = BRIGHTNESS_CONFIG["metallic_target"]
|
| 63 |
+
logger.info(
|
| 64 |
+
f"Normalized material {i}: metallicFactor {original_metallic} -> {pbr.metallicFactor} "
|
| 65 |
+
f"(threshold: {BRIGHTNESS_CONFIG['metallic_threshold']}, target: {BRIGHTNESS_CONFIG['metallic_target']})"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Ensure roughnessFactor is reasonable (0.0-1.0)
|
| 69 |
+
# Lower roughness = more shiny, but can also appear darker
|
| 70 |
+
# Higher roughness = more matte, better visibility
|
| 71 |
+
if hasattr(pbr, "roughnessFactor") and pbr.roughnessFactor is not None:
|
| 72 |
+
if pbr.roughnessFactor < BRIGHTNESS_CONFIG["roughness_min"]:
|
| 73 |
+
# Increase roughness to minimum for maximum visibility
|
| 74 |
+
pbr.roughnessFactor = max(
|
| 75 |
+
pbr.roughnessFactor,
|
| 76 |
+
BRIGHTNESS_CONFIG["roughness_min"],
|
| 77 |
+
)
|
| 78 |
+
logger.info(
|
| 79 |
+
f"Normalized material {i}: roughnessFactor -> {pbr.roughnessFactor} "
|
| 80 |
+
f"(min: {BRIGHTNESS_CONFIG['roughness_min']})"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Ensure baseColorFactor is set (white if missing)
|
| 84 |
+
if not hasattr(pbr, "baseColorFactor") or pbr.baseColorFactor is None:
|
| 85 |
+
pbr.baseColorFactor = [1.0, 1.0, 1.0, 1.0]
|
| 86 |
+
logger.info(f"Added baseColorFactor to material {i} (white)")
|
| 87 |
+
else:
|
| 88 |
+
# Boost baseColorFactor to increase brightness
|
| 89 |
+
base_color = pbr.baseColorFactor
|
| 90 |
+
if isinstance(base_color, list) and len(base_color) >= 3:
|
| 91 |
+
# Apply brightness boost multiplier
|
| 92 |
+
boost = BRIGHTNESS_CONFIG["base_color_boost"]
|
| 93 |
+
boosted_color = [
|
| 94 |
+
min(1.0, base_color[0] * boost),
|
| 95 |
+
min(1.0, base_color[1] * boost),
|
| 96 |
+
min(1.0, base_color[2] * boost),
|
| 97 |
+
base_color[3] if len(base_color) > 3 else 1.0,
|
| 98 |
+
]
|
| 99 |
+
if boosted_color != base_color:
|
| 100 |
+
pbr.baseColorFactor = boosted_color
|
| 101 |
+
boost_percent = int((boost - 1.0) * 100)
|
| 102 |
+
logger.info(
|
| 103 |
+
f"Boosted baseColorFactor for material {i} "
|
| 104 |
+
f"({boost_percent}% brightness increase, multiplier: {boost})"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Add emissive factor to make models super bright and visible
|
| 108 |
+
if (
|
| 109 |
+
not hasattr(material, "emissiveFactor")
|
| 110 |
+
or material.emissiveFactor is None
|
| 111 |
+
):
|
| 112 |
+
emissive_value = BRIGHTNESS_CONFIG["emissive_base"]
|
| 113 |
+
material.emissiveFactor = [
|
| 114 |
+
emissive_value,
|
| 115 |
+
emissive_value,
|
| 116 |
+
emissive_value,
|
| 117 |
+
]
|
| 118 |
+
logger.info(
|
| 119 |
+
f"Added emissiveFactor to material {i} "
|
| 120 |
+
f"(glow: {emissive_value}, config: {BRIGHTNESS_CONFIG['emissive_base']})"
|
| 121 |
+
)
|
| 122 |
+
else:
|
| 123 |
+
# Boost existing emissive
|
| 124 |
+
emissive = material.emissiveFactor
|
| 125 |
+
if isinstance(emissive, list) and len(emissive) >= 3:
|
| 126 |
+
emissive_max = BRIGHTNESS_CONFIG["emissive_max"]
|
| 127 |
+
emissive_base = BRIGHTNESS_CONFIG["emissive_base"]
|
| 128 |
+
boosted_emissive = [
|
| 129 |
+
min(emissive_max, emissive[0] + emissive_base),
|
| 130 |
+
min(emissive_max, emissive[1] + emissive_base),
|
| 131 |
+
min(emissive_max, emissive[2] + emissive_base),
|
| 132 |
+
]
|
| 133 |
+
material.emissiveFactor = boosted_emissive
|
| 134 |
+
logger.info(
|
| 135 |
+
f"Boosted emissiveFactor for material {i} "
|
| 136 |
+
f"(glow: {boosted_emissive}, max: {emissive_max})"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ============================================================================
|
| 141 |
+
# File-based Material Normalization
|
| 142 |
+
# ============================================================================
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def normalize_materials_for_ar(glb_path: Path) -> None:
|
| 146 |
+
"""Normalize materials in GLB/GLTF file for AR visibility.
|
| 147 |
+
|
| 148 |
+
This function can be applied to both GLB and GLTF files directly.
|
| 149 |
+
It adjusts material properties to ensure models appear bright in AR.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
glb_path: Path to the GLB or GLTF file to modify
|
| 153 |
+
"""
|
| 154 |
+
try:
|
| 155 |
+
from pygltflib import GLTF2
|
| 156 |
+
|
| 157 |
+
logger.info(f"Loading GLB/GLTF for brightness normalization: {glb_path}")
|
| 158 |
+
gltf = GLTF2.load(str(glb_path))
|
| 159 |
+
|
| 160 |
+
# Normalize materials using the shared function
|
| 161 |
+
normalize_gltf_materials(gltf)
|
| 162 |
+
|
| 163 |
+
# Save the modified GLB/GLTF file
|
| 164 |
+
gltf.save(str(glb_path))
|
| 165 |
+
logger.info(f"✓ Brightness normalization saved to: {glb_path}")
|
| 166 |
+
|
| 167 |
+
except ImportError:
|
| 168 |
+
logger.error("pygltflib not available - cannot normalize materials")
|
| 169 |
+
raise RuntimeError("pygltflib required for material normalization")
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"Failed to normalize materials: {e}")
|
| 172 |
+
import traceback
|
| 173 |
+
|
| 174 |
+
logger.error(f"Material normalization traceback: {traceback.format_exc()}")
|
| 175 |
+
raise RuntimeError(f"Failed to normalize materials: {e}") from e
|
services/database_service.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database service for SQLite operations to store API request logs."""
|
| 2 |
+
|
| 3 |
+
import sqlite3
|
| 4 |
+
import logging
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Optional, Dict, Any
|
| 8 |
+
from contextlib import contextmanager
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class DatabaseService:
|
| 14 |
+
"""Service for managing SQLite database operations for API request logging."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, db_path: str = "api_requests.db"):
|
| 17 |
+
"""Initialize the database service.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
db_path: Path to the SQLite database file
|
| 21 |
+
"""
|
| 22 |
+
self.db_path = Path(db_path)
|
| 23 |
+
self._ensure_db_directory()
|
| 24 |
+
self._initialize_database()
|
| 25 |
+
|
| 26 |
+
def _ensure_db_directory(self):
|
| 27 |
+
"""Ensure the directory for the database file exists."""
|
| 28 |
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
def _initialize_database(self):
|
| 31 |
+
"""Initialize the database and create tables if they don't exist."""
|
| 32 |
+
try:
|
| 33 |
+
with self._get_connection() as conn:
|
| 34 |
+
cursor = conn.cursor()
|
| 35 |
+
cursor.execute(
|
| 36 |
+
"""
|
| 37 |
+
CREATE TABLE IF NOT EXISTS api_requests (
|
| 38 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 39 |
+
timestamp_utc TEXT NOT NULL,
|
| 40 |
+
method TEXT NOT NULL,
|
| 41 |
+
path TEXT NOT NULL,
|
| 42 |
+
client_ip TEXT,
|
| 43 |
+
user_agent TEXT,
|
| 44 |
+
created_at TEXT NOT NULL DEFAULT (datetime('now', 'utc'))
|
| 45 |
+
)
|
| 46 |
+
"""
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# Create index on timestamp for faster queries
|
| 50 |
+
cursor.execute(
|
| 51 |
+
"""
|
| 52 |
+
CREATE INDEX IF NOT EXISTS idx_timestamp_utc
|
| 53 |
+
ON api_requests(timestamp_utc)
|
| 54 |
+
"""
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Create index on client_ip for faster queries
|
| 58 |
+
cursor.execute(
|
| 59 |
+
"""
|
| 60 |
+
CREATE INDEX IF NOT EXISTS idx_client_ip
|
| 61 |
+
ON api_requests(client_ip)
|
| 62 |
+
"""
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
conn.commit()
|
| 66 |
+
logger.info(f"✓ Database initialized: {self.db_path}")
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Failed to initialize database: {e}")
|
| 69 |
+
raise
|
| 70 |
+
|
| 71 |
+
@contextmanager
|
| 72 |
+
def _get_connection(self):
|
| 73 |
+
"""Get a database connection with proper error handling."""
|
| 74 |
+
conn = None
|
| 75 |
+
try:
|
| 76 |
+
conn = sqlite3.connect(str(self.db_path), timeout=10.0)
|
| 77 |
+
conn.row_factory = sqlite3.Row # Return rows as dictionaries
|
| 78 |
+
yield conn
|
| 79 |
+
except sqlite3.Error as e:
|
| 80 |
+
logger.error(f"Database error: {e}")
|
| 81 |
+
if conn:
|
| 82 |
+
conn.rollback()
|
| 83 |
+
raise
|
| 84 |
+
finally:
|
| 85 |
+
if conn:
|
| 86 |
+
conn.close()
|
| 87 |
+
|
| 88 |
+
def log_request(
|
| 89 |
+
self,
|
| 90 |
+
method: str,
|
| 91 |
+
path: str,
|
| 92 |
+
client_ip: Optional[str] = None,
|
| 93 |
+
user_agent: Optional[str] = None,
|
| 94 |
+
):
|
| 95 |
+
"""Log an API request to the database immediately when received.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
method: HTTP method (GET, POST, etc.)
|
| 99 |
+
path: Request path
|
| 100 |
+
client_ip: Client IP address
|
| 101 |
+
user_agent: User agent string
|
| 102 |
+
"""
|
| 103 |
+
try:
|
| 104 |
+
# Get UTC timestamp
|
| 105 |
+
timestamp_utc = datetime.now(timezone.utc).isoformat()
|
| 106 |
+
|
| 107 |
+
with self._get_connection() as conn:
|
| 108 |
+
cursor = conn.cursor()
|
| 109 |
+
cursor.execute(
|
| 110 |
+
"""
|
| 111 |
+
INSERT INTO api_requests
|
| 112 |
+
(timestamp_utc, method, path, client_ip, user_agent)
|
| 113 |
+
VALUES (?, ?, ?, ?, ?)
|
| 114 |
+
""",
|
| 115 |
+
(
|
| 116 |
+
timestamp_utc,
|
| 117 |
+
method,
|
| 118 |
+
path,
|
| 119 |
+
client_ip,
|
| 120 |
+
user_agent,
|
| 121 |
+
),
|
| 122 |
+
)
|
| 123 |
+
conn.commit()
|
| 124 |
+
except Exception as e:
|
| 125 |
+
# Log error but don't fail the request
|
| 126 |
+
logger.error(f"Failed to log request to database: {e}")
|
| 127 |
+
|
| 128 |
+
def get_requests(
|
| 129 |
+
self,
|
| 130 |
+
limit: int = 100,
|
| 131 |
+
offset: int = 0,
|
| 132 |
+
client_ip: Optional[str] = None,
|
| 133 |
+
method: Optional[str] = None,
|
| 134 |
+
) -> list[Dict[str, Any]]:
|
| 135 |
+
"""Get API requests from the database.
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
limit: Maximum number of requests to return
|
| 139 |
+
offset: Number of requests to skip
|
| 140 |
+
client_ip: Filter by client IP (optional)
|
| 141 |
+
method: Filter by HTTP method (optional)
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
List of request dictionaries
|
| 145 |
+
"""
|
| 146 |
+
try:
|
| 147 |
+
with self._get_connection() as conn:
|
| 148 |
+
cursor = conn.cursor()
|
| 149 |
+
|
| 150 |
+
query = "SELECT * FROM api_requests WHERE 1=1"
|
| 151 |
+
params = []
|
| 152 |
+
|
| 153 |
+
if client_ip:
|
| 154 |
+
query += " AND client_ip = ?"
|
| 155 |
+
params.append(client_ip)
|
| 156 |
+
|
| 157 |
+
if method:
|
| 158 |
+
query += " AND method = ?"
|
| 159 |
+
params.append(method)
|
| 160 |
+
|
| 161 |
+
query += " ORDER BY timestamp_utc DESC LIMIT ? OFFSET ?"
|
| 162 |
+
params.extend([limit, offset])
|
| 163 |
+
|
| 164 |
+
cursor.execute(query, params)
|
| 165 |
+
rows = cursor.fetchall()
|
| 166 |
+
|
| 167 |
+
return [dict(row) for row in rows]
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Failed to get requests from database: {e}")
|
| 170 |
+
return []
|
| 171 |
+
|
| 172 |
+
def get_request_count(self, client_ip: Optional[str] = None) -> int:
|
| 173 |
+
"""Get total count of requests.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
client_ip: Filter by client IP (optional)
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Total count of requests
|
| 180 |
+
"""
|
| 181 |
+
try:
|
| 182 |
+
with self._get_connection() as conn:
|
| 183 |
+
cursor = conn.cursor()
|
| 184 |
+
|
| 185 |
+
if client_ip:
|
| 186 |
+
cursor.execute(
|
| 187 |
+
"SELECT COUNT(*) FROM api_requests WHERE client_ip = ?",
|
| 188 |
+
(client_ip,),
|
| 189 |
+
)
|
| 190 |
+
else:
|
| 191 |
+
cursor.execute("SELECT COUNT(*) FROM api_requests")
|
| 192 |
+
|
| 193 |
+
return cursor.fetchone()[0]
|
| 194 |
+
except Exception as e:
|
| 195 |
+
logger.error(f"Failed to get request count from database: {e}")
|
| 196 |
+
return 0
|
services/huggingface_service.py
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
import shutil
|
| 6 |
+
import uuid
|
| 7 |
+
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from gradio_client import Client, handle_file
|
| 10 |
+
from gradio_client import exceptions as gradio_exceptions
|
| 11 |
+
import httpx
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
from config import (
|
| 15 |
+
HF_TOKEN,
|
| 16 |
+
MODEL_STORAGE_PATH,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class HuggingFaceService:
|
| 23 |
+
"""Handles 3D model generation via Hugging Face Spaces."""
|
| 24 |
+
|
| 25 |
+
# ============================================================================
|
| 26 |
+
# Initialization
|
| 27 |
+
# ============================================================================
|
| 28 |
+
|
| 29 |
+
def __init__(self):
|
| 30 |
+
"""Initializes all Gradio Clients.
|
| 31 |
+
|
| 32 |
+
Note: HF_TOKEN is optional. If not provided, Spaces will use their own quota.
|
| 33 |
+
If provided, authenticated calls may consume your GPU quota.
|
| 34 |
+
"""
|
| 35 |
+
# HF_TOKEN is optional - we'll try without it first to avoid consuming your quota
|
| 36 |
+
|
| 37 |
+
self.trellis_client: Client | None = None
|
| 38 |
+
self.shap_e_client: Client | None = None
|
| 39 |
+
self.hunyuan_client: Client | None = None
|
| 40 |
+
self.trellis_client2: Client | None = None
|
| 41 |
+
|
| 42 |
+
# Ensure model storage directory exists
|
| 43 |
+
self.storage_path = Path(MODEL_STORAGE_PATH)
|
| 44 |
+
self.storage_path.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
|
| 46 |
+
# Initialize TRELLIS client (non-blocking - text-to-3D features will be unavailable if this fails)
|
| 47 |
+
# Hugging Face Spaces can be slow or sleeping (free tier), so we retry with backoff
|
| 48 |
+
self.trellis_client = self._initialize_client_with_retry(
|
| 49 |
+
"dkatz2391/TRELLIS_TextTo3D_Try2",
|
| 50 |
+
"TRELLIS",
|
| 51 |
+
max_retries=2,
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
# Initialize Shap-E client (non-blocking)
|
| 55 |
+
self.shap_e_client = self._initialize_client_with_retry(
|
| 56 |
+
"hysts/Shap-E",
|
| 57 |
+
"Shap-E",
|
| 58 |
+
max_retries=2,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Initialize Hunyuan3D-2 client for image-to-3D (non-blocking)
|
| 62 |
+
self.hunyuan_client = self._initialize_client_with_retry(
|
| 63 |
+
"tencent/Hunyuan3D-2.1",
|
| 64 |
+
"Hunyuan3D-2",
|
| 65 |
+
max_retries=2,
|
| 66 |
+
)
|
| 67 |
+
self.trellis_client2 = self._initialize_client_with_retry(
|
| 68 |
+
"trellis-community/TRELLIS",
|
| 69 |
+
"TRELLIS-2",
|
| 70 |
+
max_retries=2,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
def _initialize_client_with_retry(
|
| 74 |
+
self, space_id: str, service_name: str, max_retries: int = 2
|
| 75 |
+
) -> Client | None:
|
| 76 |
+
"""Initialize a Gradio Client with retry logic and exponential backoff.
|
| 77 |
+
|
| 78 |
+
Hugging Face Spaces on the free tier can be slow to respond or sleeping,
|
| 79 |
+
so we retry with increasing delays.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
space_id: Hugging Face Space ID (e.g., "username/space-name")
|
| 83 |
+
service_name: Human-readable name for logging
|
| 84 |
+
max_retries: Maximum number of retry attempts
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
Client instance if successful, None if all retries failed
|
| 88 |
+
"""
|
| 89 |
+
for attempt in range(max_retries + 1):
|
| 90 |
+
try:
|
| 91 |
+
if attempt > 0:
|
| 92 |
+
# Exponential backoff: 5s, 10s
|
| 93 |
+
wait_time = 5 * (2 ** (attempt - 1))
|
| 94 |
+
logger.info(
|
| 95 |
+
f"Retrying {service_name} initialization (attempt {attempt + 1}/{max_retries + 1}) "
|
| 96 |
+
f"after {wait_time}s delay..."
|
| 97 |
+
)
|
| 98 |
+
time.sleep(wait_time)
|
| 99 |
+
|
| 100 |
+
logger.info(
|
| 101 |
+
f"Initializing {service_name} Client (attempt {attempt + 1}/{max_retries + 1})..."
|
| 102 |
+
)
|
| 103 |
+
# Try without token first to avoid consuming your GPU quota
|
| 104 |
+
# Most public Spaces work without authentication and use their own quota
|
| 105 |
+
try:
|
| 106 |
+
client = Client(space_id) # No token - uses Space owner's quota
|
| 107 |
+
logger.info(
|
| 108 |
+
f"✓ {service_name} Gradio Client initialized (no token - using Space owner's quota)"
|
| 109 |
+
)
|
| 110 |
+
return client
|
| 111 |
+
except Exception as no_token_error:
|
| 112 |
+
# If that fails, try with token (may consume your GPU quota)
|
| 113 |
+
if HF_TOKEN:
|
| 114 |
+
logger.warning(
|
| 115 |
+
f"{service_name} requires authentication. Using HF_TOKEN (may consume your GPU quota)"
|
| 116 |
+
)
|
| 117 |
+
client = Client(space_id, hf_token=HF_TOKEN)
|
| 118 |
+
logger.info(
|
| 119 |
+
f"✓ {service_name} Gradio Client initialized (with token)"
|
| 120 |
+
)
|
| 121 |
+
return client
|
| 122 |
+
else:
|
| 123 |
+
# Re-raise the original error if no token available
|
| 124 |
+
raise no_token_error
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
error_msg = str(e).lower()
|
| 128 |
+
is_timeout = "timeout" in error_msg or "timed out" in error_msg
|
| 129 |
+
|
| 130 |
+
if attempt < max_retries:
|
| 131 |
+
logger.warning(
|
| 132 |
+
f"{service_name} initialization attempt {attempt + 1} failed: {e}. "
|
| 133 |
+
f"Will retry..."
|
| 134 |
+
)
|
| 135 |
+
else:
|
| 136 |
+
# Final attempt failed
|
| 137 |
+
if is_timeout:
|
| 138 |
+
logger.warning(
|
| 139 |
+
f"Failed to initialize {service_name} Client after {max_retries + 1} attempts: {e}. "
|
| 140 |
+
f"This is likely because the Hugging Face Space is sleeping (free tier) or slow to respond. "
|
| 141 |
+
f"{service_name} features will not be available. "
|
| 142 |
+
f"The Space will wake up automatically when first used, but initialization may take longer."
|
| 143 |
+
)
|
| 144 |
+
else:
|
| 145 |
+
logger.warning(
|
| 146 |
+
f"Failed to initialize {service_name} Client after {max_retries + 1} attempts: {e}. "
|
| 147 |
+
f"{service_name} features will not be available."
|
| 148 |
+
)
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
# ============================================================================
|
| 154 |
+
# Public API - Model Generation
|
| 155 |
+
# ============================================================================
|
| 156 |
+
|
| 157 |
+
def text_to_3d(self, prompt: str, model_id: str | None = None) -> str:
|
| 158 |
+
"""Generate a 3D model directly from a text prompt using TRELLIS.
|
| 159 |
+
|
| 160 |
+
Uses the /generate_and_extract_glb endpoint which:
|
| 161 |
+
- Generates 3D model from text
|
| 162 |
+
- Extracts GLB with textures
|
| 163 |
+
- Returns GLB file path directly
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
prompt: Text prompt for 3D model generation
|
| 167 |
+
model_id: Optional model ID to use for the filename. If not provided, generates a new UUID.
|
| 168 |
+
|
| 169 |
+
Returns:
|
| 170 |
+
Path to the generated GLB file
|
| 171 |
+
"""
|
| 172 |
+
if not self.trellis_client:
|
| 173 |
+
raise RuntimeError("TRELLIS Client not initialized.")
|
| 174 |
+
|
| 175 |
+
logger.info(f"Generating 3D model from text prompt: '{prompt[:60]}...'")
|
| 176 |
+
|
| 177 |
+
filename = model_id if model_id else str(uuid.uuid4())
|
| 178 |
+
glb_path = self.storage_path / f"{filename}.glb"
|
| 179 |
+
|
| 180 |
+
try:
|
| 181 |
+
# TRELLIS API Endpoint Comparison:
|
| 182 |
+
# - /generate_and_extract_glb: Returns GLB file directly with textures
|
| 183 |
+
# Includes mesh_simplify and texture_size parameters for AR optimization
|
| 184 |
+
# - /text_to_3d: Basic endpoint, may return raw 3D data (needs testing)
|
| 185 |
+
# Missing mesh_simplify and texture_size parameters
|
| 186 |
+
#
|
| 187 |
+
# We use /generate_and_extract_glb because:
|
| 188 |
+
# 1. Returns GLB file path/URL directly (no additional extraction step)
|
| 189 |
+
# 2. Includes texture_size parameter (critical for texture export)
|
| 190 |
+
# 3. Includes mesh_simplify parameter (optimizes for AR performance)
|
| 191 |
+
# 4. One-step process - generates and extracts in single call
|
| 192 |
+
logger.info("Generating 3D model with TRELLIS...")
|
| 193 |
+
|
| 194 |
+
try:
|
| 195 |
+
result = self.trellis_client.predict(
|
| 196 |
+
prompt=prompt,
|
| 197 |
+
seed=0, # Fixed seed for reproducibility
|
| 198 |
+
ss_guidance_strength=7.5,
|
| 199 |
+
ss_sampling_steps=25,
|
| 200 |
+
slat_guidance_strength=7.5,
|
| 201 |
+
slat_sampling_steps=25,
|
| 202 |
+
mesh_simplify=0.95, # Slight simplification for AR
|
| 203 |
+
texture_size=1024, # Texture size - key for texture export!
|
| 204 |
+
api_name="/generate_and_extract_glb", # Returns GLB directly
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
logger.debug(f"TRELLIS result type: {type(result)}")
|
| 208 |
+
logger.debug(f"TRELLIS result: {result}")
|
| 209 |
+
|
| 210 |
+
except gradio_exceptions.AppError as app_error:
|
| 211 |
+
logger.error(f"TRELLIS AppError: {app_error}")
|
| 212 |
+
raise RuntimeError(
|
| 213 |
+
f"TRELLIS Space returned an error: {app_error}. "
|
| 214 |
+
f"This might mean: 1) The Space is busy or has queue limits, "
|
| 215 |
+
f"2) The prompt is invalid, 3) The Space is experiencing issues."
|
| 216 |
+
) from app_error
|
| 217 |
+
|
| 218 |
+
except Exception as api_error:
|
| 219 |
+
logger.error(f"TRELLIS API call failed: {api_error}")
|
| 220 |
+
import traceback
|
| 221 |
+
|
| 222 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 223 |
+
raise RuntimeError(
|
| 224 |
+
f"TRELLIS API call failed: {api_error}"
|
| 225 |
+
) from api_error
|
| 226 |
+
|
| 227 |
+
# TRELLIS returns a string (file path or URL) directly
|
| 228 |
+
if isinstance(result, str):
|
| 229 |
+
glb_file_path = result
|
| 230 |
+
elif isinstance(result, dict) and "value" in result:
|
| 231 |
+
# Sometimes Gradio returns dict with 'value' key
|
| 232 |
+
glb_file_path = result["value"]
|
| 233 |
+
else:
|
| 234 |
+
logger.error(
|
| 235 |
+
f"Unexpected TRELLIS result format: {type(result)}, value: {result}"
|
| 236 |
+
)
|
| 237 |
+
raise RuntimeError(f"Unexpected TRELLIS result format: {type(result)}")
|
| 238 |
+
|
| 239 |
+
if not glb_file_path or not isinstance(glb_file_path, str):
|
| 240 |
+
raise RuntimeError(
|
| 241 |
+
f"TRELLIS returned invalid file path: {glb_file_path}"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
# TRELLIS may return a URL instead of a local file path
|
| 245 |
+
if glb_file_path.startswith("http://") or glb_file_path.startswith(
|
| 246 |
+
"https://"
|
| 247 |
+
):
|
| 248 |
+
# Download the GLB file from the URL
|
| 249 |
+
logger.info(f"TRELLIS returned URL, downloading GLB: {glb_file_path}")
|
| 250 |
+
|
| 251 |
+
try:
|
| 252 |
+
with httpx.Client(timeout=60.0) as client:
|
| 253 |
+
response = client.get(glb_file_path)
|
| 254 |
+
response.raise_for_status()
|
| 255 |
+
|
| 256 |
+
# Ensure parent directory exists before saving
|
| 257 |
+
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
| 258 |
+
|
| 259 |
+
# Save to our storage
|
| 260 |
+
with open(glb_path, "wb") as f:
|
| 261 |
+
f.write(response.content)
|
| 262 |
+
|
| 263 |
+
logger.info(
|
| 264 |
+
f"✓ Downloaded TRELLIS GLB: {glb_path} ({os.path.getsize(glb_path)} bytes)"
|
| 265 |
+
)
|
| 266 |
+
except Exception as download_error:
|
| 267 |
+
logger.error(f"Failed to download GLB from URL: {download_error}")
|
| 268 |
+
raise RuntimeError(
|
| 269 |
+
f"Failed to download GLB from TRELLIS URL: {download_error}"
|
| 270 |
+
)
|
| 271 |
+
else:
|
| 272 |
+
# Local file path - check if it exists and copy it
|
| 273 |
+
if not Path(glb_file_path).exists():
|
| 274 |
+
raise RuntimeError(
|
| 275 |
+
f"Generated GLB file not found: {glb_file_path}. "
|
| 276 |
+
f"TRELLIS may have failed to generate the model."
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
# Ensure parent directory exists before copying
|
| 280 |
+
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
| 281 |
+
|
| 282 |
+
# Copy the generated GLB to our storage
|
| 283 |
+
shutil.copy(glb_file_path, glb_path)
|
| 284 |
+
logger.info(
|
| 285 |
+
f"✓ TRELLIS GLB generated: {glb_path} ({os.path.getsize(glb_path)} bytes)"
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
return str(glb_path)
|
| 289 |
+
|
| 290 |
+
except Exception as e:
|
| 291 |
+
logger.error(f"TRELLIS text-to-3D generation failed: {e}")
|
| 292 |
+
raise RuntimeError(f"3D model generation failed: {e}")
|
| 293 |
+
|
| 294 |
+
def text_to_3d_shap_e(self, prompt: str, model_id: str | None = None) -> str:
|
| 295 |
+
"""Generate a 3D model from text prompt using Shap-E.
|
| 296 |
+
|
| 297 |
+
Uses Shap-E for advanced 3D model generation from text prompts.
|
| 298 |
+
Uses the /text_to_3d endpoint with the prompt parameter for text-to-3D.
|
| 299 |
+
API Reference: https://huggingface.co/spaces/hysts/Shap-E
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
prompt: Text prompt for 3D model generation
|
| 303 |
+
model_id: Optional model ID to use for the filename. If not provided, generates a new UUID.
|
| 304 |
+
|
| 305 |
+
Returns:
|
| 306 |
+
Path to the generated GLB file
|
| 307 |
+
"""
|
| 308 |
+
if not self.shap_e_client:
|
| 309 |
+
raise RuntimeError("Shap-E Client not initialized.")
|
| 310 |
+
|
| 311 |
+
logger.info(
|
| 312 |
+
f"Generating 3D model with Shap-E from text prompt: '{prompt[:60]}...'"
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
filename = model_id if model_id else str(uuid.uuid4())
|
| 316 |
+
glb_path = self.storage_path / f"{filename}.glb"
|
| 317 |
+
|
| 318 |
+
try:
|
| 319 |
+
logger.info("Generating 3D model with Shap-E...")
|
| 320 |
+
|
| 321 |
+
try:
|
| 322 |
+
DEFAULT_SEED = 0
|
| 323 |
+
DEFAULT_GUIDANCE_SCALE = 20.0
|
| 324 |
+
DEFAULT_STEPS = 100
|
| 325 |
+
# Shap-E /text-to-3d endpoint typically accepts:
|
| 326 |
+
# - prompt (str)
|
| 327 |
+
# - seed (int/float)
|
| 328 |
+
# - guidance_scale (float)
|
| 329 |
+
# - num_inference_steps (int) - not "steps"
|
| 330 |
+
result = self.shap_e_client.predict(
|
| 331 |
+
prompt=prompt,
|
| 332 |
+
seed=DEFAULT_SEED,
|
| 333 |
+
guidance_scale=DEFAULT_GUIDANCE_SCALE,
|
| 334 |
+
num_inference_steps=DEFAULT_STEPS, # This value is correctly mapped to 'num_inference_steps'
|
| 335 |
+
api_name="/text-to-3d",
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
logger.debug(f"Shap-E result type: {type(result)}")
|
| 339 |
+
logger.debug(f"Shap-E result: {result}")
|
| 340 |
+
|
| 341 |
+
except gradio_exceptions.AppError as app_error:
|
| 342 |
+
logger.error(f"Shap-E AppError: {app_error}")
|
| 343 |
+
raise RuntimeError(
|
| 344 |
+
f"Shap-E Space returned an error: {app_error}. "
|
| 345 |
+
f"This might mean: 1) The Space is busy or has queue limits, "
|
| 346 |
+
f"2) The prompt is invalid, 3) The Space is experiencing issues."
|
| 347 |
+
) from app_error
|
| 348 |
+
|
| 349 |
+
except Exception as api_error:
|
| 350 |
+
logger.error(f"Shap-E API call failed: {api_error}")
|
| 351 |
+
import traceback
|
| 352 |
+
|
| 353 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 354 |
+
raise RuntimeError(
|
| 355 |
+
f"Shap-E API call failed: {api_error}"
|
| 356 |
+
) from api_error
|
| 357 |
+
|
| 358 |
+
# Handle result - format depends on the actual endpoint response
|
| 359 |
+
if isinstance(result, str):
|
| 360 |
+
temp_file_path = result
|
| 361 |
+
elif isinstance(result, (list, tuple)) and len(result) > 0:
|
| 362 |
+
# Shap-E may return a tuple/list with file path as first element
|
| 363 |
+
temp_file_path = result[0]
|
| 364 |
+
elif isinstance(result, dict):
|
| 365 |
+
# Check for common keys that might contain file path
|
| 366 |
+
if "file" in result:
|
| 367 |
+
temp_file_path = result["file"]
|
| 368 |
+
elif "value" in result:
|
| 369 |
+
temp_file_path = result["value"]
|
| 370 |
+
else:
|
| 371 |
+
logger.error(
|
| 372 |
+
f"Unexpected Shap-E result format: {type(result)}, value: {result}"
|
| 373 |
+
)
|
| 374 |
+
raise RuntimeError(
|
| 375 |
+
f"Unexpected Shap-E result format: {type(result)}"
|
| 376 |
+
)
|
| 377 |
+
else:
|
| 378 |
+
logger.error(
|
| 379 |
+
f"Unexpected Shap-E result format: {type(result)}, value: {result}"
|
| 380 |
+
)
|
| 381 |
+
raise RuntimeError(f"Unexpected Shap-E result format: {type(result)}")
|
| 382 |
+
|
| 383 |
+
if not temp_file_path or not isinstance(temp_file_path, str):
|
| 384 |
+
raise RuntimeError(
|
| 385 |
+
f"Shap-E returned invalid file path: {temp_file_path}"
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
# Shap-E may return a URL instead of a local file path
|
| 389 |
+
if temp_file_path.startswith("http://") or temp_file_path.startswith(
|
| 390 |
+
"https://"
|
| 391 |
+
):
|
| 392 |
+
# Download the GLB file from the URL
|
| 393 |
+
logger.info(f"Shap-E returned URL, downloading GLB: {temp_file_path}")
|
| 394 |
+
|
| 395 |
+
try:
|
| 396 |
+
with httpx.Client(timeout=120.0) as client:
|
| 397 |
+
response = client.get(temp_file_path)
|
| 398 |
+
response.raise_for_status()
|
| 399 |
+
|
| 400 |
+
# Ensure parent directory exists before saving
|
| 401 |
+
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
| 402 |
+
|
| 403 |
+
# Save to our storage
|
| 404 |
+
with open(glb_path, "wb") as f:
|
| 405 |
+
f.write(response.content)
|
| 406 |
+
|
| 407 |
+
logger.info(
|
| 408 |
+
f"✓ Downloaded Shap-E GLB: {glb_path} ({os.path.getsize(glb_path)} bytes)"
|
| 409 |
+
)
|
| 410 |
+
except Exception as download_error:
|
| 411 |
+
logger.error(f"Failed to download GLB from URL: {download_error}")
|
| 412 |
+
raise RuntimeError(
|
| 413 |
+
f"Failed to download GLB from Shap-E URL: {download_error}"
|
| 414 |
+
)
|
| 415 |
+
else:
|
| 416 |
+
# Local file path - check if it exists and copy it
|
| 417 |
+
if not Path(temp_file_path).exists():
|
| 418 |
+
raise RuntimeError(
|
| 419 |
+
f"Generated GLB file not found: {temp_file_path}. "
|
| 420 |
+
f"Shap-E may have failed to generate the model."
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
# Ensure parent directory exists before copying
|
| 424 |
+
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
| 425 |
+
|
| 426 |
+
# Note: Shap-E might return a .ply, but we save it as .glb
|
| 427 |
+
# as requested by the function signature.
|
| 428 |
+
shutil.copy(temp_file_path, glb_path)
|
| 429 |
+
logger.info(
|
| 430 |
+
f"✓ Shap-E GLB generated: {glb_path} ({os.path.getsize(glb_path)} bytes)"
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
return str(glb_path)
|
| 434 |
+
|
| 435 |
+
except Exception as e:
|
| 436 |
+
logger.error(f"Shap-E text-to-3D generation failed: {e}")
|
| 437 |
+
raise RuntimeError(f"3D model generation failed: {e}")
|
| 438 |
+
|
| 439 |
+
def image_to_3d_hunyuan(
|
| 440 |
+
self, image_file_path: str, model_id: str | None = None
|
| 441 |
+
) -> str:
|
| 442 |
+
"""
|
| 443 |
+
Generate a 3D model from a 2D image using Hunyuan3D.
|
| 444 |
+
|
| 445 |
+
This optimized version performs remote chaining of the /generation_all and
|
| 446 |
+
/on_export_click steps to avoid slow local download/re-upload cycles of intermediary files.
|
| 447 |
+
"""
|
| 448 |
+
if not self.hunyuan_client:
|
| 449 |
+
raise RuntimeError("Hunyuan3D Client not initialized.")
|
| 450 |
+
|
| 451 |
+
if not Path(image_file_path).exists():
|
| 452 |
+
logger.error(f"Input image file not found at: {image_file_path}")
|
| 453 |
+
raise FileNotFoundError(f"Input image file not found: {image_file_path}")
|
| 454 |
+
|
| 455 |
+
logger.info(
|
| 456 |
+
f"Generating 3D model with Hunyuan3D from image file: '{image_file_path}'"
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
filename = model_id if model_id else str(uuid.uuid4())
|
| 460 |
+
glb_path = self.storage_path / f"{filename}.glb"
|
| 461 |
+
|
| 462 |
+
# Default parameters - Tuned for faster generation time and to avoid GPU quota limits
|
| 463 |
+
DEFAULT_STEPS = 15 # Further reduced from 20 to decrease generation time and avoid GPU quota limits (Job requested 180s vs 75s left)
|
| 464 |
+
DEFAULT_GUIDANCE = 5.0
|
| 465 |
+
DEFAULT_SEED = 1234
|
| 466 |
+
RANDOMIZE_SEED = True
|
| 467 |
+
DEFAULT_OCTREE_RES = 128 # Further reduced from 192 to decrease generation time and avoid GPU quota limits
|
| 468 |
+
REMOVE_BG = True
|
| 469 |
+
DEFAULT_NUM_CHUNKS = (
|
| 470 |
+
4000 # Further reduced from 5000 to decrease generation time
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
# Helper function to extract file path, handling Gradio's inconsistent wrapping
|
| 474 |
+
def extract_path(result_part):
|
| 475 |
+
if isinstance(result_part, dict):
|
| 476 |
+
# Check for 'value' (from previous API return) or 'path'/'name' (if pre-wrapped)
|
| 477 |
+
if "value" in result_part:
|
| 478 |
+
return result_part["value"]
|
| 479 |
+
if "path" in result_part:
|
| 480 |
+
return result_part["path"]
|
| 481 |
+
if "name" in result_part:
|
| 482 |
+
return result_part["name"]
|
| 483 |
+
return result_part
|
| 484 |
+
|
| 485 |
+
# Helper function to wrap a remote path into the required dictionary format for the next API call
|
| 486 |
+
# CRITICAL: Use 'path' as the key to satisfy the downstream Gradio component's FileData validation.
|
| 487 |
+
def format_for_remote_api(path: str) -> dict:
|
| 488 |
+
return {"path": path}
|
| 489 |
+
|
| 490 |
+
try:
|
| 491 |
+
# --- Step 1: Call /generation_all to get mesh and texture ---
|
| 492 |
+
logger.info("Hunyuan3D Step 1/2: Calling /generation_all...")
|
| 493 |
+
try:
|
| 494 |
+
gen_result = self.hunyuan_client.predict(
|
| 495 |
+
image=handle_file(image_file_path),
|
| 496 |
+
mv_image_front=None,
|
| 497 |
+
mv_image_back=None,
|
| 498 |
+
mv_image_left=None,
|
| 499 |
+
mv_image_right=None,
|
| 500 |
+
steps=DEFAULT_STEPS,
|
| 501 |
+
guidance_scale=DEFAULT_GUIDANCE,
|
| 502 |
+
seed=DEFAULT_SEED,
|
| 503 |
+
octree_resolution=DEFAULT_OCTREE_RES,
|
| 504 |
+
check_box_rembg=REMOVE_BG,
|
| 505 |
+
num_chunks=DEFAULT_NUM_CHUNKS,
|
| 506 |
+
randomize_seed=RANDOMIZE_SEED,
|
| 507 |
+
api_name="/generation_all",
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
logger.debug(f"Hunyuan3D /generation_all result: {gen_result}")
|
| 511 |
+
|
| 512 |
+
except gradio_exceptions.AppError as app_error:
|
| 513 |
+
logger.error(f"Hunyuan3D /generation_all AppError: {app_error}")
|
| 514 |
+
raise RuntimeError(
|
| 515 |
+
f"Hunyuan3D Space (generation) returned an error: {app_error}."
|
| 516 |
+
) from app_error
|
| 517 |
+
except Exception as api_error:
|
| 518 |
+
logger.error(f"Hunyuan3D /generation_all API call failed: {api_error}")
|
| 519 |
+
import traceback
|
| 520 |
+
|
| 521 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 522 |
+
raise RuntimeError(
|
| 523 |
+
f"Hunyuan3D /generation_all API call failed: {api_error}"
|
| 524 |
+
) from api_error
|
| 525 |
+
|
| 526 |
+
# /generation_all returns a tuple of 5 elements
|
| 527 |
+
if not isinstance(gen_result, (list, tuple)) or len(gen_result) < 2:
|
| 528 |
+
raise RuntimeError(
|
| 529 |
+
f"Unexpected result from Hunyuan3D /generation_all: {gen_result}"
|
| 530 |
+
)
|
| 531 |
+
|
| 532 |
+
# Extract raw remote paths (strings)
|
| 533 |
+
mesh_raw_path = extract_path(gen_result[0])
|
| 534 |
+
texture_raw_path = extract_path(gen_result[1])
|
| 535 |
+
|
| 536 |
+
if not mesh_raw_path:
|
| 537 |
+
raise RuntimeError(
|
| 538 |
+
f"Hunyuan3D /generation_all did not return a mesh file path."
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
logger.info(f"Hunyuan3D generated mesh: {mesh_raw_path}")
|
| 542 |
+
logger.info(f"Hunyuan3D generated texture: {texture_raw_path}")
|
| 543 |
+
|
| 544 |
+
# Check if /generation_all already returned a GLB file (rare, but possible)
|
| 545 |
+
if isinstance(texture_raw_path, str) and texture_raw_path.endswith(".glb"):
|
| 546 |
+
logger.info(
|
| 547 |
+
"Hunyuan3D /generation_all already returned a GLB file, skipping export step."
|
| 548 |
+
)
|
| 549 |
+
temp_file_path = texture_raw_path
|
| 550 |
+
else:
|
| 551 |
+
# --- Step 2: Call /on_export_click to convert to GLB (Remote Chaining) ---
|
| 552 |
+
logger.info(
|
| 553 |
+
"Hunyuan3D Step 2/2: Calling /on_export_click to export GLB (Remote Chaining)..."
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
# Pass remote paths directly, formatted in the exact structure Gradio needs: {"path": path}.
|
| 557 |
+
file_out_param = format_for_remote_api(mesh_raw_path)
|
| 558 |
+
|
| 559 |
+
if texture_raw_path:
|
| 560 |
+
file_out2_param = format_for_remote_api(texture_raw_path)
|
| 561 |
+
export_texture_flag = True
|
| 562 |
+
else:
|
| 563 |
+
# Use mesh for file_out2 if texture is missing
|
| 564 |
+
file_out2_param = format_for_remote_api(mesh_raw_path)
|
| 565 |
+
export_texture_flag = False
|
| 566 |
+
logger.info(
|
| 567 |
+
"No texture file generated, using mesh file for both file_out parameters"
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
try:
|
| 571 |
+
export_result = self.hunyuan_client.predict(
|
| 572 |
+
file_out=file_out_param, # Optimized: {"path": "/remote/path"}
|
| 573 |
+
file_out2=file_out2_param, # Optimized: {"path": "/remote/path"}
|
| 574 |
+
file_type="glb",
|
| 575 |
+
reduce_face=False,
|
| 576 |
+
export_texture=export_texture_flag,
|
| 577 |
+
target_face_num=10000,
|
| 578 |
+
api_name="/on_export_click",
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
logger.debug(f"Hunyuan3D /on_export_click result: {export_result}")
|
| 582 |
+
|
| 583 |
+
except gradio_exceptions.AppError as app_error:
|
| 584 |
+
logger.error(f"Hunyuan3D /on_export_click AppError: {app_error}")
|
| 585 |
+
raise RuntimeError(
|
| 586 |
+
f"Hunyuan3D Space (export) returned an error: {app_error}."
|
| 587 |
+
) from app_error
|
| 588 |
+
except Exception as api_error:
|
| 589 |
+
logger.error(
|
| 590 |
+
f"Hunyuan3D /on_export_click API call failed: {api_error}"
|
| 591 |
+
)
|
| 592 |
+
|
| 593 |
+
import traceback
|
| 594 |
+
|
| 595 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 596 |
+
raise RuntimeError(
|
| 597 |
+
f"Hunyuan3D /on_export_click API call failed: {api_error}"
|
| 598 |
+
) from api_error
|
| 599 |
+
|
| 600 |
+
# /on_export_click returns a tuple of 2 elements
|
| 601 |
+
if (
|
| 602 |
+
not isinstance(export_result, (list, tuple))
|
| 603 |
+
or len(export_result) < 2
|
| 604 |
+
):
|
| 605 |
+
raise RuntimeError(
|
| 606 |
+
f"Unexpected result from Hunyuan3D /on_export_click: {export_result}"
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
# Final result is the downloaded GLB URL/path
|
| 610 |
+
temp_file_path = extract_path(export_result[1])
|
| 611 |
+
|
| 612 |
+
if not temp_file_path or not isinstance(temp_file_path, str):
|
| 613 |
+
raise RuntimeError(
|
| 614 |
+
f"Hunyuan3D /on_export_click returned invalid file path: {temp_file_path}"
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
# --- Step 3: Handle the final GLB file (download or copy) ---
|
| 618 |
+
# The final GLB URL is the only file we need to download locally
|
| 619 |
+
if temp_file_path.startswith("http://") or temp_file_path.startswith(
|
| 620 |
+
"https://"
|
| 621 |
+
):
|
| 622 |
+
logger.info(
|
| 623 |
+
f"Hunyuan3D returned URL, downloading GLB: {temp_file_path}"
|
| 624 |
+
)
|
| 625 |
+
try:
|
| 626 |
+
with httpx.Client(timeout=120.0) as client:
|
| 627 |
+
response = client.get(temp_file_path)
|
| 628 |
+
response.raise_for_status()
|
| 629 |
+
|
| 630 |
+
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
| 631 |
+
|
| 632 |
+
with open(glb_path, "wb") as f:
|
| 633 |
+
f.write(response.content)
|
| 634 |
+
logger.info(
|
| 635 |
+
f"✓ Downloaded Hunyuan3D GLB: {glb_path} ({os.path.getsize(glb_path)} bytes)"
|
| 636 |
+
)
|
| 637 |
+
except Exception as download_error:
|
| 638 |
+
logger.error(f"Failed to download GLB from URL: {download_error}")
|
| 639 |
+
raise RuntimeError(
|
| 640 |
+
f"Failed to download GLB from Hunyuan3D URL: {download_error}"
|
| 641 |
+
)
|
| 642 |
+
else:
|
| 643 |
+
if not Path(temp_file_path).exists():
|
| 644 |
+
raise RuntimeError(
|
| 645 |
+
f"Generated GLB file not found: {temp_file_path}. "
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
| 649 |
+
|
| 650 |
+
shutil.copy(temp_file_path, glb_path)
|
| 651 |
+
logger.info(
|
| 652 |
+
f"✓ Hunyuan3D GLB generated: {glb_path} ({os.path.getsize(glb_path)} bytes)"
|
| 653 |
+
)
|
| 654 |
+
|
| 655 |
+
return str(glb_path)
|
| 656 |
+
|
| 657 |
+
except Exception as e:
|
| 658 |
+
logger.error(f"Hunyuan3D image-to-3D generation failed: {e}")
|
| 659 |
+
raise RuntimeError(f"3D model generation failed: {e}")
|
| 660 |
+
|
| 661 |
+
def image_to_3d_TRELLIS(
|
| 662 |
+
self, image_file_path: str, model_id: str | None = None
|
| 663 |
+
) -> str:
|
| 664 |
+
"""
|
| 665 |
+
Generate a 3D model from a 2D image using the TRELLIS API endpoint.
|
| 666 |
+
|
| 667 |
+
This function has been streamlined to use the single-step TRELLIS API
|
| 668 |
+
endpoint (`/generate_and_extract_glb`) for faster, simpler generation,
|
| 669 |
+
assuming the client (self.trellis_client2) is configured for the TRELLIS Space.
|
| 670 |
+
|
| 671 |
+
Args:
|
| 672 |
+
image_file_path: Local file path to the input 2D image.
|
| 673 |
+
model_id: Optional model ID to use for the output filename.
|
| 674 |
+
|
| 675 |
+
Returns:
|
| 676 |
+
Path to the generated GLB file (local storage path).
|
| 677 |
+
"""
|
| 678 |
+
# NOTE: Assuming self.trellis_client is now used for the TRELLIS-style generation.
|
| 679 |
+
if not self.trellis_client2:
|
| 680 |
+
raise RuntimeError(
|
| 681 |
+
"3D Generation Client not initialized (using TRELLIS single-step API)."
|
| 682 |
+
)
|
| 683 |
+
|
| 684 |
+
input_path = Path(image_file_path)
|
| 685 |
+
if not input_path.exists():
|
| 686 |
+
logger.error(f"Input image file not found at: {image_file_path}")
|
| 687 |
+
raise FileNotFoundError(f"Input image file not found: {image_file_path}")
|
| 688 |
+
|
| 689 |
+
logger.info(
|
| 690 |
+
f"Generating 3D model with TRELLIS (single-step) from image file: '{image_file_path}'"
|
| 691 |
+
)
|
| 692 |
+
|
| 693 |
+
filename = model_id if model_id else str(uuid.uuid4())
|
| 694 |
+
glb_path = self.storage_path / f"{filename}.glb"
|
| 695 |
+
|
| 696 |
+
# Helper function to extract file path, handling Gradio's inconsistent wrapping
|
| 697 |
+
def extract_path(result_part):
|
| 698 |
+
if isinstance(result_part, dict):
|
| 699 |
+
if "value" in result_part:
|
| 700 |
+
return result_part["value"]
|
| 701 |
+
if "path" in result_part:
|
| 702 |
+
return result_part["path"]
|
| 703 |
+
if "name" in result_part:
|
| 704 |
+
return result_part["name"]
|
| 705 |
+
return result_part
|
| 706 |
+
|
| 707 |
+
try:
|
| 708 |
+
# --- Step 1: Call /generate_and_extract_glb (TRELLIS single-step API) ---
|
| 709 |
+
logger.info("TRELLIS Step 1/1: Calling /generate_and_extract_glb...")
|
| 710 |
+
|
| 711 |
+
# Parameters derived from the TRELLIS Gradio API example
|
| 712 |
+
# Using self.trellis_client2 as the client for this operation
|
| 713 |
+
gen_result = self.trellis_client2.predict(
|
| 714 |
+
image=handle_file(image_file_path),
|
| 715 |
+
multiimages=[],
|
| 716 |
+
seed=0,
|
| 717 |
+
ss_guidance_strength=7.5,
|
| 718 |
+
ss_sampling_steps=12,
|
| 719 |
+
slat_guidance_strength=3,
|
| 720 |
+
slat_sampling_steps=12,
|
| 721 |
+
multiimage_algo="stochastic",
|
| 722 |
+
mesh_simplify=0.95,
|
| 723 |
+
texture_size=1024,
|
| 724 |
+
api_name="/generate_and_extract_glb",
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
logger.debug(f"TRELLIS /generate_and_extract_glb result: {gen_result}")
|
| 728 |
+
|
| 729 |
+
if not isinstance(gen_result, (list, tuple)) or not gen_result:
|
| 730 |
+
raise RuntimeError(
|
| 731 |
+
f"Unexpected result from TRELLIS /generate_and_extract_glb: {gen_result}"
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
# The TRELLIS endpoint returns a tuple of 3 elements: [video, glb/gaussian, download_glb]
|
| 735 |
+
# The second element (index [1]) is the file path for the Litmodel3d component (glb/gaussian).
|
| 736 |
+
# We check the first element ([0]) which is the video component, as it often contains the file path first.
|
| 737 |
+
|
| 738 |
+
# The TRELLIS API documentation shows the video component (index 0) is the first return element.
|
| 739 |
+
# We need the path to the downloaded GLB file. Let's assume the first element path or the second element path.
|
| 740 |
+
# Based on the API doc:
|
| 741 |
+
# Returns tuple of 3 elements:
|
| 742 |
+
# [0] dict(video: filepath, subtitles: filepath | None) -> The Video component path
|
| 743 |
+
# [1] filepath -> The Litmodel3d component path (GLB/Gaussian)
|
| 744 |
+
# [2] filepath -> The Downloadbutton path (GLB)
|
| 745 |
+
|
| 746 |
+
# The most reliable path to the output file is often the second element [1] or third [2],
|
| 747 |
+
# but the original TRELLIS function used [0]. We will update to use [1] for the Litmodel3d path
|
| 748 |
+
# as it is more likely to contain the actual GLB data path when running remotely.
|
| 749 |
+
|
| 750 |
+
# Let's use index [1] or [2] if available, falling back to [0].
|
| 751 |
+
temp_file_path = (
|
| 752 |
+
extract_path(gen_result[1]) if len(gen_result) > 1 else None
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
if not temp_file_path:
|
| 756 |
+
temp_file_path = extract_path(gen_result[0])
|
| 757 |
+
|
| 758 |
+
if not temp_file_path or not isinstance(temp_file_path, str):
|
| 759 |
+
raise RuntimeError(
|
| 760 |
+
f"TRELLIS returned invalid file path: {temp_file_path}"
|
| 761 |
+
)
|
| 762 |
+
|
| 763 |
+
# --- Step 2: Handle the final GLB file (download or copy) ---
|
| 764 |
+
if temp_file_path.startswith("http://") or temp_file_path.startswith(
|
| 765 |
+
"https://"
|
| 766 |
+
):
|
| 767 |
+
logger.info(f"TRELLIS returned URL, downloading GLB: {temp_file_path}")
|
| 768 |
+
try:
|
| 769 |
+
with httpx.Client(timeout=120.0) as http_client:
|
| 770 |
+
response = http_client.get(temp_file_path)
|
| 771 |
+
response.raise_for_status()
|
| 772 |
+
|
| 773 |
+
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
| 774 |
+
|
| 775 |
+
with open(glb_path, "wb") as f:
|
| 776 |
+
f.write(response.content)
|
| 777 |
+
logger.info(
|
| 778 |
+
f"✓ Downloaded TRELLIS GLB: {glb_path} ({os.path.getsize(glb_path)} bytes)"
|
| 779 |
+
)
|
| 780 |
+
except Exception as download_error:
|
| 781 |
+
logger.error(f"Failed to download GLB from URL: {download_error}")
|
| 782 |
+
raise RuntimeError(
|
| 783 |
+
f"Failed to download GLB from TRELLIS URL: {download_error}"
|
| 784 |
+
)
|
| 785 |
+
else:
|
| 786 |
+
if not Path(temp_file_path).exists():
|
| 787 |
+
raise RuntimeError(
|
| 788 |
+
f"Generated GLB file not found: {temp_file_path}. "
|
| 789 |
+
)
|
| 790 |
+
|
| 791 |
+
glb_path.parent.mkdir(parents=True, exist_ok=True)
|
| 792 |
+
|
| 793 |
+
shutil.copy(temp_file_path, glb_path)
|
| 794 |
+
logger.info(
|
| 795 |
+
f"✓ TRELLIS GLB generated: {glb_path} ({os.path.getsize(glb_path)} bytes)"
|
| 796 |
+
)
|
| 797 |
+
|
| 798 |
+
return str(glb_path)
|
| 799 |
+
|
| 800 |
+
except gradio_exceptions.AppError as app_error:
|
| 801 |
+
logger.error(f"TRELLIS AppError: {app_error}")
|
| 802 |
+
raise RuntimeError(
|
| 803 |
+
f"TRELLIS Space returned an error during generation: {app_error}."
|
| 804 |
+
) from app_error
|
| 805 |
+
except Exception as e:
|
| 806 |
+
logger.error(f"TRELLIS image-to-3D generation failed: {e}")
|
| 807 |
+
raise RuntimeError(f"3D model generation failed: {e}")
|
services/storage_service.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Storage service for managing generated models."""
|
| 2 |
+
from typing import Dict, Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class StorageService:
|
| 8 |
+
"""In-memory storage service for generated models.
|
| 9 |
+
|
| 10 |
+
In production, this would be replaced with a database like PostgreSQL or MongoDB.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self._models: Dict[str, dict] = {}
|
| 15 |
+
|
| 16 |
+
def create_model_record(self, prompt: str) -> str:
|
| 17 |
+
"""Create a new model record and return its ID."""
|
| 18 |
+
model_id = str(uuid.uuid4())
|
| 19 |
+
self._models[model_id] = {
|
| 20 |
+
"model_id": model_id,
|
| 21 |
+
"prompt": prompt,
|
| 22 |
+
"created_at": datetime.now().isoformat(),
|
| 23 |
+
"status": "processing",
|
| 24 |
+
"file_path": None,
|
| 25 |
+
"available_formats": []
|
| 26 |
+
}
|
| 27 |
+
return model_id
|
| 28 |
+
|
| 29 |
+
def get_model(self, model_id: str) -> Optional[dict]:
|
| 30 |
+
"""Get a model by ID."""
|
| 31 |
+
return self._models.get(model_id)
|
| 32 |
+
|
| 33 |
+
def update_model_status(self, model_id: str, status: str):
|
| 34 |
+
"""Update the status of a model."""
|
| 35 |
+
if model_id in self._models:
|
| 36 |
+
self._models[model_id]["status"] = status
|
| 37 |
+
|
| 38 |
+
def set_model_file(self, model_id: str, file_path: str, fmt: str = "glb"):
|
| 39 |
+
"""Set the file path and available format for a model."""
|
| 40 |
+
if model_id in self._models:
|
| 41 |
+
self._models[model_id]["file_path"] = file_path
|
| 42 |
+
formats = set(self._models[model_id].get("available_formats", []))
|
| 43 |
+
formats.add(fmt)
|
| 44 |
+
self._models[model_id]["available_formats"] = list(formats)
|
| 45 |
+
|
utils/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility modules for the backend application."""
|
| 2 |
+
|
utils/logging_config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Logging configuration with colored output."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class ColoredFormatter(logging.Formatter):
|
| 8 |
+
"""Custom formatter with colored output for different log levels."""
|
| 9 |
+
|
| 10 |
+
# ANSI color codes
|
| 11 |
+
COLORS = {
|
| 12 |
+
"DEBUG": "\033[36m", # Cyan
|
| 13 |
+
"INFO": "\033[0m", # White/Reset
|
| 14 |
+
"WARNING": "\033[33m", # Yellow
|
| 15 |
+
"ERROR": "\033[31m", # Red
|
| 16 |
+
"CRITICAL": "\033[35m", # Magenta
|
| 17 |
+
}
|
| 18 |
+
RESET = "\033[0m"
|
| 19 |
+
BOLD = "\033[1m"
|
| 20 |
+
|
| 21 |
+
def format(self, record):
|
| 22 |
+
"""Format log record with colors."""
|
| 23 |
+
# Get the color for this log level
|
| 24 |
+
color = self.COLORS.get(record.levelname, self.RESET)
|
| 25 |
+
|
| 26 |
+
# Format the base message
|
| 27 |
+
log_message = super().format(record)
|
| 28 |
+
|
| 29 |
+
# For ERROR and CRITICAL, color the entire message
|
| 30 |
+
if record.levelname in ["ERROR", "CRITICAL"]:
|
| 31 |
+
# Color the whole message, with bold levelname
|
| 32 |
+
levelname_colored = (
|
| 33 |
+
f"{self.BOLD}{color}{record.levelname}{self.RESET}{color}"
|
| 34 |
+
)
|
| 35 |
+
log_message = log_message.replace(record.levelname, levelname_colored)
|
| 36 |
+
log_message = f"{color}{log_message}{self.RESET}"
|
| 37 |
+
else:
|
| 38 |
+
# For other levels, just color the levelname (bold)
|
| 39 |
+
levelname_colored = f"{self.BOLD}{color}{record.levelname}{self.RESET}"
|
| 40 |
+
log_message = log_message.replace(record.levelname, levelname_colored)
|
| 41 |
+
|
| 42 |
+
return log_message
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def setup_colored_logging():
|
| 46 |
+
"""Set up colored logging configuration."""
|
| 47 |
+
# Create console handler
|
| 48 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 49 |
+
console_handler.setLevel(logging.INFO)
|
| 50 |
+
|
| 51 |
+
# Create formatter with colors
|
| 52 |
+
formatter = ColoredFormatter(
|
| 53 |
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 54 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 55 |
+
)
|
| 56 |
+
console_handler.setFormatter(formatter)
|
| 57 |
+
|
| 58 |
+
# Configure root logger
|
| 59 |
+
root_logger = logging.getLogger()
|
| 60 |
+
root_logger.setLevel(logging.INFO)
|
| 61 |
+
root_logger.handlers = [] # Clear existing handlers
|
| 62 |
+
root_logger.addHandler(console_handler)
|
| 63 |
+
|
| 64 |
+
# Suppress noisy loggers
|
| 65 |
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
| 66 |
+
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
| 67 |
+
|