Spaces:
Paused
Paused
new model
Browse files- create_thumbnail.py +114 -0
- main.py +0 -4
- outputerss.png +0 -0
- requirements.txt +4 -0
- routers/user_models.py +484 -226
- services/hunyuan_service.py +66 -24
- test_hunyuan_service.py +21 -0
create_thumbnail.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse, io, math, os, sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Tuple
|
| 4 |
+
|
| 5 |
+
import numpy as np, requests
|
| 6 |
+
from PIL import Image
|
| 7 |
+
import trimesh, pyrender
|
| 8 |
+
from trimesh.transformations import translation_matrix, rotation_matrix # NEW
|
| 9 |
+
|
| 10 |
+
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def download_to_memory(url: str) -> bytes:
|
| 14 |
+
resp = requests.get(url, timeout=30)
|
| 15 |
+
resp.raise_for_status()
|
| 16 |
+
return resp.content
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def load_mesh(data: bytes) -> trimesh.Trimesh:
|
| 20 |
+
return trimesh.load(io.BytesIO(data), file_type="glb", force="mesh")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def build_scene(mesh: trimesh.Trimesh) -> Tuple[pyrender.Scene, float]:
|
| 24 |
+
tm_mesh = pyrender.Mesh.from_trimesh(mesh, smooth=False)
|
| 25 |
+
scene = pyrender.Scene(bg_color=[1, 1, 1, 0])
|
| 26 |
+
scene.add(tm_mesh)
|
| 27 |
+
|
| 28 |
+
bb = mesh.bounding_box_oriented.extents # fixed name
|
| 29 |
+
if not np.all(bb):
|
| 30 |
+
bb = mesh.extents
|
| 31 |
+
radius = np.linalg.norm(bb) * 0.6
|
| 32 |
+
return scene, radius
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def add_lighting(scene: pyrender.Scene, radius: float) -> None:
|
| 36 |
+
key = pyrender.PointLight(color=np.ones(3), intensity=40.0)
|
| 37 |
+
fill = pyrender.PointLight(color=np.ones(3), intensity=20.0)
|
| 38 |
+
back = pyrender.PointLight(color=np.ones(3), intensity=10.0)
|
| 39 |
+
|
| 40 |
+
scene.add(key, pose=translation_matrix([ radius, radius, radius]))
|
| 41 |
+
scene.add(fill, pose=translation_matrix([-radius, radius, radius]))
|
| 42 |
+
scene.add(back, pose=translation_matrix([ 0, -radius, -radius]))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def setup_camera(scene: pyrender.Scene, radius: float) -> None:
|
| 46 |
+
cam = pyrender.PerspectiveCamera(yfov=np.radians(45.0))
|
| 47 |
+
cam_pose = translation_matrix([0, 0, radius * 2.5])
|
| 48 |
+
scene.add(cam, pose=cam_pose)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def render_thumbnail(scene: pyrender.Scene, size: int) -> Image.Image:
|
| 52 |
+
r = pyrender.OffscreenRenderer(viewport_width=size, viewport_height=size)
|
| 53 |
+
try:
|
| 54 |
+
color, _ = r.render(scene)
|
| 55 |
+
finally:
|
| 56 |
+
r.delete()
|
| 57 |
+
return Image.fromarray(color)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def generate_thumbnail(url: str, out_path: Path, size: int = 512) -> None:
|
| 61 |
+
raw = download_to_memory(url)
|
| 62 |
+
mesh = load_mesh(raw)
|
| 63 |
+
|
| 64 |
+
# Get mesh dimensions before any transformations
|
| 65 |
+
original_extents = mesh.extents
|
| 66 |
+
longest_dimension = np.max(original_extents)
|
| 67 |
+
|
| 68 |
+
# Print dimension info
|
| 69 |
+
print(f"Mesh dimensions (X, Y, Z): {original_extents}")
|
| 70 |
+
print(f"Longest dimension: {longest_dimension:.4f}")
|
| 71 |
+
|
| 72 |
+
# Scaling constant - you can use this to normalize models to a target size
|
| 73 |
+
target_size = 2.5 # Target longest dimension
|
| 74 |
+
scale_factor = target_size / longest_dimension
|
| 75 |
+
print(f"Scale factor to normalize to {target_size}: {scale_factor:.4f}")
|
| 76 |
+
|
| 77 |
+
# Calculate radius BEFORE scaling for consistent camera/lighting positioning
|
| 78 |
+
bb = mesh.bounding_box_oriented.extents
|
| 79 |
+
if not np.all(bb):
|
| 80 |
+
bb = mesh.extents
|
| 81 |
+
fixed_radius = np.linalg.norm(bb) * 0.6
|
| 82 |
+
|
| 83 |
+
mesh.apply_translation(-mesh.bounding_box.centroid)
|
| 84 |
+
|
| 85 |
+
# Apply the scaling transformation
|
| 86 |
+
mesh.apply_scale(scale_factor)
|
| 87 |
+
|
| 88 |
+
# Rotate 45 degrees to the left (around Y-axis)
|
| 89 |
+
rotation = rotation_matrix(np.radians(30), [0.3, -0.5, 0])
|
| 90 |
+
mesh.apply_transform(rotation)
|
| 91 |
+
|
| 92 |
+
# Build scene but use fixed radius for camera/lighting
|
| 93 |
+
tm_mesh = pyrender.Mesh.from_trimesh(mesh, smooth=False)
|
| 94 |
+
scene = pyrender.Scene(bg_color=[0.15, 0.15, 0.15, 1]) # Gray background
|
| 95 |
+
scene.add(tm_mesh)
|
| 96 |
+
|
| 97 |
+
add_lighting(scene, fixed_radius)
|
| 98 |
+
setup_camera(scene, fixed_radius)
|
| 99 |
+
img = render_thumbnail(scene, size)
|
| 100 |
+
|
| 101 |
+
img.save(out_path, "PNG")
|
| 102 |
+
print(f"Saved thumbnail → {out_path.resolve()}")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def parse_args(argv):
|
| 106 |
+
p = argparse.ArgumentParser(description="Render a GLB file to a PNG thumbnail")
|
| 107 |
+
p.add_argument("url"), p.add_argument("output")
|
| 108 |
+
p.add_argument("size", nargs="?", type=int, default=512)
|
| 109 |
+
return p.parse_args(argv)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
if __name__ == "__main__":
|
| 113 |
+
args = parse_args(sys.argv[1:])
|
| 114 |
+
generate_thumbnail(args.url, Path(args.output), args.size)
|
main.py
CHANGED
|
@@ -23,11 +23,7 @@ dev_mode = False
|
|
| 23 |
class Settings:
|
| 24 |
def __init__(self):
|
| 25 |
self.mesh_api_key = os.getenv("MESHY_API_KEY")
|
| 26 |
-
if not self.mesh_api_key:
|
| 27 |
-
raise RuntimeError("MESHY_API_KEY environment variable not set")
|
| 28 |
self.openai_api_key = os.getenv("OPENAI_API_KEY")
|
| 29 |
-
if not self.openai_api_key:
|
| 30 |
-
raise RuntimeError("OPENAI_API_KEY environment variable not set")
|
| 31 |
self.stripe_secret_key = os.getenv("STRIPE_SECRET_KEY")
|
| 32 |
if not self.stripe_secret_key:
|
| 33 |
raise RuntimeError("STRIPE_SECRET_KEY environment variable not set")
|
|
|
|
| 23 |
class Settings:
|
| 24 |
def __init__(self):
|
| 25 |
self.mesh_api_key = os.getenv("MESHY_API_KEY")
|
|
|
|
|
|
|
| 26 |
self.openai_api_key = os.getenv("OPENAI_API_KEY")
|
|
|
|
|
|
|
| 27 |
self.stripe_secret_key = os.getenv("STRIPE_SECRET_KEY")
|
| 28 |
if not self.stripe_secret_key:
|
| 29 |
raise RuntimeError("STRIPE_SECRET_KEY environment variable not set")
|
outputerss.png
ADDED
|
requirements.txt
CHANGED
|
@@ -14,3 +14,7 @@ requests>=2.32.3
|
|
| 14 |
PyJWT>=2.10.1
|
| 15 |
sse-starlette>=1.3.2
|
| 16 |
trimesh>=4.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
PyJWT>=2.10.1
|
| 15 |
sse-starlette>=1.3.2
|
| 16 |
trimesh>=4.0.0
|
| 17 |
+
replicate>=0.16.0
|
| 18 |
+
pillow>=10.0.0
|
| 19 |
+
pyrender>=0.1.40
|
| 20 |
+
numpy>=1.21.0
|
routers/user_models.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File, Form
|
| 2 |
-
from fastapi.responses import JSONResponse
|
| 3 |
from auth import get_current_active_user, User, supabase
|
| 4 |
import logging
|
| 5 |
import httpx
|
|
@@ -7,16 +7,100 @@ import os
|
|
| 7 |
from typing import Optional, Dict, Any
|
| 8 |
from pydantic import BaseModel
|
| 9 |
import base64
|
|
|
|
| 10 |
from services.hunyuan_service import _hunyuan_image_to_3d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
router = APIRouter(
|
| 13 |
prefix="/user/models",
|
| 14 |
-
tags=["User Models"]
|
| 15 |
-
dependencies=[Depends(get_current_active_user)]
|
| 16 |
)
|
| 17 |
|
| 18 |
-
@router.get("/progress_update/{generated_model_id}")
|
| 19 |
-
async def refresh_generated_model(generated_model_id: str
|
| 20 |
"""
|
| 21 |
Manual refresh endpoint.
|
| 22 |
|
|
@@ -25,25 +109,48 @@ async def refresh_generated_model(generated_model_id: str, current_user: User =
|
|
| 25 |
For text-to-3d with texture, this handles the two-step process (preview + refine).
|
| 26 |
"""
|
| 27 |
try:
|
| 28 |
-
#
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
if not db_resp.data:
|
| 32 |
-
raise HTTPException(status_code=404, detail="Model not found
|
| 33 |
|
| 34 |
-
generated_model = db_resp.data
|
| 35 |
prompts_config = generated_model.get("prompts_and_models_config", {})
|
| 36 |
generation_type = prompts_config.get("generation_type")
|
| 37 |
should_texture = prompts_config.get("should_texture", False)
|
| 38 |
|
| 39 |
# Special handling for Hunyuan generation (doesn't use Meshy API)
|
| 40 |
if generation_type == "hunyuan_image_to_3d":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
return {
|
| 42 |
-
"task_id":
|
| 43 |
"status": generated_model.get("status", "IN_PROGRESS"),
|
| 44 |
"progress": 100 if generated_model.get("status") == "COMPLETED" else 50,
|
| 45 |
-
"model_urls":
|
| 46 |
-
"thumbnail_url":
|
| 47 |
"texture_urls": None,
|
| 48 |
"created_at": generated_model.get("created_at"),
|
| 49 |
"started_at": generated_model.get("created_at"),
|
|
@@ -51,12 +158,42 @@ async def refresh_generated_model(generated_model_id: str, current_user: User =
|
|
| 51 |
"task_error": None,
|
| 52 |
"database_updated": True,
|
| 53 |
"generation_type": "hunyuan_image_to_3d",
|
| 54 |
-
"message": "Hunyuan generation completed.
|
| 55 |
}
|
| 56 |
|
| 57 |
meshy_task_id = generated_model.get("meshy_api_job_id")
|
| 58 |
if not meshy_task_id:
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
# 2) Query Meshy API for the latest status.
|
| 62 |
meshy_api_key = os.getenv("MESHY_API_KEY")
|
|
@@ -69,7 +206,7 @@ async def refresh_generated_model(generated_model_id: str, current_user: User =
|
|
| 69 |
# Special handling for text-to-3d with texture (two-step process)
|
| 70 |
if generation_type == "text_to_3d" and should_texture:
|
| 71 |
return await _handle_text_to_3d_with_texture(
|
| 72 |
-
|
| 73 |
meshy_task_id, meshy_api_key, client, headers
|
| 74 |
)
|
| 75 |
|
|
@@ -112,7 +249,7 @@ async def refresh_generated_model(generated_model_id: str, current_user: User =
|
|
| 112 |
|
| 113 |
# Return the progress information along with update status
|
| 114 |
return {
|
| 115 |
-
"task_id":
|
| 116 |
"status": meshy_response.get("status"),
|
| 117 |
"progress": meshy_response.get("progress"),
|
| 118 |
"model_urls": meshy_response.get("model_urls"),
|
|
@@ -134,7 +271,7 @@ async def refresh_generated_model(generated_model_id: str, current_user: User =
|
|
| 134 |
|
| 135 |
|
| 136 |
async def _handle_text_to_3d_with_texture(
|
| 137 |
-
generated_model_id:
|
| 138 |
generated_model: Dict[str, Any],
|
| 139 |
prompts_config: Dict[str, Any],
|
| 140 |
preview_task_id: str,
|
|
@@ -466,6 +603,14 @@ async def _process_hunyuan_image_to_3d_background(generated_model_id: int, image
|
|
| 466 |
file_format = file_name.split(".")[-1].lower()
|
| 467 |
file_size = len(mesh_data)
|
| 468 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
# Update DB record with Hunyuan response
|
| 470 |
supabase.from_("Generated_Models").update({
|
| 471 |
"status": "COMPLETED",
|
|
@@ -473,11 +618,14 @@ async def _process_hunyuan_image_to_3d_background(generated_model_id: int, image
|
|
| 473 |
"prompts_and_models_config": hunyuan_response,
|
| 474 |
}).eq("generated_model_id", generated_model_id).execute()
|
| 475 |
|
|
|
|
|
|
|
|
|
|
| 476 |
# Insert the mesh file into Model_Files table
|
| 477 |
supabase.from_("Model_Files").insert({
|
| 478 |
"user_id": user_id,
|
| 479 |
"generated_model_id": generated_model_id,
|
| 480 |
-
"model_data":
|
| 481 |
"file_name": file_name,
|
| 482 |
"file_format": file_format,
|
| 483 |
"file_size": file_size,
|
|
@@ -485,6 +633,28 @@ async def _process_hunyuan_image_to_3d_background(generated_model_id: int, image
|
|
| 485 |
"is_preview_file": False,
|
| 486 |
}).execute()
|
| 487 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
logging.info(f"Successfully completed Hunyuan image-to-3d generation for model {generated_model_id}")
|
| 489 |
|
| 490 |
except Exception as ex:
|
|
@@ -617,26 +787,88 @@ async def text_to_3d(prompt: TextPrompt, background_tasks: BackgroundTasks, curr
|
|
| 617 |
@router.post("/image-to-3d")
|
| 618 |
async def image_to_3d(
|
| 619 |
background_tasks: BackgroundTasks,
|
| 620 |
-
|
|
|
|
| 621 |
current_user: User = Depends(get_current_active_user),
|
| 622 |
):
|
| 623 |
"""
|
| 624 |
-
Create a Hunyuan3D Image-to-3D generation job
|
| 625 |
|
| 626 |
-
|
| 627 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
"""
|
| 629 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
# Credit check and decrement - Hunyuan generation costs 2 credits
|
| 631 |
await _check_and_decrement_credits(current_user.id, 2)
|
| 632 |
|
|
|
|
|
|
|
| 633 |
# Insert initial DB record and return immediately
|
| 634 |
try:
|
| 635 |
insert_res = supabase.from_("Generated_Models").insert({
|
| 636 |
"status": "IN_PROGRESS",
|
| 637 |
"user_id": current_user.id,
|
| 638 |
"meshy_api_job_id": None,
|
| 639 |
-
"model_name": f"Hunyuan 3D from {
|
| 640 |
"prompts_and_models_config": {
|
| 641 |
"generation_type": "hunyuan_image_to_3d",
|
| 642 |
"input_image_url": image_url,
|
|
@@ -645,10 +877,10 @@ async def image_to_3d(
|
|
| 645 |
},
|
| 646 |
}).execute()
|
| 647 |
generated_model_id = insert_res.data[0]["generated_model_id"] if insert_res.data else None
|
| 648 |
-
|
| 649 |
if not generated_model_id:
|
| 650 |
raise HTTPException(status_code=500, detail="Failed to create model record")
|
| 651 |
-
|
| 652 |
# Add background task for Hunyuan processing
|
| 653 |
background_tasks.add_task(
|
| 654 |
_process_hunyuan_image_to_3d_background,
|
|
@@ -656,216 +888,57 @@ async def image_to_3d(
|
|
| 656 |
image_url,
|
| 657 |
current_user.id,
|
| 658 |
)
|
| 659 |
-
|
| 660 |
-
# Return immediately with explicit headers
|
| 661 |
response_data = {
|
| 662 |
"generated_model_id": generated_model_id,
|
| 663 |
"status": "initializing",
|
| 664 |
"input_image_url": image_url,
|
| 665 |
-
"message": "Hunyuan 3D generation started. Use the progress_update endpoint to check status."
|
| 666 |
-
}
|
| 667 |
-
|
| 668 |
-
logging.info(f"Returning response for hunyuan image-to-3d: {response_data}")
|
| 669 |
-
|
| 670 |
-
response = JSONResponse(content=response_data, status_code=200)
|
| 671 |
-
# Allowed header; avoids disallowed connection-specific headers under HTTP/2
|
| 672 |
-
response.headers["Cache-Control"] = "no-cache"
|
| 673 |
-
return response
|
| 674 |
-
|
| 675 |
-
except Exception as ex:
|
| 676 |
-
logging.error(f"Failed to create initial model DB record: {ex}")
|
| 677 |
-
raise HTTPException(status_code=500, detail=f"Failed to start generation: {ex}")
|
| 678 |
-
|
| 679 |
-
@router.post("/image-to-3d/upload")
|
| 680 |
-
async def image_to_3d_upload(
|
| 681 |
-
background_tasks: BackgroundTasks,
|
| 682 |
-
file: UploadFile = File(...),
|
| 683 |
-
should_texture: bool = Form(False),
|
| 684 |
-
current_user: User = Depends(get_current_active_user),
|
| 685 |
-
):
|
| 686 |
-
"""Upload an image file and create a Meshy *Image-to-3D* task.
|
| 687 |
-
|
| 688 |
-
Returns immediately after creating the database record and encoding the image.
|
| 689 |
-
Meshy API call happens in the background.
|
| 690 |
-
""":
|
| 691 |
-
|
| 692 |
-
# 1. Credit check and decrement – texture generation costs 3 credits
|
| 693 |
-
credit_cost = 3 if should_texture else 1
|
| 694 |
-
await _check_and_decrement_credits(current_user.id, credit_cost)
|
| 695 |
-
|
| 696 |
-
# 2. Read & encode file (this is fast, so we do it synchronously)
|
| 697 |
-
try:
|
| 698 |
-
file_bytes = await file.read()
|
| 699 |
-
mime_type = file.content_type or "image/png"
|
| 700 |
-
b64 = base64.b64encode(file_bytes).decode("utf-8")
|
| 701 |
-
data_uri = f"data:{mime_type};base64,{b64}"
|
| 702 |
-
except Exception as ex:
|
| 703 |
-
raise HTTPException(status_code=400, detail=f"Failed to read uploaded file: {ex}")
|
| 704 |
-
|
| 705 |
-
# 3. Insert initial DB record and return immediately
|
| 706 |
-
try:
|
| 707 |
-
insert_res = supabase.from_("Generated_Models").insert({
|
| 708 |
-
"status": "IN_PROGRESS",
|
| 709 |
-
"user_id": current_user.id,
|
| 710 |
-
"meshy_api_job_id": None,
|
| 711 |
-
"model_name": f"{file.filename}",
|
| 712 |
-
"prompts_and_models_config": {
|
| 713 |
-
"generation_type": "image_to_3d",
|
| 714 |
-
"input_image_filename": file.filename,
|
| 715 |
-
"status": "initializing",
|
| 716 |
-
"stage": "uploading_image",
|
| 717 |
-
},
|
| 718 |
-
}).execute()
|
| 719 |
-
generated_model_id = insert_res.data[0]["generated_model_id"] if insert_res.data else None
|
| 720 |
-
|
| 721 |
-
if not generated_model_id:
|
| 722 |
-
raise HTTPException(status_code=500, detail="Failed to create model record")
|
| 723 |
-
|
| 724 |
-
# Build payload – only include "should_texture": False when texture is NOT requested
|
| 725 |
-
payload: Dict[str, Any] = {
|
| 726 |
-
"image_url": data_uri,
|
| 727 |
-
"mode": "preview",
|
| 728 |
-
"ai_model": "meshy-5",
|
| 729 |
}
|
| 730 |
|
| 731 |
-
|
| 732 |
-
# Explicitly disable texturing when the user hasn't requested it
|
| 733 |
-
payload["should_texture"] = False
|
| 734 |
-
|
| 735 |
-
# Add background task for Meshy API processing
|
| 736 |
-
background_tasks.add_task(_process_image_to_3d_background, generated_model_id, payload, "image_to_3d")
|
| 737 |
|
| 738 |
-
# Return immediately with explicit headers
|
| 739 |
-
response_data = {
|
| 740 |
-
"generated_model_id": generated_model_id,
|
| 741 |
-
"status": "initializing",
|
| 742 |
-
"input_image_filename": file.filename,
|
| 743 |
-
"message": "Generation started. Use the progress_update endpoint to check status."
|
| 744 |
-
}
|
| 745 |
-
|
| 746 |
-
logging.info(f"Returning response for image-to-3d/upload: {response_data}")
|
| 747 |
-
|
| 748 |
response = JSONResponse(content=response_data, status_code=200)
|
| 749 |
-
# Allowed header; avoids disallowed connection-specific headers under HTTP/2
|
| 750 |
response.headers["Cache-Control"] = "no-cache"
|
| 751 |
return response
|
| 752 |
-
|
| 753 |
-
except Exception as ex:
|
| 754 |
-
logging.error(f"Failed to create initial model DB record: {ex}")
|
| 755 |
-
raise HTTPException(status_code=500, detail=f"Failed to start generation: {ex}")
|
| 756 |
-
|
| 757 |
-
@router.post("/multi-image-to-3d")
|
| 758 |
-
async def multi_image_to_3d_upload(
|
| 759 |
-
background_tasks: BackgroundTasks,
|
| 760 |
-
files: list[UploadFile] = File(...),
|
| 761 |
-
should_texture: bool = Form(False),
|
| 762 |
-
current_user: User = Depends(get_current_active_user),
|
| 763 |
-
):
|
| 764 |
-
"""Upload multiple image files and create a Meshy *Multi-Image-to-3D* task.
|
| 765 |
-
|
| 766 |
-
Returns immediately after creating the database record and encoding the images.
|
| 767 |
-
Meshy API call happens in the background.
|
| 768 |
-
"""
|
| 769 |
-
|
| 770 |
-
# 1. Credit check and decrement – texture generation costs 3 credits
|
| 771 |
-
credit_cost = 3 if should_texture else 1
|
| 772 |
-
await _check_and_decrement_credits(current_user.id, credit_cost)
|
| 773 |
-
|
| 774 |
-
# 2. Validate we have at least 2 images (typical requirement for multi-image)
|
| 775 |
-
if len(files) < 2:
|
| 776 |
-
raise HTTPException(status_code=400, detail="At least 2 images are required for multi-image generation")
|
| 777 |
|
| 778 |
-
# 3. Read & encode all files (this is reasonably fast, so we do it synchronously)
|
| 779 |
-
try:
|
| 780 |
-
image_urls = []
|
| 781 |
-
filenames = []
|
| 782 |
-
for file in files:
|
| 783 |
-
file_bytes = await file.read()
|
| 784 |
-
mime_type = file.content_type or "image/png"
|
| 785 |
-
b64 = base64.b64encode(file_bytes).decode("utf-8")
|
| 786 |
-
data_uri = f"data:{mime_type};base64,{b64}"
|
| 787 |
-
image_urls.append(data_uri)
|
| 788 |
-
filenames.append(file.filename)
|
| 789 |
-
except Exception as ex:
|
| 790 |
-
raise HTTPException(status_code=400, detail=f"Failed to read uploaded files: {ex}")
|
| 791 |
-
|
| 792 |
-
# 4. Insert initial DB record and return immediately
|
| 793 |
-
try:
|
| 794 |
-
insert_res = supabase.from_("Generated_Models").insert({
|
| 795 |
-
"status": "IN_PROGRESS",
|
| 796 |
-
"user_id": current_user.id,
|
| 797 |
-
"meshy_api_job_id": None,
|
| 798 |
-
"model_name": f"Multi-Image to 3D - {', '.join(filenames)}",
|
| 799 |
-
"prompts_and_models_config": {
|
| 800 |
-
"generation_type": "multi_image_to_3d",
|
| 801 |
-
"input_image_filenames": filenames,
|
| 802 |
-
"should_texture": should_texture,
|
| 803 |
-
"status": "initializing",
|
| 804 |
-
"stage": "uploading_images",
|
| 805 |
-
},
|
| 806 |
-
}).execute()
|
| 807 |
-
generated_model_id = insert_res.data[0]["generated_model_id"] if insert_res.data else None
|
| 808 |
-
|
| 809 |
-
if not generated_model_id:
|
| 810 |
-
raise HTTPException(status_code=500, detail="Failed to create model record")
|
| 811 |
-
|
| 812 |
-
# Build payload
|
| 813 |
-
payload: Dict[str, Any] = {
|
| 814 |
-
"image_urls": image_urls,
|
| 815 |
-
"mode": "preview",
|
| 816 |
-
"ai_model": "meshy-5",
|
| 817 |
-
}
|
| 818 |
-
|
| 819 |
-
if not should_texture:
|
| 820 |
-
payload["should_texture"] = False
|
| 821 |
-
|
| 822 |
-
# Add background task for Meshy API processing
|
| 823 |
-
background_tasks.add_task(_process_image_to_3d_background, generated_model_id, payload, "multi_image_to_3d")
|
| 824 |
-
|
| 825 |
-
# Return immediately with explicit headers
|
| 826 |
-
response_data = {
|
| 827 |
-
"generated_model_id": generated_model_id,
|
| 828 |
-
"status": "initializing",
|
| 829 |
-
"input_image_filenames": filenames,
|
| 830 |
-
"message": "Generation started. Use the progress_update endpoint to check status."
|
| 831 |
-
}
|
| 832 |
-
|
| 833 |
-
logging.info(f"Returning response for multi-image-to-3d: {response_data}")
|
| 834 |
-
|
| 835 |
-
response = JSONResponse(content=response_data, status_code=200)
|
| 836 |
-
# Allowed header; avoids disallowed connection-specific headers under HTTP/2
|
| 837 |
-
response.headers["Cache-Control"] = "no-cache"
|
| 838 |
-
return response
|
| 839 |
-
|
| 840 |
except Exception as ex:
|
| 841 |
logging.error(f"Failed to create initial model DB record: {ex}")
|
| 842 |
raise HTTPException(status_code=500, detail=f"Failed to start generation: {ex}")
|
| 843 |
|
| 844 |
@router.get("/{generated_model_id}/file")
|
| 845 |
async def get_model_file(
|
| 846 |
-
generated_model_id: str
|
| 847 |
-
current_user: User = Depends(get_current_active_user)
|
| 848 |
):
|
| 849 |
"""
|
| 850 |
Get the model file info for a generated model belonging to the current user.
|
| 851 |
"""
|
| 852 |
try:
|
| 853 |
-
#
|
| 854 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
|
| 856 |
if not model_check.data:
|
| 857 |
-
raise HTTPException(status_code=404, detail="Model not found
|
| 858 |
|
| 859 |
-
# Get the single model file for this generated model
|
| 860 |
-
file_result = supabase.from_("Model_Files").select("model_file_id, file_name, file_format, file_size, metadata, is_preview_file, created_at").eq("generated_model_id",
|
| 861 |
|
| 862 |
if not file_result.data:
|
| 863 |
raise HTTPException(status_code=404, detail="No model file found for this generated model.")
|
| 864 |
|
| 865 |
return {
|
| 866 |
"generated_model_id": generated_model_id,
|
| 867 |
-
"model_name": model_check.data.get("model_name"),
|
| 868 |
-
"file": file_result.data
|
| 869 |
}
|
| 870 |
|
| 871 |
except HTTPException:
|
|
@@ -877,35 +950,54 @@ async def get_model_file(
|
|
| 877 |
|
| 878 |
@router.get("/{generated_model_id}/download")
|
| 879 |
async def download_model_file(
|
| 880 |
-
generated_model_id: str
|
| 881 |
-
current_user: User = Depends(get_current_active_user)
|
| 882 |
):
|
| 883 |
"""
|
| 884 |
Download the model file for a generated model.
|
| 885 |
"""
|
| 886 |
try:
|
| 887 |
-
#
|
| 888 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 889 |
|
| 890 |
if not model_check.data:
|
| 891 |
-
raise HTTPException(status_code=404, detail="Model not found
|
| 892 |
|
| 893 |
-
# Get the model file
|
| 894 |
-
file_result = supabase.from_("Model_Files").select("*").eq("generated_model_id",
|
| 895 |
|
| 896 |
if not file_result.data:
|
| 897 |
raise HTTPException(status_code=404, detail="No model file found for this generated model.")
|
| 898 |
|
| 899 |
-
file_data = file_result.data
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
return Response(
|
| 904 |
-
content=
|
| 905 |
media_type="application/octet-stream",
|
| 906 |
headers={
|
| 907 |
-
"Content-Disposition": f"attachment; filename={file_data['file_name']}"
|
| 908 |
-
"Content-Length": str(file_data["file_size"])
|
| 909 |
}
|
| 910 |
)
|
| 911 |
|
|
@@ -916,23 +1008,188 @@ async def download_model_file(
|
|
| 916 |
logging.error(f"Failed to download model file for {generated_model_id}: {str(e)}")
|
| 917 |
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 918 |
|
| 919 |
-
@router.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 920 |
async def delete_model(
|
| 921 |
generated_model_id: str,
|
| 922 |
current_user: User = Depends(get_current_active_user)
|
| 923 |
):
|
| 924 |
"""
|
| 925 |
-
Delete a generated model for the current user.
|
| 926 |
"""
|
| 927 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 928 |
# First check if the model exists and belongs to the user
|
| 929 |
-
model_check = supabase.from_("Generated_Models").select("generated_model_id, user_id, model_name").eq("generated_model_id",
|
| 930 |
|
| 931 |
if not model_check.data:
|
| 932 |
raise HTTPException(status_code=404, detail="Model not found or you do not have permission to delete it.")
|
| 933 |
|
| 934 |
-
# Delete
|
| 935 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 936 |
|
| 937 |
# The delete operation should return the deleted record(s)
|
| 938 |
if not delete_result.data:
|
|
@@ -941,9 +1198,10 @@ async def delete_model(
|
|
| 941 |
logging.info(f"Successfully deleted model {generated_model_id} for user {current_user.id}")
|
| 942 |
|
| 943 |
return {
|
| 944 |
-
"message": "Model deleted successfully.",
|
| 945 |
"deleted_model_id": generated_model_id,
|
| 946 |
-
"model_name": model_check.data.get("model_name", "Unknown")
|
|
|
|
| 947 |
}
|
| 948 |
|
| 949 |
except HTTPException:
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File, Form
|
| 2 |
+
from fastapi.responses import JSONResponse, Response
|
| 3 |
from auth import get_current_active_user, User, supabase
|
| 4 |
import logging
|
| 5 |
import httpx
|
|
|
|
| 7 |
from typing import Optional, Dict, Any
|
| 8 |
from pydantic import BaseModel
|
| 9 |
import base64
|
| 10 |
+
from uuid import uuid4
|
| 11 |
from services.hunyuan_service import _hunyuan_image_to_3d
|
| 12 |
+
import io
|
| 13 |
+
import numpy as np
|
| 14 |
+
from PIL import Image
|
| 15 |
+
import trimesh
|
| 16 |
+
import pyrender
|
| 17 |
+
from trimesh.transformations import translation_matrix, rotation_matrix
|
| 18 |
+
|
| 19 |
+
# Set PyOpenGL platform for headless rendering
|
| 20 |
+
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
|
| 21 |
+
|
| 22 |
+
def generate_thumbnail_from_bytes(mesh_data: bytes, size: int = 512) -> bytes:
|
| 23 |
+
"""
|
| 24 |
+
Generate a thumbnail image from 3D mesh bytes data.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
mesh_data: The 3D mesh file as bytes (GLB format)
|
| 28 |
+
size: Output image size in pixels (default 512x512)
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
PNG image data as bytes
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
# Load mesh from bytes
|
| 35 |
+
mesh = trimesh.load(io.BytesIO(mesh_data), file_type="glb", force="mesh")
|
| 36 |
+
|
| 37 |
+
# Get mesh dimensions before any transformations
|
| 38 |
+
original_extents = mesh.extents
|
| 39 |
+
longest_dimension = np.max(original_extents)
|
| 40 |
+
|
| 41 |
+
# Scaling to normalize models to a target size
|
| 42 |
+
target_size = 2.5
|
| 43 |
+
scale_factor = target_size / longest_dimension if longest_dimension > 0 else 1.0
|
| 44 |
+
|
| 45 |
+
# Calculate radius BEFORE scaling for consistent camera/lighting positioning
|
| 46 |
+
bb = mesh.bounding_box_oriented.extents
|
| 47 |
+
if not np.all(bb):
|
| 48 |
+
bb = mesh.extents
|
| 49 |
+
fixed_radius = np.linalg.norm(bb) * 0.6
|
| 50 |
+
|
| 51 |
+
# Center the mesh
|
| 52 |
+
mesh.apply_translation(-mesh.bounding_box.centroid)
|
| 53 |
+
|
| 54 |
+
# Apply scaling transformation
|
| 55 |
+
mesh.apply_scale(scale_factor)
|
| 56 |
+
|
| 57 |
+
# Rotate for better viewing angle
|
| 58 |
+
rotation = rotation_matrix(np.radians(30), [0.3, -0.5, 0])
|
| 59 |
+
mesh.apply_transform(rotation)
|
| 60 |
+
|
| 61 |
+
# Build scene
|
| 62 |
+
tm_mesh = pyrender.Mesh.from_trimesh(mesh, smooth=False)
|
| 63 |
+
scene = pyrender.Scene(bg_color=[0.15, 0.15, 0.15, 1]) # Gray background
|
| 64 |
+
scene.add(tm_mesh)
|
| 65 |
+
|
| 66 |
+
# Add lighting
|
| 67 |
+
key_light = pyrender.PointLight(color=np.ones(3), intensity=40.0)
|
| 68 |
+
fill_light = pyrender.PointLight(color=np.ones(3), intensity=20.0)
|
| 69 |
+
back_light = pyrender.PointLight(color=np.ones(3), intensity=10.0)
|
| 70 |
+
|
| 71 |
+
scene.add(key_light, pose=translation_matrix([fixed_radius, fixed_radius, fixed_radius]))
|
| 72 |
+
scene.add(fill_light, pose=translation_matrix([-fixed_radius, fixed_radius, fixed_radius]))
|
| 73 |
+
scene.add(back_light, pose=translation_matrix([0, -fixed_radius, -fixed_radius]))
|
| 74 |
+
|
| 75 |
+
# Setup camera
|
| 76 |
+
cam = pyrender.PerspectiveCamera(yfov=np.radians(45.0))
|
| 77 |
+
cam_pose = translation_matrix([0, 0, fixed_radius * 2.5])
|
| 78 |
+
scene.add(cam, pose=cam_pose)
|
| 79 |
+
|
| 80 |
+
# Render thumbnail
|
| 81 |
+
renderer = pyrender.OffscreenRenderer(viewport_width=size, viewport_height=size)
|
| 82 |
+
try:
|
| 83 |
+
color, _ = renderer.render(scene)
|
| 84 |
+
finally:
|
| 85 |
+
renderer.delete()
|
| 86 |
+
|
| 87 |
+
# Convert to PIL Image and save as PNG bytes
|
| 88 |
+
img = Image.fromarray(color)
|
| 89 |
+
img_bytes = io.BytesIO()
|
| 90 |
+
img.save(img_bytes, format='PNG')
|
| 91 |
+
return img_bytes.getvalue()
|
| 92 |
+
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logging.error(f"Failed to generate thumbnail: {str(e)}")
|
| 95 |
+
raise
|
| 96 |
|
| 97 |
router = APIRouter(
|
| 98 |
prefix="/user/models",
|
| 99 |
+
tags=["User Models"] # Removed global auth dependency; individual endpoints add it where needed
|
|
|
|
| 100 |
)
|
| 101 |
|
| 102 |
+
@router.get("/progress_update/{generated_model_id}", dependencies=[])
|
| 103 |
+
async def refresh_generated_model(generated_model_id: str):
|
| 104 |
"""
|
| 105 |
Manual refresh endpoint.
|
| 106 |
|
|
|
|
| 109 |
For text-to-3d with texture, this handles the two-step process (preview + refine).
|
| 110 |
"""
|
| 111 |
try:
|
| 112 |
+
# Handle placeholder IDs from frontend
|
| 113 |
+
if generated_model_id.startswith("placeholder_"):
|
| 114 |
+
raise HTTPException(status_code=400, detail="Invalid model ID. Model may not be ready yet or generation is still initializing.")
|
| 115 |
+
|
| 116 |
+
# Validate that generated_model_id is a valid integer
|
| 117 |
+
try:
|
| 118 |
+
model_id_int = int(generated_model_id)
|
| 119 |
+
except ValueError:
|
| 120 |
+
raise HTTPException(status_code=400, detail="Invalid model ID format. Expected numeric ID.")
|
| 121 |
+
|
| 122 |
+
# 1) Validate existence & retrieve the record (removed ownership check for public access).
|
| 123 |
+
db_resp = supabase.from_("Generated_Models").select("*").eq("generated_model_id", model_id_int).limit(1).execute() # .eq("user_id", current_user.id) - commented out for public access
|
| 124 |
|
| 125 |
if not db_resp.data:
|
| 126 |
+
raise HTTPException(status_code=404, detail="Model not found")
|
| 127 |
|
| 128 |
+
generated_model = db_resp.data[0]
|
| 129 |
prompts_config = generated_model.get("prompts_and_models_config", {})
|
| 130 |
generation_type = prompts_config.get("generation_type")
|
| 131 |
should_texture = prompts_config.get("should_texture", False)
|
| 132 |
|
| 133 |
# Special handling for Hunyuan generation (doesn't use Meshy API)
|
| 134 |
if generation_type == "hunyuan_image_to_3d":
|
| 135 |
+
# For completed Hunyuan models, provide the view URL for 3D display
|
| 136 |
+
model_urls = None
|
| 137 |
+
thumbnail_url = None
|
| 138 |
+
|
| 139 |
+
if generated_model.get("status") == "COMPLETED":
|
| 140 |
+
model_urls = {
|
| 141 |
+
"glb": f"/user/models/{model_id_int}/view.glb" # Relative URL to our view endpoint with extension
|
| 142 |
+
}
|
| 143 |
+
# Check if thumbnail exists
|
| 144 |
+
thumbnail_check = supabase.from_("Model_Files").select("model_file_id").eq("generated_model_id", model_id_int).eq("is_preview_file", True).eq("file_format", "png").limit(1).execute()
|
| 145 |
+
if thumbnail_check.data:
|
| 146 |
+
thumbnail_url = f"/user/models/{model_id_int}/thumbnail"
|
| 147 |
+
|
| 148 |
return {
|
| 149 |
+
"task_id": model_id_int,
|
| 150 |
"status": generated_model.get("status", "IN_PROGRESS"),
|
| 151 |
"progress": 100 if generated_model.get("status") == "COMPLETED" else 50,
|
| 152 |
+
"model_urls": model_urls,
|
| 153 |
+
"thumbnail_url": thumbnail_url,
|
| 154 |
"texture_urls": None,
|
| 155 |
"created_at": generated_model.get("created_at"),
|
| 156 |
"started_at": generated_model.get("created_at"),
|
|
|
|
| 158 |
"task_error": None,
|
| 159 |
"database_updated": True,
|
| 160 |
"generation_type": "hunyuan_image_to_3d",
|
| 161 |
+
"message": "Hunyuan generation completed. 3D model ready for viewing." if generated_model.get("status") == "COMPLETED" else "Hunyuan generation in progress..."
|
| 162 |
}
|
| 163 |
|
| 164 |
meshy_task_id = generated_model.get("meshy_api_job_id")
|
| 165 |
if not meshy_task_id:
|
| 166 |
+
# Check if this model has files stored locally (similar to Hunyuan models) - removed ownership check for public access
|
| 167 |
+
file_check = supabase.from_("Model_Files").select("model_file_id, file_name, file_format").eq("generated_model_id", model_id_int).limit(1).execute() # .eq("user_id", current_user.id) - commented out for public access
|
| 168 |
+
|
| 169 |
+
if file_check.data:
|
| 170 |
+
# Model has local files - treat it like a Hunyuan model
|
| 171 |
+
model_urls = None
|
| 172 |
+
if generated_model.get("status") == "COMPLETED":
|
| 173 |
+
# Determine file format for URL
|
| 174 |
+
file_format = file_check.data[0].get("file_format", "glb").lower()
|
| 175 |
+
model_urls = {
|
| 176 |
+
file_format: f"/user/models/{model_id_int}/view.{file_format}"
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return {
|
| 180 |
+
"task_id": model_id_int,
|
| 181 |
+
"status": generated_model.get("status", "IN_PROGRESS"),
|
| 182 |
+
"progress": 100 if generated_model.get("status") == "COMPLETED" else 50,
|
| 183 |
+
"model_urls": model_urls,
|
| 184 |
+
"thumbnail_url": None,
|
| 185 |
+
"texture_urls": None,
|
| 186 |
+
"created_at": generated_model.get("created_at"),
|
| 187 |
+
"started_at": generated_model.get("created_at"),
|
| 188 |
+
"finished_at": generated_model.get("updated_at") if generated_model.get("status") == "COMPLETED" else None,
|
| 189 |
+
"task_error": None,
|
| 190 |
+
"database_updated": True,
|
| 191 |
+
"generation_type": generation_type,
|
| 192 |
+
"message": "Model completed. 3D model ready for viewing." if generated_model.get("status") == "COMPLETED" else "Model generation in progress..."
|
| 193 |
+
}
|
| 194 |
+
else:
|
| 195 |
+
# No local files and no Meshy task ID - this model might be incomplete or from old system
|
| 196 |
+
raise HTTPException(status_code=400, detail="Model has no associated files or Meshy task ID. This model may be incomplete or from an older system.")
|
| 197 |
|
| 198 |
# 2) Query Meshy API for the latest status.
|
| 199 |
meshy_api_key = os.getenv("MESHY_API_KEY")
|
|
|
|
| 206 |
# Special handling for text-to-3d with texture (two-step process)
|
| 207 |
if generation_type == "text_to_3d" and should_texture:
|
| 208 |
return await _handle_text_to_3d_with_texture(
|
| 209 |
+
model_id_int, generated_model, prompts_config,
|
| 210 |
meshy_task_id, meshy_api_key, client, headers
|
| 211 |
)
|
| 212 |
|
|
|
|
| 249 |
|
| 250 |
# Return the progress information along with update status
|
| 251 |
return {
|
| 252 |
+
"task_id": model_id_int,
|
| 253 |
"status": meshy_response.get("status"),
|
| 254 |
"progress": meshy_response.get("progress"),
|
| 255 |
"model_urls": meshy_response.get("model_urls"),
|
|
|
|
| 271 |
|
| 272 |
|
| 273 |
async def _handle_text_to_3d_with_texture(
|
| 274 |
+
generated_model_id: int,
|
| 275 |
generated_model: Dict[str, Any],
|
| 276 |
prompts_config: Dict[str, Any],
|
| 277 |
preview_task_id: str,
|
|
|
|
| 603 |
file_format = file_name.split(".")[-1].lower()
|
| 604 |
file_size = len(mesh_data)
|
| 605 |
|
| 606 |
+
# Generate thumbnail from the mesh data
|
| 607 |
+
thumbnail_data = None
|
| 608 |
+
try:
|
| 609 |
+
thumbnail_data = generate_thumbnail_from_bytes(mesh_data, size=512)
|
| 610 |
+
logging.info(f"Successfully generated thumbnail for model {generated_model_id}")
|
| 611 |
+
except Exception as ex:
|
| 612 |
+
logging.error(f"Failed to generate thumbnail for model {generated_model_id}: {ex}")
|
| 613 |
+
|
| 614 |
# Update DB record with Hunyuan response
|
| 615 |
supabase.from_("Generated_Models").update({
|
| 616 |
"status": "COMPLETED",
|
|
|
|
| 618 |
"prompts_and_models_config": hunyuan_response,
|
| 619 |
}).eq("generated_model_id", generated_model_id).execute()
|
| 620 |
|
| 621 |
+
# Convert binary data to Postgres bytea hex format ("\\x" prefix) for safe insertion
|
| 622 |
+
encoded_hex_data = "\\x" + mesh_data.hex()
|
| 623 |
+
|
| 624 |
# Insert the mesh file into Model_Files table
|
| 625 |
supabase.from_("Model_Files").insert({
|
| 626 |
"user_id": user_id,
|
| 627 |
"generated_model_id": generated_model_id,
|
| 628 |
+
"model_data": encoded_hex_data, # stored as hex string compatible with bytea
|
| 629 |
"file_name": file_name,
|
| 630 |
"file_format": file_format,
|
| 631 |
"file_size": file_size,
|
|
|
|
| 633 |
"is_preview_file": False,
|
| 634 |
}).execute()
|
| 635 |
|
| 636 |
+
# Insert the thumbnail if generation was successful
|
| 637 |
+
if thumbnail_data:
|
| 638 |
+
try:
|
| 639 |
+
# Convert thumbnail bytes to hex format for Postgres bytea
|
| 640 |
+
thumbnail_hex_data = "\\x" + thumbnail_data.hex()
|
| 641 |
+
thumbnail_file_name = f"thumbnail_{generated_model_id}.png"
|
| 642 |
+
|
| 643 |
+
supabase.from_("Model_Files").insert({
|
| 644 |
+
"user_id": user_id,
|
| 645 |
+
"generated_model_id": generated_model_id,
|
| 646 |
+
"model_data": thumbnail_hex_data,
|
| 647 |
+
"file_name": thumbnail_file_name,
|
| 648 |
+
"file_format": "png",
|
| 649 |
+
"file_size": len(thumbnail_data),
|
| 650 |
+
"metadata": "Generated thumbnail image for 3D model preview",
|
| 651 |
+
"is_preview_file": True, # Flag to indicate this is a thumbnail/preview
|
| 652 |
+
}).execute()
|
| 653 |
+
|
| 654 |
+
logging.info(f"Successfully stored thumbnail for model {generated_model_id}")
|
| 655 |
+
except Exception as ex:
|
| 656 |
+
logging.error(f"Failed to store thumbnail for model {generated_model_id}: {ex}")
|
| 657 |
+
|
| 658 |
logging.info(f"Successfully completed Hunyuan image-to-3d generation for model {generated_model_id}")
|
| 659 |
|
| 660 |
except Exception as ex:
|
|
|
|
| 787 |
@router.post("/image-to-3d")
|
| 788 |
async def image_to_3d(
|
| 789 |
background_tasks: BackgroundTasks,
|
| 790 |
+
image: UploadFile = File(None),
|
| 791 |
+
image_url: Optional[str] = Form(None),
|
| 792 |
current_user: User = Depends(get_current_active_user),
|
| 793 |
):
|
| 794 |
"""
|
| 795 |
+
Create a Hunyuan3D Image-to-3D generation job.
|
| 796 |
|
| 797 |
+
The client can either:
|
| 798 |
+
1. Upload an image file (multipart/form-data) via the "image" field
|
| 799 |
+
2. Provide an already publicly accessible URL via the "image_url" form field
|
| 800 |
+
|
| 801 |
+
If a file is uploaded we first store it in Supabase Storage and use the
|
| 802 |
+
resulting public URL when triggering the Hunyuan job.
|
| 803 |
"""
|
| 804 |
+
|
| 805 |
+
# Validate input – at least one source must be provided
|
| 806 |
+
if image is None and not image_url:
|
| 807 |
+
raise HTTPException(status_code=400, detail="Either an image file or image_url must be provided")
|
| 808 |
+
|
| 809 |
+
# If we received an image file, upload it to Supabase Storage to obtain a public URL
|
| 810 |
+
if image is not None:
|
| 811 |
+
content = await image.read()
|
| 812 |
+
if not content:
|
| 813 |
+
raise HTTPException(status_code=400, detail="Uploaded image is empty")
|
| 814 |
+
|
| 815 |
+
file_ext = os.path.splitext(image.filename)[1] or ".jpg"
|
| 816 |
+
unique_name = f"{uuid4().hex}{file_ext}"
|
| 817 |
+
# Determine bucket name (hard-coded to avoid missing env vars)
|
| 818 |
+
bucket_name = "hunyuan-inputs" # storage bucket for Hunyuan inputs
|
| 819 |
+
|
| 820 |
+
try:
|
| 821 |
+
# Upload bytes to Supabase Storage
|
| 822 |
+
upload_resp = supabase.storage.from_(bucket_name).upload(
|
| 823 |
+
unique_name,
|
| 824 |
+
content,
|
| 825 |
+
{"content-type": image.content_type or "application/octet-stream"},
|
| 826 |
+
)
|
| 827 |
+
|
| 828 |
+
# Handle both supabase-py <2.0 (dict response) and >=2.0 (UploadResponse object)
|
| 829 |
+
upload_error = None
|
| 830 |
+
if isinstance(upload_resp, dict):
|
| 831 |
+
upload_error = upload_resp.get("error")
|
| 832 |
+
elif hasattr(upload_resp, "error"):
|
| 833 |
+
upload_error = upload_resp.error
|
| 834 |
+
|
| 835 |
+
if upload_error:
|
| 836 |
+
# Ensure we always raise a string for logging / HTTPException
|
| 837 |
+
raise RuntimeError(str(upload_error))
|
| 838 |
+
|
| 839 |
+
public_url_resp = supabase.storage.from_(bucket_name).get_public_url(unique_name)
|
| 840 |
+
|
| 841 |
+
# Similar compatibility handling for get_public_url()
|
| 842 |
+
if isinstance(public_url_resp, str):
|
| 843 |
+
image_url = public_url_resp
|
| 844 |
+
elif isinstance(public_url_resp, dict):
|
| 845 |
+
image_url = public_url_resp.get("publicURL") or public_url_resp.get("publicUrl")
|
| 846 |
+
elif hasattr(public_url_resp, "data") and isinstance(public_url_resp.data, dict):
|
| 847 |
+
image_url = public_url_resp.data.get("publicURL") or public_url_resp.data.get("publicUrl")
|
| 848 |
+
else:
|
| 849 |
+
image_url = None
|
| 850 |
+
if not image_url:
|
| 851 |
+
raise RuntimeError("Failed to retrieve public URL for uploaded image")
|
| 852 |
+
except Exception as ex:
|
| 853 |
+
logging.error(f"Failed to upload image to Supabase storage: {ex}")
|
| 854 |
+
raise HTTPException(status_code=500, detail="Failed to upload image to storage")
|
| 855 |
+
|
| 856 |
+
# At this point, image_url should be a publicly accessible URL
|
| 857 |
+
if not image_url:
|
| 858 |
+
raise HTTPException(status_code=400, detail="Could not determine image URL")
|
| 859 |
+
|
| 860 |
# Credit check and decrement - Hunyuan generation costs 2 credits
|
| 861 |
await _check_and_decrement_credits(current_user.id, 2)
|
| 862 |
|
| 863 |
+
source_name = image.filename if image is not None else (image_url.split('/')[-1] if '/' in image_url else 'image')
|
| 864 |
+
|
| 865 |
# Insert initial DB record and return immediately
|
| 866 |
try:
|
| 867 |
insert_res = supabase.from_("Generated_Models").insert({
|
| 868 |
"status": "IN_PROGRESS",
|
| 869 |
"user_id": current_user.id,
|
| 870 |
"meshy_api_job_id": None,
|
| 871 |
+
"model_name": f"Hunyuan 3D from {source_name}",
|
| 872 |
"prompts_and_models_config": {
|
| 873 |
"generation_type": "hunyuan_image_to_3d",
|
| 874 |
"input_image_url": image_url,
|
|
|
|
| 877 |
},
|
| 878 |
}).execute()
|
| 879 |
generated_model_id = insert_res.data[0]["generated_model_id"] if insert_res.data else None
|
| 880 |
+
|
| 881 |
if not generated_model_id:
|
| 882 |
raise HTTPException(status_code=500, detail="Failed to create model record")
|
| 883 |
+
|
| 884 |
# Add background task for Hunyuan processing
|
| 885 |
background_tasks.add_task(
|
| 886 |
_process_hunyuan_image_to_3d_background,
|
|
|
|
| 888 |
image_url,
|
| 889 |
current_user.id,
|
| 890 |
)
|
| 891 |
+
|
|
|
|
| 892 |
response_data = {
|
| 893 |
"generated_model_id": generated_model_id,
|
| 894 |
"status": "initializing",
|
| 895 |
"input_image_url": image_url,
|
| 896 |
+
"message": "Hunyuan 3D generation started. Use the progress_update endpoint to check status.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 897 |
}
|
| 898 |
|
| 899 |
+
logging.info(f"Returning response for hunyuan image-to-3d: {response_data}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 901 |
response = JSONResponse(content=response_data, status_code=200)
|
|
|
|
| 902 |
response.headers["Cache-Control"] = "no-cache"
|
| 903 |
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
except Exception as ex:
|
| 906 |
logging.error(f"Failed to create initial model DB record: {ex}")
|
| 907 |
raise HTTPException(status_code=500, detail=f"Failed to start generation: {ex}")
|
| 908 |
|
| 909 |
@router.get("/{generated_model_id}/file")
|
| 910 |
async def get_model_file(
|
| 911 |
+
generated_model_id: str
|
|
|
|
| 912 |
):
|
| 913 |
"""
|
| 914 |
Get the model file info for a generated model belonging to the current user.
|
| 915 |
"""
|
| 916 |
try:
|
| 917 |
+
# Handle placeholder IDs and validate integer format
|
| 918 |
+
if generated_model_id.startswith("placeholder_"):
|
| 919 |
+
raise HTTPException(status_code=400, detail="Invalid model ID. Model may not be ready yet or generation is still initializing.")
|
| 920 |
+
|
| 921 |
+
try:
|
| 922 |
+
model_id_int = int(generated_model_id)
|
| 923 |
+
except ValueError:
|
| 924 |
+
raise HTTPException(status_code=400, detail="Invalid model ID format. Expected numeric ID.")
|
| 925 |
+
|
| 926 |
+
# First check if the model exists (removed user ownership check for public access)
|
| 927 |
+
model_check = supabase.from_("Generated_Models").select("generated_model_id, user_id, model_name").eq("generated_model_id", model_id_int).limit(1).execute() # .eq("user_id", current_user.id) - commented out for public access
|
| 928 |
|
| 929 |
if not model_check.data:
|
| 930 |
+
raise HTTPException(status_code=404, detail="Model not found.")
|
| 931 |
|
| 932 |
+
# Get the single model file for this generated model (removed ownership verification for public access)
|
| 933 |
+
file_result = supabase.from_("Model_Files").select("model_file_id, file_name, file_format, file_size, metadata, is_preview_file, created_at").eq("generated_model_id", model_id_int).limit(1).execute() # .eq("user_id", current_user.id) - commented out for public access
|
| 934 |
|
| 935 |
if not file_result.data:
|
| 936 |
raise HTTPException(status_code=404, detail="No model file found for this generated model.")
|
| 937 |
|
| 938 |
return {
|
| 939 |
"generated_model_id": generated_model_id,
|
| 940 |
+
"model_name": model_check.data[0].get("model_name"),
|
| 941 |
+
"file": file_result.data[0]
|
| 942 |
}
|
| 943 |
|
| 944 |
except HTTPException:
|
|
|
|
| 950 |
|
| 951 |
@router.get("/{generated_model_id}/download")
|
| 952 |
async def download_model_file(
|
| 953 |
+
generated_model_id: str
|
|
|
|
| 954 |
):
|
| 955 |
"""
|
| 956 |
Download the model file for a generated model.
|
| 957 |
"""
|
| 958 |
try:
|
| 959 |
+
# Handle placeholder IDs and validate integer format
|
| 960 |
+
if generated_model_id.startswith("placeholder_"):
|
| 961 |
+
raise HTTPException(status_code=400, detail="Invalid model ID. Model may not be ready yet or generation is still initializing.")
|
| 962 |
+
|
| 963 |
+
try:
|
| 964 |
+
model_id_int = int(generated_model_id)
|
| 965 |
+
except ValueError:
|
| 966 |
+
raise HTTPException(status_code=400, detail="Invalid model ID format. Expected numeric ID.")
|
| 967 |
+
|
| 968 |
+
# First check if the model exists (removed user ownership check for public access)
|
| 969 |
+
model_check = supabase.from_("Generated_Models").select("generated_model_id, user_id, model_name").eq("generated_model_id", model_id_int).limit(1).execute() # .eq("user_id", current_user.id) - commented out for public access
|
| 970 |
|
| 971 |
if not model_check.data:
|
| 972 |
+
raise HTTPException(status_code=404, detail="Model not found.")
|
| 973 |
|
| 974 |
+
# Get the model file (removed ownership verification for public access)
|
| 975 |
+
file_result = supabase.from_("Model_Files").select("*").eq("generated_model_id", model_id_int).limit(1).execute() # .eq("user_id", current_user.id) - commented out for public access
|
| 976 |
|
| 977 |
if not file_result.data:
|
| 978 |
raise HTTPException(status_code=404, detail="No model file found for this generated model.")
|
| 979 |
|
| 980 |
+
file_data = file_result.data[0]
|
| 981 |
+
|
| 982 |
+
# Supabase stores bytea as base64-encoded strings; decode before sending.
|
| 983 |
+
raw_data = file_data.get("model_data")
|
| 984 |
+
if isinstance(raw_data, str):
|
| 985 |
+
try:
|
| 986 |
+
# Attempt base64 decode
|
| 987 |
+
raw_data = base64.b64decode(raw_data)
|
| 988 |
+
except Exception:
|
| 989 |
+
# Fallback for hex format ("\\x" prefix)
|
| 990 |
+
if raw_data.startswith("\\x"):
|
| 991 |
+
raw_data = bytes.fromhex(raw_data[2:])
|
| 992 |
+
|
| 993 |
+
if raw_data is None:
|
| 994 |
+
raise HTTPException(status_code=500, detail="Failed to decode model file data.")
|
| 995 |
+
|
| 996 |
return Response(
|
| 997 |
+
content=raw_data,
|
| 998 |
media_type="application/octet-stream",
|
| 999 |
headers={
|
| 1000 |
+
"Content-Disposition": f"attachment; filename={file_data['file_name']}"
|
|
|
|
| 1001 |
}
|
| 1002 |
)
|
| 1003 |
|
|
|
|
| 1008 |
logging.error(f"Failed to download model file for {generated_model_id}: {str(e)}")
|
| 1009 |
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 1010 |
|
| 1011 |
+
@router.get("/{generated_model_id}/view")
|
| 1012 |
+
async def view_model_file(
|
| 1013 |
+
generated_model_id: str
|
| 1014 |
+
):
|
| 1015 |
+
"""
|
| 1016 |
+
Serve the 3D model file inline for frontend 3D viewers.
|
| 1017 |
+
This endpoint serves the file with appropriate headers for direct consumption by 3D libraries.
|
| 1018 |
+
"""
|
| 1019 |
+
print("view_model_file")
|
| 1020 |
+
try:
|
| 1021 |
+
# Handle placeholder IDs and validate integer format
|
| 1022 |
+
if generated_model_id.startswith("placeholder_"):
|
| 1023 |
+
raise HTTPException(status_code=400, detail="Invalid model ID. Model may not be ready yet or generation is still initializing.")
|
| 1024 |
+
|
| 1025 |
+
try:
|
| 1026 |
+
model_id_int = int(generated_model_id)
|
| 1027 |
+
except ValueError:
|
| 1028 |
+
raise HTTPException(status_code=400, detail="Invalid model ID format. Expected numeric ID.")
|
| 1029 |
+
|
| 1030 |
+
# First check if the model exists (removed user ownership check for public access)
|
| 1031 |
+
model_check = supabase.from_("Generated_Models").select("generated_model_id, user_id, model_name").eq("generated_model_id", model_id_int).limit(1).execute() # .eq("user_id", current_user.id) - commented out for public access
|
| 1032 |
+
|
| 1033 |
+
if not model_check.data:
|
| 1034 |
+
raise HTTPException(status_code=404, detail="Model not found.")
|
| 1035 |
+
|
| 1036 |
+
# Get the model file (removed ownership verification for public access)
|
| 1037 |
+
file_result = supabase.from_("Model_Files").select("*").eq("generated_model_id", model_id_int).limit(1).execute() # .eq("user_id", current_user.id) - commented out for public access
|
| 1038 |
+
|
| 1039 |
+
if not file_result.data:
|
| 1040 |
+
raise HTTPException(status_code=404, detail="No model file found for this generated model.")
|
| 1041 |
+
|
| 1042 |
+
file_data = file_result.data[0]
|
| 1043 |
+
|
| 1044 |
+
# Supabase stores bytea as base64-encoded strings; decode before sending.
|
| 1045 |
+
raw_data = file_data.get("model_data")
|
| 1046 |
+
if isinstance(raw_data, str):
|
| 1047 |
+
try:
|
| 1048 |
+
# Attempt base64 decode
|
| 1049 |
+
raw_data = base64.b64decode(raw_data)
|
| 1050 |
+
except Exception:
|
| 1051 |
+
# Fallback for hex format ("\\x" prefix)
|
| 1052 |
+
if raw_data.startswith("\\x"):
|
| 1053 |
+
raw_data = bytes.fromhex(raw_data[2:])
|
| 1054 |
+
|
| 1055 |
+
if raw_data is None:
|
| 1056 |
+
raise HTTPException(status_code=500, detail="Failed to decode model file data.")
|
| 1057 |
+
|
| 1058 |
+
# Determine appropriate MIME type based on file format
|
| 1059 |
+
file_format = file_data.get("file_format", "").lower()
|
| 1060 |
+
content_type = "application/octet-stream" # Default fallback
|
| 1061 |
+
|
| 1062 |
+
if file_format == "glb":
|
| 1063 |
+
content_type = "model/gltf-binary"
|
| 1064 |
+
elif file_format == "gltf":
|
| 1065 |
+
content_type = "model/gltf+json"
|
| 1066 |
+
elif file_format == "obj":
|
| 1067 |
+
content_type = "text/plain" # OBJ files are text-based
|
| 1068 |
+
elif file_format == "stl":
|
| 1069 |
+
content_type = "model/stl"
|
| 1070 |
+
elif file_format == "fbx":
|
| 1071 |
+
content_type = "application/octet-stream"
|
| 1072 |
+
|
| 1073 |
+
return Response(
|
| 1074 |
+
content=raw_data,
|
| 1075 |
+
media_type=content_type,
|
| 1076 |
+
headers={
|
| 1077 |
+
"Access-Control-Allow-Origin": "*",
|
| 1078 |
+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
| 1079 |
+
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
| 1080 |
+
"Cache-Control": "public, max-age=3600" # Cache for 1 hour
|
| 1081 |
+
}
|
| 1082 |
+
)
|
| 1083 |
+
|
| 1084 |
+
except HTTPException:
|
| 1085 |
+
# Re-raise HTTP exceptions as-is
|
| 1086 |
+
raise
|
| 1087 |
+
except Exception as e:
|
| 1088 |
+
logging.error(f"Failed to serve model file for {generated_model_id}: {str(e)}")
|
| 1089 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 1090 |
+
|
| 1091 |
+
# Allow URLs like /user/models/{id}/view.glb or .gltf etc.
|
| 1092 |
+
@router.get("/{generated_model_id}/view.{file_ext}")
|
| 1093 |
+
async def view_model_file_with_ext(generated_model_id: str, file_ext: str):
|
| 1094 |
+
"""Proxy to view_model_file to serve model regardless of extension in URL."""
|
| 1095 |
+
return await view_model_file(generated_model_id)
|
| 1096 |
+
|
| 1097 |
+
@router.get("/{generated_model_id}/thumbnail")
|
| 1098 |
+
async def get_model_thumbnail(generated_model_id: str):
|
| 1099 |
+
"""
|
| 1100 |
+
Serve the thumbnail image for a generated model.
|
| 1101 |
+
Returns a PNG image that can be displayed in the frontend for model previews.
|
| 1102 |
+
"""
|
| 1103 |
+
try:
|
| 1104 |
+
# Handle placeholder IDs and validate integer format
|
| 1105 |
+
if generated_model_id.startswith("placeholder_"):
|
| 1106 |
+
raise HTTPException(status_code=400, detail="Invalid model ID. Model may not be ready yet or generation is still initializing.")
|
| 1107 |
+
|
| 1108 |
+
try:
|
| 1109 |
+
model_id_int = int(generated_model_id)
|
| 1110 |
+
except ValueError:
|
| 1111 |
+
raise HTTPException(status_code=400, detail="Invalid model ID format. Expected numeric ID.")
|
| 1112 |
+
|
| 1113 |
+
# First check if the model exists (removed user ownership check for public access)
|
| 1114 |
+
model_check = supabase.from_("Generated_Models").select("generated_model_id, user_id, model_name").eq("generated_model_id", model_id_int).limit(1).execute()
|
| 1115 |
+
|
| 1116 |
+
if not model_check.data:
|
| 1117 |
+
raise HTTPException(status_code=404, detail="Model not found.")
|
| 1118 |
+
|
| 1119 |
+
# Get the thumbnail file (removed ownership verification for public access)
|
| 1120 |
+
thumbnail_result = supabase.from_("Model_Files").select("*").eq("generated_model_id", model_id_int).eq("is_preview_file", True).eq("file_format", "png").limit(1).execute()
|
| 1121 |
+
|
| 1122 |
+
if not thumbnail_result.data:
|
| 1123 |
+
raise HTTPException(status_code=404, detail="No thumbnail found for this model.")
|
| 1124 |
+
|
| 1125 |
+
thumbnail_data = thumbnail_result.data[0]
|
| 1126 |
+
|
| 1127 |
+
# Decode the thumbnail data
|
| 1128 |
+
raw_data = thumbnail_data.get("model_data")
|
| 1129 |
+
if isinstance(raw_data, str):
|
| 1130 |
+
try:
|
| 1131 |
+
# Attempt base64 decode
|
| 1132 |
+
raw_data = base64.b64decode(raw_data)
|
| 1133 |
+
except Exception:
|
| 1134 |
+
# Fallback for hex format ("\\x" prefix)
|
| 1135 |
+
if raw_data.startswith("\\x"):
|
| 1136 |
+
raw_data = bytes.fromhex(raw_data[2:])
|
| 1137 |
+
|
| 1138 |
+
if raw_data is None:
|
| 1139 |
+
raise HTTPException(status_code=500, detail="Failed to decode thumbnail data.")
|
| 1140 |
+
|
| 1141 |
+
return Response(
|
| 1142 |
+
content=raw_data,
|
| 1143 |
+
media_type="image/png",
|
| 1144 |
+
headers={
|
| 1145 |
+
"Access-Control-Allow-Origin": "*",
|
| 1146 |
+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
| 1147 |
+
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
| 1148 |
+
"Cache-Control": "public, max-age=3600" # Cache for 1 hour
|
| 1149 |
+
}
|
| 1150 |
+
)
|
| 1151 |
+
|
| 1152 |
+
except HTTPException:
|
| 1153 |
+
# Re-raise HTTP exceptions as-is
|
| 1154 |
+
raise
|
| 1155 |
+
except Exception as e:
|
| 1156 |
+
logging.error(f"Failed to serve thumbnail for {generated_model_id}: {str(e)}")
|
| 1157 |
+
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
| 1158 |
+
|
| 1159 |
+
@router.delete("/{generated_model_id}", dependencies=[Depends(get_current_active_user)])
|
| 1160 |
async def delete_model(
|
| 1161 |
generated_model_id: str,
|
| 1162 |
current_user: User = Depends(get_current_active_user)
|
| 1163 |
):
|
| 1164 |
"""
|
| 1165 |
+
Delete a generated model and its associated files for the current user.
|
| 1166 |
"""
|
| 1167 |
try:
|
| 1168 |
+
# Handle placeholder IDs and validate integer format
|
| 1169 |
+
if generated_model_id.startswith("placeholder_"):
|
| 1170 |
+
raise HTTPException(status_code=400, detail="Invalid model ID. Model may not be ready yet or generation is still initializing.")
|
| 1171 |
+
|
| 1172 |
+
try:
|
| 1173 |
+
model_id_int = int(generated_model_id)
|
| 1174 |
+
except ValueError:
|
| 1175 |
+
raise HTTPException(status_code=400, detail="Invalid model ID format. Expected numeric ID.")
|
| 1176 |
+
|
| 1177 |
# First check if the model exists and belongs to the user
|
| 1178 |
+
model_check = supabase.from_("Generated_Models").select("generated_model_id, user_id, model_name").eq("generated_model_id", model_id_int).eq("user_id", current_user.id).limit(1).execute()
|
| 1179 |
|
| 1180 |
if not model_check.data:
|
| 1181 |
raise HTTPException(status_code=404, detail="Model not found or you do not have permission to delete it.")
|
| 1182 |
|
| 1183 |
+
# Delete associated model files first
|
| 1184 |
+
files_delete_result = supabase.from_("Model_Files").delete().eq("generated_model_id", model_id_int).eq("user_id", current_user.id).execute()
|
| 1185 |
+
|
| 1186 |
+
# Log how many files were deleted
|
| 1187 |
+
files_deleted_count = len(files_delete_result.data) if files_delete_result.data else 0
|
| 1188 |
+
if files_deleted_count > 0:
|
| 1189 |
+
logging.info(f"Deleted {files_deleted_count} model file(s) for model {generated_model_id}")
|
| 1190 |
+
|
| 1191 |
+
# Delete the model record
|
| 1192 |
+
delete_result = supabase.from_("Generated_Models").delete().eq("generated_model_id", model_id_int).eq("user_id", current_user.id).execute()
|
| 1193 |
|
| 1194 |
# The delete operation should return the deleted record(s)
|
| 1195 |
if not delete_result.data:
|
|
|
|
| 1198 |
logging.info(f"Successfully deleted model {generated_model_id} for user {current_user.id}")
|
| 1199 |
|
| 1200 |
return {
|
| 1201 |
+
"message": "Model and associated files deleted successfully.",
|
| 1202 |
"deleted_model_id": generated_model_id,
|
| 1203 |
+
"model_name": model_check.data[0].get("model_name", "Unknown"),
|
| 1204 |
+
"files_deleted": files_deleted_count
|
| 1205 |
}
|
| 1206 |
|
| 1207 |
except HTTPException:
|
services/hunyuan_service.py
CHANGED
|
@@ -1,10 +1,24 @@
|
|
| 1 |
from auth import supabase
|
| 2 |
from fastapi import HTTPException
|
| 3 |
-
import requests
|
| 4 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
REPLICATE_API_TOKEN = os.getenv("REPLICATE_API_TOKEN")
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
def _credit_check_decrement(user_id: str, cost: int = 1) -> bool:
|
| 9 |
|
| 10 |
user_credit = (
|
|
@@ -30,31 +44,59 @@ def _credit_check_decrement(user_id: str, cost: int = 1) -> bool:
|
|
| 30 |
return True
|
| 31 |
|
| 32 |
def _hunyuan_image_to_3d(image_url: str) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if not REPLICATE_API_TOKEN:
|
| 34 |
raise HTTPException(status_code=500, detail="REPLICATE_API_TOKEN not configured")
|
| 35 |
-
|
| 36 |
-
payload
|
| 37 |
-
|
| 38 |
-
"
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
"remove_background": True,
|
| 44 |
-
}
|
| 45 |
}
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
"Authorization": f"Bearer {REPLICATE_API_TOKEN}",
|
| 51 |
-
"Content-Type": "application/json",
|
| 52 |
-
"Prefer": "wait"
|
| 53 |
-
},
|
| 54 |
-
json=payload,
|
| 55 |
-
)
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from auth import supabase
|
| 2 |
from fastapi import HTTPException
|
|
|
|
| 3 |
import os
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
# NOTE: We now use the official Replicate SDK instead of manual HTTP requests.
|
| 7 |
+
import replicate
|
| 8 |
|
| 9 |
REPLICATE_API_TOKEN = os.getenv("REPLICATE_API_TOKEN")
|
| 10 |
|
| 11 |
+
# Debug mode configuration: When `HUNYUAN_DEBUG_MODE` is set to a truthy value ("1", "true", "yes"),
|
| 12 |
+
# the `_hunyuan_image_to_3d` helper will bypass the Replicate API call and instead return a
|
| 13 |
+
# deterministic payload that contains a known mesh URL. This makes local development and CI
|
| 14 |
+
# easier because it avoids external network calls and removes the dependency on valid API
|
| 15 |
+
# credentials or available credits.
|
| 16 |
+
|
| 17 |
+
HUNYUAN_DEBUG_MODE = False
|
| 18 |
+
|
| 19 |
+
# Constant mesh URL used in debug mode (sourced from `test_hunyuan_service.py`).
|
| 20 |
+
DEBUG_MESH_URL = "https://replicate.delivery/xezq/JzB2nOPjM5pXEJk7NC7gcMU12bz3a6tKS4e3fVCpotbstFfpA/gray_mesh.glb"
|
| 21 |
+
|
| 22 |
def _credit_check_decrement(user_id: str, cost: int = 1) -> bool:
|
| 23 |
|
| 24 |
user_credit = (
|
|
|
|
| 44 |
return True
|
| 45 |
|
| 46 |
def _hunyuan_image_to_3d(image_url: str) -> dict:
|
| 47 |
+
# If debug mode is enabled, return a stub response immediately.
|
| 48 |
+
print(f"HUNYUAN_DEBUG_MODE: {HUNYUAN_DEBUG_MODE}")
|
| 49 |
+
if HUNYUAN_DEBUG_MODE:
|
| 50 |
+
time.sleep(10)
|
| 51 |
+
print("Returning debug mesh")
|
| 52 |
+
return {
|
| 53 |
+
"mesh": DEBUG_MESH_URL,
|
| 54 |
+
"status": "succeeded",
|
| 55 |
+
"is_debug": True,
|
| 56 |
+
"input": {"image": image_url},
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
if not REPLICATE_API_TOKEN:
|
| 60 |
raise HTTPException(status_code=500, detail="REPLICATE_API_TOKEN not configured")
|
| 61 |
+
|
| 62 |
+
# Build the input payload expected by the Hunyuan3D model
|
| 63 |
+
model_input = {
|
| 64 |
+
"image": image_url,
|
| 65 |
+
"steps": 50,
|
| 66 |
+
"guidance_scale": 5.5,
|
| 67 |
+
"octree_resolution": 256,
|
| 68 |
+
"remove_background": True,
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
+
try:
|
| 72 |
+
# Instantiate a dedicated Replicate client to avoid relying solely on env var side-effects
|
| 73 |
+
client = replicate.Client(api_token=REPLICATE_API_TOKEN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
# Run the inference synchronously (Replicate waits by default here)
|
| 76 |
+
raw_output = client.run(
|
| 77 |
+
"tencent/hunyuan3d-2:b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb",
|
| 78 |
+
input=model_input,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# The output is a dict containing a `mesh` FileOutput object. Convert it to a plain URL string.
|
| 82 |
+
if isinstance(raw_output, dict) and "mesh" in raw_output:
|
| 83 |
+
mesh_url = str(raw_output["mesh"])
|
| 84 |
+
sanitized_output = {**raw_output, "mesh": mesh_url}
|
| 85 |
+
else:
|
| 86 |
+
mesh_url = None
|
| 87 |
+
sanitized_output = raw_output
|
| 88 |
+
|
| 89 |
+
if not mesh_url:
|
| 90 |
+
raise ValueError("No 'mesh' field found in Replicate output")
|
| 91 |
+
|
| 92 |
+
# Return a response compatible with the existing background handler
|
| 93 |
+
return {
|
| 94 |
+
"mesh": mesh_url,
|
| 95 |
+
"status": "succeeded",
|
| 96 |
+
"input": model_input,
|
| 97 |
+
"output": sanitized_output, # mesh field now guaranteed to be str
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
except Exception as err:
|
| 101 |
+
# Wrap any error from the Replicate SDK into an HTTPException so the caller gets proper feedback
|
| 102 |
+
raise HTTPException(status_code=500, detail=f"Replicate SDK error: {err}")
|
test_hunyuan_service.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import replicate
|
| 2 |
+
|
| 3 |
+
input = {
|
| 4 |
+
"image": "https://blhjlpokxsdalllewjbx.supabase.co/storage/v1/object/public/hunyuan-inputs//bda24d1443ca45ff886fb582908acfc9.jpg"
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
# Run the model
|
| 8 |
+
output = replicate.run(
|
| 9 |
+
"tencent/hunyuan3d-2:b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb",
|
| 10 |
+
input=input
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
# The model returns a dict; grab the mesh file object
|
| 14 |
+
mesh_file = output["mesh"] # type: replicate.FileOutput
|
| 15 |
+
|
| 16 |
+
# 1) Option A – just get the URL
|
| 17 |
+
print("Mesh URL:", str(mesh_file))
|
| 18 |
+
|
| 19 |
+
# 2) Option B – download it
|
| 20 |
+
local_path = mesh_file.download("mesh.obj") # saves to ./mesh.obj (returns the path)
|
| 21 |
+
print("Saved locally to:", local_path)
|