Reverb commited on
Commit
56ebf77
·
verified ·
1 Parent(s): 87d0a58

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +451 -0
app.py ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Open3DForge — Image-to-game-ready 3D asset pipeline
3
+ ====================================================
4
+
5
+ Milestone 1: Foundation
6
+ -----------------------
7
+ This is the scaffold. It validates that:
8
+ - The HF Space builds with the right Gradio + ZeroGPU configuration
9
+ - `@spaces.GPU` allocates and releases an H200 successfully
10
+ - `gr.Model3D` renders GLB files inline
11
+ - The 5-tab UI structure is sound
12
+ - Workspace folder management works
13
+ - Quota tracking persists across page reloads
14
+
15
+ Subsequent milestones fill in the actual pipeline stages.
16
+
17
+ Deployed exclusively on HF Spaces ZeroGPU — no local execution path.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import time
23
+ from pathlib import Path
24
+
25
+ import gradio as gr
26
+ import spaces # HF ZeroGPU
27
+
28
+ from src import quota, ui_helpers, workspace
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # ZeroGPU test function — proves that GPU allocation works.
32
+ # Replaced in Milestone 2 with the actual TRELLIS.2 generator.
33
+ # ---------------------------------------------------------------------------
34
+
35
+ @spaces.GPU(duration=15)
36
+ def zerogpu_smoke_test() -> str:
37
+ """Allocate a GPU, run a trivial torch op, release. Reports timing."""
38
+ start = time.time()
39
+ try:
40
+ import torch
41
+ if not torch.cuda.is_available():
42
+ return "❌ GPU not available inside @spaces.GPU. Something is wrong."
43
+
44
+ device = torch.device("cuda")
45
+ # Trivial GPU work
46
+ a = torch.randn(1024, 1024, device=device)
47
+ b = torch.randn(1024, 1024, device=device)
48
+ c = a @ b
49
+ torch.cuda.synchronize()
50
+ result_sum = float(c.sum().item())
51
+
52
+ gpu_name = torch.cuda.get_device_name(0)
53
+ vram_total = torch.cuda.get_device_properties(0).total_memory / 1e9
54
+ elapsed = time.time() - start
55
+
56
+ quota.record_usage("smoke_test", elapsed)
57
+
58
+ return (
59
+ f"✅ **GPU allocation successful**\n\n"
60
+ f"- Device: `{gpu_name}`\n"
61
+ f"- VRAM: `{vram_total:.1f} GB`\n"
62
+ f"- Test op (1024×1024 matmul): `{elapsed:.2f}s`\n"
63
+ f"- Result sum: `{result_sum:.2f}` (sanity check, non-zero = ✓)\n\n"
64
+ f"ZeroGPU integration is working. Ready for Milestone 2."
65
+ )
66
+ except Exception as e:
67
+ elapsed = time.time() - start
68
+ return f"❌ GPU test failed after {elapsed:.2f}s:\n```\n{type(e).__name__}: {e}\n```"
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Stage stubs — return placeholder messages until milestones implement them.
73
+ # ---------------------------------------------------------------------------
74
+
75
+ def _stub(stage: str) -> str:
76
+ return (
77
+ f"🚧 **{stage}** — not implemented yet.\n\n"
78
+ f"This stub will be replaced in a future milestone. "
79
+ f"See `PLAN.md` for the full pipeline spec."
80
+ )
81
+
82
+
83
+ def stub_generate(*_args, **_kwargs):
84
+ return _stub("Stage 1: Generation")
85
+
86
+
87
+ def stub_post_process(*_args, **_kwargs):
88
+ return _stub("Stage 2: Post-Processing")
89
+
90
+
91
+ def stub_auto_rig(*_args, **_kwargs):
92
+ return _stub("Stage 3: Auto-Rigging")
93
+
94
+
95
+ def stub_export(*_args, **_kwargs):
96
+ return _stub("Stage 4: Export"), None # message, file
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Presets tab logic (this one is real even in Milestone 1)
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def ui_refresh_presets() -> gr.Dropdown:
104
+ return gr.Dropdown(choices=workspace.list_presets(), label="Saved presets")
105
+
106
+
107
+ def ui_save_preset(name: str) -> tuple[str, gr.Dropdown]:
108
+ if not name or not name.strip():
109
+ return "❌ Preset name cannot be empty.", ui_refresh_presets()
110
+ # Placeholder config — Milestone 9 wires in real settings from each tab
111
+ config = {
112
+ "name": name,
113
+ "version": 1,
114
+ "created_at": time.time(),
115
+ "note": "Placeholder — real preset data wired in Milestone 9",
116
+ }
117
+ workspace.save_preset(name, config)
118
+ return f"✅ Saved preset: `{name}`", ui_refresh_presets()
119
+
120
+
121
+ def ui_delete_preset(name: str) -> tuple[str, gr.Dropdown]:
122
+ if not name:
123
+ return "❌ Select a preset to delete.", ui_refresh_presets()
124
+ if workspace.delete_preset(name):
125
+ return f"🗑️ Deleted preset: `{name}`", ui_refresh_presets()
126
+ return f"❌ Preset not found: `{name}`", ui_refresh_presets()
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Layout
131
+ # ---------------------------------------------------------------------------
132
+
133
+ CUSTOM_CSS = """
134
+ .status-bar {
135
+ font-size: 0.85em;
136
+ color: #888;
137
+ padding: 8px 12px;
138
+ border-top: 1px solid #333;
139
+ margin-top: 12px;
140
+ }
141
+ .asset-summary {
142
+ font-size: 0.9em;
143
+ background: rgba(255,255,255,0.03);
144
+ padding: 12px;
145
+ border-radius: 6px;
146
+ border: 1px solid rgba(255,255,255,0.08);
147
+ }
148
+ """
149
+
150
+
151
+ def build_ui() -> gr.Blocks:
152
+ with gr.Blocks(
153
+ title="Open3DForge",
154
+ ) as demo:
155
+
156
+ # --- Header --------------------------------------------------------
157
+ gr.Markdown(
158
+ "# 🛠️ Open3DForge\n"
159
+ "*Personal image-to-game-ready 3D asset pipeline · UE5-first · "
160
+ "Built on HF ZeroGPU*"
161
+ )
162
+
163
+ # --- Tabs ----------------------------------------------------------
164
+ with gr.Tabs() as tabs:
165
+
166
+ # ============ Tab 1: Generate =================================
167
+ with gr.Tab("1. Generate", id=1):
168
+ gr.Markdown(
169
+ "### Stage 1 — Image to 3D\n"
170
+ "Upload 1–4 reference images. Multi-view dramatically "
171
+ "improves quality for characters (front / 3-quarter / side / back)."
172
+ )
173
+ with gr.Row():
174
+ with gr.Column(scale=1):
175
+ gen_images = gr.File(
176
+ label="Reference images (1–4)",
177
+ file_count="multiple",
178
+ file_types=["image"],
179
+ )
180
+ gen_model = gr.Radio(
181
+ choices=[
182
+ "TRELLIS.2 (Hard Surface)",
183
+ "Hunyuan3D-2 (Organic / Characters)",
184
+ ],
185
+ value="TRELLIS.2 (Hard Surface)",
186
+ label="Generation model",
187
+ )
188
+ gen_quality = gr.Radio(
189
+ choices=["Fast (~30s)", "Balanced (~60s)", "Hero (~90s)"],
190
+ value="Balanced (~60s)",
191
+ label="Quality preset",
192
+ )
193
+ with gr.Accordion("Advanced", open=False):
194
+ gen_seed = gr.Number(value=42, label="Seed", precision=0)
195
+ gen_steps = gr.Slider(
196
+ 20, 50, value=35, step=5,
197
+ label="Inference steps",
198
+ )
199
+ gen_octree = gr.Dropdown(
200
+ choices=[128, 192, 256], value=192,
201
+ label="Octree resolution",
202
+ )
203
+ gen_tex_size = gr.Dropdown(
204
+ choices=[1024, 2048, 4096], value=2048,
205
+ label="Texture size",
206
+ )
207
+ gen_symmetry = gr.Radio(
208
+ choices=["off", "bilateral", "radial"],
209
+ value="off",
210
+ label="Symmetry hint",
211
+ )
212
+ gen_btn = gr.Button("Generate", variant="primary")
213
+ with gr.Column(scale=1):
214
+ gen_status = gr.Markdown("*Awaiting input.*")
215
+ gen_btn.click(
216
+ fn=stub_generate,
217
+ inputs=[gen_images, gen_model, gen_quality, gen_seed,
218
+ gen_steps, gen_octree, gen_tex_size, gen_symmetry],
219
+ outputs=gen_status,
220
+ )
221
+
222
+ # ============ Tab 2: Post-Process =============================
223
+ with gr.Tab("2. Post-Process", id=2):
224
+ gr.Markdown(
225
+ "### Stage 2 — Mesh cleanup, UV unwrap, texture baking\n"
226
+ "Toggle steps on/off. Decimation has a live preview, the "
227
+ "rest run on confirm."
228
+ )
229
+ with gr.Row():
230
+ with gr.Column(scale=1):
231
+ pp_repair = gr.Checkbox(value=True, label="Mesh repair (pymeshfix)")
232
+ pp_cleanup = gr.Checkbox(value=True, label="Geometry cleanup (PyMeshLab)")
233
+ pp_decimate = gr.Checkbox(value=True, label="Decimation")
234
+ pp_target_faces = gr.Slider(
235
+ 1000, 200000, value=25000, step=1000,
236
+ label="Target faces",
237
+ )
238
+ pp_symmetry = gr.Checkbox(value=False, label="Enforce bilateral symmetry")
239
+ pp_unwrap = gr.Checkbox(value=True, label="UV unwrap (xatlas)")
240
+ pp_normal_bake = gr.Checkbox(value=True, label="Normal bake (nvdiffrast)")
241
+ pp_normal_format = gr.Radio(
242
+ choices=["DirectX (UE5)", "OpenGL (Unity/Godot)"],
243
+ value="DirectX (UE5)",
244
+ label="Normal format",
245
+ )
246
+ pp_albedo_bake = gr.Checkbox(value=True, label="Albedo bake")
247
+ pp_material_bake = gr.Checkbox(value=True, label="Material bake (TRELLIS.2 attrs)")
248
+ pp_ao = gr.Checkbox(value=True, label="AO bake")
249
+ pp_ao_quality = gr.Radio(
250
+ choices=["Fast", "Standard", "High"],
251
+ value="Standard",
252
+ label="AO quality",
253
+ )
254
+ pp_inpaint = gr.Checkbox(value=False, label="SDXL inpaint hidden UVs (~30s GPU)")
255
+ pp_lods = gr.Checkbox(value=True, label="Generate LODs (LOD0/1/2)")
256
+ pp_collision = gr.Checkbox(value=True, label="Collision mesh (CoACD)")
257
+ pp_pivot = gr.Radio(
258
+ choices=["bottom_center", "geometric_center", "custom"],
259
+ value="bottom_center",
260
+ label="Pivot point",
261
+ )
262
+ pp_scale_m = gr.Number(value=1.8, label="Real-world height (meters)")
263
+ pp_btn = gr.Button("Run Post-Processing", variant="primary")
264
+ with gr.Column(scale=1):
265
+ pp_status = gr.Markdown("*No asset to process. Generate one first.*")
266
+ pp_btn.click(
267
+ fn=stub_post_process,
268
+ inputs=[pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
269
+ pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
270
+ pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
271
+ pp_inpaint, pp_lods, pp_collision, pp_pivot, pp_scale_m],
272
+ outputs=pp_status,
273
+ )
274
+
275
+ # ============ Tab 3: Auto-Rig =================================
276
+ with gr.Tab("3. Auto-Rig", id=3):
277
+ gr.Markdown(
278
+ "### Stage 3 — Auto-rigging (optional)\n"
279
+ "Uses UniRig (VAST-AI). For characters and creatures. "
280
+ "After rigging, drop the FBX into [Mixamo](https://mixamo.com) "
281
+ "for free animation presets."
282
+ )
283
+ with gr.Row():
284
+ with gr.Column(scale=1):
285
+ rig_type = gr.Dropdown(
286
+ choices=["Humanoid", "Quadruped", "Bird", "Insect", "Custom"],
287
+ value="Humanoid",
288
+ label="Character type",
289
+ )
290
+ rig_seed = gr.Number(value=0, label="Skeleton seed", precision=0)
291
+ rig_spring = gr.Checkbox(value=False, label="Spring bones (hair/cloth/tail)")
292
+ rig_format = gr.Radio(
293
+ choices=["FBX (UE5 recommended)", "GLB"],
294
+ value="FBX (UE5 recommended)",
295
+ label="Export format",
296
+ )
297
+ rig_btn = gr.Button("Auto-Rig", variant="primary")
298
+ with gr.Column(scale=1):
299
+ rig_status = gr.Markdown("*Process an asset in Stage 2 first.*")
300
+ rig_btn.click(
301
+ fn=stub_auto_rig,
302
+ inputs=[rig_type, rig_seed, rig_spring, rig_format],
303
+ outputs=rig_status,
304
+ )
305
+
306
+ # ============ Tab 4: Export ===================================
307
+ with gr.Tab("4. Export", id=4):
308
+ gr.Markdown(
309
+ "### Stage 4 — Engine-ready export\n"
310
+ "UE5 default: FBX with DirectX normals + ORM-packed textures."
311
+ )
312
+ with gr.Row():
313
+ with gr.Column(scale=1):
314
+ ex_engine = gr.Dropdown(
315
+ choices=["UE5", "Unity (HDRP)", "Godot 4", "Blender", "Web (Three.js)"],
316
+ value="UE5",
317
+ label="Target engine",
318
+ )
319
+ ex_name = gr.Textbox(value="Asset_01", label="Asset name")
320
+ ex_type = gr.Radio(
321
+ choices=["Character (SK_)", "Prop (SM_)", "Environment (SM_)"],
322
+ value="Prop (SM_)",
323
+ label="Asset type",
324
+ )
325
+ ex_include_lods = gr.Checkbox(value=True, label="Include LODs")
326
+ ex_include_collision = gr.Checkbox(value=True, label="Include collision mesh")
327
+ ex_btn = gr.Button("Export", variant="primary")
328
+ with gr.Column(scale=1):
329
+ ex_status = gr.Markdown("*Nothing to export yet.*")
330
+ ex_file = gr.File(label="Download", visible=True)
331
+ ex_btn.click(
332
+ fn=stub_export,
333
+ inputs=[ex_engine, ex_name, ex_type, ex_include_lods, ex_include_collision],
334
+ outputs=[ex_status, ex_file],
335
+ )
336
+
337
+ # ============ Tab 5: Presets ==================================
338
+ with gr.Tab("5. Presets", id=5):
339
+ gr.Markdown(
340
+ "### Saved configurations\n"
341
+ "Save parameter sets for asset types you create repeatedly "
342
+ "(e.g., \"character_UE5_hero\", \"prop_UE5_standard\"). "
343
+ "Wired up properly in Milestone 9."
344
+ )
345
+ with gr.Row():
346
+ with gr.Column():
347
+ pr_list = gr.Dropdown(
348
+ choices=workspace.list_presets(),
349
+ label="Saved presets",
350
+ )
351
+ pr_refresh = gr.Button("Refresh list", size="sm")
352
+ with gr.Row():
353
+ pr_name = gr.Textbox(label="New preset name", scale=2)
354
+ pr_save = gr.Button("Save current settings", variant="primary", scale=1)
355
+ pr_delete = gr.Button("Delete selected", variant="stop")
356
+ pr_status = gr.Markdown()
357
+ pr_refresh.click(fn=ui_refresh_presets, outputs=pr_list)
358
+ pr_save.click(fn=ui_save_preset, inputs=pr_name, outputs=[pr_status, pr_list])
359
+ pr_delete.click(fn=ui_delete_preset, inputs=pr_list, outputs=[pr_status, pr_list])
360
+
361
+ # ============ Tab 6: Diagnostics (hidden in prod, useful now) =
362
+ with gr.Tab("Diagnostics", id=99):
363
+ gr.Markdown(
364
+ "### Milestone 1 — Foundation check\n"
365
+ "Verify the Space environment is working correctly before "
366
+ "building out the pipeline."
367
+ )
368
+ with gr.Row():
369
+ with gr.Column():
370
+ diag_btn = gr.Button("🧪 Run GPU smoke test", variant="primary")
371
+ diag_out = gr.Markdown()
372
+ with gr.Column():
373
+ gr.Markdown("**Workspace state:**")
374
+ diag_state = gr.JSON(value=workspace.get_state().to_dict())
375
+ diag_refresh = gr.Button("Refresh state", size="sm")
376
+ diag_btn.click(fn=zerogpu_smoke_test, outputs=diag_out)
377
+ diag_refresh.click(
378
+ fn=lambda: workspace.get_state().to_dict(),
379
+ outputs=diag_state,
380
+ )
381
+
382
+ # --- Persistent right-side viewer + asset summary ------------------
383
+ gr.Markdown("---")
384
+ with gr.Row():
385
+ with gr.Column(scale=2):
386
+ viewer = gr.Model3D(
387
+ label="3D viewer",
388
+ value=ui_helpers.get_viewer_model_path(),
389
+ clear_color=[0.1, 0.1, 0.12, 1.0],
390
+ height=500,
391
+ )
392
+ with gr.Column(scale=1):
393
+ summary = gr.Markdown(
394
+ value=ui_helpers.get_asset_summary(),
395
+ elem_classes=["asset-summary"],
396
+ )
397
+ refresh_summary = gr.Button("🔄 Refresh viewer", size="sm")
398
+ refresh_summary.click(
399
+ fn=lambda: (
400
+ ui_helpers.get_viewer_model_path(),
401
+ ui_helpers.get_asset_summary(),
402
+ ),
403
+ outputs=[viewer, summary],
404
+ )
405
+
406
+ # --- Global status bar --------------------------------------------
407
+ status_bar = gr.Markdown(
408
+ value=ui_helpers.get_status_bar(),
409
+ elem_classes=["status-bar"],
410
+ )
411
+
412
+ # --- Global refresh: every pipeline action updates the viewer, summary,
413
+ # and status bar. We chain .then() onto each button so the refresh runs
414
+ # only after the action completes (and even if it fails — Gradio still
415
+ # fires .then() after exceptions inside the main handler).
416
+ def _global_refresh():
417
+ return (
418
+ ui_helpers.get_viewer_model_path(),
419
+ ui_helpers.get_asset_summary(),
420
+ ui_helpers.get_status_bar(),
421
+ )
422
+
423
+ for trigger_btn in (gen_btn, pp_btn, rig_btn, ex_btn,
424
+ diag_btn, diag_refresh, refresh_summary):
425
+ trigger_btn.click(
426
+ fn=_global_refresh,
427
+ outputs=[viewer, summary, status_bar],
428
+ )
429
+
430
+ return demo
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # Entrypoint
435
+ # ---------------------------------------------------------------------------
436
+ # On HF Spaces, app.py is executed directly. We construct the demo and call
437
+ # .launch() at module level. The HF Space runtime handles all networking;
438
+ # we just need to bind to 0.0.0.0:7860.
439
+
440
+ workspace.ensure_dirs()
441
+ demo = build_ui()
442
+ demo.queue(default_concurrency_limit=1).launch(
443
+ server_name="0.0.0.0",
444
+ server_port=7860,
445
+ show_error=True,
446
+ # Gradio 6: show_api removed. Use footer_links instead.
447
+ # Equivalent of old show_api=False:
448
+ footer_links=["gradio", "settings"],
449
+ theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
450
+ css=CUSTOM_CSS,
451
+ )