Nikkon commited on
Commit
c840ad0
·
1 Parent(s): 8d96b4e

Deploy PromptAR backend to HF Spaces

Browse files
.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: Prmpt Ar Be
3
- emoji: 📊
4
- colorFrom: pink
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
- license: creativeml-openrail-m
9
- short_description: Back end for Prompt AR application
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+