Yang2001 akhaliq HF Staff commited on
Commit
680cbca
·
1 Parent(s): 25145b8

gradio server (#3)

Browse files

- feat: add frontend UI for Pixal3D image-to-3D generation interface (0d71da7dc709a3e1bd5af6990304fe9d19eb169a)
- refactor: overhaul UI layout with sidebar shell, updated color palette, and component-based navigation structure (9f7d349c499da40b71f55b9ab8e39f6170ad73d8)
- feat: add thread-safe model initialization, nest_asyncio support, and pre-loading on startup (3b7c6289670a11950a24634380508d942e680286)
- fix: access file path using dictionary key instead of attribute in image processing functions (d8b4140ea7360ffde94af4b8ffd3ca219591d264)
- feat: mount /tmp directory and add client-side fallback logic for image previews (c80fdaeef52eb14bf00a5cf6dae057f4b6128f98)


Co-authored-by: AK <akhaliq@users.noreply.huggingface.co>

Files changed (2) hide show
  1. app.py +171 -412
  2. index.html +936 -0
app.py CHANGED
@@ -1,51 +1,55 @@
1
- """
2
- Pixal3D (TRELLIS.2 Backbone) - Gradio App
3
-
4
- Image-to-3D generation using Proj-mode Cascade inference (512->1024/1536).
5
-
6
- """
7
-
8
- import spaces
9
- import gradio as gr
10
-
11
  import os
12
  import subprocess
13
- subprocess.run([
14
- "pip", "install", "--force-reinstall", "--no-deps",
15
- "https://github.com/LDYang694/Storages/releases/download/20260430/utils3d-0.0.2-py3-none-any.whl"
16
- ], check=True)
17
-
18
- os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1'
19
- os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
20
- os.environ["ATTN_BACKEND"] = "flash_attn_3"
21
- os.environ["FLEX_GEMM_AUTOTUNE_CACHE_PATH"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'autotune_cache.json')
22
- os.environ["FLEX_GEMM_AUTOTUNER_VERBOSE"] = '1'
23
-
24
  import argparse
25
  import math
26
  import time
27
- from datetime import datetime
28
  import shutil
29
  import cv2
30
- from typing import *
31
  import torch
32
  import numpy as np
33
- from PIL import Image
34
  import base64
35
  import io
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  from trellis2.modules.sparse import SparseTensor
37
  from trellis2.pipelines import Pixal3DImageTo3DPipeline
38
  from trellis2.renderers import EnvMap
39
  from trellis2.utils import render_utils
40
  import o_voxel
41
 
42
-
43
  # ============================================================================
44
  # Constants & Defaults
45
  # ============================================================================
46
 
47
  MAX_SEED = np.iinfo(np.int32).max
48
  TMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
 
 
49
  MODES = [
50
  {"name": "Normal", "icon": "assets/app/normal.png", "render_key": "normal"},
51
  {"name": "Clay render", "icon": "assets/app/clay.png", "render_key": "clay"},
@@ -55,8 +59,6 @@ MODES = [
55
  {"name": "HDRI courtyard", "icon": "assets/app/hdri_courtyard.png", "render_key": "shaded_courtyard"},
56
  ]
57
  STEPS = 8
58
- DEFAULT_MODE = 3
59
- DEFAULT_STEP = 0
60
 
61
  # Cascade parameters
62
  CASCADE_LR_RESOLUTION = 512
@@ -68,7 +70,7 @@ WILD_MESH_SCALE = 1.0
68
  WILD_EXTEND_PIXEL = 0
69
  WILD_IMAGE_RESOLUTION = 512
70
 
71
- # Image Cond Model configs (extracted from training configs, hardcoded)
72
  IMAGE_COND_CONFIGS = {
73
  "ss": {
74
  "model_name": "camenduru/dinov3-vitl16-pretrain-lvd1689m",
@@ -98,126 +100,63 @@ IMAGE_COND_CONFIGS = {
98
  },
99
  }
100
 
101
-
102
  # ============================================================================
103
- # CSS & JS
104
- # ============================================================================
105
-
106
- css = """
107
- .stepper-wrapper { padding: 0; }
108
- .stepper-container { padding: 0; align-items: center; }
109
- .step-button { flex-direction: row; }
110
- .step-connector { transform: none; }
111
- .step-number { width: 16px; height: 16px; }
112
- .step-label { position: relative; bottom: 0; }
113
- .wrap.center.full { inset: 0; height: 100%; }
114
- .wrap.center.full.translucent { background: var(--block-background-fill); }
115
- .meta-text-center {
116
- display: block !important; position: absolute !important;
117
- top: unset !important; bottom: 0 !important; right: 0 !important; transform: unset !important;
118
- }
119
- .previewer-container {
120
- position: relative;
121
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
122
- width: 100%; height: 722px; margin: 0 auto; padding: 20px;
123
- display: flex; flex-direction: column; align-items: center; justify-content: center;
124
- }
125
- .previewer-container .tips-icon {
126
- position: absolute; right: 10px; top: 10px; z-index: 10;
127
- border-radius: 10px; color: #fff; background-color: var(--color-accent); padding: 3px 6px; user-select: none;
128
- }
129
- .previewer-container .tips-text {
130
- position: absolute; right: 10px; top: 50px; color: #fff; background-color: var(--color-accent);
131
- border-radius: 10px; padding: 6px; text-align: left; max-width: 300px; z-index: 10;
132
- transition: all 0.3s; opacity: 0%; user-select: none;
133
- }
134
- .previewer-container .tips-text p { font-size: 14px; line-height: 1.2; }
135
- .tips-icon:hover + .tips-text { display: block; opacity: 100%; }
136
- .previewer-container .mode-row {
137
- width: 100%; display: flex; gap: 8px; justify-content: center; margin-bottom: 20px; flex-wrap: wrap;
138
- }
139
- .previewer-container .mode-btn {
140
- width: 24px; height: 24px; border-radius: 50%; cursor: pointer; opacity: 0.5;
141
- transition: all 0.2s; border: 2px solid #ddd; object-fit: cover;
142
- }
143
- .previewer-container .mode-btn:hover { opacity: 0.9; transform: scale(1.1); }
144
- .previewer-container .mode-btn.active { opacity: 1; border-color: var(--color-accent); transform: scale(1.1); }
145
- .previewer-container .display-row {
146
- margin-bottom: 20px; min-height: 400px; width: 100%; flex-grow: 1;
147
- display: flex; justify-content: center; align-items: center;
148
- }
149
- .previewer-container .previewer-main-image {
150
- max-width: 100%; max-height: 100%; flex-grow: 1; object-fit: contain; display: none;
151
- }
152
- .previewer-container .previewer-main-image.visible { display: block; }
153
- .previewer-container .slider-row {
154
- width: 100%; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 0 10px;
155
- }
156
- .previewer-container input[type=range] { -webkit-appearance: none; width: 100%; max-width: 400px; background: transparent; }
157
- .previewer-container input[type=range]::-webkit-slider-runnable-track {
158
- width: 100%; height: 8px; cursor: pointer; background: #ddd; border-radius: 5px;
159
- }
160
- .previewer-container input[type=range]::-webkit-slider-thumb {
161
- height: 20px; width: 20px; border-radius: 50%; background: var(--color-accent);
162
- cursor: pointer; -webkit-appearance: none; margin-top: -6px;
163
- box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: transform 0.1s;
164
- }
165
- .previewer-container input[type=range]::-webkit-slider-thumb:hover { transform: scale(1.2); }
166
- .gradio-container .padded:has(.previewer-container) { padding: 0 !important; }
167
- .gradio-container:has(.previewer-container) [data-testid="block-label"] { position: absolute; top: 0; left: 0; }
168
- """
169
-
170
- head = """
171
- <script>
172
- function refreshView(mode, step) {
173
- const allImgs = document.querySelectorAll('.previewer-main-image');
174
- for (let i = 0; i < allImgs.length; i++) {
175
- const img = allImgs[i];
176
- if (img.classList.contains('visible')) {
177
- const id = img.id;
178
- const [_, m, s] = id.split('-');
179
- if (mode === -1) mode = parseInt(m.slice(1));
180
- if (step === -1) step = parseInt(s.slice(1));
181
- break;
182
- }
183
- }
184
- allImgs.forEach(img => img.classList.remove('visible'));
185
- const targetId = 'view-m' + mode + '-s' + step;
186
- const targetImg = document.getElementById(targetId);
187
- if (targetImg) targetImg.classList.add('visible');
188
- const allBtns = document.querySelectorAll('.mode-btn');
189
- allBtns.forEach((btn, idx) => {
190
- if (idx === mode) btn.classList.add('active');
191
- else btn.classList.remove('active');
192
- });
193
- }
194
- function selectMode(mode) { refreshView(mode, -1); }
195
- function onSliderChange(val) { refreshView(-1, parseInt(val)); }
196
- </script>
197
- """
198
-
199
- empty_html = f"""
200
- <div class="previewer-container">
201
- <svg style=" opacity: .5; height: var(--size-5); color: var(--body-text-color);"
202
- xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="feather feather-image"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
203
- </div>
204
- """
205
-
206
-
207
- # ============================================================================
208
- # Model Loading Utilities
209
  # ============================================================================
210
 
211
  def build_image_cond_model(config: dict):
212
- """Build DinoV3ProjFeatureExtractor."""
213
  from trellis2.trainers.flow_matching.mixins.image_conditioned_proj import DinoV3ProjFeatureExtractor
214
  model = DinoV3ProjFeatureExtractor(**config)
215
  model.eval()
216
  return model
217
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  # ============================================================================
220
- # Camera Parameter Utilities
221
  # ============================================================================
222
 
223
  def compute_f_pixels(camera_angle_x: float, resolution: int) -> float:
@@ -225,7 +164,6 @@ def compute_f_pixels(camera_angle_x: float, resolution: int) -> float:
225
  f_pixels = focal_length * resolution / 32.0
226
  return float(f_pixels.item())
227
 
228
-
229
  def distance_from_fov(camera_angle_x, grid_point, target_point, mesh_scale, image_resolution):
230
  rotation_matrix = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]])
231
  gp = grid_point.to(torch.float32) @ rotation_matrix.T
@@ -238,25 +176,8 @@ def distance_from_fov(camera_angle_x, grid_point, target_point, mesh_scale, imag
238
  distance_x = f_pixels * xw / x_ndc - yw
239
  return {"distance_from_x": float(distance_x), "f_pixels": float(f_pixels)}
240
 
241
-
242
- def load_moge_model(device="cuda", model_name=MOGE_MODEL_NAME):
243
- print(f"[MoGe-2] Loading model {model_name}...")
244
- from moge.model.v2 import MoGeModel
245
- moge_model = MoGeModel.from_pretrained(model_name).to(device)
246
- moge_model.eval()
247
- print("[MoGe-2] Model loaded!")
248
- return moge_model
249
-
250
-
251
- def get_camera_params_wild_moge(image, moge_model, device="cuda",
252
- mesh_scale=1.0, extend_pixel=0, image_resolution=512):
253
- """Estimate camera parameters via MoGe-2."""
254
- if isinstance(image, str):
255
- pil_image = Image.open(image).convert("RGB")
256
- elif isinstance(image, Image.Image):
257
- pil_image = image.convert("RGB")
258
- else:
259
- raise ValueError(f"Unsupported image type: {type(image)}")
260
  width, height = pil_image.size
261
  image_np = np.array(pil_image).astype(np.float32) / 255.0
262
  image_tensor = torch.from_numpy(image_np).permute(2, 0, 1).to(device)
@@ -275,88 +196,81 @@ def get_camera_params_wild_moge(image, moge_model, device="cuda",
275
  )["distance_from_x"]
276
  return {'camera_angle_x': camera_angle_x, 'distance': distance, 'mesh_scale': mesh_scale}
277
 
278
-
279
- # ============================================================================
280
- # UI Utilities
281
- # ============================================================================
282
-
283
- def image_to_base64(image):
284
- buffered = io.BytesIO()
285
- image = image.convert("RGB")
286
- image.save(buffered, format="jpeg", quality=85)
287
- img_str = base64.b64encode(buffered.getvalue()).decode()
288
- return f"data:image/jpeg;base64,{img_str}"
289
-
290
-
291
- def start_session(req: gr.Request):
292
- user_dir = os.path.join(TMP_DIR, str(req.session_hash))
293
- os.makedirs(user_dir, exist_ok=True)
294
-
295
-
296
- def end_session(req: gr.Request):
297
- user_dir = os.path.join(TMP_DIR, str(req.session_hash))
298
- if os.path.exists(user_dir):
299
- shutil.rmtree(user_dir)
300
-
301
-
302
- def preprocess_image(image: Image.Image) -> Image.Image:
303
- return pipeline.preprocess_image(image)
304
-
305
-
306
  def pack_state(shape_slat, tex_slat, res):
307
- return {
308
  'shape_slat_feats': shape_slat.feats.cpu().numpy(),
309
  'tex_slat_feats': tex_slat.feats.cpu().numpy(),
310
  'coords': shape_slat.coords.cpu().numpy(),
311
  'res': res,
312
  }
 
 
 
313
 
314
-
315
- def unpack_state(state):
316
  shape_slat = SparseTensor(
317
- feats=torch.from_numpy(state['shape_slat_feats']).cuda(),
318
- coords=torch.from_numpy(state['coords']).cuda(),
319
  )
320
- tex_slat = shape_slat.replace(torch.from_numpy(state['tex_slat_feats']).cuda())
321
- return shape_slat, tex_slat, state['res']
322
 
 
 
 
323
 
324
- def get_seed(randomize_seed, seed):
325
- return np.random.randint(0, MAX_SEED) if randomize_seed else seed
326
 
 
 
 
 
 
327
 
328
- # ============================================================================
329
- # Core Inference
330
- # ============================================================================
 
 
 
 
 
331
 
 
332
  @spaces.GPU(duration=120)
333
- def image_to_3d(
334
- image, seed, resolution,
335
- ss_guidance_strength, ss_guidance_rescale, ss_sampling_steps, ss_rescale_t,
336
- shape_slat_guidance_strength, shape_slat_guidance_rescale, shape_slat_sampling_steps, shape_slat_rescale_t,
337
- tex_slat_guidance_strength, tex_slat_guidance_rescale, tex_slat_sampling_steps, tex_slat_rescale_t,
338
- req: gr.Request,
339
- progress=gr.Progress(track_tqdm=True),
340
- ):
341
- device = pipeline.device
 
 
 
 
 
 
 
 
 
342
  torch.manual_seed(seed)
343
  hr_resolution = int(resolution)
344
-
345
- total_t0 = time.time()
346
- print(f"\n{'='*60}")
347
- print(f" [Generate] Start | seed={seed}, resolution={hr_resolution}")
348
- print(f"{'='*60}")
349
-
350
- # Preprocessing
351
- image_preprocessed = pipeline.preprocess_image(image)
352
-
353
- # Camera estimation via MoGe-2
354
  camera_params = get_camera_params_wild_moge(
355
- image_preprocessed, moge_model, device=str(device),
356
  mesh_scale=WILD_MESH_SCALE, extend_pixel=WILD_EXTEND_PIXEL,
357
  image_resolution=WILD_IMAGE_RESOLUTION,
358
  )
359
-
360
  ss_sampler_override = {"steps": ss_sampling_steps, "guidance_strength": ss_guidance_strength,
361
  "guidance_rescale": ss_guidance_rescale, "rescale_t": ss_rescale_t}
362
  shape_sampler_override = {"steps": shape_slat_sampling_steps, "guidance_strength": shape_slat_guidance_strength,
@@ -364,7 +278,6 @@ def image_to_3d(
364
  tex_sampler_override = {"steps": tex_slat_sampling_steps, "guidance_strength": tex_slat_guidance_strength,
365
  "guidance_rescale": tex_slat_guidance_rescale, "rescale_t": tex_slat_rescale_t}
366
 
367
- # Run pipeline
368
  pipeline_type = f"{hr_resolution}_cascade"
369
  mesh_list, (shape_slat, tex_slat, res) = pipeline.run(
370
  image_preprocessed,
@@ -378,60 +291,37 @@ def image_to_3d(
378
  pipeline_type=pipeline_type,
379
  max_num_tokens=CASCADE_MAX_NUM_TOKENS,
380
  )
 
381
  mesh = mesh_list[0]
382
- state = pack_state(shape_slat, tex_slat, res)
383
- del shape_slat, tex_slat, mesh_list
384
- torch.cuda.empty_cache()
385
-
386
- # Render
387
  mesh.simplify(16777216)
388
- images = render_utils.render_proj_aligned_video(
389
  mesh, camera_angle_x=camera_params['camera_angle_x'],
390
  distance=camera_params['distance'], resolution=1024,
391
  num_frames=STEPS, envmap=envmap,
392
  )
393
- del mesh
394
- torch.cuda.empty_cache()
395
- print(f"\n [Generate] Total time: {time.time()-total_t0:.2f}s")
396
-
397
- # Build HTML
398
- images_html = ""
399
- for m_idx, mode in enumerate(MODES):
400
- for s_idx in range(STEPS):
401
- unique_id = f"view-m{m_idx}-s{s_idx}"
402
- is_visible = (m_idx == DEFAULT_MODE and s_idx == DEFAULT_STEP)
403
- vis_class = "visible" if is_visible else ""
404
- img_base64 = image_to_base64(Image.fromarray(images[mode['render_key']][s_idx]))
405
- images_html += f'<img id="{unique_id}" class="previewer-main-image {vis_class}" src="{img_base64}" loading="eager">'
406
-
407
- btns_html = ""
408
- for idx, mode in enumerate(MODES):
409
- active_class = "active" if idx == DEFAULT_MODE else ""
410
- btns_html += f'<img src="{mode["icon_base64"]}" class="mode-btn {active_class}" onclick="selectMode({idx})" title="{mode["name"]}">'
411
-
412
- full_html = f"""
413
- <div class="previewer-container">
414
- <div class="tips-wrapper">
415
- <div class="tips-icon">Tips</div>
416
- <div class="tips-text">
417
- <p>Render Mode - Click circular buttons to switch render modes.</p>
418
- <p>View Angle - Drag the slider to change the view angle.</p>
419
- </div>
420
- </div>
421
- <div class="display-row">{images_html}</div>
422
- <div class="mode-row" id="btn-group">{btns_html}</div>
423
- <div class="slider-row">
424
- <input type="range" id="custom-slider" min="0" max="{STEPS - 1}" value="{DEFAULT_STEP}" step="1" oninput="onSliderChange(this.value)">
425
- </div>
426
- </div>
427
- """
428
- return state, full_html
429
 
 
 
 
 
430
 
 
431
  @spaces.GPU(duration=120)
432
- def extract_glb(state, decimation_target, texture_size, req: gr.Request, progress=gr.Progress(track_tqdm=True)):
433
- user_dir = os.path.join(TMP_DIR, str(req.session_hash))
434
- shape_slat, tex_slat, res = unpack_state(state)
435
  mesh = pipeline.decode_latent(shape_slat, tex_slat, res)[0]
436
  glb = o_voxel.postprocess.to_glb(
437
  vertices=mesh.vertices, faces=mesh.faces, attr_volume=mesh.attrs,
@@ -440,7 +330,6 @@ def extract_glb(state, decimation_target, texture_size, req: gr.Request, progres
440
  decimation_target=decimation_target, texture_size=texture_size,
441
  remesh=True, remesh_band=1, remesh_project=0, use_tqdm=True,
442
  )
443
- # Ry(180°) @ Rx(90°): (x,y,z) → (-x, -z, -y)
444
  rot = np.array([
445
  [-1, 0, 0, 0],
446
  [ 0, 0, -1, 0],
@@ -448,153 +337,23 @@ def extract_glb(state, decimation_target, texture_size, req: gr.Request, progres
448
  [ 0, 0, 0, 1],
449
  ], dtype=np.float64)
450
  glb.apply_transform(rot)
451
- now = datetime.now()
452
- timestamp = now.strftime("%Y-%m-%dT%H%M%S") + f".{now.microsecond // 1000:03d}"
453
- os.makedirs(user_dir, exist_ok=True)
454
- glb_path = os.path.join(user_dir, f'sample_{timestamp}.glb')
455
- glb.export(glb_path, extension_webp=True)
456
- torch.cuda.empty_cache()
457
- return glb_path, glb_path
458
-
459
-
460
- # ============================================================================
461
- # Gradio UI
462
- # ============================================================================
463
-
464
- with gr.Blocks(delete_cache=(600, 600)) as demo:
465
- gr.Markdown("""
466
- ## Pixal3D: Pixel-Aligned 3D Generation from Images
467
- [[Project Page](https://ldyang694.github.io/projects/pixal3d/)]
468
- * Upload an image and click **Generate** to create a 3D asset using Pixal3D with TRELLIS.2 backbone.
469
- * Click **Extract GLB** to export and download the generated GLB file.
470
- * Camera parameters are estimated automatically via MoGe-2.
471
- """)
472
-
473
- with gr.Row():
474
- with gr.Column(scale=1, min_width=360):
475
- image_prompt = gr.Image(label="Image Prompt", format="png", image_mode="RGBA", type="pil", height=400)
476
- resolution = gr.Radio(["1024", "1536"], label="Resolution", value="1536")
477
- seed = gr.Slider(0, MAX_SEED, label="Seed", value=42, step=1)
478
- randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
479
- decimation_target = gr.Slider(100000, 1000000, label="Decimation Target", value=1000000, step=10000)
480
- texture_size = gr.Slider(1024, 4096, label="Texture Size", value=4096, step=1024)
481
- generate_btn = gr.Button("Generate")
482
-
483
- with gr.Accordion(label="Advanced Settings", open=False):
484
- gr.Markdown("Stage 1: Sparse Structure Generation")
485
- with gr.Row():
486
- ss_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=7.5, step=0.1)
487
- ss_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.7, step=0.01)
488
- ss_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
489
- ss_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=5.0, step=0.1)
490
- gr.Markdown("Stage 2: Shape Generation")
491
- with gr.Row():
492
- shape_slat_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=7.5, step=0.1)
493
- shape_slat_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.5, step=0.01)
494
- shape_slat_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
495
- shape_slat_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=3.0, step=0.1)
496
- gr.Markdown("Stage 3: Material Generation")
497
- with gr.Row():
498
- tex_slat_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance Strength", value=1.0, step=0.1)
499
- tex_slat_guidance_rescale = gr.Slider(0.0, 1.0, label="Guidance Rescale", value=0.0, step=0.01)
500
- tex_slat_sampling_steps = gr.Slider(1, 50, label="Sampling Steps", value=12, step=1)
501
- tex_slat_rescale_t = gr.Slider(1.0, 6.0, label="Rescale T", value=3.0, step=0.1)
502
-
503
- with gr.Column(scale=10):
504
- with gr.Walkthrough(selected=0) as walkthrough:
505
- with gr.Step("Preview", id=0):
506
- preview_output = gr.HTML(empty_html, label="3D Asset Preview", show_label=True, container=True)
507
- extract_btn = gr.Button("Extract GLB")
508
- with gr.Step("Extract", id=1):
509
- glb_output = gr.Model3D(label="Extracted GLB", height=724, show_label=True, display_mode="solid", clear_color=(0.25, 0.25, 0.25, 1.0), camera_position=(-90, 90, None))
510
- download_btn = gr.DownloadButton(label="Download GLB")
511
-
512
- with gr.Column(scale=1, min_width=172):
513
- examples = gr.Examples(
514
- examples=[f'assets/example_image/{image}' for image in os.listdir("assets/example_image")],
515
- inputs=[image_prompt], fn=preprocess_image, outputs=[image_prompt],
516
- run_on_click=True, examples_per_page=18,
517
- )
518
-
519
- output_buf = gr.State()
520
-
521
- demo.load(start_session)
522
- demo.unload(end_session)
523
- image_prompt.upload(preprocess_image, inputs=[image_prompt], outputs=[image_prompt])
524
-
525
- generate_btn.click(get_seed, inputs=[randomize_seed, seed], outputs=[seed]).then(
526
- lambda: gr.Walkthrough(selected=0), outputs=walkthrough
527
- ).then(
528
- image_to_3d,
529
- inputs=[image_prompt, seed, resolution,
530
- ss_guidance_strength, ss_guidance_rescale, ss_sampling_steps, ss_rescale_t,
531
- shape_slat_guidance_strength, shape_slat_guidance_rescale, shape_slat_sampling_steps, shape_slat_rescale_t,
532
- tex_slat_guidance_strength, tex_slat_guidance_rescale, tex_slat_sampling_steps, tex_slat_rescale_t],
533
- outputs=[output_buf, preview_output],
534
- )
535
-
536
- extract_btn.click(lambda: gr.Walkthrough(selected=1), outputs=walkthrough).then(
537
- extract_glb, inputs=[output_buf, decimation_target, texture_size], outputs=[glb_output, download_btn],
538
- )
539
-
540
-
541
- # ============================================================================
542
- # Launch
543
- # ============================================================================
544
-
545
- def parse_args():
546
- parser = argparse.ArgumentParser(description="Pixal3D Gradio App")
547
- parser.add_argument("--model_path", type=str, default="TencentARC/Pixal3D-T",
548
- help="HuggingFace repo ID or local path (default: TencentARC/Pixal3D-T)")
549
- parser.add_argument("--port", type=int, default=7860)
550
- parser.add_argument("--share", action="store_true", default=True)
551
- return parser.parse_args()
552
 
 
 
 
553
 
554
  if __name__ == "__main__":
555
- args = parse_args()
556
- os.makedirs(TMP_DIR, exist_ok=True)
557
-
558
- # Construct UI icon base64
559
- for i in range(len(MODES)):
560
- icon = Image.open(MODES[i]['icon'])
561
- MODES[i]['icon_base64'] = image_to_base64(icon)
562
-
563
- # Load pipeline from HuggingFace or local path
564
- print(f"[Pipeline] Loading from {args.model_path}...")
565
- pipeline = Pixal3DImageTo3DPipeline.from_pretrained(args.model_path)
566
-
567
- # Load environment maps
568
- envmap = {
569
- 'forest': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/forest.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
570
- 'sunset': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/sunset.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
571
- 'courtyard': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/courtyard.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
572
- }
573
-
574
- # Build image cond models and set on pipeline
575
- print("[ImageCond] Building DinoV3ProjFeatureExtractor models...")
576
- pipeline.image_cond_model_ss = build_image_cond_model(IMAGE_COND_CONFIGS["ss"])
577
- pipeline.image_cond_model_shape_512 = build_image_cond_model(IMAGE_COND_CONFIGS["shape_512"])
578
- pipeline.image_cond_model_shape_1024 = build_image_cond_model(IMAGE_COND_CONFIGS["shape_1024"])
579
- pipeline.image_cond_model_tex_1024 = build_image_cond_model(IMAGE_COND_CONFIGS["tex_1024"])
580
-
581
- pipeline.cuda()
582
-
583
- # Pre-download NAF model (avoid lazy-loading during inference)
584
- print("[NAF] Pre-loading NAF upsampler model...")
585
- for attr in ['image_cond_model_ss', 'image_cond_model_shape_512', 'image_cond_model_shape_1024', 'image_cond_model_tex_1024']:
586
- model = getattr(pipeline, attr, None)
587
- if model is not None and getattr(model, 'use_naf_upsample', False):
588
- model._load_naf()
589
- print("[NAF] NAF model loaded.")
590
-
591
- # Load MoGe-2
592
- print("\n[MoGe-2] Loading model for camera estimation...")
593
- moge_model = load_moge_model(device="cuda")
594
-
595
- print(f"\n{'=' * 60}")
596
- print(f" Pixal3D ready! Model loaded from: {args.model_path}")
597
- print(f" Cascade: {CASCADE_LR_RESOLUTION} -> 1024/1536")
598
- print(f"{'=' * 60}\n")
599
-
600
- demo.launch(css=css, head=head, server_port=args.port, share=args.share)
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import subprocess
 
 
 
 
 
 
 
 
 
 
 
3
  import argparse
4
  import math
5
  import time
 
6
  import shutil
7
  import cv2
 
8
  import torch
9
  import numpy as np
 
10
  import base64
11
  import io
12
+ import json
13
+ from datetime import datetime
14
+ from typing import *
15
+ from PIL import Image
16
+
17
+ import threading
18
+ try:
19
+ import nest_asyncio
20
+ nest_asyncio.apply()
21
+ except ImportError:
22
+ pass
23
+
24
+ # Lock for model initialization
25
+ init_lock = threading.Lock()
26
+
27
+ os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1'
28
+ os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
29
+ os.environ["ATTN_BACKEND"] = "flash_attn_3"
30
+ os.environ["FLEX_GEMM_AUTOTUNE_CACHE_PATH"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'autotune_cache.json')
31
+ os.environ["FLEX_GEMM_AUTOTUNER_VERBOSE"] = '1'
32
+
33
+ import spaces
34
+ from gradio import Server
35
+ from gradio.data_classes import FileData
36
+ from fastapi.responses import HTMLResponse
37
+ from fastapi.staticfiles import StaticFiles
38
+
39
  from trellis2.modules.sparse import SparseTensor
40
  from trellis2.pipelines import Pixal3DImageTo3DPipeline
41
  from trellis2.renderers import EnvMap
42
  from trellis2.utils import render_utils
43
  import o_voxel
44
 
 
45
  # ============================================================================
46
  # Constants & Defaults
47
  # ============================================================================
48
 
49
  MAX_SEED = np.iinfo(np.int32).max
50
  TMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
51
+ os.makedirs(TMP_DIR, exist_ok=True)
52
+
53
  MODES = [
54
  {"name": "Normal", "icon": "assets/app/normal.png", "render_key": "normal"},
55
  {"name": "Clay render", "icon": "assets/app/clay.png", "render_key": "clay"},
 
59
  {"name": "HDRI courtyard", "icon": "assets/app/hdri_courtyard.png", "render_key": "shaded_courtyard"},
60
  ]
61
  STEPS = 8
 
 
62
 
63
  # Cascade parameters
64
  CASCADE_LR_RESOLUTION = 512
 
70
  WILD_EXTEND_PIXEL = 0
71
  WILD_IMAGE_RESOLUTION = 512
72
 
73
+ # Image Cond Model configs
74
  IMAGE_COND_CONFIGS = {
75
  "ss": {
76
  "model_name": "camenduru/dinov3-vitl16-pretrain-lvd1689m",
 
100
  },
101
  }
102
 
 
103
  # ============================================================================
104
+ # Model Loading
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  # ============================================================================
106
 
107
  def build_image_cond_model(config: dict):
 
108
  from trellis2.trainers.flow_matching.mixins.image_conditioned_proj import DinoV3ProjFeatureExtractor
109
  model = DinoV3ProjFeatureExtractor(**config)
110
  model.eval()
111
  return model
112
 
113
+ def load_moge_model(device="cuda", model_name=MOGE_MODEL_NAME):
114
+ from moge.model.v2 import MoGeModel
115
+ moge_model = MoGeModel.from_pretrained(model_name).to(device)
116
+ moge_model.eval()
117
+ return moge_model
118
+
119
+ # Global instances (lazy loaded or loaded at start)
120
+ pipeline = None
121
+ moge_model = None
122
+ envmap = None
123
+
124
+ def init_models():
125
+ global pipeline, moge_model, envmap
126
+ with init_lock:
127
+ if pipeline is not None:
128
+ return
129
+
130
+ model_path = "TencentARC/Pixal3D-T"
131
+ print(f"[Pipeline] Loading from {model_path}...")
132
+ pipeline = Pixal3DImageTo3DPipeline.from_pretrained(model_path)
133
+
134
+ print("[ImageCond] Building DinoV3ProjFeatureExtractor models...")
135
+ pipeline.image_cond_model_ss = build_image_cond_model(IMAGE_COND_CONFIGS["ss"])
136
+ pipeline.image_cond_model_shape_512 = build_image_cond_model(IMAGE_COND_CONFIGS["shape_512"])
137
+ pipeline.image_cond_model_shape_1024 = build_image_cond_model(IMAGE_COND_CONFIGS["shape_1024"])
138
+ pipeline.image_cond_model_tex_1024 = build_image_cond_model(IMAGE_COND_CONFIGS["tex_1024"])
139
+
140
+ pipeline.cuda()
141
+
142
+ print("[NAF] Pre-loading NAF upsampler model...")
143
+ for attr in ['image_cond_model_ss', 'image_cond_model_shape_512', 'image_cond_model_shape_1024', 'image_cond_model_tex_1024']:
144
+ model = getattr(pipeline, attr, None)
145
+ if model is not None and getattr(model, 'use_naf_upsample', False):
146
+ model._load_naf()
147
+
148
+ print("[MoGe-2] Loading model for camera estimation...")
149
+ moge_model = load_moge_model(device="cuda")
150
+
151
+ print("[EnvMap] Loading environment maps...")
152
+ envmap = {
153
+ 'forest': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/forest.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
154
+ 'sunset': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/sunset.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
155
+ 'courtyard': EnvMap(torch.tensor(cv2.cvtColor(cv2.imread('assets/hdri/courtyard.exr', cv2.IMREAD_UNCHANGED), cv2.COLOR_BGR2RGB), dtype=torch.float32, device='cuda')),
156
+ }
157
 
158
  # ============================================================================
159
+ # Utilities
160
  # ============================================================================
161
 
162
  def compute_f_pixels(camera_angle_x: float, resolution: int) -> float:
 
164
  f_pixels = focal_length * resolution / 32.0
165
  return float(f_pixels.item())
166
 
 
167
  def distance_from_fov(camera_angle_x, grid_point, target_point, mesh_scale, image_resolution):
168
  rotation_matrix = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]])
169
  gp = grid_point.to(torch.float32) @ rotation_matrix.T
 
176
  distance_x = f_pixels * xw / x_ndc - yw
177
  return {"distance_from_x": float(distance_x), "f_pixels": float(f_pixels)}
178
 
179
+ def get_camera_params_wild_moge(image_path, device="cuda", mesh_scale=1.0, extend_pixel=0, image_resolution=512):
180
+ pil_image = Image.open(image_path).convert("RGB")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  width, height = pil_image.size
182
  image_np = np.array(pil_image).astype(np.float32) / 255.0
183
  image_tensor = torch.from_numpy(image_np).permute(2, 0, 1).to(device)
 
196
  )["distance_from_x"]
197
  return {'camera_angle_x': camera_angle_x, 'distance': distance, 'mesh_scale': mesh_scale}
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  def pack_state(shape_slat, tex_slat, res):
200
+ state_data = {
201
  'shape_slat_feats': shape_slat.feats.cpu().numpy(),
202
  'tex_slat_feats': tex_slat.feats.cpu().numpy(),
203
  'coords': shape_slat.coords.cpu().numpy(),
204
  'res': res,
205
  }
206
+ state_path = os.path.join(TMP_DIR, f"state_{int(time.time()*1000)}.npz")
207
+ np.savez_compressed(state_path, **state_data)
208
+ return state_path
209
 
210
+ def unpack_state(state_path):
211
+ data = np.load(state_path)
212
  shape_slat = SparseTensor(
213
+ feats=torch.from_numpy(data['shape_slat_feats']).cuda(),
214
+ coords=torch.from_numpy(data['coords']).cuda(),
215
  )
216
+ tex_slat = shape_slat.replace(torch.from_numpy(data['tex_slat_feats']).cuda())
217
+ return shape_slat, tex_slat, int(data['res'])
218
 
219
+ # ============================================================================
220
+ # API Implementation
221
+ # ============================================================================
222
 
223
+ app = Server()
 
224
 
225
+ @app.get("/")
226
+ async def homepage():
227
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
228
+ with open(html_path, "r", encoding="utf-8") as f:
229
+ return HTMLResponse(content=f.read())
230
 
231
+ @app.api()
232
+ def preprocess(image: FileData) -> FileData:
233
+ init_models()
234
+ img = Image.open(image["path"])
235
+ processed = pipeline.preprocess_image(img)
236
+ out_path = os.path.join(TMP_DIR, f"preprocessed_{int(time.time()*1000)}.png")
237
+ processed.save(out_path)
238
+ return FileData(path=out_path)
239
 
240
+ @app.api()
241
  @spaces.GPU(duration=120)
242
+ def generate_3d(
243
+ image: FileData,
244
+ seed: int,
245
+ resolution: int,
246
+ ss_guidance_strength: float = 7.5,
247
+ ss_guidance_rescale: float = 0.7,
248
+ ss_sampling_steps: int = 12,
249
+ ss_rescale_t: float = 5.0,
250
+ shape_slat_guidance_strength: float = 7.5,
251
+ shape_slat_guidance_rescale: float = 0.5,
252
+ shape_slat_sampling_steps: int = 12,
253
+ shape_slat_rescale_t: float = 3.0,
254
+ tex_slat_guidance_strength: float = 1.0,
255
+ tex_slat_guidance_rescale: float = 0.0,
256
+ tex_slat_sampling_steps: int = 12,
257
+ tex_slat_rescale_t: float = 3.0,
258
+ ) -> Dict:
259
+ init_models()
260
  torch.manual_seed(seed)
261
  hr_resolution = int(resolution)
262
+
263
+ img = Image.open(image["path"])
264
+ image_preprocessed = pipeline.preprocess_image(img)
265
+ temp_processed_path = os.path.join(TMP_DIR, "temp_proc.png")
266
+ image_preprocessed.save(temp_processed_path)
267
+
 
 
 
 
268
  camera_params = get_camera_params_wild_moge(
269
+ temp_processed_path, device="cuda",
270
  mesh_scale=WILD_MESH_SCALE, extend_pixel=WILD_EXTEND_PIXEL,
271
  image_resolution=WILD_IMAGE_RESOLUTION,
272
  )
273
+
274
  ss_sampler_override = {"steps": ss_sampling_steps, "guidance_strength": ss_guidance_strength,
275
  "guidance_rescale": ss_guidance_rescale, "rescale_t": ss_rescale_t}
276
  shape_sampler_override = {"steps": shape_slat_sampling_steps, "guidance_strength": shape_slat_guidance_strength,
 
278
  tex_sampler_override = {"steps": tex_slat_sampling_steps, "guidance_strength": tex_slat_guidance_strength,
279
  "guidance_rescale": tex_slat_guidance_rescale, "rescale_t": tex_slat_rescale_t}
280
 
 
281
  pipeline_type = f"{hr_resolution}_cascade"
282
  mesh_list, (shape_slat, tex_slat, res) = pipeline.run(
283
  image_preprocessed,
 
291
  pipeline_type=pipeline_type,
292
  max_num_tokens=CASCADE_MAX_NUM_TOKENS,
293
  )
294
+
295
  mesh = mesh_list[0]
296
+ state_path = pack_state(shape_slat, tex_slat, res)
297
+
 
 
 
298
  mesh.simplify(16777216)
299
+ renders = render_utils.render_proj_aligned_video(
300
  mesh, camera_angle_x=camera_params['camera_angle_x'],
301
  distance=camera_params['distance'], resolution=1024,
302
  num_frames=STEPS, envmap=envmap,
303
  )
304
+
305
+ # Save renders and return paths
306
+ render_files = {}
307
+ for mode_key, frames in renders.items():
308
+ mode_files = []
309
+ for i, frame in enumerate(frames):
310
+ p = os.path.abspath(os.path.join(TMP_DIR, f"render_{mode_key}_{i}_{int(time.time()*1000)}.jpg"))
311
+ Image.fromarray(frame).save(p, quality=85)
312
+ mode_files.append(FileData(path=p))
313
+ render_files[mode_key] = mode_files
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
+ return {
316
+ "render_paths": render_files,
317
+ "state_path": os.path.abspath(state_path)
318
+ }
319
 
320
+ @app.api()
321
  @spaces.GPU(duration=120)
322
+ def extract_glb_api(state_path: str, decimation_target: int, texture_size: int) -> FileData:
323
+ init_models()
324
+ shape_slat, tex_slat, res = unpack_state(state_path)
325
  mesh = pipeline.decode_latent(shape_slat, tex_slat, res)[0]
326
  glb = o_voxel.postprocess.to_glb(
327
  vertices=mesh.vertices, faces=mesh.faces, attr_volume=mesh.attrs,
 
330
  decimation_target=decimation_target, texture_size=texture_size,
331
  remesh=True, remesh_band=1, remesh_project=0, use_tqdm=True,
332
  )
 
333
  rot = np.array([
334
  [-1, 0, 0, 0],
335
  [ 0, 0, -1, 0],
 
337
  [ 0, 0, 0, 1],
338
  ], dtype=np.float64)
339
  glb.apply_transform(rot)
340
+
341
+ out_glb = os.path.join(TMP_DIR, f"result_{int(time.time()*1000)}.glb")
342
+ glb.export(out_glb, extension_webp=True)
343
+ return FileData(path=out_glb)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
 
345
+ # Mount assets and tmp for direct access
346
+ app.mount("/assets", StaticFiles(directory="assets"), name="assets")
347
+ app.mount("/tmp", StaticFiles(directory=TMP_DIR), name="tmp")
348
 
349
  if __name__ == "__main__":
350
+ # Re-install utils3d as in original app.py
351
+ subprocess.run([
352
+ "pip", "install", "--force-reinstall", "--no-deps",
353
+ "https://github.com/LDYang694/Storages/releases/download/20260430/utils3d-0.0.2-py3-none-any.whl"
354
+ ], check=True)
355
+
356
+ # Pre-initialize models before launching the server
357
+ init_models()
358
+
359
+ app.launch(show_error=True, share=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
index.html ADDED
@@ -0,0 +1,936 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Pixal3D | AI Image-to-3D</title>
7
+
8
+ <!-- Fonts & Icons -->
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
12
+ <script src="https://unpkg.com/lucide@latest"></script>
13
+ <script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/4.0.0/model-viewer.min.js"></script>
14
+
15
+ <style>
16
+ :root {
17
+ --primary: #818cf8;
18
+ --primary-dark: #6366f1;
19
+ --accent: #10b981;
20
+ --bg: #0b0f1a;
21
+ --surface: #161c2d;
22
+ --surface-light: #222b3e;
23
+ --border: rgba(255, 255, 255, 0.08);
24
+ --text: #f1f5f9;
25
+ --text-dim: #94a3b8;
26
+ --glass: rgba(255, 255, 255, 0.03);
27
+ --radius-lg: 24px;
28
+ --radius-md: 16px;
29
+ --radius-sm: 8px;
30
+ }
31
+
32
+ * {
33
+ margin: 0;
34
+ padding: 0;
35
+ box-sizing: border-box;
36
+ }
37
+
38
+ body {
39
+ font-family: 'Plus Jakarta Sans', sans-serif;
40
+ background: var(--bg);
41
+ color: var(--text);
42
+ min-height: 100vh;
43
+ display: flex;
44
+ flex-direction: column;
45
+ overflow-x: hidden;
46
+ background:
47
+ radial-gradient(circle at 0% 0%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
48
+ radial-gradient(circle at 100% 100%, rgba(16, 185, 129, 0.1) 0%, transparent 40%);
49
+ }
50
+
51
+ /* Top Navigation / Steps */
52
+ .app-shell {
53
+ display: flex;
54
+ height: 100vh;
55
+ width: 100vw;
56
+ }
57
+
58
+ .sidebar {
59
+ width: 380px;
60
+ background: var(--surface);
61
+ border-right: 1px solid var(--border);
62
+ display: flex;
63
+ flex-direction: column;
64
+ padding: 1.5rem;
65
+ overflow-y: auto;
66
+ z-index: 10;
67
+ }
68
+
69
+ .main-content {
70
+ flex: 1;
71
+ display: flex;
72
+ flex-direction: column;
73
+ position: relative;
74
+ background: rgba(0,0,0,0.2);
75
+ }
76
+
77
+ header {
78
+ padding: 1rem 2rem;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: space-between;
82
+ border-bottom: 1px solid var(--border);
83
+ background: rgba(11, 15, 26, 0.8);
84
+ backdrop-filter: blur(10px);
85
+ }
86
+
87
+ .logo {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 0.75rem;
91
+ font-family: 'Outfit', sans-serif;
92
+ font-weight: 800;
93
+ font-size: 1.5rem;
94
+ background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
95
+ -webkit-background-clip: text;
96
+ -webkit-text-fill-color: transparent;
97
+ }
98
+
99
+ .logo i {
100
+ color: var(--primary);
101
+ -webkit-text-fill-color: initial;
102
+ }
103
+
104
+ .steps-nav {
105
+ display: flex;
106
+ gap: 2rem;
107
+ }
108
+
109
+ .step-item {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 0.5rem;
113
+ font-size: 0.9rem;
114
+ font-weight: 600;
115
+ color: var(--text-dim);
116
+ transition: all 0.3s;
117
+ cursor: pointer;
118
+ padding: 0.5rem 0;
119
+ border-bottom: 2px solid transparent;
120
+ }
121
+
122
+ .step-item.active {
123
+ color: var(--primary);
124
+ border-bottom-color: var(--primary);
125
+ }
126
+
127
+ .step-item.completed {
128
+ color: var(--accent);
129
+ }
130
+
131
+ /* Workspace Panels */
132
+ .workspace {
133
+ flex: 1;
134
+ padding: 2rem;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ position: relative;
139
+ }
140
+
141
+ .panel {
142
+ width: 100%;
143
+ height: 100%;
144
+ display: none;
145
+ flex-direction: column;
146
+ align-items: center;
147
+ justify-content: center;
148
+ animation: fadeIn 0.4s ease-out;
149
+ }
150
+
151
+ .panel.active {
152
+ display: flex;
153
+ }
154
+
155
+ @keyframes fadeIn {
156
+ from { opacity: 0; transform: translateY(10px); }
157
+ to { opacity: 1; transform: translateY(0); }
158
+ }
159
+
160
+ /* Upload Zone */
161
+ .upload-card {
162
+ width: 100%;
163
+ max-width: 600px;
164
+ aspect-ratio: 4/3;
165
+ background: var(--surface-light);
166
+ border: 2px dashed var(--border);
167
+ border-radius: var(--radius-lg);
168
+ display: flex;
169
+ flex-direction: column;
170
+ align-items: center;
171
+ justify-content: center;
172
+ cursor: pointer;
173
+ transition: all 0.3s;
174
+ position: relative;
175
+ overflow: hidden;
176
+ }
177
+
178
+ .upload-card:hover {
179
+ border-color: var(--primary);
180
+ background: rgba(99, 102, 241, 0.05);
181
+ }
182
+
183
+ .upload-card img {
184
+ width: 100%;
185
+ height: 100%;
186
+ object-fit: contain;
187
+ display: none;
188
+ }
189
+
190
+ .upload-hint {
191
+ display: flex;
192
+ flex-direction: column;
193
+ align-items: center;
194
+ gap: 1rem;
195
+ color: var(--text-dim);
196
+ text-align: center;
197
+ padding: 2rem;
198
+ }
199
+
200
+ .upload-hint i {
201
+ width: 48px;
202
+ height: 48px;
203
+ color: var(--primary);
204
+ }
205
+
206
+ /* Result Viewers */
207
+ .viewer-wrapper {
208
+ width: 100%;
209
+ height: 100%;
210
+ border-radius: var(--radius-lg);
211
+ overflow: hidden;
212
+ background: #000;
213
+ position: relative;
214
+ box-shadow: 0 40px 100px rgba(0,0,0,0.6);
215
+ }
216
+
217
+ #frame-container {
218
+ width: 100%;
219
+ height: 100%;
220
+ position: relative;
221
+ }
222
+
223
+ .preview-frame {
224
+ position: absolute;
225
+ inset: 0;
226
+ width: 100%;
227
+ height: 100%;
228
+ object-fit: contain;
229
+ display: none;
230
+ }
231
+
232
+ .preview-frame.active {
233
+ display: block;
234
+ }
235
+
236
+ .viewer-overlay {
237
+ position: absolute;
238
+ bottom: 2rem;
239
+ left: 50%;
240
+ transform: translateX(-50%);
241
+ background: rgba(11, 15, 26, 0.6);
242
+ backdrop-filter: blur(12px);
243
+ padding: 1rem 2rem;
244
+ border-radius: 100px;
245
+ border: 1px solid var(--border);
246
+ display: flex;
247
+ align-items: center;
248
+ gap: 1.5rem;
249
+ width: 80%;
250
+ max-width: 600px;
251
+ }
252
+
253
+ /* Model Viewer Customization */
254
+ model-viewer {
255
+ width: 100%;
256
+ height: 100%;
257
+ background: radial-gradient(circle at 50% 50%, #1a2235 0%, #0b0f1a 100%);
258
+ }
259
+
260
+ /* Sidebar Controls */
261
+ .sidebar-section {
262
+ margin-bottom: 2rem;
263
+ }
264
+
265
+ .sidebar-section h3 {
266
+ font-size: 0.75rem;
267
+ text-transform: uppercase;
268
+ letter-spacing: 0.1em;
269
+ color: var(--text-dim);
270
+ margin-bottom: 1.25rem;
271
+ display: flex;
272
+ align-items: center;
273
+ gap: 0.5rem;
274
+ }
275
+
276
+ .control-group {
277
+ display: flex;
278
+ flex-direction: column;
279
+ gap: 1rem;
280
+ }
281
+
282
+ .input-wrapper {
283
+ display: flex;
284
+ flex-direction: column;
285
+ gap: 0.5rem;
286
+ }
287
+
288
+ .input-wrapper label {
289
+ font-size: 0.85rem;
290
+ font-weight: 600;
291
+ color: #cbd5e1;
292
+ display: flex;
293
+ justify-content: space-between;
294
+ }
295
+
296
+ .input-wrapper label span {
297
+ color: var(--primary);
298
+ font-family: monospace;
299
+ }
300
+
301
+ select, input[type="number"] {
302
+ background: var(--surface-light);
303
+ border: 1px solid var(--border);
304
+ color: white;
305
+ padding: 0.75rem;
306
+ border-radius: var(--radius-sm);
307
+ width: 100%;
308
+ outline: none;
309
+ transition: border-color 0.2s;
310
+ }
311
+
312
+ select:focus {
313
+ border-color: var(--primary);
314
+ }
315
+
316
+ input[type="range"] {
317
+ -webkit-appearance: none;
318
+ height: 4px;
319
+ background: var(--border);
320
+ border-radius: 2px;
321
+ margin: 10px 0;
322
+ }
323
+
324
+ input[type="range"]::-webkit-slider-thumb {
325
+ -webkit-appearance: none;
326
+ width: 16px;
327
+ height: 16px;
328
+ background: var(--primary);
329
+ border-radius: 50%;
330
+ cursor: pointer;
331
+ border: 3px solid var(--surface);
332
+ box-shadow: 0 0 10px rgba(129, 140, 248, 0.4);
333
+ }
334
+
335
+ /* Action Buttons */
336
+ .btn-stack {
337
+ margin-top: auto;
338
+ display: flex;
339
+ flex-direction: column;
340
+ gap: 0.75rem;
341
+ }
342
+
343
+ .btn {
344
+ width: 100%;
345
+ padding: 1rem;
346
+ border-radius: var(--radius-md);
347
+ font-weight: 700;
348
+ font-size: 0.95rem;
349
+ cursor: pointer;
350
+ transition: all 0.3s;
351
+ display: flex;
352
+ align-items: center;
353
+ justify-content: center;
354
+ gap: 0.75rem;
355
+ border: none;
356
+ }
357
+
358
+ .btn-primary {
359
+ background: var(--primary);
360
+ color: white;
361
+ box-shadow: 0 10px 20px rgba(99, 102, 241, 0.2);
362
+ }
363
+
364
+ .btn-primary:hover {
365
+ background: var(--primary-dark);
366
+ transform: translateY(-2px);
367
+ }
368
+
369
+ .btn-primary:disabled {
370
+ background: #334155;
371
+ color: #64748b;
372
+ cursor: not-allowed;
373
+ transform: none;
374
+ }
375
+
376
+ .btn-outline {
377
+ background: transparent;
378
+ border: 1px solid var(--border);
379
+ color: var(--text);
380
+ }
381
+
382
+ .btn-outline:hover {
383
+ background: var(--border);
384
+ }
385
+
386
+ /* Mode Buttons */
387
+ .mode-grid {
388
+ display: grid;
389
+ grid-template-columns: repeat(3, 1fr);
390
+ gap: 0.5rem;
391
+ }
392
+
393
+ .mode-tab {
394
+ background: var(--surface-light);
395
+ border: 1px solid var(--border);
396
+ padding: 0.5rem;
397
+ border-radius: var(--radius-sm);
398
+ font-size: 0.75rem;
399
+ font-weight: 600;
400
+ text-align: center;
401
+ cursor: pointer;
402
+ transition: all 0.2s;
403
+ color: var(--text-dim);
404
+ }
405
+
406
+ .mode-tab.active {
407
+ background: var(--primary);
408
+ color: white;
409
+ border-color: var(--primary);
410
+ }
411
+
412
+ /* Examples Footer */
413
+ .examples-drawer {
414
+ padding: 1.5rem 2rem;
415
+ border-top: 1px solid var(--border);
416
+ background: var(--surface);
417
+ }
418
+
419
+ .examples-grid {
420
+ display: flex;
421
+ gap: 1rem;
422
+ overflow-x: auto;
423
+ padding-bottom: 0.5rem;
424
+ }
425
+
426
+ .example-item {
427
+ flex: 0 0 100px;
428
+ aspect-ratio: 1/1;
429
+ border-radius: var(--radius-md);
430
+ overflow: hidden;
431
+ cursor: pointer;
432
+ border: 2px solid transparent;
433
+ transition: all 0.2s;
434
+ }
435
+
436
+ .example-item:hover {
437
+ transform: translateY(-4px);
438
+ border-color: var(--primary);
439
+ }
440
+
441
+ .example-item img {
442
+ width: 100%;
443
+ height: 100%;
444
+ object-fit: cover;
445
+ }
446
+
447
+ /* Loading & Status */
448
+ .loading-overlay {
449
+ position: fixed;
450
+ inset: 0;
451
+ background: rgba(11, 15, 26, 0.9);
452
+ z-index: 1000;
453
+ display: none;
454
+ flex-direction: column;
455
+ align-items: center;
456
+ justify-content: center;
457
+ gap: 2rem;
458
+ backdrop-filter: blur(8px);
459
+ }
460
+
461
+ .loader-ring {
462
+ width: 80px;
463
+ height: 80px;
464
+ border-radius: 50%;
465
+ border: 4px solid var(--border);
466
+ border-top-color: var(--primary);
467
+ animation: spin 1s linear infinite;
468
+ }
469
+
470
+ @keyframes spin { 100% { transform: rotate(360deg); } }
471
+
472
+ .status-toast {
473
+ position: fixed;
474
+ bottom: 2rem;
475
+ right: 2rem;
476
+ background: var(--surface-light);
477
+ padding: 1rem 1.5rem;
478
+ border-radius: var(--radius-md);
479
+ border: 1px solid var(--border);
480
+ border-left: 4px solid var(--primary);
481
+ box-shadow: 0 20px 40px rgba(0,0,0,0.4);
482
+ display: none;
483
+ z-index: 2000;
484
+ animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
485
+ }
486
+
487
+ @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
488
+
489
+ /* Scrollbar */
490
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
491
+ ::-webkit-scrollbar-track { background: transparent; }
492
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
493
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
494
+
495
+ </style>
496
+ </head>
497
+ <body>
498
+
499
+ <div class="app-shell">
500
+ <!-- Left Sidebar: Controls -->
501
+ <div class="sidebar">
502
+ <div class="logo" style="margin-bottom: 2.5rem;">
503
+ <i data-lucide="sparkles"></i>
504
+ <span>Pixal3D</span>
505
+ </div>
506
+
507
+ <div class="sidebar-section">
508
+ <h3><i data-lucide="sliders-horizontal" style="width: 14px;"></i> Base Settings</h3>
509
+ <div class="control-group">
510
+ <div class="input-wrapper">
511
+ <label>Target Resolution</label>
512
+ <select id="resolution">
513
+ <option value="1024">1024 (Balanced)</option>
514
+ <option value="1536" selected>1536 (High Quality)</option>
515
+ </select>
516
+ </div>
517
+ <div class="input-wrapper">
518
+ <label>Generation Seed <span>#<span id="seed-display">42</span></span></label>
519
+ <div style="display: flex; gap: 0.5rem;">
520
+ <input type="number" id="seed" value="42" style="flex: 1;">
521
+ <button class="btn btn-outline" style="width: 50px; padding: 0;" onclick="randomizeSeed()">
522
+ <i data-lucide="rotate-cw" style="width: 16px;"></i>
523
+ </button>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ </div>
528
+
529
+ <div class="sidebar-section" id="render-controls" style="display: none;">
530
+ <h3><i data-lucide="palette" style="width: 14px;"></i> Render Mode</h3>
531
+ <div class="mode-grid" id="mode-grid">
532
+ <!-- Tabs injected via JS -->
533
+ </div>
534
+ </div>
535
+
536
+ <div class="sidebar-section">
537
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; cursor: pointer;" onclick="toggleAdvanced()">
538
+ <h3 style="margin-bottom: 0;"><i data-lucide="shield-alert" style="width: 14px;"></i> Advanced Engine</h3>
539
+ <i data-lucide="chevron-down" id="adv-chevron" style="width: 16px; transition: transform 0.3s;"></i>
540
+ </div>
541
+ <div id="advanced-settings" style="display: none; padding-top: 1rem; border-top: 1px solid var(--border);">
542
+ <div class="control-group">
543
+ <div class="input-wrapper">
544
+ <label>SS Guidance <span><span id="ss_gs_val">7.5</span></span></label>
545
+ <input type="range" id="ss_gs" min="1" max="10" step="0.1" value="7.5" oninput="updateVal('ss_gs')">
546
+ </div>
547
+ <div class="input-wrapper">
548
+ <label>SS Sampling <span><span id="ss_steps_val">12</span></span></label>
549
+ <input type="range" id="ss_steps" min="1" max="50" step="1" value="12" oninput="updateVal('ss_steps')">
550
+ </div>
551
+ <div class="input-wrapper">
552
+ <label>Shape Guidance <span><span id="shape_gs_val">7.5</span></span></label>
553
+ <input type="range" id="shape_gs" min="1" max="10" step="0.1" value="7.5" oninput="updateVal('shape_gs')">
554
+ </div>
555
+ <hr style="border: 0; border-top: 1px solid var(--border); margin: 0.5rem 0;">
556
+ <div class="input-wrapper">
557
+ <label>Decimation <span><span id="decim_val">1M</span></span></label>
558
+ <input type="range" id="decimation" min="100000" max="1000000" step="10000" value="1000000" oninput="updateVal('decimation')">
559
+ </div>
560
+ </div>
561
+ </div>
562
+ </div>
563
+
564
+ <div class="btn-stack">
565
+ <button class="btn btn-primary" id="generate-btn" disabled>
566
+ <i data-lucide="zap"></i>
567
+ Start Generation
568
+ </button>
569
+ <button class="btn btn-outline" id="extract-btn" style="display: none;">
570
+ <i data-lucide="box"></i>
571
+ Extract Mesh (GLB)
572
+ </button>
573
+ <button class="btn btn-outline" id="download-btn" style="display: none; background: rgba(16, 185, 129, 0.1); border-color: var(--accent); color: var(--accent);">
574
+ <i data-lucide="download"></i>
575
+ Download Asset
576
+ </button>
577
+ </div>
578
+ </div>
579
+
580
+ <!-- Right: Main Area -->
581
+ <div class="main-content">
582
+ <header>
583
+ <div class="steps-nav">
584
+ <div class="step-item active" id="step-1">
585
+ <i data-lucide="image"></i>
586
+ <span>1. SOURCE</span>
587
+ </div>
588
+ <div class="step-item" id="step-2">
589
+ <i data-lucide="view"></i>
590
+ <span>2. PREVIEW</span>
591
+ </div>
592
+ <div class="step-item" id="step-3">
593
+ <i data-lucide="box"></i>
594
+ <span>3. RESULT</span>
595
+ </div>
596
+ </div>
597
+ <div style="color: var(--text-dim); font-size: 0.8rem; font-weight: 500;">
598
+ TRELLIS.2 Engine • V2.6
599
+ </div>
600
+ </header>
601
+
602
+ <div class="workspace">
603
+ <!-- Panel 1: Upload -->
604
+ <div class="panel active" id="panel-1">
605
+ <div class="upload-card" id="drop-zone" onclick="document.getElementById('file-input').click()">
606
+ <input type="file" id="file-input" hidden accept="image/*">
607
+ <div class="upload-hint" id="upload-hint">
608
+ <i data-lucide="cloud-upload"></i>
609
+ <h2 style="font-family: 'Outfit'; margin-top: 1rem;">Upload Reference</h2>
610
+ <p>Drag and drop any image, or click to browse</p>
611
+ </div>
612
+ <img id="source-preview" src="" alt="Source">
613
+ </div>
614
+ </div>
615
+
616
+ <!-- Panel 2: Multi-frame Preview -->
617
+ <div class="panel" id="panel-2">
618
+ <div class="viewer-wrapper">
619
+ <div id="frame-container">
620
+ <!-- Injected via JS -->
621
+ </div>
622
+ <div class="viewer-overlay">
623
+ <i data-lucide="move-horizontal" style="color: var(--primary); width: 20px;"></i>
624
+ <input type="range" id="angle-slider" min="0" max="7" value="0" step="1" style="flex: 1;">
625
+ <div style="font-family: monospace; font-weight: 700; color: var(--primary); font-size: 0.8rem;">
626
+ VIEW_ANGLE: <span id="angle-display">00</span>°
627
+ </div>
628
+ </div>
629
+ </div>
630
+ </div>
631
+
632
+ <!-- Panel 3: 3D Result -->
633
+ <div class="panel" id="panel-3">
634
+ <div class="viewer-wrapper">
635
+ <model-viewer id="main-3d-viewer"
636
+ camera-controls
637
+ auto-rotate
638
+ shadow-intensity="1.5"
639
+ environment-image="neutral"
640
+ exposure="1.2">
641
+ <div slot="progress-bar" style="background: var(--primary); height: 4px;"></div>
642
+ </model-viewer>
643
+ </div>
644
+ </div>
645
+ </div>
646
+
647
+ <!-- Footer: Examples -->
648
+ <div class="examples-drawer">
649
+ <h4 style="font-size: 0.75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem;">Sample Gallery</h4>
650
+ <div class="examples-grid" id="examples-grid">
651
+ <!-- Injected via JS -->
652
+ </div>
653
+ </div>
654
+ </div>
655
+ </div>
656
+
657
+ <div class="loading-overlay" id="loading-overlay">
658
+ <div class="loader-ring"></div>
659
+ <div style="text-align: center;">
660
+ <h2 id="loading-title" style="font-family: 'Outfit'; margin-bottom: 0.5rem;">Synthesizing Geometry</h2>
661
+ <p id="loading-subtitle" style="color: var(--text-dim);">The neural engine is crafting your 3D model...</p>
662
+ </div>
663
+ </div>
664
+
665
+ <div class="status-toast" id="toast">Generation started!</div>
666
+
667
+ <script type="module">
668
+ import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
669
+
670
+ let client;
671
+ let currentFile = null;
672
+ let generationResult = null;
673
+ let currentMode = "shaded_forest";
674
+ let currentFrame = 0;
675
+ let currentStep = 1;
676
+
677
+ const MODES = [
678
+ { name: "Normal", key: "normal" },
679
+ { name: "Clay", key: "clay" },
680
+ { name: "Color", key: "base_color" },
681
+ { name: "Forest", key: "shaded_forest" },
682
+ { name: "Sunset", key: "shaded_sunset" },
683
+ { name: "Blue", key: "shaded_courtyard" }
684
+ ];
685
+
686
+ async function init() {
687
+ lucide.createIcons();
688
+ try {
689
+ client = await Client.connect(window.location.origin);
690
+ setupUI();
691
+ loadSamples();
692
+ } catch (err) {
693
+ console.error("Connection error:", err);
694
+ showToast("Connection failed. Try refreshing.");
695
+ }
696
+ }
697
+
698
+ function setupUI() {
699
+ // File Handling
700
+ const dropZone = document.getElementById('drop-zone');
701
+ const fileInput = document.getElementById('file-input');
702
+
703
+ dropZone.ondragover = (e) => { e.preventDefault(); dropZone.style.borderColor = 'var(--primary)'; };
704
+ dropZone.ondragleave = () => dropZone.style.borderColor = 'var(--border)';
705
+ dropZone.ondrop = (e) => {
706
+ e.preventDefault();
707
+ if (e.dataTransfer.files.length) handleImageUpload(e.dataTransfer.files[0]);
708
+ };
709
+ fileInput.onchange = (e) => { if (e.target.files.length) handleImageUpload(e.target.files[0]); };
710
+
711
+ // Buttons
712
+ document.getElementById('generate-btn').onclick = startGeneration;
713
+ document.getElementById('extract-btn').onclick = startExtraction;
714
+ document.getElementById('download-btn').onclick = () => {
715
+ const link = document.createElement('a');
716
+ link.href = document.getElementById('main-3d-viewer').src;
717
+ link.download = "pixal3d_export.glb";
718
+ link.click();
719
+ };
720
+
721
+ // Slider
722
+ document.getElementById('angle-slider').oninput = (e) => {
723
+ currentFrame = parseInt(e.target.value);
724
+ document.getElementById('angle-display').textContent = (currentFrame * 22.5).toFixed(0).padStart(2, '0');
725
+ updateFrame();
726
+ };
727
+
728
+ // Mode Grid
729
+ const grid = document.getElementById('mode-grid');
730
+ MODES.forEach(m => {
731
+ const tab = document.createElement('div');
732
+ tab.className = `mode-tab ${m.key === currentMode ? 'active' : ''}`;
733
+ tab.textContent = m.name;
734
+ tab.onclick = () => {
735
+ currentMode = m.key;
736
+ document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
737
+ tab.classList.add('active');
738
+ updateFrame();
739
+ };
740
+ grid.appendChild(tab);
741
+ });
742
+ }
743
+
744
+ async function handleImageUpload(file) {
745
+ currentFile = file;
746
+ const reader = new FileReader();
747
+ reader.onload = (e) => {
748
+ const img = document.getElementById('source-preview');
749
+ const hint = document.getElementById('upload-hint');
750
+ img.src = e.target.result;
751
+ img.style.display = 'block';
752
+ hint.style.display = 'none';
753
+ document.getElementById('generate-btn').disabled = false;
754
+ setStep(1);
755
+ };
756
+ reader.readAsDataURL(file);
757
+
758
+ // Background pre-warm
759
+ client.predict("/preprocess", { image: handle_file(file) }).catch(console.error);
760
+ }
761
+
762
+ function setStep(num) {
763
+ currentStep = num;
764
+ document.querySelectorAll('.step-item').forEach((item, i) => {
765
+ item.className = 'step-item';
766
+ if (i + 1 < num) item.classList.add('completed');
767
+ if (i + 1 === num) item.classList.add('active');
768
+ });
769
+ document.querySelectorAll('.panel').forEach((p, i) => {
770
+ p.classList.toggle('active', i + 1 === num);
771
+ });
772
+
773
+ // Toggle side controls based on step
774
+ document.getElementById('render-controls').style.display = (num >= 2) ? 'block' : 'none';
775
+ document.getElementById('extract-btn').style.display = (num === 2) ? 'flex' : 'none';
776
+ document.getElementById('download-btn').style.display = (num === 3) ? 'flex' : 'none';
777
+ }
778
+
779
+ async function startGeneration() {
780
+ if (!currentFile) return;
781
+
782
+ showLoading("Neural Synthesis", "Optimizing geometry for " + (document.getElementById('resolution').value) + "px output...");
783
+ try {
784
+ const params = {
785
+ image: handle_file(currentFile),
786
+ seed: parseInt(document.getElementById('seed').value),
787
+ resolution: parseInt(document.getElementById('resolution').value),
788
+ ss_guidance_strength: parseFloat(document.getElementById('ss_gs').value),
789
+ ss_sampling_steps: parseInt(document.getElementById('ss_steps').value),
790
+ shape_slat_guidance_strength: parseFloat(document.getElementById('shape_gs').value)
791
+ };
792
+
793
+ const result = await client.predict("/generate_3d", params);
794
+ generationResult = result.data[0];
795
+
796
+ populateFrames(generationResult.render_paths);
797
+ setStep(2);
798
+ hideLoading();
799
+ showToast("Generation complete!");
800
+ } catch (err) {
801
+ console.error(err);
802
+ hideLoading();
803
+ showToast("An error occurred during synthesis.");
804
+ }
805
+ }
806
+
807
+ function populateFrames(renderPaths) {
808
+ const container = document.getElementById('frame-container');
809
+ container.innerHTML = '';
810
+ Object.entries(renderPaths).forEach(([mode, files]) => {
811
+ files.forEach((file, i) => {
812
+ const img = document.createElement('img');
813
+ // Try the URL from Gradio, fallback to our mounted /tmp route if it's an absolute local path
814
+ let url = file.url;
815
+ if (!url && file.path) {
816
+ const filename = file.path.split(/[\\/]/).pop();
817
+ url = `/tmp/${filename}`;
818
+ }
819
+ img.src = url;
820
+ img.className = 'preview-frame';
821
+ img.id = `frame-${mode}-${i}`;
822
+ img.onerror = () => {
823
+ // Fallback attempt if the first URL fails
824
+ const filename = file.path ? file.path.split(/[\\/]/).pop() : null;
825
+ if (filename && !img.src.includes('/tmp/')) {
826
+ img.src = `/tmp/${filename}`;
827
+ }
828
+ };
829
+ container.appendChild(img);
830
+ });
831
+ });
832
+ updateFrame();
833
+ }
834
+
835
+ function updateFrame() {
836
+ document.querySelectorAll('.preview-frame').forEach(f => f.classList.remove('active'));
837
+ const active = document.getElementById(`frame-${currentMode}-${currentFrame}`);
838
+ if (active) active.classList.add('active');
839
+ }
840
+
841
+ async function startExtraction() {
842
+ if (!generationResult) return;
843
+
844
+ showLoading("Finalizing Mesh", "Performing PBR texture baking and decimation...");
845
+ try {
846
+ const params = {
847
+ state_path: generationResult.state_path,
848
+ decimation_target: parseInt(document.getElementById('decimation').value),
849
+ texture_size: 4096 // Constant for highest quality
850
+ };
851
+
852
+ const result = await client.predict("/extract_glb_api", params);
853
+ const glbUrl = result.data[0].url;
854
+
855
+ const viewer = document.getElementById('main-3d-viewer');
856
+ viewer.src = glbUrl;
857
+ setStep(3);
858
+ hideLoading();
859
+ showToast("3D Asset ready!");
860
+ } catch (err) {
861
+ console.error(err);
862
+ hideLoading();
863
+ showToast("Extraction failed.");
864
+ }
865
+ }
866
+
867
+ function loadSamples() {
868
+ const grid = document.getElementById('examples-grid');
869
+ const samples = [
870
+ 'assets/example_image/0a34fae7ba57cb8870df5325b9c30ea474def1b0913c19c596655b85a79fdee4.webp',
871
+ 'assets/example_image/0e4984a9b3765ce80e9853443f9319ecedf90885c74b56cccfebc09402740f8a.webp',
872
+ 'assets/example_image/130c2b18f1651a70f8aa15b2c99f8dba29bb943044d92871f9223bd3e989e8b1.webp',
873
+ 'assets/example_image/22a868bac8e62511fccd2bc82ed31ae77ed31ae2a8a149be7150957f11b30c9b.webp',
874
+ 'assets/example_image/3903b87907a6b4947006e6fc7c0c64f40cd98932a02bf0ecf7d6dfae776f3a38.webp',
875
+ 'assets/example_image/4bc7abe209c8673dd3766ee4fad14d40acbed02d118e7629f645c60fd77313f1.webp'
876
+ ];
877
+
878
+ samples.forEach(path => {
879
+ const div = document.createElement('div');
880
+ div.className = 'example-item';
881
+ div.innerHTML = `<img src="${path}">`;
882
+ div.onclick = async () => {
883
+ showLoading("Fetching Sample", "Loading high-resolution asset from gallery...");
884
+ const res = await fetch(path);
885
+ const blob = await res.blob();
886
+ const file = new File([blob], "sample.webp", { type: "image/webp" });
887
+ await handleImageUpload(file);
888
+ hideLoading();
889
+ };
890
+ grid.appendChild(div);
891
+ });
892
+ }
893
+
894
+ // Helpers
895
+ window.toggleAdvanced = () => {
896
+ const el = document.getElementById('advanced-settings');
897
+ const chev = document.getElementById('adv-chevron');
898
+ const isOpen = el.style.display === 'block';
899
+ el.style.display = isOpen ? 'none' : 'block';
900
+ chev.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(180deg)';
901
+ };
902
+
903
+ window.updateVal = (id) => {
904
+ const val = document.getElementById(id).value;
905
+ let label = val;
906
+ if (id === 'decimation') label = (val/1000000).toFixed(1) + 'M';
907
+ document.getElementById(id + '_val').textContent = label;
908
+ };
909
+
910
+ window.randomizeSeed = () => {
911
+ const s = Math.floor(Math.random() * 999999);
912
+ document.getElementById('seed').value = s;
913
+ document.getElementById('seed-display').textContent = s;
914
+ };
915
+
916
+ function showLoading(title, sub) {
917
+ document.getElementById('loading-title').textContent = title;
918
+ document.getElementById('loading-subtitle').textContent = sub;
919
+ document.getElementById('loading-overlay').style.display = 'flex';
920
+ }
921
+
922
+ function hideLoading() {
923
+ document.getElementById('loading-overlay').style.display = 'none';
924
+ }
925
+
926
+ function showToast(msg) {
927
+ const t = document.getElementById('toast');
928
+ t.textContent = msg;
929
+ t.style.display = 'block';
930
+ setTimeout(() => t.style.display = 'none', 3000);
931
+ }
932
+
933
+ init();
934
+ </script>
935
+ </body>
936
+ </html>