maxjski commited on
Commit
07c2969
·
1 Parent(s): 496a746

new model

Browse files
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, current_user: User = Depends(get_current_active_user)):
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
- # 1) Validate ownership & retrieve the record.
29
- db_resp = supabase.from_("Generated_Models").select("*").eq("generated_model_id", generated_model_id).eq("user_id", current_user.id).single().execute()
 
 
 
 
 
 
 
 
 
 
30
 
31
  if not db_resp.data:
32
- raise HTTPException(status_code=404, detail="Model not found or does not belong to user")
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": generated_model_id,
43
  "status": generated_model.get("status", "IN_PROGRESS"),
44
  "progress": 100 if generated_model.get("status") == "COMPLETED" else 50,
45
- "model_urls": None, # Hunyuan files are stored in Model_Files table
46
- "thumbnail_url": None,
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. Check Model_Files table for 3D mesh file." if generated_model.get("status") == "COMPLETED" else "Hunyuan generation in progress..."
55
  }
56
 
57
  meshy_task_id = generated_model.get("meshy_api_job_id")
58
  if not meshy_task_id:
59
- raise HTTPException(status_code=400, detail="Meshy task id missing for this model")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- generated_model_id, generated_model, prompts_config,
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": generated_model_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: str,
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": mesh_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
- image_url: str,
 
621
  current_user: User = Depends(get_current_active_user),
622
  ):
623
  """
624
- Create a Hunyuan3D Image-to-3D generation job using Replicate API.
625
 
626
- Returns immediately after creating the database record. Hunyuan API call
627
- and mesh file download happen in the background.
 
 
 
 
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 {image_url.split('/')[-1] if '/' in image_url else 'image'}",
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
- if not should_texture:
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
- # First check if the model exists and belongs to the user
854
- model_check = supabase.from_("Generated_Models").select("generated_model_id, user_id, model_name").eq("generated_model_id", generated_model_id).eq("user_id", current_user.id).single().execute()
 
 
 
 
 
 
 
 
 
855
 
856
  if not model_check.data:
857
- raise HTTPException(status_code=404, detail="Model not found or you do not have permission to access it.")
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", generated_model_id).eq("user_id", current_user.id).single().execute()
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
- # First check if the model exists and belongs to the user
888
- model_check = supabase.from_("Generated_Models").select("generated_model_id, user_id, model_name").eq("generated_model_id", generated_model_id).eq("user_id", current_user.id).single().execute()
 
 
 
 
 
 
 
 
 
889
 
890
  if not model_check.data:
891
- raise HTTPException(status_code=404, detail="Model not found or you do not have permission to access it.")
892
 
893
- # Get the model file and verify ownership
894
- file_result = supabase.from_("Model_Files").select("*").eq("generated_model_id", generated_model_id).eq("user_id", current_user.id).single().execute()
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
- from fastapi.responses import Response
902
-
 
 
 
 
 
 
 
 
 
 
 
 
903
  return Response(
904
- content=file_data["model_data"],
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.delete("/{generated_model_id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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", generated_model_id).eq("user_id", current_user.id).single().execute()
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 the model
935
- delete_result = supabase.from_("Generated_Models").delete().eq("generated_model_id", generated_model_id).eq("user_id", current_user.id).execute()
 
 
 
 
 
 
 
 
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
- "version": "b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb",
38
- "input": {
39
- "image": image_url,
40
- "steps": 50,
41
- "guidance_scale": 5.5,
42
- "octree_resolution": 256,
43
- "remove_background": True,
44
- }
45
  }
46
 
47
- response = requests.post(
48
- "https://api.replicate.com/v1/predictions",
49
- headers={
50
- "Authorization": f"Bearer {REPLICATE_API_TOKEN}",
51
- "Content-Type": "application/json",
52
- "Prefer": "wait"
53
- },
54
- json=payload,
55
- )
56
 
57
- if response.status_code != 200:
58
- raise HTTPException(status_code=response.status_code, detail=f"Replicate API error: {response.text}")
59
-
60
- return response.json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)