UniLat3D commited on
Commit
97d1731
·
verified ·
1 Parent(s): 6c216ff

Show real GLB extraction stages and elapsed time; add seven TRELLIS.2 HDR preview modes

Browse files
.gitattributes CHANGED
@@ -50,3 +50,6 @@ assets/supratex_fighter_typography.mp4 filter=lfs diff=lfs merge=lfs -text
50
  assets/fighter.png filter=lfs diff=lfs merge=lfs -text
51
 
52
  assets/urban_gardener.png filter=lfs diff=lfs merge=lfs -text
 
 
 
 
50
  assets/fighter.png filter=lfs diff=lfs merge=lfs -text
51
 
52
  assets/urban_gardener.png filter=lfs diff=lfs merge=lfs -text
53
+ assets/preview-lighting/courtyard.exr filter=lfs diff=lfs merge=lfs -text
54
+ assets/preview-lighting/forest.exr filter=lfs diff=lfs merge=lfs -text
55
+ assets/preview-lighting/sunset.exr filter=lfs diff=lfs merge=lfs -text
app.py CHANGED
@@ -48,8 +48,8 @@ def process_image_generator(input_image, seed=42, generation_token=None, progres
48
 
49
 
50
  @spaces.GPU(duration=600, size='large')
51
- def extract_glb(identifier):
52
- return ovoxel_results.extract(identifier)
53
 
54
 
55
  from hf_space.ui import build_ui, CSS, THEME
 
48
 
49
 
50
  @spaces.GPU(duration=600, size='large')
51
+ def extract_glb(identifier, extraction_token=None):
52
+ return ovoxel_results.extract(identifier, extraction_token)
53
 
54
 
55
  from hf_space.ui import build_ui, CSS, THEME
assets/preview-lighting/README.md ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ HDR environment maps and render-mode swatches from Microsoft's TRELLIS.2 Space,
2
+ revision ebf60b20fc5a4607f90a1c11c0aab0ceeda5429d:
3
+ https://huggingface.co/spaces/microsoft/TRELLIS.2/tree/ebf60b20fc5a4607f90a1c11c0aab0ceeda5429d/assets
4
+
5
+ Environment-map attribution is retained in license.txt. The four HDR files are
6
+ used by the original TRELLIS.2 PbrMeshRenderer / nvdiffrec environment-light backend.
assets/preview-lighting/basecolor.png ADDED
assets/preview-lighting/clay.png ADDED
assets/preview-lighting/courtyard.exr ADDED

Git LFS Details

  • SHA256: 6690b47725965531559380121e6878373eb599655e35fefd109a4bd0911366f3
  • Pointer size: 131 Bytes
  • Size of remote file: 255 kB
assets/preview-lighting/forest.exr ADDED

Git LFS Details

  • SHA256: bdf2298244affa0f85509380fd130ac6d4dfaa3c856df065998f7f4c1a93dc0d
  • Pointer size: 131 Bytes
  • Size of remote file: 553 kB
assets/preview-lighting/hdri_courtyard.png ADDED
assets/preview-lighting/hdri_forest.png ADDED
assets/preview-lighting/hdri_studio.png ADDED
assets/preview-lighting/hdri_sunset.png ADDED
assets/preview-lighting/license.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ All HDRIs are licensed as CC0.
2
+
3
+ These were created by Greg Zaal (Poly Haven https://polyhaven.com).
4
+ Originals used for each HDRI:
5
+ - City: https://polyhaven.com/a/portland_landing_pad
6
+ - Courtyard: https://polyhaven.com/a/courtyard
7
+ - Forest: https://polyhaven.com/a/ninomaru_teien
8
+ - Interior: https://polyhaven.com/a/hotel_room
9
+ - Night: Probably https://polyhaven.com/a/moonless_golf
10
+ - Studio: Probably https://polyhaven.com/a/studio_small_01
11
+ - Sunrise: https://polyhaven.com/a/spruit_sunrise
12
+ - Sunset: https://polyhaven.com/a/venice_sunset
13
+
14
+ 1K resolution of each was taken, and compressed with oiiotool:
15
+ oiiotool input.exr --ch R,G,B -d float --compression dwab:300 --clamp:min=0.0:max=32000.0 -o output.exr
assets/preview-lighting/normal.png ADDED
assets/preview-lighting/studio.exr ADDED
assets/preview-lighting/sunset.exr ADDED

Git LFS Details

  • SHA256: 3bcafdda4f2d7b9759cc1d73004d34d721a274c29b1a0947be88a602dbac426b
  • Pointer size: 131 Bytes
  • Size of remote file: 171 kB
hf_space/extraction_progress.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cross-process GLB extraction stages, polled independently of the GPU queue."""
2
+ import html
3
+ import time
4
+ from hf_space import generation_progress as records
5
+
6
+ # These are completed workflow stages, not a prediction of remaining time.
7
+ STAGES = {
8
+ 'load': (0, 'Loading o-voxel data'),
9
+ 'export_prepare': (1, 'Preparing GLB export'),
10
+ 'mesh_prepare': (1, 'Preparing the mesh'),
11
+ 'initial_cleanup': (1, 'Cleaning the mesh'),
12
+ 'bvh': (2, 'Building surface acceleration'),
13
+ 'cleanup': (2, 'Remeshing and simplifying'),
14
+ 'uv_unwrap': (3, 'Unwrapping UVs'),
15
+ 'texture_sample': (4, 'Baking 4096 px PBR textures'),
16
+ 'finalize': (5, 'Preparing PBR materials'),
17
+ 'file_write': (6, 'Writing GLB'),
18
+ 'validate': (7, 'Validating GLB'),
19
+ }
20
+ TOTAL = 8
21
+
22
+
23
+ def start():
24
+ token = records.start()
25
+ records.update(token, label='Waiting for GPU', completed=0)
26
+ return token
27
+
28
+
29
+ def stage(token, name):
30
+ if name in STAGES:
31
+ completed, label = STAGES[name]
32
+ records.update(token, status='running', label=label, completed=completed)
33
+
34
+
35
+ def finish(token, failed=False):
36
+ records.update(token, status='failed' if failed else 'complete',
37
+ label='Extraction failed — please retry' if failed else 'GLB ready to download',
38
+ **({} if failed else {'completed': TOTAL}), finished_unix=time.time())
39
+
40
+
41
+ def status_html(token):
42
+ record = records._read(token)
43
+ if not record:
44
+ return ''
45
+ elapsed = max(0, record.get('finished_unix', time.time()) - record['started_unix'])
46
+ completed = record.get('completed', 0)
47
+ label = html.escape(record.get('label', 'Waiting for GPU'))
48
+ status = html.escape(record.get('status', 'queued'), quote=True)
49
+ return (f'<div class="st-extraction-progress {status}" role="status" aria-live="polite">'
50
+ f'<div><strong>{label}</strong><span>Elapsed: {int(elapsed)} s</span></div>'
51
+ f'<progress max="{TOTAL}" value="{completed}" aria-label="GLB extraction stages"></progress>'
52
+ f'<small>{completed} / {TOTAL} stages complete</small></div>')
hf_space/native_build.py CHANGED
@@ -113,10 +113,14 @@ def build():
113
  wheels.mkdir(exist_ok=True)
114
  pip = [sys.executable, '-m', 'pip']
115
  completed = []
116
- for name, url, revision, subdir in SOURCES:
 
 
 
117
  update(stage='compiling', current=name, completed=completed)
118
  # Wheel distribution names differ from the public repository names.
119
- distribution = {'CuMesh': 'cumesh', 'FlexGEMM': 'flex_gemm', 'nvdiffrast': 'nvdiffrast', 'TRELLIS.2': 'o_voxel'}[name]
 
120
  cached = list(persistent.glob(distribution + '-*.whl'))
121
  if cached:
122
  local = wheels / cached[0].name
 
113
  wheels.mkdir(exist_ok=True)
114
  pip = [sys.executable, '-m', 'pip']
115
  completed = []
116
+ preview_sources = ((
117
+ 'nvdiffrec', 'https://github.com/JeffreyXiang/nvdiffrec.git',
118
+ 'b296927cc7fd01c2ac1087c8065c4d7248f72da4', ''),)
119
+ for name, url, revision, subdir in (*SOURCES, *preview_sources):
120
  update(stage='compiling', current=name, completed=completed)
121
  # Wheel distribution names differ from the public repository names.
122
+ distribution = {'CuMesh': 'cumesh', 'FlexGEMM': 'flex_gemm', 'nvdiffrast': 'nvdiffrast',
123
+ 'TRELLIS.2': 'o_voxel', 'nvdiffrec': 'nvdiffrec_render'}[name]
124
  cached = list(persistent.glob(distribution + '-*.whl'))
125
  if cached:
126
  local = wheels / cached[0].name
hf_space/ovoxel_preview.js CHANGED
@@ -6,7 +6,9 @@
6
  image.hidden = image.dataset.mode !== root.dataset.mode || image.dataset.view !== root.dataset.view;
7
  });
8
  root.querySelectorAll('.st-ovoxel-modes button').forEach(button => {
9
- button.classList.toggle('active', button.dataset.mode === root.dataset.mode);
 
 
10
  });
11
  };
12
  document.addEventListener('input', event => {
 
6
  image.hidden = image.dataset.mode !== root.dataset.mode || image.dataset.view !== root.dataset.view;
7
  });
8
  root.querySelectorAll('.st-ovoxel-modes button').forEach(button => {
9
+ const active = button.dataset.mode === root.dataset.mode;
10
+ button.classList.toggle('active', active);
11
+ button.setAttribute('aria-pressed', String(active));
12
  });
13
  };
14
  document.addEventListener('input', event => {
hf_space/ovoxel_preview.py CHANGED
@@ -1,36 +1,36 @@
1
  """Render the final decoded o-voxel directly, before UV or GLB extraction.
2
 
3
- Uses TRELLIS.2's bundled render_snapshot/PbrMeshRenderer, with analytic studio
4
- lighting so preview does not require an extra HDR environment dependency.
5
  """
6
  import json
7
  from pathlib import Path
8
  from hf_space.timing import span
9
 
10
 
11
- class StudioLight:
12
- def shade(self, pos, normal, kd, ks, view_pos, specular=True):
13
- import torch
14
- import torch.nn.functional as F
15
- n = F.normalize(normal, dim=-1)
16
- light = F.normalize(n.new_tensor([-.4, .8, 1.]), dim=0)
17
- diffuse = (n*light).sum(-1, keepdim=True).clamp(0, 1)
18
- color = kd*(.55+.45*diffuse)
19
- if specular:
20
- view = F.normalize(view_pos-pos, dim=-1)
21
- half = F.normalize(view+light, dim=-1)
22
- rough = ks[..., 1:2].clamp(.08, 1)
23
- metal = ks[..., 2:3].clamp(0, 1)
24
- highlight = (n*half).sum(-1, keepdim=True).clamp(0, 1).pow(2/rough.square())
25
- color += highlight*(.04*(1-metal)+kd*metal)*.4
26
- return color
 
 
27
 
28
 
29
  def save_and_render(output, representation):
30
  import numpy as np
31
  import torch
32
  from PIL import Image
33
- from supratex.utils.render_utils import render_snapshot
34
  if representation.vertices.device.type != 'cuda':
35
  raise RuntimeError('Multiview rendering requires CUDA')
36
  output = Path(output)
@@ -54,20 +54,21 @@ def save_and_render(output, representation):
54
  extrinsics = [matrix@rotation for matrix in extrinsics]
55
  with torch.no_grad():
56
  frames = render_frames(representation, extrinsics, intrinsics,
57
- options={'resolution':768,'ssaa':1,'peel_layers':2}, envmap=StudioLight())
58
  folder = output.parent/'views'
59
  folder.mkdir(exist_ok=True)
60
- modes = ('shaded','base_color','normal')
61
  for mode in modes:
62
  for index, pixels in enumerate(frames[mode]):
63
  mask = frames['mask'][index][..., :1].astype(np.float32)/255
64
  # Composite the same neutral background in every view.
65
- if mode == 'shaded':
66
  pixels = np.clip(pixels.astype(np.float32)+(1-mask)*25,0,255).astype(np.uint8)
67
  else:
68
  pixels = np.clip(pixels*mask+(1-mask)*25,0,255).astype(np.uint8)
69
  Image.fromarray(pixels).save(folder/f'{mode}-{index:02d}.webp',quality=90)
70
  (output.parent/'preview.json').write_text(json.dumps({
71
  'views':count,'modes':list(modes),'resolution':768,'device':'cuda',
72
- 'renderer':'TRELLIS.2 PbrMeshRenderer','lighting':'analytic studio',
 
73
  'automatic_glb_export':False}))
 
1
  """Render the final decoded o-voxel directly, before UV or GLB extraction.
2
 
3
+ Uses TRELLIS.2's PbrMeshRenderer and HDR environments for material inspection.
 
4
  """
5
  import json
6
  from pathlib import Path
7
  from hf_space.timing import span
8
 
9
 
10
+ ENVIRONMENTS = ('studio', 'forest', 'sunset', 'courtyard')
11
+
12
+
13
+ def load_environments():
14
+ import os
15
+ os.environ['OPENCV_IO_ENABLE_OPENEXR'] = '1'
16
+ import cv2
17
+ import torch
18
+ from supratex.renderers import EnvMap
19
+ folder = Path(__file__).resolve().parents[1]/'assets/preview-lighting'
20
+ environments = {}
21
+ for name in ENVIRONMENTS:
22
+ image = cv2.imread(str(folder/f'{name}.exr'), cv2.IMREAD_UNCHANGED)
23
+ if image is None:
24
+ raise RuntimeError(f'Could not load HDR environment: {name}')
25
+ environments[name] = EnvMap(torch.tensor(cv2.cvtColor(image, cv2.COLOR_BGR2RGB),
26
+ dtype=torch.float32, device='cuda'))
27
+ return environments
28
 
29
 
30
  def save_and_render(output, representation):
31
  import numpy as np
32
  import torch
33
  from PIL import Image
 
34
  if representation.vertices.device.type != 'cuda':
35
  raise RuntimeError('Multiview rendering requires CUDA')
36
  output = Path(output)
 
54
  extrinsics = [matrix@rotation for matrix in extrinsics]
55
  with torch.no_grad():
56
  frames = render_frames(representation, extrinsics, intrinsics,
57
+ options={'resolution':768,'ssaa':1,'peel_layers':2}, envmap=load_environments())
58
  folder = output.parent/'views'
59
  folder.mkdir(exist_ok=True)
60
+ modes = ('normal','clay','base_color',*(f'shaded_{name}' for name in ENVIRONMENTS))
61
  for mode in modes:
62
  for index, pixels in enumerate(frames[mode]):
63
  mask = frames['mask'][index][..., :1].astype(np.float32)/255
64
  # Composite the same neutral background in every view.
65
+ if mode.startswith('shaded_'):
66
  pixels = np.clip(pixels.astype(np.float32)+(1-mask)*25,0,255).astype(np.uint8)
67
  else:
68
  pixels = np.clip(pixels*mask+(1-mask)*25,0,255).astype(np.uint8)
69
  Image.fromarray(pixels).save(folder/f'{mode}-{index:02d}.webp',quality=90)
70
  (output.parent/'preview.json').write_text(json.dumps({
71
  'views':count,'modes':list(modes),'resolution':768,'device':'cuda',
72
+ 'renderer':'TRELLIS.2 PbrMeshRenderer','lighting':'HDR image-based lighting',
73
+ 'environments':list(ENVIRONMENTS),'default_mode':'shaded_forest',
74
  'automatic_glb_export':False}))
hf_space/ovoxel_results.py CHANGED
@@ -3,8 +3,20 @@ import json
3
  from pathlib import Path
4
  import re
5
  import shutil
 
6
  from urllib.parse import quote
7
 
 
 
 
 
 
 
 
 
 
 
 
8
  ROOT = Path('/data/generated/ovoxel-results-v1')
9
 
10
 
@@ -30,21 +42,50 @@ def persist(source, report):
30
  def preview_html(identifier):
31
  root = directory(identifier)
32
  metadata = json.loads((root/'preview.json').read_text())
 
 
 
33
  images = []
34
  for mode in metadata['modes']:
35
  for view in range(metadata['views']):
36
  url = '/gradio_api/file='+quote(str(root/'views'/f'{mode}-{view:02d}.webp'),safe='/')
37
- hidden = '' if mode == 'shaded' and view == 0 else ' hidden'
38
  images.append(f'<img src="{url}" data-mode="{mode}" data-view="{view}" alt="{mode.replace("_"," ")} view {view+1}"{hidden}>')
39
- return ('<div class="st-ovoxel-preview" data-mode="shaded" data-view="0">'
 
 
 
 
 
 
 
 
 
 
 
 
40
  '<div class="st-ovoxel-frames">'+''.join(images)+'</div>'
41
- '<div class="st-ovoxel-modes"><button type="button" data-mode="shaded" class="active">Studio</button>'
42
- '<button type="button" data-mode="base_color">Base color</button>'
43
- '<button type="button" data-mode="normal">Normal</button></div>'
44
  f'<label class="st-ovoxel-angle">View angle <input type="range" min="0" max="{metadata["views"]-1}" value="0" step="1" aria-label="View angle"></label></div>')
45
 
46
 
47
- def extract(identifier):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  """Runs only inside the explicitly requested GPU extraction callback."""
49
  root = directory(identifier)
50
  if not (root/'ready.json').is_file():
@@ -67,6 +108,8 @@ def extract(identifier):
67
  import time
68
  started = time.monotonic()
69
  _export(output, representation,4096,500000,'png',_runtime())
 
 
70
  from hf_space.backend import validate_glb
71
  metadata = validate_glb(output)
72
  (root/'extraction.json').write_text(json.dumps({'status':'complete','device':'cuda',
 
3
  from pathlib import Path
4
  import re
5
  import shutil
6
+ from html import escape
7
  from urllib.parse import quote
8
 
9
+ PREVIEW_MODES = (
10
+ ('normal', 'Normal', 'normal.png'),
11
+ ('clay', 'Clay', 'clay.png'),
12
+ ('base_color', 'Base color', 'basecolor.png'),
13
+ ('shaded_studio', 'HDRI Studio', 'hdri_studio.png'),
14
+ ('shaded_forest', 'HDRI Forest', 'hdri_forest.png'),
15
+ ('shaded_sunset', 'HDRI Sunset', 'hdri_sunset.png'),
16
+ ('shaded_courtyard', 'HDRI Courtyard', 'hdri_courtyard.png'),
17
+ ('shaded', 'Studio', 'hdri_studio.png'), # Previously saved previews.
18
+ )
19
+
20
  ROOT = Path('/data/generated/ovoxel-results-v1')
21
 
22
 
 
42
  def preview_html(identifier):
43
  root = directory(identifier)
44
  metadata = json.loads((root/'preview.json').read_text())
45
+ default = metadata.get('default_mode', 'shaded' if 'shaded' in metadata['modes'] else 'shaded_forest')
46
+ if default not in metadata['modes']:
47
+ default = metadata['modes'][0]
48
  images = []
49
  for mode in metadata['modes']:
50
  for view in range(metadata['views']):
51
  url = '/gradio_api/file='+quote(str(root/'views'/f'{mode}-{view:02d}.webp'),safe='/')
52
+ hidden = '' if mode == default and view == 0 else ' hidden'
53
  images.append(f'<img src="{url}" data-mode="{mode}" data-view="{view}" alt="{mode.replace("_"," ")} view {view+1}"{hidden}>')
54
+ buttons = []
55
+ for mode, label, icon in PREVIEW_MODES:
56
+ if mode not in metadata['modes']:
57
+ continue
58
+ # Inline the tiny swatches; preview frames remain individually cached files.
59
+ import base64
60
+ icon_path = Path(__file__).resolve().parents[1]/'assets/preview-lighting'/icon
61
+ swatch = base64.b64encode(icon_path.read_bytes()).decode('ascii')
62
+ active = mode == default
63
+ buttons.append(f'<button type="button" data-mode="{mode}" class="{"active" if active else ""}" '
64
+ f'title="{label}" aria-label="{label}" aria-pressed="{str(active).lower()}">'
65
+ f'<img src="data:image/png;base64,{swatch}" alt=""><span class="st-mode-name">{label}</span></button>')
66
+ return (f'<div class="st-ovoxel-preview" data-mode="{escape(default, quote=True)}" data-view="0">'
67
  '<div class="st-ovoxel-frames">'+''.join(images)+'</div>'
68
+ '<div class="st-ovoxel-modes" role="group" aria-label="Render mode">'+''.join(buttons)+'</div>'
 
 
69
  f'<label class="st-ovoxel-angle">View angle <input type="range" min="0" max="{metadata["views"]-1}" value="0" step="1" aria-label="View angle"></label></div>')
70
 
71
 
72
+ def extract(identifier, extraction_token=None):
73
+ from hf_space import extraction_progress as progress
74
+ from hf_space import generation_progress
75
+ from hf_space.timing import observe_stages
76
+ token = extraction_token if generation_progress.valid(extraction_token) else progress.start()
77
+ try:
78
+ progress.stage(token, 'load')
79
+ with observe_stages(lambda name: progress.stage(token, name)):
80
+ output = _extract(identifier, token)
81
+ progress.finish(token)
82
+ return output
83
+ except Exception:
84
+ progress.finish(token, failed=True)
85
+ raise
86
+
87
+
88
+ def _extract(identifier, token):
89
  """Runs only inside the explicitly requested GPU extraction callback."""
90
  root = directory(identifier)
91
  if not (root/'ready.json').is_file():
 
108
  import time
109
  started = time.monotonic()
110
  _export(output, representation,4096,500000,'png',_runtime())
111
+ from hf_space import extraction_progress
112
+ extraction_progress.stage(token, 'validate')
113
  from hf_space.backend import validate_glb
114
  metadata = validate_glb(output)
115
  (root/'extraction.json').write_text(json.dumps({'status':'complete','device':'cuda',
hf_space/timing.py CHANGED
@@ -9,6 +9,17 @@ import time
9
  import uuid
10
 
11
  _current = ContextVar('supratex_timing_span', default=None)
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
  def _sync_cuda():
@@ -33,6 +44,9 @@ def _record(path, data):
33
 
34
  @contextmanager
35
  def span(name):
 
 
 
36
  path = os.environ.get('SUPRATEX_TIMING_PATH')
37
  if not path:
38
  yield
 
9
  import uuid
10
 
11
  _current = ContextVar('supratex_timing_span', default=None)
12
+ _observer = ContextVar('supratex_stage_observer', default=None)
13
+
14
+
15
+ @contextmanager
16
+ def observe_stages(callback):
17
+ """Report actual stage starts within this request, even without a timing log."""
18
+ token = _observer.set(callback)
19
+ try:
20
+ yield
21
+ finally:
22
+ _observer.reset(token)
23
 
24
 
25
  def _sync_cuda():
 
44
 
45
  @contextmanager
46
  def span(name):
47
+ callback = _observer.get()
48
+ if callback is not None:
49
+ callback(name)
50
  path = os.environ.get('SUPRATEX_TIMING_PATH')
51
  if not path:
52
  yield
hf_space/ui.css CHANGED
@@ -180,12 +180,25 @@
180
  #st-app .st-ovoxel-frames{position:relative;flex:1;min-height:230px;overflow:hidden}
181
  #st-app .st-ovoxel-frames img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
182
  #st-app .st-ovoxel-frames img[hidden]{display:none!important}
183
- #st-app .st-ovoxel-modes{display:flex;justify-content:center;gap:10px}
184
- #st-app .st-ovoxel-modes button{padding:7px 14px;border:1px solid #484846;border-radius:20px;background:#252523;color:#ccc;font-size:12px}
185
- #st-app .st-ovoxel-modes button.active{color:#f2cd70;border-color:#f2cd70}
 
 
 
186
  #st-app .st-ovoxel-angle{display:flex;align-items:center;gap:14px;color:#aaa;font-size:12px}
187
  #st-app .st-ovoxel-angle input{flex:1;accent-color:#f2cd70}
188
  #st-extract{flex:0 0 auto!important}
 
 
 
 
 
 
 
 
 
 
189
  #st-app .st-extraction-note{font-size:14px;line-height:1.6;color:#999;text-align:center}
190
  @media(max-width:700px){#st-ovoxel{height:370px;flex:auto!important}}
191
  #st-output-panel{position:relative}
 
180
  #st-app .st-ovoxel-frames{position:relative;flex:1;min-height:230px;overflow:hidden}
181
  #st-app .st-ovoxel-frames img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
182
  #st-app .st-ovoxel-frames img[hidden]{display:none!important}
183
+ #st-app .st-ovoxel-modes{display:flex;justify-content:center;gap:12px;flex-wrap:wrap;padding:4px}
184
+ #st-app .st-ovoxel-modes button{position:relative;padding:2px;width:34px;height:34px;border:2px solid transparent;border-radius:50%;background:#252523;color:#ccc;cursor:pointer}
185
+ #st-app .st-ovoxel-modes button img{width:100%;height:100%;border-radius:50%;object-fit:cover}
186
+ #st-app .st-ovoxel-modes button.active{border-color:#f2cd70;box-shadow:0 0 0 2px #f2cd7024}
187
+ #st-app .st-ovoxel-modes button:focus-visible{outline:2px solid #f2cd70;outline-offset:4px}
188
+ #st-app .st-mode-name{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
189
  #st-app .st-ovoxel-angle{display:flex;align-items:center;gap:14px;color:#aaa;font-size:12px}
190
  #st-app .st-ovoxel-angle input{flex:1;accent-color:#f2cd70}
191
  #st-extract{flex:0 0 auto!important}
192
+ #st-download,#st-extraction-progress{flex:0 0 auto!important}
193
+ #st-app .st-extraction-progress{display:flex;flex-direction:column;gap:8px;padding:12px 16px;border:1px solid #484438;border-radius:10px;background:#22211e;color:#ddd8cd;font-size:12px}
194
+ #st-app .st-extraction-progress>div{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
195
+ #st-app .st-extraction-progress strong{font-weight:500}
196
+ #st-app .st-extraction-progress span{color:#aaa;font-variant-numeric:tabular-nums}
197
+ #st-app .st-extraction-progress progress{width:100%;height:5px;accent-color:#f2cd70;border:0;border-radius:5px;overflow:hidden;background:#35322b}
198
+ #st-app .st-extraction-progress progress::-webkit-progress-bar{background:#35322b}
199
+ #st-app .st-extraction-progress progress::-webkit-progress-value{background:#f2cd70}
200
+ #st-app .st-extraction-progress small{color:#aaa;font-size:11px}
201
+ #st-app .st-extraction-progress.failed{border-color:#c36f67}
202
  #st-app .st-extraction-note{font-size:14px;line-height:1.6;color:#999;text-align:center}
203
  @media(max-width:700px){#st-ovoxel{height:370px;flex:auto!important}}
204
  #st-output-panel{position:relative}
hf_space/ui.py CHANGED
@@ -3,7 +3,7 @@ from pathlib import Path
3
  import os
4
  import gradio as gr
5
  from PIL import Image
6
- from hf_space import generation_progress, startup_progress
7
 
8
  ROOT = Path(__file__).resolve().parents[1]
9
  CSS = Path(__file__).with_name('ui.css').read_text(encoding='utf-8')
@@ -102,6 +102,8 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
102
  input_present = gr.State(default_image is not None)
103
  generated_asset = gr.Textbox(value='',visible=False)
104
  progress_timer = gr.Timer(1, active=False)
 
 
105
  with gr.Column(elem_id='st-app'):
106
  html(HEADER, elem_id='st-header')
107
  with gr.Row(elem_id='st-workspace', equal_height=False):
@@ -138,22 +140,25 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
138
  clear_color=(0.098, 0.098, 0.098, 1), elem_id='st-model')
139
  render_output = html('',visible='hidden',elem_id='st-ovoxel')
140
  extract_btn = gr.Button('Extract GLB',interactive=False,elem_id='st-extract')
 
 
141
  html('<div class="st-extraction-note">Initial geometry generation can take longer. GLB extraction can also be slow.</div>',elem_id='st-extraction-note')
142
  status_output = gr.JSON(visible=False)
143
  refresh = gr.Button(visible=False)
144
 
145
  def select_example(current_phase, evt: gr.SelectData):
146
- if current_phase == 'running':
147
- raise gr.Error('Please wait for the current generation to finish.')
148
  path = examples[int(evt.index)][0]
149
  with Image.open(path) as image:
150
  model = cached_result(image, 42)
151
  return (path, 42, gr.update(value=model, visible=True if model is not None else 'hidden'),
152
  gr.update(visible=True if model is None else 'hidden'), 'idle', gr.update(value='', visible='hidden'),
153
- gr.update(value='',visible='hidden'),'',gr.update(interactive=False))
 
154
 
155
  if examples:
156
- gallery.select(select_example, [phase], [input_image, seed, mesh_output, empty, phase, estimate,render_output,generated_asset,extract_btn],
157
  api_visibility='private', queue=False)
158
  if examples_status:
159
  example_trigger = gr.Button(visible=False)
@@ -163,12 +168,12 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
163
  state = deployment_status()
164
  usable = available(state, image, current_seed)
165
  status = gr.update(value='', visible='hidden') if usable and current_phase != 'failed' else status_label(state, current_phase)
166
- return state, gr.update(interactive=usable and current_phase != 'running'), status, image is not None
167
 
168
  def sync_startup(current_phase, has_image):
169
  state = deployment_status()
170
  usable = ready(state) and has_image
171
- return state, gr.update(interactive=usable and current_phase != 'running'), status_label(state, current_phase)
172
 
173
  def begin(image, current_seed):
174
  if image is None:
@@ -178,7 +183,8 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
178
  gr.update(value=None, visible='hidden'), gr.update(visible=True),
179
  gr.update(value=generation_progress.overlay_html(token, estimated_seconds), visible=True),
180
  token, gr.update(active=True), generation_progress.status_html(token),
181
- gr.update(value='',visible='hidden'),'',gr.update(interactive=False))
 
182
 
183
  def finish(image, current_seed):
184
  usable = available(deployment_status(), image, current_seed)
@@ -202,7 +208,7 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
202
  return gr.update(value=rendered, visible=True), identifier
203
 
204
  start = generate_btn.click(begin, [input_image, seed],
205
- [phase, generate_btn, live_status, mesh_output, empty, estimate, generation_token, progress_timer, progress_state,render_output,generated_asset,extract_btn],
206
  queue=False, api_visibility='private')
207
  run = start.then(generate_preview, [input_image, seed, generation_token], [render_output,generated_asset], api_name='generate',
208
  concurrency_limit=1, concurrency_id='supra-tex-gpu', show_progress='hidden')
@@ -214,15 +220,31 @@ def build_ui(generate, deployment_status, *, cached_result, default_image=None,
214
  def begin_extract(identifier):
215
  if not identifier:
216
  raise gr.Error('Generate a new asset before extracting GLB.')
217
- return gr.update(interactive=False)
218
- def display_extraction(identifier):
219
- return gr.update(value=extract_glb(identifier),visible=True)
220
- extraction = extract_btn.click(begin_extract,[generated_asset],extract_btn,queue=False,api_visibility='private')
221
- extraction = extraction.then(display_extraction,[generated_asset],mesh_output,api_name='extract_glb',
222
- concurrency_limit=1,concurrency_id='supra-tex-gpu',show_progress='minimal')
223
- extraction.success(lambda:(gr.update(visible='hidden'),gr.update(interactive=True)),
224
- [],[render_output,extract_btn],queue=False,api_visibility='private')
225
- extraction.failure(lambda:gr.update(interactive=True),[],extract_btn,queue=False,api_visibility='private')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  progress_timer.tick(generation_progress.status_html, [generation_token], progress_state,
227
  queue=False, api_visibility='private', show_progress='hidden')
228
  refresh.click(deployment_status, [], status_output, api_name='deployment_status', queue=False)
 
3
  import os
4
  import gradio as gr
5
  from PIL import Image
6
+ from hf_space import generation_progress, startup_progress, extraction_progress
7
 
8
  ROOT = Path(__file__).resolve().parents[1]
9
  CSS = Path(__file__).with_name('ui.css').read_text(encoding='utf-8')
 
102
  input_present = gr.State(default_image is not None)
103
  generated_asset = gr.Textbox(value='',visible=False)
104
  progress_timer = gr.Timer(1, active=False)
105
+ extraction_token = gr.State('')
106
+ extraction_timer = gr.Timer(1, active=False)
107
  with gr.Column(elem_id='st-app'):
108
  html(HEADER, elem_id='st-header')
109
  with gr.Row(elem_id='st-workspace', equal_height=False):
 
140
  clear_color=(0.098, 0.098, 0.098, 1), elem_id='st-model')
141
  render_output = html('',visible='hidden',elem_id='st-ovoxel')
142
  extract_btn = gr.Button('Extract GLB',interactive=False,elem_id='st-extract')
143
+ extraction_status = html('', visible='hidden', elem_id='st-extraction-progress')
144
+ download_btn = gr.DownloadButton('Download GLB', visible='hidden', elem_id='st-download')
145
  html('<div class="st-extraction-note">Initial geometry generation can take longer. GLB extraction can also be slow.</div>',elem_id='st-extraction-note')
146
  status_output = gr.JSON(visible=False)
147
  refresh = gr.Button(visible=False)
148
 
149
  def select_example(current_phase, evt: gr.SelectData):
150
+ if current_phase in ('running', 'extracting'):
151
+ raise gr.Error('Please wait for the current operation to finish.')
152
  path = examples[int(evt.index)][0]
153
  with Image.open(path) as image:
154
  model = cached_result(image, 42)
155
  return (path, 42, gr.update(value=model, visible=True if model is not None else 'hidden'),
156
  gr.update(visible=True if model is None else 'hidden'), 'idle', gr.update(value='', visible='hidden'),
157
+ gr.update(value='',visible='hidden'),'',gr.update(interactive=False),
158
+ gr.update(value=None,visible='hidden'),gr.update(value='',visible='hidden'))
159
 
160
  if examples:
161
+ gallery.select(select_example, [phase], [input_image, seed, mesh_output, empty, phase, estimate,render_output,generated_asset,extract_btn,download_btn,extraction_status],
162
  api_visibility='private', queue=False)
163
  if examples_status:
164
  example_trigger = gr.Button(visible=False)
 
168
  state = deployment_status()
169
  usable = available(state, image, current_seed)
170
  status = gr.update(value='', visible='hidden') if usable and current_phase != 'failed' else status_label(state, current_phase)
171
+ return state, gr.update(interactive=usable and current_phase not in ('running', 'extracting')), status, image is not None
172
 
173
  def sync_startup(current_phase, has_image):
174
  state = deployment_status()
175
  usable = ready(state) and has_image
176
+ return state, gr.update(interactive=usable and current_phase not in ('running', 'extracting')), status_label(state, current_phase)
177
 
178
  def begin(image, current_seed):
179
  if image is None:
 
183
  gr.update(value=None, visible='hidden'), gr.update(visible=True),
184
  gr.update(value=generation_progress.overlay_html(token, estimated_seconds), visible=True),
185
  token, gr.update(active=True), generation_progress.status_html(token),
186
+ gr.update(value='',visible='hidden'),'',gr.update(interactive=False),
187
+ gr.update(value=None,visible='hidden'),gr.update(value='',visible='hidden'))
188
 
189
  def finish(image, current_seed):
190
  usable = available(deployment_status(), image, current_seed)
 
208
  return gr.update(value=rendered, visible=True), identifier
209
 
210
  start = generate_btn.click(begin, [input_image, seed],
211
+ [phase, generate_btn, live_status, mesh_output, empty, estimate, generation_token, progress_timer, progress_state,render_output,generated_asset,extract_btn,download_btn,extraction_status],
212
  queue=False, api_visibility='private')
213
  run = start.then(generate_preview, [input_image, seed, generation_token], [render_output,generated_asset], api_name='generate',
214
  concurrency_limit=1, concurrency_id='supra-tex-gpu', show_progress='hidden')
 
220
  def begin_extract(identifier):
221
  if not identifier:
222
  raise gr.Error('Generate a new asset before extracting GLB.')
223
+ token = extraction_progress.start()
224
+ return ('extracting', gr.update(interactive=False), gr.update(interactive=False),
225
+ token, gr.update(active=True),
226
+ gr.update(value=extraction_progress.status_html(token),visible=True),
227
+ gr.update(value=None,visible='hidden'))
228
+ def display_extraction(identifier, token=None):
229
+ return gr.update(value=extract_glb(identifier, token),visible=True)
230
+ def finish_extract(token, image, current_seed, failed=False):
231
+ extraction_progress.finish(token, failed=failed)
232
+ return ('complete', gr.update(interactive=True),
233
+ gr.update(interactive=available(deployment_status(),image,current_seed)),
234
+ gr.update(active=False),
235
+ gr.update(value=extraction_progress.status_html(token),visible=True))
236
+ extraction = extract_btn.click(begin_extract,[generated_asset],
237
+ [phase,extract_btn,generate_btn,extraction_token,extraction_timer,extraction_status,download_btn],
238
+ queue=False,api_visibility='private')
239
+ extraction = extraction.then(display_extraction,[generated_asset,extraction_token],download_btn,api_name='extract_glb',
240
+ concurrency_limit=1,concurrency_id='supra-tex-gpu',show_progress='hidden')
241
+ extraction.success(finish_extract,[extraction_token,input_image,seed],
242
+ [phase,extract_btn,generate_btn,extraction_timer,extraction_status],queue=False,api_visibility='private')
243
+ extraction.failure(lambda token,image,current_seed:finish_extract(token,image,current_seed,failed=True),
244
+ [extraction_token,input_image,seed], [phase,extract_btn,generate_btn,extraction_timer,extraction_status],
245
+ queue=False,api_visibility='private')
246
+ extraction_timer.tick(extraction_progress.status_html,[extraction_token],extraction_status,
247
+ queue=False,api_visibility='private',show_progress='hidden')
248
  progress_timer.tick(generation_progress.status_html, [generation_token], progress_state,
249
  queue=False, api_visibility='private', show_progress='hidden')
250
  refresh.click(deployment_status, [], status_output, api_name='deployment_status', queue=False)