everydaycats commited on
Commit
ec7e3da
·
verified ·
1 Parent(s): fe1a826

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +327 -189
main.py CHANGED
@@ -4,10 +4,12 @@ import uuid
4
  import shutil
5
  import logging
6
  import requests
7
- from typing import Optional
 
 
8
 
9
- from fastapi import FastAPI, UploadFile, File, HTTPException, Query, Form, BackgroundTasks
10
- from fastapi.responses import FileResponse, HTMLResponse
11
  from huggingface_hub import login
12
  from app.utils import run_inference
13
 
@@ -17,7 +19,7 @@ if hf_token:
17
  try:
18
  login(token=hf_token)
19
  except Exception:
20
- # If login fails, continue (some deployments don't need it)
21
  pass
22
 
23
  TMP_DIR = os.environ.get("TMP_DIR", "/app/tmp")
@@ -27,41 +29,34 @@ os.makedirs(TMP_DIR, exist_ok=True)
27
  logging.basicConfig(level=logging.INFO)
28
  logger = logging.getLogger("stable-fast-3d-api")
29
 
30
- app = FastAPI(title="Stable Fast 3D API")
31
-
32
- # --- Utility helpers ---
33
- def _cleanup_paths(input_path: Optional[str], output_dir: Optional[str]) -> None:
34
- """Remove the input file and output directory after response has been sent."""
35
- try:
36
- if input_path and os.path.exists(input_path):
37
- try:
38
- os.remove(input_path)
39
- logger.info("Cleaned input file: %s", input_path)
40
- except Exception as e:
41
- logger.warning("Failed to remove input file %s: %s", input_path, e)
42
- except Exception as e:
43
- logger.exception("Unexpected error while cleaning input file: %s", e)
44
-
45
- try:
46
- if output_dir and os.path.exists(output_dir):
47
- try:
48
- shutil.rmtree(output_dir, ignore_errors=True)
49
- logger.info("Removed output directory: %s", output_dir)
50
- except Exception as e:
51
- logger.warning("Failed to remove output dir %s: %s", output_dir, e)
52
- except Exception as e:
53
- logger.exception("Unexpected error while cleaning output directory: %s", e)
54
-
55
-
56
  def _save_upload_file(upload_file: UploadFile, dest_path: str) -> None:
57
- """Save UploadFile to destination path (binary stream)."""
58
  with open(dest_path, "wb") as f:
59
  shutil.copyfileobj(upload_file.file, f)
60
  upload_file.file.close()
61
 
62
 
63
  def _download_to_file(url: str, dest_path: str, timeout: int = 30) -> None:
64
- """Download a URL to a file (streaming)."""
65
  resp = requests.get(url, stream=True, timeout=timeout)
66
  if resp.status_code != 200:
67
  raise HTTPException(status_code=400, detail=f"Failed to download image: status {resp.status_code}")
@@ -72,7 +67,76 @@ def _download_to_file(url: str, dest_path: str, timeout: int = 30) -> None:
72
  f.write(chunk)
73
 
74
 
75
- # --- Embedded UI root ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  @app.get("/", response_class=HTMLResponse)
77
  async def root_ui():
78
  html = """
@@ -80,7 +144,7 @@ async def root_ui():
80
  <html>
81
  <head>
82
  <meta charset="utf-8" />
83
- <title>Stable Fast 3D API — Demo</title>
84
  <meta name="viewport" content="width=device-width,initial-scale=1" />
85
  <script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
86
  <style>
@@ -98,8 +162,8 @@ async def root_ui():
98
  </head>
99
  <body>
100
  <div class="container">
101
- <h1>Stable Fast 3D API — Demo</h1>
102
- <p>Upload an image or paste an image URL to generate a 3D model (GLB).</p>
103
 
104
  <form id="generateForm">
105
  <label for="fileInput">Upload image file</label>
@@ -109,13 +173,19 @@ async def root_ui():
109
  <input id="urlInput" name="image_url" type="url" placeholder="https://example.com/image.png" />
110
 
111
  <div class="row">
112
- <button id="submitBtn" type="submit">Generate 3D Model</button>
113
  <button id="clearBtn" type="button">Clear</button>
114
  </div>
115
  </form>
116
 
117
  <div class="status" id="status">Status: idle</div>
118
- <a id="downloadLink" class="download-link" style="display:none" target="_blank">Download model (click if not auto-downloaded)</a>
 
 
 
 
 
 
119
 
120
  <model-viewer id="preview" camera-controls auto-rotate environment-image="neutral" style="display:none;"></model-viewer>
121
  </div>
@@ -125,93 +195,133 @@ async def root_ui():
125
  const fileInput = document.getElementById('fileInput');
126
  const urlInput = document.getElementById('urlInput');
127
  const status = document.getElementById('status');
128
- const downloadLink = document.getElementById('downloadLink');
 
 
 
 
129
  const preview = document.getElementById('preview');
130
  const submitBtn = document.getElementById('submitBtn');
131
  const clearBtn = document.getElementById('clearBtn');
132
 
 
 
 
133
  clearBtn.addEventListener('click', () => {
134
  fileInput.value = '';
135
  urlInput.value = '';
136
  status.textContent = 'Status: idle';
137
- downloadLink.style.display = 'none';
138
  preview.style.display = 'none';
139
- preview.src = '';
140
  });
141
 
142
  form.addEventListener('submit', async (e) => {
143
  e.preventDefault();
144
- status.textContent = 'Status: preparing request...';
145
  submitBtn.disabled = true;
146
- downloadLink.style.display = 'none';
147
- preview.style.display = 'none';
148
- preview.src = '';
149
-
150
  const hasFile = fileInput.files && fileInput.files.length > 0;
151
  const hasUrl = urlInput.value && urlInput.value.trim().length > 0;
152
-
153
  if (!hasFile && !hasUrl) {
154
  status.textContent = 'Status: Please upload a file or provide an image URL.';
155
  submitBtn.disabled = false;
156
  return;
157
  }
158
 
 
 
 
 
159
  try {
160
- const formData = new FormData();
161
- if (hasFile) {
162
- formData.append('image', fileInput.files[0]);
163
- } else {
164
- formData.append('image_url', urlInput.value.trim());
165
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
 
167
- status.textContent = 'Status: sending request... (this may take some time)';
168
- const resp = await fetch('/generate-3d/', {
169
- method: 'POST',
170
- body: formData,
171
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
 
 
 
173
  if (!resp.ok) {
174
- const text = await resp.text();
175
- throw new Error('Server error: ' + resp.status + ' - ' + text);
176
  }
177
-
178
- status.textContent = 'Status: received response, preparing download...';
179
-
180
  const blob = await resp.blob();
181
- const contentDisposition = resp.headers.get('content-disposition') || '';
182
- const suggestedName = (contentDisposition.match(/filename=([^;]+)/) || [])[1] || 'model.glb';
183
-
184
  const url = URL.createObjectURL(blob);
185
-
186
  const a = document.createElement('a');
187
  a.href = url;
188
- a.download = suggestedName.replace(/"/g, '');
189
  document.body.appendChild(a);
190
  a.click();
191
  a.remove();
192
-
193
- downloadLink.href = url;
194
- downloadLink.download = a.download;
195
- downloadLink.textContent = 'Download ' + a.download;
196
- downloadLink.style.display = 'inline-block';
197
-
198
- try {
199
- preview.src = url;
200
- preview.style.display = 'block';
201
- } catch (err) {
202
- console.warn('Preview failed', err);
203
- }
204
-
205
- status.textContent = 'Status: Done — model generated.';
206
  setTimeout(() => URL.revokeObjectURL(url), 5 * 60 * 1000);
 
 
 
 
 
207
 
 
 
 
 
 
 
 
 
 
 
 
208
  } catch (err) {
209
  console.error(err);
210
- status.textContent = 'Error: ' + (err.message || err);
211
- } finally {
212
- submitBtn.disabled = false;
213
  }
214
- });
215
  </script>
216
  </body>
217
  </html>
@@ -219,125 +329,153 @@ async def root_ui():
219
  return HTMLResponse(content=html, status_code=200)
220
 
221
 
222
- # --- API endpoints: POST and GET ---
223
- @app.post("/generate-3d/", response_class=FileResponse)
224
- async def generate_3d_model_post(
 
 
225
  image: Optional[UploadFile] = File(None),
226
  image_url: Optional[str] = Form(None),
227
- background_tasks: BackgroundTasks = None,
228
  ):
229
  """
230
- Accept either an uploaded image file (multipart/form-data) OR an image_url form field.
231
- The final GLB will be returned as a FileResponse; cleanup is scheduled after response.
232
  """
233
- return await _process_image_and_respond(image=image, image_url=image_url, background_tasks=background_tasks)
234
-
235
-
236
- @app.get("/generate-3d/", response_class=FileResponse)
237
- async def generate_3d_model_get(
238
- image_url: str = Query(..., description="URL of the image to convert to 3D"),
239
- background_tasks: BackgroundTasks = None,
240
- ):
241
- return await _process_image_and_respond(image=None, image_url=image_url, background_tasks=background_tasks)
242
-
243
-
244
- # --- Core processing function ---
245
- async def _process_image_and_respond(
246
- image: Optional[UploadFile] = None,
247
- image_url: Optional[str] = None,
248
- background_tasks: BackgroundTasks = None,
249
- ):
250
- """Handles request, writes input file, runs inference, returns FileResponse, schedules cleanup."""
251
  request_id = str(uuid.uuid4())
252
  input_path = os.path.join(TMP_DIR, f"{request_id}.png")
253
  output_dir = os.path.join(TMP_DIR, f"{request_id}_output")
254
  os.makedirs(output_dir, exist_ok=True)
255
 
256
- logger.info("New request %s: writing input & running inference", request_id)
257
-
258
  try:
259
- # Save input image
260
  if image is not None:
261
- logger.info("Saving uploaded file to %s", input_path)
262
  _save_upload_file(image, input_path)
263
  elif image_url:
264
- logger.info("Downloading image URL to %s", input_path)
265
- try:
266
- _download_to_file(image_url, input_path, timeout=30)
267
- except HTTPException:
268
- raise
269
- except Exception as e:
270
- logger.exception("Downloading image failed: %s", e)
271
- raise HTTPException(status_code=400, detail=f"Failed to download image: {e}")
272
  else:
273
- raise HTTPException(status_code=400, detail="Either image file or image_url must be provided")
274
-
275
- # Run your heavy inference / external process. It must return a glb path.
276
- logger.info("Running inference on image: %s", input_path)
277
- try:
278
- glb_path = run_inference(input_path, output_dir)
279
- except Exception as e:
280
- logger.exception("run_inference failed: %s", e)
281
- # debug listing
282
- listing = []
283
- try:
284
- for root, dirs, files in os.walk(output_dir):
285
- for fn in files:
286
- listing.append(os.path.join(root, fn))
287
- logger.info("Output directory listing after failure: %s", listing)
288
- except Exception as e2:
289
- logger.exception("Failed to list output dir: %s", e2)
290
- raise HTTPException(status_code=500, detail=f"run_inference failed: {e}")
291
-
292
- # glb_path could be None or incorrect; verify exists
293
- if not glb_path or not os.path.exists(glb_path):
294
- # If run_inference returns directory instead, try to locate a GLB inside
295
- found = None
296
- for root, dirs, files in os.walk(output_dir):
297
- for fn in files:
298
- if fn.lower().endswith(".glb"):
299
- found = os.path.join(root, fn)
300
- break
301
- if found:
302
- break
303
- if found:
304
- glb_path = found
305
- logger.info("Found GLB via fallback discovery: %s", glb_path)
306
- else:
307
- listing = []
308
- for root, dirs, files in os.walk(output_dir):
309
- for fn in files:
310
- listing.append(os.path.join(root, fn))
311
- logger.error("GLB not produced. output_dir listing: %s", listing)
312
- raise HTTPException(status_code=500, detail="Failed to generate 3D model (GLB missing). Check server logs.")
313
-
314
- # Schedule cleanup to run after response completes
315
- if background_tasks is not None:
316
- background_tasks.add_task(_cleanup_paths, input_path, output_dir)
317
- logger.info("Scheduled cleanup for request %s", request_id)
318
- else:
319
- logger.info("No BackgroundTasks provided; output will not be deleted automatically for request %s", request_id)
320
-
321
- # Return the GLB as FileResponse (Starlette will stat the file before sending)
322
- filename = os.path.basename(glb_path)
323
- logger.info("Returning GLB %s for request %s", glb_path, request_id)
324
- return FileResponse(
325
- path=glb_path,
326
- media_type="model/gltf-binary",
327
- filename=filename,
328
- )
329
-
330
  except HTTPException:
331
- # pass through HTTPException so FastAPI can send correct response
332
  raise
333
  except Exception as e:
334
- logger.exception("Unhandled exception during process: %s", e)
335
- # Attempt to cleanup synchronously on error (best-effort)
336
- try:
337
- if os.path.exists(input_path):
338
- os.remove(input_path)
339
- if os.path.exists(output_dir):
340
- shutil.rmtree(output_dir, ignore_errors=True)
341
- except Exception:
342
- pass
343
- raise HTTPException(status_code=500, detail=f"Server error while processing image: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import shutil
5
  import logging
6
  import requests
7
+ import asyncio
8
+ import time
9
+ from typing import Optional, Dict, Any
10
 
11
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Query, Form
12
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
13
  from huggingface_hub import login
14
  from app.utils import run_inference
15
 
 
19
  try:
20
  login(token=hf_token)
21
  except Exception:
22
+ # Non-fatal if login fails in some deployments
23
  pass
24
 
25
  TMP_DIR = os.environ.get("TMP_DIR", "/app/tmp")
 
29
  logging.basicConfig(level=logging.INFO)
30
  logger = logging.getLogger("stable-fast-3d-api")
31
 
32
+ app = FastAPI(title="Stable Fast 3D API (Background Jobs)")
33
+
34
+ # In-memory job registry
35
+ # Structure:
36
+ # JOBS[request_id] = {
37
+ # "status": "pending" | "running" | "done" | "error",
38
+ # "input_path": "...",
39
+ # "output_dir": "...",
40
+ # "glb_path": Optional[str],
41
+ # "error": Optional[str],
42
+ # "created_at": float,
43
+ # "started_at": Optional[float],
44
+ # "finished_at": Optional[float],
45
+ # }
46
+ JOBS: Dict[str, Dict[str, Any]] = {}
47
+ JOBS_LOCK = asyncio.Lock()
48
+
49
+
50
+ # -------------------------
51
+ # Utility helpers
52
+ # -------------------------
 
 
 
 
 
53
  def _save_upload_file(upload_file: UploadFile, dest_path: str) -> None:
 
54
  with open(dest_path, "wb") as f:
55
  shutil.copyfileobj(upload_file.file, f)
56
  upload_file.file.close()
57
 
58
 
59
  def _download_to_file(url: str, dest_path: str, timeout: int = 30) -> None:
 
60
  resp = requests.get(url, stream=True, timeout=timeout)
61
  if resp.status_code != 200:
62
  raise HTTPException(status_code=400, detail=f"Failed to download image: status {resp.status_code}")
 
67
  f.write(chunk)
68
 
69
 
70
+ def _find_glb_in_dir(output_dir: str) -> Optional[str]:
71
+ for root, _, files in os.walk(output_dir):
72
+ for fn in files:
73
+ if fn.lower().endswith(".glb"):
74
+ return os.path.join(root, fn)
75
+ return None
76
+
77
+
78
+ async def _set_job_field(job_id: str, key: str, value):
79
+ async with JOBS_LOCK:
80
+ if job_id in JOBS:
81
+ JOBS[job_id][key] = value
82
+
83
+
84
+ async def _get_job(job_id: str):
85
+ async with JOBS_LOCK:
86
+ return JOBS.get(job_id)
87
+
88
+
89
+ # -------------------------
90
+ # Background worker
91
+ # -------------------------
92
+ async def _background_run_inference(job_id: str):
93
+ """Runs run_inference in a thread to avoid blocking the event loop."""
94
+ job = await _get_job(job_id)
95
+ if not job:
96
+ logger.error("Job not found when starting background task: %s", job_id)
97
+ return
98
+
99
+ input_path = job["input_path"]
100
+ output_dir = job["output_dir"]
101
+
102
+ logger.info("[%s] Background job starting. input=%s output=%s", job_id, input_path, output_dir)
103
+ await _set_job_field(job_id, "status", "running")
104
+ await _set_job_field(job_id, "started_at", time.time())
105
+
106
+ try:
107
+ # run_inference is synchronous / heavy — move to thread
108
+ glb_path = await asyncio.to_thread(run_inference, input_path, output_dir)
109
+
110
+ # If run_inference returned None or not a path, try to discover a .glb
111
+ if not glb_path or not os.path.exists(glb_path):
112
+ found = _find_glb_in_dir(output_dir)
113
+ if found:
114
+ glb_path = found
115
+
116
+ if not glb_path or not os.path.exists(glb_path):
117
+ # List files for debugging
118
+ listing = []
119
+ for root, _, files in os.walk(output_dir):
120
+ for fn in files:
121
+ listing.append(os.path.join(root, fn))
122
+ raise RuntimeError(f"GLB not produced. output_dir listing: {listing}")
123
+
124
+ # Mark success
125
+ await _set_job_field(job_id, "glb_path", glb_path)
126
+ await _set_job_field(job_id, "status", "done")
127
+ await _set_job_field(job_id, "finished_at", time.time())
128
+ logger.info("[%s] Background job finished successfully. glb=%s", job_id, glb_path)
129
+
130
+ except Exception as e:
131
+ logger.exception("[%s] Background inference failed: %s", job_id, e)
132
+ await _set_job_field(job_id, "status", "error")
133
+ await _set_job_field(job_id, "error", str(e))
134
+ await _set_job_field(job_id, "finished_at", time.time())
135
+
136
+
137
+ # -------------------------
138
+ # Embedded UI root (polling-based)
139
+ # -------------------------
140
  @app.get("/", response_class=HTMLResponse)
141
  async def root_ui():
142
  html = """
 
144
  <html>
145
  <head>
146
  <meta charset="utf-8" />
147
+ <title>Stable Fast 3D API — Background Jobs</title>
148
  <meta name="viewport" content="width=device-width,initial-scale=1" />
149
  <script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
150
  <style>
 
162
  </head>
163
  <body>
164
  <div class="container">
165
+ <h1>Stable Fast 3D API — Background Jobs</h1>
166
+ <p>Upload an image or paste an image URL to generate a 3D model (GLB). The job runs server-side and continues even if you close this page.</p>
167
 
168
  <form id="generateForm">
169
  <label for="fileInput">Upload image file</label>
 
173
  <input id="urlInput" name="image_url" type="url" placeholder="https://example.com/image.png" />
174
 
175
  <div class="row">
176
+ <button id="submitBtn" type="submit">Start Job</button>
177
  <button id="clearBtn" type="button">Clear</button>
178
  </div>
179
  </form>
180
 
181
  <div class="status" id="status">Status: idle</div>
182
+
183
+ <div id="jobArea" style="display:none;">
184
+ <p>Job ID: <code id="jobId"></code></p>
185
+ <p id="jobStatus">Waiting...</p>
186
+ <button id="downloadBtn" style="display:none;">Download GLB</button>
187
+ <button id="deleteBtn" style="display:none;">Delete Job & Files</button>
188
+ </div>
189
 
190
  <model-viewer id="preview" camera-controls auto-rotate environment-image="neutral" style="display:none;"></model-viewer>
191
  </div>
 
195
  const fileInput = document.getElementById('fileInput');
196
  const urlInput = document.getElementById('urlInput');
197
  const status = document.getElementById('status');
198
+ const jobArea = document.getElementById('jobArea');
199
+ const jobIdEl = document.getElementById('jobId');
200
+ const jobStatusEl = document.getElementById('jobStatus');
201
+ const downloadBtn = document.getElementById('downloadBtn');
202
+ const deleteBtn = document.getElementById('deleteBtn');
203
  const preview = document.getElementById('preview');
204
  const submitBtn = document.getElementById('submitBtn');
205
  const clearBtn = document.getElementById('clearBtn');
206
 
207
+ let pollInterval = null;
208
+ let currentJobId = null;
209
+
210
  clearBtn.addEventListener('click', () => {
211
  fileInput.value = '';
212
  urlInput.value = '';
213
  status.textContent = 'Status: idle';
214
+ jobArea.style.display = 'none';
215
  preview.style.display = 'none';
 
216
  });
217
 
218
  form.addEventListener('submit', async (e) => {
219
  e.preventDefault();
 
220
  submitBtn.disabled = true;
221
+ status.textContent = 'Status: starting job...';
 
 
 
222
  const hasFile = fileInput.files && fileInput.files.length > 0;
223
  const hasUrl = urlInput.value && urlInput.value.trim().length > 0;
 
224
  if (!hasFile && !hasUrl) {
225
  status.textContent = 'Status: Please upload a file or provide an image URL.';
226
  submitBtn.disabled = false;
227
  return;
228
  }
229
 
230
+ const formData = new FormData();
231
+ if (hasFile) formData.append('image', fileInput.files[0]);
232
+ else formData.append('image_url', urlInput.value.trim());
233
+
234
  try {
235
+ const resp = await fetch('/generate-3d/', { method: 'POST', body: formData });
236
+ if (!resp.ok) {
237
+ const txt = await resp.text();
238
+ throw new Error('Server error: ' + resp.status + ' ' + txt);
 
239
  }
240
+ const data = await resp.json();
241
+ const id = data.id;
242
+ currentJobId = id;
243
+ jobIdEl.textContent = id;
244
+ jobArea.style.display = 'block';
245
+ status.textContent = 'Status: job started: ' + id;
246
+ pollStatus(id);
247
+ pollInterval = setInterval(() => pollStatus(id), 5000);
248
+ } catch (err) {
249
+ console.error(err);
250
+ status.textContent = 'Error starting job: ' + (err.message || err);
251
+ } finally {
252
+ submitBtn.disabled = false;
253
+ }
254
+ });
255
 
256
+ async function pollStatus(id) {
257
+ jobStatusEl.textContent = 'Checking...';
258
+ try {
259
+ const resp = await fetch(`/status/${id}`);
260
+ if (resp.status === 404) {
261
+ jobStatusEl.textContent = 'Job not found';
262
+ return;
263
+ }
264
+ const data = await resp.json();
265
+ jobStatusEl.textContent = 'Status: ' + data.status + (data.error ? ' — ' + data.error : '');
266
+ if (data.status === 'done') {
267
+ clearInterval(pollInterval);
268
+ downloadBtn.style.display = 'inline-block';
269
+ deleteBtn.style.display = 'inline-block';
270
+ jobStatusEl.textContent += ' — ready';
271
+ // enable preview + download
272
+ downloadBtn.onclick = () => downloadGLB(id);
273
+ deleteBtn.onclick = () => deleteJob(id);
274
+ } else if (data.status === 'error') {
275
+ clearInterval(pollInterval);
276
+ deleteBtn.style.display = 'inline-block';
277
+ }
278
+ } catch (err) {
279
+ console.error('poll error', err);
280
+ jobStatusEl.textContent = 'Status: poll error';
281
+ }
282
+ }
283
 
284
+ async function downloadGLB(id) {
285
+ try {
286
+ const resp = await fetch(`/download/${id}`);
287
  if (!resp.ok) {
288
+ const txt = await resp.text();
289
+ throw new Error('Download failed: ' + resp.status + ' ' + txt);
290
  }
 
 
 
291
  const blob = await resp.blob();
 
 
 
292
  const url = URL.createObjectURL(blob);
 
293
  const a = document.createElement('a');
294
  a.href = url;
295
+ a.download = 'model_' + id + '.glb';
296
  document.body.appendChild(a);
297
  a.click();
298
  a.remove();
299
+ // preview with model-viewer
300
+ preview.src = url;
301
+ preview.style.display = 'block';
 
 
 
 
 
 
 
 
 
 
 
302
  setTimeout(() => URL.revokeObjectURL(url), 5 * 60 * 1000);
303
+ } catch (err) {
304
+ console.error(err);
305
+ alert('Download failed: ' + err.message);
306
+ }
307
+ }
308
 
309
+ async function deleteJob(id) {
310
+ if (!confirm('Delete job and all stored files? This is irreversible.')) return;
311
+ try {
312
+ const resp = await fetch(`/delete/${id}`, { method: 'DELETE' });
313
+ if (!resp.ok) {
314
+ const txt = await resp.text();
315
+ throw new Error('Delete failed: ' + resp.status + ' ' + txt);
316
+ }
317
+ alert('Deleted job ' + id);
318
+ jobArea.style.display = 'none';
319
+ preview.style.display = 'none';
320
  } catch (err) {
321
  console.error(err);
322
+ alert('Delete failed: ' + err.message);
 
 
323
  }
324
+ }
325
  </script>
326
  </body>
327
  </html>
 
329
  return HTMLResponse(content=html, status_code=200)
330
 
331
 
332
+ # -------------------------
333
+ # API: Start job (non-blocking)
334
+ # -------------------------
335
+ @app.post("/generate-3d/")
336
+ async def generate_3d_start(
337
  image: Optional[UploadFile] = File(None),
338
  image_url: Optional[str] = Form(None),
 
339
  ):
340
  """
341
+ Start a background job to generate a 3D model.
342
+ Returns JSON: { "id": "<job_id>", "status_url": "/status/<id>", "download_url": "/download/<id>" }
343
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  request_id = str(uuid.uuid4())
345
  input_path = os.path.join(TMP_DIR, f"{request_id}.png")
346
  output_dir = os.path.join(TMP_DIR, f"{request_id}_output")
347
  os.makedirs(output_dir, exist_ok=True)
348
 
349
+ # Save input
 
350
  try:
 
351
  if image is not None:
 
352
  _save_upload_file(image, input_path)
353
  elif image_url:
354
+ _download_to_file(image_url, input_path, timeout=30)
 
 
 
 
 
 
 
355
  else:
356
+ raise HTTPException(status_code=400, detail="Either image or image_url must be provided")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  except HTTPException:
 
358
  raise
359
  except Exception as e:
360
+ logger.exception("Failed to save input for job %s: %s", request_id, e)
361
+ raise HTTPException(status_code=500, detail=f"Failed to save input: {e}")
362
+
363
+ # Register job (pending)
364
+ async with JOBS_LOCK:
365
+ JOBS[request_id] = {
366
+ "status": "pending",
367
+ "input_path": input_path,
368
+ "output_dir": output_dir,
369
+ "glb_path": None,
370
+ "error": None,
371
+ "created_at": time.time(),
372
+ "started_at": None,
373
+ "finished_at": None,
374
+ }
375
+
376
+ # Kick off background task (does not block the request)
377
+ asyncio.create_task(_background_run_inference(request_id))
378
+
379
+ logger.info("Started background job %s", request_id)
380
+ return JSONResponse({
381
+ "id": request_id,
382
+ "status_url": f"/status/{request_id}",
383
+ "download_url": f"/download/{request_id}",
384
+ })
385
+
386
+
387
+ # -------------------------
388
+ # API: Check status
389
+ # -------------------------
390
+ @app.get("/status/{job_id}")
391
+ async def job_status(job_id: str):
392
+ job = await _get_job(job_id)
393
+ if not job:
394
+ raise HTTPException(status_code=404, detail="Job not found")
395
+ # return the public fields
396
+ return JSONResponse({
397
+ "id": job_id,
398
+ "status": job["status"],
399
+ "glb_path": bool(job.get("glb_path")),
400
+ "error": job.get("error"),
401
+ "created_at": job.get("created_at"),
402
+ "started_at": job.get("started_at"),
403
+ "finished_at": job.get("finished_at"),
404
+ })
405
+
406
+
407
+ # -------------------------
408
+ # API: Download result (if ready)
409
+ # -------------------------
410
+ @app.get("/download/{job_id}")
411
+ async def download_result(job_id: str):
412
+ job = await _get_job(job_id)
413
+ if not job:
414
+ raise HTTPException(status_code=404, detail="Job not found")
415
+
416
+ if job["status"] != "done" or not job.get("glb_path"):
417
+ # Not ready
418
+ raise HTTPException(status_code=404, detail="Result not ready")
419
+
420
+ glb_path = job["glb_path"]
421
+ if not os.path.exists(glb_path):
422
+ raise HTTPException(status_code=404, detail="GLB file missing on disk")
423
+
424
+ # Return FileResponse without deleting it (user must call DELETE to remove)
425
+ return FileResponse(path=glb_path, media_type="model/gltf-binary", filename=os.path.basename(glb_path))
426
+
427
+
428
+ # -------------------------
429
+ # API: Delete job & files (manual)
430
+ # -------------------------
431
+ @app.delete("/delete/{job_id}")
432
+ async def delete_job(job_id: str):
433
+ job = await _get_job(job_id)
434
+ if not job:
435
+ raise HTTPException(status_code=404, detail="Job not found")
436
+
437
+ # Remove files
438
+ input_path = job.get("input_path")
439
+ output_dir = job.get("output_dir")
440
+ glb_path = job.get("glb_path")
441
+
442
+ errors = []
443
+ try:
444
+ if input_path and os.path.exists(input_path):
445
+ os.remove(input_path)
446
+ except Exception as e:
447
+ errors.append(f"input removal error: {e}")
448
+
449
+ try:
450
+ if output_dir and os.path.exists(output_dir):
451
+ shutil.rmtree(output_dir, ignore_errors=True)
452
+ except Exception as e:
453
+ errors.append(f"output dir removal error: {e}")
454
+
455
+ # Remove job entry
456
+ async with JOBS_LOCK:
457
+ JOBS.pop(job_id, None)
458
+
459
+ if errors:
460
+ logger.warning("Delete job %s completed with errors: %s", job_id, errors)
461
+ return JSONResponse({"deleted": True, "errors": errors})
462
+ return JSONResponse({"deleted": True})
463
+
464
+
465
+ # -------------------------
466
+ # API: List jobs (optional)
467
+ # -------------------------
468
+ @app.get("/jobs")
469
+ async def list_jobs():
470
+ async with JOBS_LOCK:
471
+ out = {
472
+ jid: {
473
+ "status": j["status"],
474
+ "created_at": j["created_at"],
475
+ "started_at": j["started_at"],
476
+ "finished_at": j["finished_at"],
477
+ "has_glb": bool(j.get("glb_path")),
478
+ }
479
+ for jid, j in JOBS.items()
480
+ }
481
+ return JSONResponse(out)