UniLat3D commited on
Commit
f3948d0
·
verified ·
1 Parent(s): 0105a52

Synchronize production exactly with verified ZeroGPU Test Space

Browse files

Copy the full repository tree from UniLat3D/Supra-Tex-Test@5d0fd45cfb49fb8be442ac96d05627caf28c70b6. Includes per-user ZeroGPU, split geometry and textures, depth pre-filter, durable history and deferred export, plus the completed foreground-preview refresh fix. Preserve production Space settings, identity, secrets and its existing /data Bucket.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +43 -13
  2. app.py +33 -9
  3. deployment/vendor/eigen-21e4582d1739107337a03460c81412981130373e.tar.gz +3 -0
  4. deployment/vendor/eigen-source.json +18 -0
  5. hf_space/backend.py +1 -1
  6. hf_space/brand/huggingface-logo.svg +37 -0
  7. hf_space/curated_refresh.py +1 -1
  8. hf_space/generation_progress.py +9 -1
  9. hf_space/geometry_preview.py +52 -0
  10. hf_space/geometry_results.py +105 -0
  11. hf_space/gpu_mode.py +14 -0
  12. hf_space/history_jobs.py +122 -63
  13. hf_space/history_routes.py +37 -5
  14. hf_space/history_service.py +64 -11
  15. hf_space/history_store.py +89 -10
  16. hf_space/history_ui.css +34 -10
  17. hf_space/history_ui.js +379 -123
  18. hf_space/history_ui.py +24 -7
  19. hf_space/native_build.py +113 -7
  20. hf_space/ovoxel_results.py +53 -5
  21. hf_space/preview_recovery.py +13 -2
  22. hf_space/release_backend.py +215 -17
  23. hf_space/resident_pipeline.py +42 -13
  24. hf_space/runtime_worker.py +29 -4
  25. hf_space/split_pipeline.py +146 -0
  26. hf_space/vendor/gradio-client-2.1.0.js +1940 -0
  27. hf_space/vendor/gradio-client-LICENSE +201 -0
  28. hf_space/vendor/gradio-client-source.json +9 -0
  29. hf_space/viewport.js +2 -2
  30. hf_space/zerogpu_client.js +295 -0
  31. hf_space/zerogpu_jobs.py +78 -0
  32. hf_space/zerogpu_runtime.py +149 -0
  33. infer.py +10 -1
  34. requirements.txt +1 -1
  35. supratex/frontend/camera.py +39 -4
  36. supratex/frontend/pipeline.py +30 -5
  37. supratex/inference/image_depth.py +115 -0
  38. supratex/inference/native_observation.py +73 -4
  39. supratex/inference/pipeline.py +107 -19
  40. supratex/inference/reprojection/__init__.py +41 -4
  41. tests/test_depth_prefilter.py +379 -0
  42. tests/test_frontend_depth.py +92 -0
  43. tests/test_geometry_results.py +129 -0
  44. tests/test_history_geometry_texture.py +344 -0
  45. tests/test_history_service_workflow.py +155 -0
  46. tests/test_history_ui_resume.cjs +811 -0
  47. tests/test_native_eigen.py +83 -0
  48. tests/test_observation_filter_export.py +115 -0
  49. tests/test_zerogpu_client.cjs +631 -0
  50. tests/test_zerogpu_concurrency.py +165 -0
README.md CHANGED
@@ -26,18 +26,36 @@ geometry, Qwen material, completion, and PBR asset export stages.
26
 
27
  Target runtime: Python 3.10, PyTorch 2.8.0 + CUDA 12.8, torchvision 0.23.0,
28
  and SDPA attention. The Space compiles pinned public revisions of CuMesh,
29
- FlexGEMM, nvdiffrast, and O-Voxel for NVIDIA L4 (sm_89), and installs the
30
  matching official NATTEN 0.21.1 wheel. CUDA compiler archives are downloaded
31
  remotely from NVIDIA and checked against their SHA256 digests.
32
 
33
- The live Space uses one dedicated NVIDIA L4 (24 GB). GPU usage is billed to
34
- the Space owner; visitors do not need ZeroGPU quota. The retained `spaces.GPU`
35
- decorators are inactive on dedicated hardware. L4 wheels use a separate
36
- `/data/native-wheels/torch280-cu128-cp310-sm89-v1` cache.
37
-
38
- The ZeroGPU source is preserved at tag
39
- `zerogpu-before-l4-20261020-77f93e49`, along with its original sm_120 cache.
40
- Rollback requires restoring that source and choosing ZeroGPU hardware.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  An isolated L4 validation on 2026-10-20 completed seed 42 pitcher-plant
43
  generation in 338.2 seconds and GLB export in 49.8 seconds. Sampled peak
@@ -75,9 +93,13 @@ originals in the persistent Bucket are retained.
75
  ## Startup and checkpoint selection
76
 
77
  Mount a writable Bucket at `/data` and provide an approved read token as the
78
- private `HF_TOKEN` Space secret. Native extension installation and a shared
79
- background model-preparation worker start automatically with the server.
80
- The worker waits for native runtime readiness before loading weights.
 
 
 
 
81
  Successful CUDA validation reports are bound to the wheel
82
  inventory and cached in the Bucket. Matching cached wheels and checks are reused
83
  after restarts. Diagnostic panels and GPU test buttons are not part of the public
@@ -92,7 +114,9 @@ during startup. Archived project weights remain under
92
  shared across all user sessions and generations. A request arriving during
93
  startup waits for that same worker, with weight-loading progress in the
94
  loading panel. Opening or refreshing a page does not start another
95
- load. A restart or wake from sleep automatically starts preparation again.
 
 
96
  Transfers retry without discarding their verified source identity or partial
97
  bytes; exhausted retries report failure instead of an endless loading state.
98
  Private artifact reads have their own limited worker capacity so slow storage
@@ -127,6 +151,12 @@ Optional Space variables:
127
 
128
  - `SUPRATEX_MODEL_REPO`: project checkpoint repository, default `supra3d/supra-tex`.
129
  - `SUPRATEX_MODEL_REVISION`: immutable project checkpoint commit SHA.
 
 
 
 
 
 
130
  The frontend waiting estimate is set to 390 seconds in `app.py`.
131
  This estimate is not measured model progress or an inference time limit.
132
 
 
26
 
27
  Target runtime: Python 3.10, PyTorch 2.8.0 + CUDA 12.8, torchvision 0.23.0,
28
  and SDPA attention. The Space compiles pinned public revisions of CuMesh,
29
+ FlexGEMM, nvdiffrast, and O-Voxel for the ZeroGPU CUDA architecture, and installs the
30
  matching official NATTEN 0.21.1 wheel. CUDA compiler archives are downloaded
31
  remotely from NVIDIA and checked against their SHA256 digests.
32
 
33
+ The live Space uses ZeroGPU. Visitors sign in with their own Hugging Face
34
+ accounts and submit GPU operations through the official Gradio client in the
35
+ Hugging Face Space page. The client's signed quota header stays attached to the
36
+ live Gradio request; the Space owner's model-download token is never supplied
37
+ as a visitor's GPU credential. Missing quota headers and explicit quota-token
38
+ errors are rejected before inference.
39
+
40
+ Opening the page and starting the Space do not load models or compile native
41
+ extensions. An explicitly submitted task starts its required CPU preparation.
42
+ Background removal loads only the pinned RMBG model, independently of native
43
+ compilation and the full 18-model 3D pipeline. Its upload panel shows one status
44
+ message throughout image loading and background removal. 3D preparation shows
45
+ per-phase percentages from downloaded bytes, loaded models, or completed build steps.
46
+ Foreground removal, generation, export,
47
+ and preview recovery each acquire a temporary `spaces.GPU` lease. No permanent
48
+ CUDA worker, unattended GPU queue, curated refresh, or GPU keepalive runs in
49
+ ZeroGPU mode. Saved unfinished requests require an explicit Resume action;
50
+ opening history never starts GPU work. Independent requests can execute
51
+ concurrently, subject to Hugging Face GPU availability. Repeated submissions of
52
+ the same operation execute once.
53
+ Native wheels use a separate ZeroGPU architecture cache under
54
+ `/data/native-wheels`; the former L4 cache is retained for dedicated hardware.
55
+
56
+ The application mounts Gradio into FastAPI instead of calling `Blocks.launch`.
57
+ With the pinned `spaces==0.51.3`, it explicitly reports SDK startup after GPU
58
+ functions are registered. CPU-only preparation starts later, on demand.
59
 
60
  An isolated L4 validation on 2026-10-20 completed seed 42 pitcher-plant
61
  generation in 338.2 seconds and GLB export in 49.8 seconds. Sampled peak
 
93
  ## Startup and checkpoint selection
94
 
95
  Mount a writable Bucket at `/data` and provide an approved read token as the
96
+ private `HF_TOKEN` Space secret. In ZeroGPU mode, only the HTTP interface and
97
+ history storage initialize with the server. A verified task's explicit prepare
98
+ request starts native extension installation and one shared CPU model-loading
99
+ controller for 3D tasks. Background-removal tasks use their own one-model CPU
100
+ controller and do not start native installation or full-pipeline loading. The
101
+ 3D controller waits for native runtime readiness before loading weights.
102
+ Preparation does not acquire a GPU lease.
103
  Successful CUDA validation reports are bound to the wheel
104
  inventory and cached in the Bucket. Matching cached wheels and checks are reused
105
  after restarts. Diagnostic panels and GPU test buttons are not part of the public
 
114
  shared across all user sessions and generations. A request arriving during
115
  startup waits for that same worker, with weight-loading progress in the
116
  loading panel. Opening or refreshing a page does not start another
117
+ load. After a restart or wake from sleep, CPU preparation waits for the next
118
+ explicit task. Browsing history, selecting an existing preview, and status polls
119
+ never initiate model loading.
120
  Transfers retry without discarding their verified source identity or partial
121
  bytes; exhausted retries report failure instead of an endless loading state.
122
  Private artifact reads have their own limited worker capacity so slow storage
 
151
 
152
  - `SUPRATEX_MODEL_REPO`: project checkpoint repository, default `supra3d/supra-tex`.
153
  - `SUPRATEX_MODEL_REVISION`: immutable project checkpoint commit SHA.
154
+ - `SUPRATEX_ZERO_GENERATE_SECONDS`, `SUPRATEX_ZERO_FOREGROUND_SECONDS`,
155
+ `SUPRATEX_ZERO_EXPORT_SECONDS`, `SUPRATEX_ZERO_RECOVER_SECONDS`: requested GPU
156
+ durations, defaulting to 180, 60, 180, and 120 seconds respectively. The ZeroGPU
157
+ scheduler may apply a hardware duration factor. These are time limits, not
158
+ measured completion times; insufficient personal quota rejects a request.
159
+
160
  The frontend waiting estimate is set to 390 seconds in `app.py`.
161
  This estimate is not measured model progress or an inference time limit.
162
 
app.py CHANGED
@@ -1,9 +1,21 @@
1
- """Supra-Tex with account-owned history and browser-independent queued tasks."""
2
  import os
3
  from pathlib import Path
4
  import sys
5
  import threading
6
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from scripts.run_example import ROOT as SOURCE_ROOT, _pipeline_config
8
  sys.path.insert(0, str(_pipeline_config().runtime_root(SOURCE_ROOT)/'utils3d'))
9
 
@@ -17,7 +29,8 @@ from hf_space.history_routes import install_routes
17
  from hf_space.history_ui import build_ui, CSS, THEME, I18N
18
 
19
  ROOT = Path(__file__).resolve().parent
20
- PUBLIC_PATHS = [ROOT/'hf_space/gallery-viewer.bundle.js', ROOT/'hf_space/gallery-lights']
 
21
  PRIVATE_PATHS = ['/data', '/tmp/supratex-jobs', '/tmp/gradio',
22
  '/tmp/supratex-history-private', '/tmp/supratex-progress']
23
 
@@ -25,8 +38,16 @@ PRIVATE_PATHS = ['/data', '/tmp/supratex-jobs', '/tmp/gradio',
25
  def create_app(service=None):
26
  service = service if service is not None else Service()
27
  demo = build_ui(deployment_status)
28
- # Browser callbacks only mount the UI. All GPU operations share Service.runner.
29
- demo.queue(api_open=False)
 
 
 
 
 
 
 
 
30
  web = gr.mount_gradio_app(FastAPI(), demo, path='/',
31
  allowed_paths=[str(path) for path in PUBLIC_PATHS], blocked_paths=PRIVATE_PATHS,
32
  css=CSS, theme=THEME, i18n=I18N, ssr_mode=False, footer_links=['api', 'gradio'])
@@ -36,17 +57,20 @@ def create_app(service=None):
36
  install_routes(gradio_app, service.store, deployment_status=deployment_status,
37
  examples_provider=service.examples, example_file_provider=service.example_file,
38
  prepare_example=service.prepare_example, preview_provider=service.preview,
 
39
  public_gradio_paths=PUBLIC_PATHS, private_file_cache=service.private_files)
40
  web.state.history_service = service
41
  return web
42
 
43
 
44
  def main():
45
- # Do not block the HTTP server on Bucket reads or weight/model preparation.
46
- # Public models normally arrive directly from their immutable CDN URLs.
47
- # Prepare a local fallback only when explicitly requested, never at boot.
48
- native_build.start()
49
- release_backend.start_preparation()
 
 
50
  service = Service()
51
  web = create_app(service)
52
  service.start()
 
1
+ """Supra-Tex with private history and on-demand per-user GPU operations."""
2
  import os
3
  from pathlib import Path
4
  import sys
5
  import threading
6
 
7
+ # Install ZeroGPU's CUDA interception before any project/Torch imports.
8
+ from hf_space.gpu_mode import enabled as zero_gpu_enabled
9
+ if zero_gpu_enabled():
10
+ # The SDK sets PyTorch's one-shot inter-op count during its first import.
11
+ os.environ.setdefault('OMP_NUM_THREADS', '1')
12
+ os.environ.setdefault('MKL_NUM_THREADS', '1')
13
+ import spaces
14
+ if zero_gpu_enabled():
15
+ # HF checks registered GPU tasks as soon as the HTTP server starts. Model
16
+ # weights remain unloaded until a visitor submits a task.
17
+ from hf_space import zerogpu_runtime
18
+
19
  from scripts.run_example import ROOT as SOURCE_ROOT, _pipeline_config
20
  sys.path.insert(0, str(_pipeline_config().runtime_root(SOURCE_ROOT)/'utils3d'))
21
 
 
29
  from hf_space.history_ui import build_ui, CSS, THEME, I18N
30
 
31
  ROOT = Path(__file__).resolve().parent
32
+ PUBLIC_PATHS = [ROOT/'hf_space/gallery-viewer.bundle.js', ROOT/'hf_space/gallery-lights',
33
+ ROOT/'hf_space/vendor/gradio-client-2.1.0.js']
34
  PRIVATE_PATHS = ['/data', '/tmp/supratex-jobs', '/tmp/gradio',
35
  '/tmp/supratex-history-private', '/tmp/supratex-progress']
36
 
 
38
  def create_app(service=None):
39
  service = service if service is not None else Service()
40
  demo = build_ui(deployment_status)
41
+ from hf_space.gpu_mode import enabled
42
+ if enabled():
43
+ from hf_space.zerogpu_jobs import register
44
+ register(demo, service)
45
+ # ZeroGPU callbacks retain each visitor's request through GPU scheduling.
46
+ demo.queue(api_open=False, max_size=None)
47
+ if enabled():
48
+ # Gradio 6.10 changes None to 1 on ZeroGPU. Restore its Queue's actual
49
+ # unlimited value; event transport must not add a global admission cap.
50
+ demo._queue.max_size = None
51
  web = gr.mount_gradio_app(FastAPI(), demo, path='/',
52
  allowed_paths=[str(path) for path in PUBLIC_PATHS], blocked_paths=PRIVATE_PATHS,
53
  css=CSS, theme=THEME, i18n=I18N, ssr_mode=False, footer_links=['api', 'gradio'])
 
57
  install_routes(gradio_app, service.store, deployment_status=deployment_status,
58
  examples_provider=service.examples, example_file_provider=service.example_file,
59
  prepare_example=service.prepare_example, preview_provider=service.preview,
60
+ prepare_models=service.prepare_models,
61
  public_gradio_paths=PUBLIC_PATHS, private_file_cache=service.private_files)
62
  web.state.history_service = service
63
  return web
64
 
65
 
66
  def main():
67
+ if zero_gpu_enabled():
68
+ zerogpu_runtime.start_mounted_app()
69
+ else:
70
+ # Dedicated GPU deployments retain their startup preparation. ZeroGPU
71
+ # starts this shared CPU preparation only when a visitor submits work.
72
+ native_build.start()
73
+ release_backend.start_preparation()
74
  service = Service()
75
  web = create_app(service)
76
  service.start()
deployment/vendor/eigen-21e4582d1739107337a03460c81412981130373e.tar.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:399bbf2c2239b286f21edfebfe1430a689c0995acd32e8017701346f6d35be7a
3
+ size 3025643
deployment/vendor/eigen-source.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "project": "Eigen",
3
+ "repository": "https://gitlab.com/libeigen/eigen.git",
4
+ "revision": "21e4582d1739107337a03460c81412981130373e",
5
+ "source": "https://gitlab.com/libeigen/eigen/-/archive/21e4582d1739107337a03460c81412981130373e/eigen-21e4582d1739107337a03460c81412981130373e.tar.gz",
6
+ "archive": "eigen-21e4582d1739107337a03460c81412981130373e.tar.gz",
7
+ "sha256": "399bbf2c2239b286f21edfebfe1430a689c0995acd32e8017701346f6d35be7a",
8
+ "bytes": 3025643,
9
+ "required_by": {
10
+ "repository": "https://github.com/microsoft/TRELLIS.2.git",
11
+ "revision": "5565d240c4a494caaf9ece7a554542b76ffa36d3",
12
+ "submodule": "o-voxel/third_party/eigen"
13
+ },
14
+ "license": "Primarily MPL-2.0; some files have BSD or other MPL-2.0-compatible licenses, as documented by upstream COPYING.README.",
15
+ "license_files": ["LICENSE", "COPYING.README", "COPYING.MPL2", "COPYING.BSD", "COPYING.APACHE", "COPYING.MINPACK"],
16
+ "modified": false,
17
+ "note": "Unmodified official source archive. All upstream copyright notices and licenses remain in the archive and extracted source. Stored with Hugging Face LFS and included in the Space checkout to avoid a build-time GitLab fetch."
18
+ }
hf_space/backend.py CHANGED
@@ -219,7 +219,7 @@ def generate(image, seed, progress=None, timeout_seconds=None, generation_token=
219
  job = Path('/tmp/supratex-jobs') / uuid.uuid4().hex
220
  job.mkdir(parents=True)
221
  generation_progress.update(generation_token, status='running', job=job.name,
222
- label='Initializing the pipeline', generation_started_unix=time.time())
223
  image.save(job / 'input.png')
224
  config = str(READY / 'models.json')
225
  output = job / 'asset.npz'
 
219
  job = Path('/tmp/supratex-jobs') / uuid.uuid4().hex
220
  job.mkdir(parents=True)
221
  generation_progress.update(generation_token, status='running', job=job.name,
222
+ label='Starting generation…', generation_started_unix=time.time())
223
  image.save(job / 'input.png')
224
  config = str(READY / 'models.json')
225
  output = job / 'asset.npz'
hf_space/brand/huggingface-logo.svg ADDED
hf_space/curated_refresh.py CHANGED
@@ -169,7 +169,7 @@ class Coordinator:
169
  raise RefreshError('The approved input pixels changed.')
170
  upload = self.store.save_upload(self.owner, source.read_bytes())
171
  job = self.store.submit(self.owner, upload['id'], self.plan['seed'],
172
- self._key(entry['name'], 'generate'))
173
  self.state['items'][entry['name']] = {
174
  'name': entry['name'], 'source': deepcopy(entry), 'job_id': job['id'],
175
  'upload_id': job['upload_id'], 'input_sha256': job['input_sha256'],
 
169
  raise RefreshError('The approved input pixels changed.')
170
  upload = self.store.save_upload(self.owner, source.read_bytes())
171
  job = self.store.submit(self.owner, upload['id'], self.plan['seed'],
172
+ self._key(entry['name'], 'generate'), workflow=None)
173
  self.state['items'][entry['name']] = {
174
  'name': entry['name'], 'source': deepcopy(entry), 'job_id': job['id'],
175
  'upload_id': job['upload_id'], 'input_sha256': job['input_sha256'],
hf_space/generation_progress.py CHANGED
@@ -54,13 +54,19 @@ def start(*, preparing=False):
54
 
55
  def stage_label(name):
56
  if name in ('pipeline', 'frontend'):
57
- return 'Initializing the pipeline'
58
  if name in ('frontend.validation', 'frontend.preprocess'):
59
  return 'Preparing the image'
60
  if name == 'frontend.camera':
61
  return 'Estimating the camera'
62
  if name == 'geometry.model_load':
63
  return 'Loading the geometry model'
 
 
 
 
 
 
64
  if name == 'frontend.geometry' or name.startswith('geometry.'):
65
  return 'Generating geometry'
66
  if name in ('qwen.checkpoint_load', 'qwen.transformer_load', 'qwen.adapter_load'):
@@ -74,6 +80,8 @@ def stage_label(name):
74
  return 'Loading completion models'
75
  if name in ('sample', 'sample_25_steps'):
76
  return 'Completing surface materials'
 
 
77
  if name in ('decoder_load', 'decode'):
78
  return 'Decoding the 3D asset'
79
  if name == 'ovoxel_save':
 
54
 
55
  def stage_label(name):
56
  if name in ('pipeline', 'frontend'):
57
+ return 'Starting generation…'
58
  if name in ('frontend.validation', 'frontend.preprocess'):
59
  return 'Preparing the image'
60
  if name == 'frontend.camera':
61
  return 'Estimating the camera'
62
  if name == 'geometry.model_load':
63
  return 'Loading the geometry model'
64
+ if name == 'geometry.save':
65
+ return 'Saving geometry checkpoint'
66
+ if name == 'geometry.preview':
67
+ return 'Rendering clay preview'
68
+ if name == 'geometry.cleanup':
69
+ return 'Cleaning the geometry'
70
  if name == 'frontend.geometry' or name.startswith('geometry.'):
71
  return 'Generating geometry'
72
  if name in ('qwen.checkpoint_load', 'qwen.transformer_load', 'qwen.adapter_load'):
 
80
  return 'Loading completion models'
81
  if name in ('sample', 'sample_25_steps'):
82
  return 'Completing surface materials'
83
+ if name == 'texture.completion':
84
+ return 'Completing surface materials'
85
  if name in ('decoder_load', 'decode'):
86
  return 'Decoding the 3D asset'
87
  if name == 'ovoxel_save':
hf_space/geometry_preview.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lightweight clay views of a saved mesh, without material inference or UVs."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ from hf_space.timing import span
6
+
7
+
8
+ def render_geometry(folder, vertices, faces, camera):
9
+ import numpy as np
10
+ import torch
11
+ from PIL import Image
12
+ from supratex.representations import Mesh
13
+ from supratex.renderers import MeshRenderer
14
+ from supratex.utils.render_utils import yaw_pitch_r_fov_to_extrinsics_intrinsics
15
+ from hf_space.ovoxel_preview import INPUT_VIEW_LAYOUT, orbit_parameters
16
+
17
+ folder = Path(folder)
18
+ with span('geometry.preview'):
19
+ preview = Mesh(vertices.cuda(), faces.cuda())
20
+ if len(faces) > 16777216:
21
+ preview.simplify(16777216)
22
+ count, resolution = 16, 768
23
+ render_camera = {'camera_angle_x': float(camera['camera_angle_x']),
24
+ 'distance': float(camera['distance']) * float(camera.get('mesh_scale', 1.0))}
25
+ yaws, pitches, distance, fov = orbit_parameters(count, render_camera)
26
+ extrinsics, intrinsics = yaw_pitch_r_fov_to_extrinsics_intrinsics(yaws, pitches, distance, fov)
27
+ rotation = torch.tensor([[1.,0,0,0],[0,0,-1.,0],[0,1.,0,0],[0,0,0,1]], device='cuda')
28
+ renderer = MeshRenderer({'resolution': resolution, 'ssaa': 1,
29
+ 'near': min(.01, distance/100), 'far': max(10., distance+2)})
30
+ views = folder / 'views'
31
+ views.mkdir(exist_ok=True)
32
+ light = np.asarray([-.35, .45, 1.], dtype=np.float32)
33
+ light /= np.linalg.norm(light)
34
+ with torch.no_grad():
35
+ for index, (extrinsic, intrinsic) in enumerate(zip(extrinsics, intrinsics)):
36
+ result = renderer.render(preview, extrinsic @ rotation, intrinsic,
37
+ return_types=['normal', 'mask'])
38
+ normal = result['normal'].detach().cpu().numpy().transpose(1, 2, 0) * 2 - 1
39
+ mask = result['mask'].detach().cpu().numpy()
40
+ if mask.ndim == 3:
41
+ mask = mask[0]
42
+ # A neutral, view-relative diffuse light reveals shape only.
43
+ diffuse = np.clip(np.sum(normal * light, axis=-1), 0, 1)
44
+ gray = (70 + 150 * diffuse) * mask + 25 * (1 - mask)
45
+ pixels = np.repeat(np.clip(gray, 0, 255).astype(np.uint8)[..., None], 3, axis=-1)
46
+ Image.fromarray(pixels).save(views / f'clay-{index:02d}.webp', quality=90)
47
+ (folder / 'preview.json').write_text(json.dumps({
48
+ 'views': count, 'modes': ['clay'], 'default_mode': 'clay', 'resolution': resolution,
49
+ 'device': 'cuda', 'renderer': 'MeshRenderer', 'lighting': 'Neutral diffuse clay',
50
+ 'source_faces': len(faces), 'preview_faces': len(preview.faces),
51
+ 'view_layout': INPUT_VIEW_LAYOUT, 'input_view': 0, 'input_camera': render_camera,
52
+ 'automatic_glb_export': False, 'output_kind': 'geometry'}), encoding='utf-8')
hf_space/geometry_results.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Durable, integrity-checked geometry handoffs for explicit texture requests."""
2
+ import hashlib
3
+ import json
4
+ from pathlib import Path
5
+ import shutil
6
+
7
+ from hf_space import ovoxel_results
8
+ from hf_space.split_pipeline import CHECKPOINT_FILES, OPTIONAL_CHECKPOINT_FILES, validate_checkpoint
9
+
10
+ HANDOFF_FILES = ('input.png', 'geometry.npz',
11
+ *(f'geometry-checkpoint/{name}' for name in CHECKPOINT_FILES))
12
+
13
+
14
+ def _handoff_files(folder):
15
+ metadata = validate_checkpoint(folder / 'geometry-checkpoint')
16
+ return (*HANDOFF_FILES, *(f'geometry-checkpoint/{name}' for name in OPTIONAL_CHECKPOINT_FILES
17
+ if name in metadata['files']))
18
+
19
+
20
+ def _digest(path):
21
+ digest = hashlib.sha256()
22
+ with path.open('rb') as stream:
23
+ for block in iter(lambda: stream.read(1024 * 1024), b''):
24
+ digest.update(block)
25
+ return digest.hexdigest()
26
+
27
+
28
+ def _file(root, name):
29
+ path = root / name
30
+ if (root.is_symlink() or path.is_symlink() or path.parent.is_symlink()
31
+ or not path.resolve().is_relative_to(root.resolve()) or not path.is_file()
32
+ or path.stat().st_size <= 0):
33
+ raise ValueError('The saved geometry is incomplete or invalid.')
34
+ return path
35
+
36
+
37
+ def persist(source, report):
38
+ """Publish the white preview and its exact texturing input; ready is last."""
39
+ source = Path(source)
40
+ folder = source.parent
41
+ identifier = folder.name
42
+ destination = ovoxel_results.directory(identifier)
43
+ if source.name != 'geometry.npz' or destination.exists():
44
+ raise ValueError('The geometry output destination is invalid.')
45
+ files, metadata = ovoxel_results.local_manifest(folder)
46
+ expected_frames = ovoxel_results._frame_names(metadata)
47
+ if metadata.get('modes') != ['clay'] or metadata.get('default_mode') != 'clay':
48
+ raise ValueError('Geometry must have a clay preview.')
49
+ if set(files) != {f'frames/{name}' for name in expected_frames}:
50
+ raise ValueError('The geometry preview is incomplete.')
51
+ # No user-selected path or archive member is accepted as a handoff file.
52
+ handoff_files = _handoff_files(folder)
53
+ names = (*handoff_files, 'report.json', 'preview.json', 'timings.jsonl',
54
+ *(f'views/{name}' for name in expected_frames))
55
+ for name in names:
56
+ _file(folder, name)
57
+ destination.mkdir(parents=True)
58
+ bindings = {}
59
+ for name in names:
60
+ original = _file(folder, name)
61
+ target = destination / name
62
+ target.parent.mkdir(parents=True, exist_ok=True)
63
+ expected = {'size': original.stat().st_size, 'sha256': _digest(original)}
64
+ shutil.copyfile(original, target)
65
+ if target.stat().st_size != expected['size'] or _digest(target) != expected['sha256']:
66
+ raise ValueError('The saved geometry failed integrity validation.')
67
+ if name in handoff_files:
68
+ bindings[name] = expected
69
+ (destination / 'geometry-manifest.json').write_text(json.dumps({
70
+ 'schema_version': 1, 'files': bindings}), encoding='utf-8')
71
+ (destination / 'display-manifest.json').write_text(json.dumps({
72
+ 'files': files, 'preview_metadata': metadata}), encoding='utf-8')
73
+ (destination / 'ready.json').write_text(json.dumps({
74
+ 'complete': True, 'identifier': identifier, 'output_kind': 'geometry',
75
+ 'project_revision': report['project_revision']}), encoding='utf-8')
76
+ return identifier
77
+
78
+
79
+ def restore(identifier, destination):
80
+ """Restore only a verified geometry handoff, without allocating a GPU."""
81
+ folder = ovoxel_results.directory(identifier)
82
+ ready = json.loads(_file(folder, 'ready.json').read_text(encoding='utf-8'))
83
+ if ready.get('complete') is not True or ready.get('output_kind') != 'geometry' or ready.get('identifier') != identifier:
84
+ raise ValueError('The saved geometry is not ready for texturing.')
85
+ manifest = json.loads(_file(folder, 'geometry-manifest.json').read_text(encoding='utf-8'))
86
+ bindings = manifest.get('files')
87
+ handoff_files = _handoff_files(folder)
88
+ if manifest.get('schema_version') != 1 or not isinstance(bindings, dict) or set(bindings) != set(handoff_files):
89
+ raise ValueError('The saved geometry handoff is invalid.')
90
+ destination = Path(destination)
91
+ if destination.is_symlink() or destination.exists() and any(destination.iterdir()):
92
+ raise ValueError('Geometry must be restored into an empty local directory.')
93
+ destination.mkdir(parents=True, exist_ok=True)
94
+ for name in handoff_files:
95
+ source = _file(folder, name)
96
+ expected = bindings[name]
97
+ if (not isinstance(expected, dict) or source.stat().st_size != expected.get('size')
98
+ or _digest(source) != expected.get('sha256')):
99
+ raise ValueError('The saved geometry failed integrity validation.')
100
+ target = destination / name
101
+ target.parent.mkdir(parents=True, exist_ok=True)
102
+ shutil.copyfile(source, target)
103
+ if target.stat().st_size != expected['size'] or _digest(target) != expected['sha256']:
104
+ raise ValueError('The restored geometry failed integrity validation.')
105
+ return destination
hf_space/gpu_mode.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hardware mode without importing Torch or starting a GPU runtime."""
2
+ import os
3
+
4
+
5
+ def enabled():
6
+ return os.environ.get('SPACES_ZERO_GPU', '').lower() in ('1', 'true', 't')
7
+
8
+
9
+ def pending_fields(operations):
10
+ if not enabled():
11
+ return {}
12
+ pending = next((op for op in operations if op.get('status') == 'queued'), None)
13
+ return {'gpu_mode': 'zerogpu', 'gpu_operation_id': pending['id'] if pending else None,
14
+ 'gpu_operation_kind': pending['kind'] if pending else None}
hf_space/history_jobs.py CHANGED
@@ -1,4 +1,4 @@
1
- """One browser-independent FIFO worker for generation, export and recovery."""
2
  from __future__ import annotations
3
 
4
  import logging
@@ -11,6 +11,7 @@ LOG = logging.getLogger(__name__)
11
  FAILURE_MESSAGES = {
12
  "foreground": "Background removal failed. Please retry or choose another image.",
13
  "generate": "Generation failed. Please submit again.",
 
14
  "export": "GLB export failed. Please retry.",
15
  "recover": "Preview recovery failed. Please retry.",
16
  }
@@ -28,9 +29,17 @@ class Runner:
28
  self._thread = None
29
  self._start_lock = threading.Lock()
30
  self._pending_terminal = None
 
 
 
31
  self.error = None
32
 
33
  def start(self):
 
 
 
 
 
34
  with self._start_lock:
35
  if self._thread is not None:
36
  return self._thread
@@ -63,10 +72,8 @@ class Runner:
63
  self.store.changed.wait(self.poll_interval)
64
  self.store.changed.clear()
65
 
66
- def _finish_pending(self):
67
- if self._pending_terminal is None:
68
- return False
69
- operation_id, kind, result, error = self._pending_terminal
70
  try:
71
  self.store.finish_operation(operation_id, result, error=error)
72
  except ValidationError:
@@ -74,75 +81,127 @@ class Runner:
74
  # to rerun costly inference or block every later queued job.
75
  LOG.exception("Personal-history %s returned invalid output", kind)
76
  self.store.finish_operation(operation_id, error=FAILURE_MESSAGES[kind])
 
 
 
 
 
77
  self._pending_terminal = None
78
  return True
79
 
80
- def run_once(self):
81
- """Execute at most one durable operation; useful for deterministic tests."""
 
 
 
82
  if not self.store.execution_lock.acquire(blocking=False):
83
  return False
84
  try:
85
- if self._finish_pending():
86
  return True
87
- pending = self.store.next_operation()
88
- if pending is None or self._stop.is_set():
89
- return False
90
- operation = pending["operation"]
91
- kind = operation["kind"]
92
- if not self.readiness(kind):
93
  return False
94
- job = self.store.claim_operation(operation["id"])
95
- operation = next(item for item in job["operations"] if item["id"] == operation["id"])
96
- job["operation"] = operation
97
- job["operation_id"] = operation["id"]
98
- job["operation_kind"] = kind
99
- watcher_stop = threading.Event()
100
- update_lock = threading.Lock()
101
- last_persisted = [0.0]
102
-
103
- def update(values=None, **changes):
104
- if values is not None and changes:
105
- raise ValidationError("Supply one progress object.")
106
- value = values if values is not None else changes
107
- if isinstance(value, dict) and set(value) == {"progress"}:
108
- value = value["progress"]
109
- # This callback can update progress only. It never edits owner,
110
- # the task identity, persisted input, seed, or completion status.
111
- with update_lock:
112
- now = time.monotonic()
113
- persist = now - last_persisted[0] >= 3.0
114
- self.store.update_progress(operation["id"], value, persist=persist)
115
- if persist:
116
- last_persisted[0] = now
117
-
118
- def watch():
119
- while not watcher_stop.wait(1.0):
120
- try:
121
- value = self.progress_reader(job, operation)
122
- if value:
123
- update(value)
124
- except Exception:
125
- # A transient progress read is not a failed GPU task.
126
- LOG.warning("Could not read current task progress", exc_info=True)
127
-
128
- watcher = None
129
- if self.progress_reader is not None:
130
- watcher = threading.Thread(target=watch, name="personal-task-progress", daemon=True)
131
- watcher.start()
132
- result, failure = None, None
133
- try:
134
- result = self.handlers[kind](job, update)
135
- except Exception:
136
- failure = FAILURE_MESSAGES[kind]
137
- LOG.exception("Personal-history %s task failed", kind)
138
- finally:
139
- watcher_stop.set()
140
- if watcher is not None:
141
- watcher.join(timeout=5)
142
  # Retain the outcome in memory if persistence temporarily fails.
143
  # Retry only the terminal write, never the completed GPU callback.
144
- self._pending_terminal = (operation["id"], kind, result, failure)
145
  self._finish_pending()
146
  return True
147
  finally:
148
  self.store.execution_lock.release()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Durable operations: direct user requests on ZeroGPU, one FIFO on paid GPUs."""
2
  from __future__ import annotations
3
 
4
  import logging
 
11
  FAILURE_MESSAGES = {
12
  "foreground": "Background removal failed. Please retry or choose another image.",
13
  "generate": "Generation failed. Please submit again.",
14
+ "texture": "Texture generation failed. Your geometry is saved; please retry texturing.",
15
  "export": "GLB export failed. Please retry.",
16
  "recover": "Preview recovery failed. Please retry.",
17
  }
 
29
  self._thread = None
30
  self._start_lock = threading.Lock()
31
  self._pending_terminal = None
32
+ self._operation_guard = threading.Lock()
33
+ self._operation_locks = {}
34
+ self._pending_terminals = {}
35
  self.error = None
36
 
37
  def start(self):
38
+ from hf_space.gpu_mode import enabled
39
+ if enabled():
40
+ # ZeroGPU work must retain the requesting visitor's Gradio context.
41
+ # Even an accidental call cannot create an anonymous FIFO worker.
42
+ return None
43
  with self._start_lock:
44
  if self._thread is not None:
45
  return self._thread
 
72
  self.store.changed.wait(self.poll_interval)
73
  self.store.changed.clear()
74
 
75
+ def _persist_terminal(self, outcome):
76
+ operation_id, kind, result, error = outcome
 
 
77
  try:
78
  self.store.finish_operation(operation_id, result, error=error)
79
  except ValidationError:
 
81
  # to rerun costly inference or block every later queued job.
82
  LOG.exception("Personal-history %s returned invalid output", kind)
83
  self.store.finish_operation(operation_id, error=FAILURE_MESSAGES[kind])
84
+
85
+ def _finish_pending(self):
86
+ if self._pending_terminal is None:
87
+ return False
88
+ self._persist_terminal(self._pending_terminal)
89
  self._pending_terminal = None
90
  return True
91
 
92
+ def run_once(self, *, operation_id=None, owner=None):
93
+ """Run the selected visitor's operation, or advance the paid-GPU FIFO."""
94
+ from hf_space.gpu_mode import enabled
95
+ if enabled():
96
+ return self._run_selected(operation_id, owner)
97
  if not self.store.execution_lock.acquire(blocking=False):
98
  return False
99
  try:
100
+ if self._finish_pending() and operation_id is None:
101
  return True
102
+ pending = (self.store.get_operation(owner, operation_id) if operation_id is not None
103
+ else self.store.next_operation())
104
+ outcome = self._execute_pending(pending, zero_gpu=False)
105
+ if outcome is None:
 
 
106
  return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  # Retain the outcome in memory if persistence temporarily fails.
108
  # Retry only the terminal write, never the completed GPU callback.
109
+ self._pending_terminal = outcome
110
  self._finish_pending()
111
  return True
112
  finally:
113
  self.store.execution_lock.release()
114
+
115
+ def _run_selected(self, operation_id, owner):
116
+ if operation_id is None:
117
+ return False
118
+ # Validate ownership before looking at an in-flight lock or retained
119
+ # result. A duplicate request never reveals another visitor's task.
120
+ self.store.get_operation(owner, operation_id)
121
+ with self._operation_guard:
122
+ lock = self._operation_locks.setdefault(operation_id, threading.Lock())
123
+ if not lock.acquire(blocking=False):
124
+ return False
125
+ try:
126
+ pending = self.store.get_operation(owner, operation_id)
127
+ with self._operation_guard:
128
+ outcome = self._pending_terminals.get(operation_id)
129
+ if outcome is None:
130
+ outcome = self._execute_pending(pending, zero_gpu=True)
131
+ if outcome is None:
132
+ return False
133
+ with self._operation_guard:
134
+ self._pending_terminals[operation_id] = outcome
135
+ self._persist_terminal(outcome)
136
+ with self._operation_guard:
137
+ del self._pending_terminals[operation_id]
138
+ return True
139
+ finally:
140
+ # Registry changes and acquisition share this short lock, so a
141
+ # waiter cannot acquire an old lock while a new one is installed.
142
+ with self._operation_guard:
143
+ lock.release()
144
+ del self._operation_locks[operation_id]
145
+
146
+ def _execute_pending(self, pending, *, zero_gpu):
147
+ if pending is None or self._stop.is_set():
148
+ return None
149
+ operation = pending["operation"]
150
+ if operation['status'] != 'queued':
151
+ return None
152
+ kind = operation["kind"]
153
+ if not self.readiness(kind):
154
+ return None
155
+ job = self.store.claim_operation(operation["id"])
156
+ operation = next(item for item in job["operations"] if item["id"] == operation["id"])
157
+ job["operation"] = operation
158
+ job["operation_id"] = operation["id"]
159
+ job["operation_kind"] = kind
160
+ watcher_stop = threading.Event()
161
+ update_lock = threading.Lock()
162
+ last_persisted = [0.0]
163
+
164
+ def update(values=None, **changes):
165
+ if values is not None and changes:
166
+ raise ValidationError("Supply one progress object.")
167
+ value = values if values is not None else changes
168
+ if isinstance(value, dict) and set(value) == {"progress"}:
169
+ value = value["progress"]
170
+ # This callback can update progress only. It never edits owner,
171
+ # the task identity, persisted input, seed, or completion status.
172
+ with update_lock:
173
+ now = time.monotonic()
174
+ persist = now - last_persisted[0] >= 3.0
175
+ self.store.update_progress(operation["id"], value, persist=persist)
176
+ if persist:
177
+ last_persisted[0] = now
178
+
179
+ def watch():
180
+ while not watcher_stop.wait(1.0):
181
+ try:
182
+ value = self.progress_reader(job, operation)
183
+ if value:
184
+ update(value)
185
+ except Exception:
186
+ # A transient progress read is not a failed GPU task.
187
+ LOG.warning("Could not read current task progress", exc_info=True)
188
+
189
+ watcher = None
190
+ if self.progress_reader is not None:
191
+ watcher = threading.Thread(target=watch, name="personal-task-progress", daemon=True)
192
+ watcher.start()
193
+ result, failure = None, None
194
+ try:
195
+ result = self.handlers[kind](job, update)
196
+ except Exception as error:
197
+ if zero_gpu:
198
+ from hf_space.zerogpu_jobs import public_gpu_error
199
+ failure = public_gpu_error(error, FAILURE_MESSAGES[kind])
200
+ else:
201
+ failure = FAILURE_MESSAGES[kind]
202
+ LOG.exception("Personal-history %s task failed", kind)
203
+ finally:
204
+ watcher_stop.set()
205
+ if watcher is not None:
206
+ watcher.join(timeout=5)
207
+ return operation["id"], kind, result, failure
hf_space/history_routes.py CHANGED
@@ -221,6 +221,7 @@ def install_routes(
221
  examples_provider: Callable | None = None,
222
  example_file_provider: Callable | None = None,
223
  prepare_example: Callable | None = None,
 
224
  preview_provider: Callable | None = None,
225
  public_gradio_paths=(),
226
  trusted_origins=(),
@@ -275,8 +276,9 @@ def install_routes(
275
 
276
  async def require_ready():
277
  state = await invoke(deployment_status, _limiter=status_limiter)
278
- if (state.get('native_build', {}).get('stage') != 'compiled'
279
- or state.get('models', {}).get('stage') != 'ready_released'):
 
280
  raise HTTPException(503, 'Models are loading in the background. Please wait until loading is complete.')
281
  if not store.ready:
282
  raise HTTPException(503, 'Preparing your workspace. Please wait a moment.')
@@ -374,6 +376,9 @@ def install_routes(
374
  result['status_url'] = f'{BASE}/uploads/{upload_id}'
375
  if upload['foreground'].get('status') == 'complete' and upload.get('files', {}).get('foreground.png'):
376
  result['foreground_url'] = f'{BASE}/uploads/{upload_id}/files/foreground.png'
 
 
 
377
  return result
378
 
379
  def prime_completed_upload(owner, upload):
@@ -487,6 +492,27 @@ def install_routes(
487
  state = await invoke(deployment_status, _limiter=status_limiter)
488
  return {**state, 'workspace': workspace_status()}
489
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  @router.get('/examples')
491
  async def examples():
492
  # This manifest only describes committed public assets. It does not
@@ -561,7 +587,7 @@ def install_routes(
561
  models = state.get('models', {}) if isinstance(state, dict) else {}
562
  if native.get('stage') == 'failed' or models.get('stage') == 'failed':
563
  raise HTTPException(503, 'Model loading failed. Please try again later.')
564
- if native.get('stage') != 'compiled' or models.get('stage') != 'ready_released':
565
  raise HTTPException(503, 'Models are loading in the background. Please wait until loading is complete.')
566
  upload_id = value.get('upload_id')
567
  if value.get('example'):
@@ -637,7 +663,8 @@ def install_routes(
637
  await require_ready()
638
  available()
639
  value = await body(request, ('idempotency_key',))
640
- function = store.submit_export if kind == 'export' else store.submit_recover
 
641
  job = await invoke(function, identity.owner, job_id, key(value.get('idempotency_key')), _limiter=mutation_limiter)
642
  return await invoke(snapshot, job)
643
 
@@ -645,6 +672,10 @@ def install_routes(
645
  async def export(job_id: str, request: Request):
646
  return await operation(request, job_id, 'export')
647
 
 
 
 
 
648
  @router.post('/jobs/{job_id}/recover')
649
  async def recover(job_id: str, request: Request):
650
  return await operation(request, job_id, 'recover')
@@ -668,7 +699,8 @@ def install_routes(
668
  local, release = await invoke(prepare, _limiter=artifact_limiter if name.endswith('.glb') else image_limiter)
669
  except RuntimeError:
670
  raise HTTPException(503, 'Artifact storage is temporarily unavailable. Please retry.') from None
671
- media = mimetypes.guess_type(name)[0] or 'application/octet-stream'
 
672
  if name.lower().endswith('.glb'):
673
  media = 'model/gltf-binary'
674
  return PrivateFileResponse(local, media_type=media, filename=Path(name).name,
 
221
  examples_provider: Callable | None = None,
222
  example_file_provider: Callable | None = None,
223
  prepare_example: Callable | None = None,
224
+ prepare_models: Callable | None = None,
225
  preview_provider: Callable | None = None,
226
  public_gradio_paths=(),
227
  trusted_origins=(),
 
276
 
277
  async def require_ready():
278
  state = await invoke(deployment_status, _limiter=status_limiter)
279
+ if (state.get('gpu_mode') != 'zerogpu'
280
+ and (state.get('native_build', {}).get('stage') != 'compiled'
281
+ or state.get('models', {}).get('stage') != 'ready_released')):
282
  raise HTTPException(503, 'Models are loading in the background. Please wait until loading is complete.')
283
  if not store.ready:
284
  raise HTTPException(503, 'Preparing your workspace. Please wait a moment.')
 
376
  result['status_url'] = f'{BASE}/uploads/{upload_id}'
377
  if upload['foreground'].get('status') == 'complete' and upload.get('files', {}).get('foreground.png'):
378
  result['foreground_url'] = f'{BASE}/uploads/{upload_id}/files/foreground.png'
379
+ for field in ('gpu_mode', 'gpu_operation_id', 'gpu_operation_kind'):
380
+ if field in upload:
381
+ result[field] = upload[field]
382
  return result
383
 
384
  def prime_completed_upload(owner, upload):
 
492
  state = await invoke(deployment_status, _limiter=status_limiter)
493
  return {**state, 'workspace': workspace_status()}
494
 
495
+ @router.post('/gpu/prepare')
496
+ async def prepare_gpu_task(request: Request):
497
+ identity = require_mutation(request, trusted_origins, csrf_secret)
498
+ available()
499
+ value = await body(request, ('operation_id',))
500
+ pending = await invoke(store.get_operation, identity.owner, value.get('operation_id'),
501
+ _limiter=status_limiter)
502
+ state = await invoke(deployment_status, _limiter=status_limiter)
503
+ if state.get('gpu_mode') != 'zerogpu' or prepare_models is None:
504
+ raise HTTPException(404, 'On-demand preparation is unavailable.')
505
+ if pending['operation']['status'] == 'queued':
506
+ kind = pending['operation']['kind']
507
+ required = ('foreground',) if kind == 'foreground' else ('native_build', 'models')
508
+ if any(state.get(key, {}).get('stage') == 'failed' for key in required):
509
+ raise HTTPException(503, 'Model preparation failed. Please try again later.')
510
+ # This callback only starts the shared CPU preparation controller;
511
+ # GPU allocation remains in the visitor's later Gradio request.
512
+ await invoke(prepare_models, kind, _limiter=mutation_limiter)
513
+ return {'operation_id': pending['operation']['id'],
514
+ 'status': pending['operation']['status'], 'gpu_mode': 'zerogpu'}
515
+
516
  @router.get('/examples')
517
  async def examples():
518
  # This manifest only describes committed public assets. It does not
 
587
  models = state.get('models', {}) if isinstance(state, dict) else {}
588
  if native.get('stage') == 'failed' or models.get('stage') == 'failed':
589
  raise HTTPException(503, 'Model loading failed. Please try again later.')
590
+ if state.get('gpu_mode') != 'zerogpu' and (native.get('stage') != 'compiled' or models.get('stage') != 'ready_released'):
591
  raise HTTPException(503, 'Models are loading in the background. Please wait until loading is complete.')
592
  upload_id = value.get('upload_id')
593
  if value.get('example'):
 
663
  await require_ready()
664
  available()
665
  value = await body(request, ('idempotency_key',))
666
+ function = {'export': store.submit_export, 'recover': store.submit_recover,
667
+ 'texture': store.submit_texture}[kind]
668
  job = await invoke(function, identity.owner, job_id, key(value.get('idempotency_key')), _limiter=mutation_limiter)
669
  return await invoke(snapshot, job)
670
 
 
672
  async def export(job_id: str, request: Request):
673
  return await operation(request, job_id, 'export')
674
 
675
+ @router.post('/jobs/{job_id}/texture')
676
+ async def texture(job_id: str, request: Request):
677
+ return await operation(request, job_id, 'texture')
678
+
679
  @router.post('/jobs/{job_id}/recover')
680
  async def recover(job_id: str, request: Request):
681
  return await operation(request, job_id, 'recover')
 
699
  local, release = await invoke(prepare, _limiter=artifact_limiter if name.endswith('.glb') else image_limiter)
700
  except RuntimeError:
701
  raise HTTPException(503, 'Artifact storage is temporarily unavailable. Please retry.') from None
702
+ media = {'.webp': 'image/webp', '.png': 'image/png'}.get(Path(name).suffix.lower())
703
+ media = media or mimetypes.guess_type(name)[0] or 'application/octet-stream'
704
  if name.lower().endswith('.glb'):
705
  media = 'model/gltf-binary'
706
  return PrivateFileResponse(local, media_type=media, filename=Path(name).name,
hf_space/history_service.py CHANGED
@@ -10,7 +10,7 @@ import logging
10
 
11
  from PIL import Image
12
 
13
- from hf_space import release_backend, native_build, generation_progress, extraction_progress, ovoxel_results, example_assets
14
  from hf_space.history_store import Store, NotFound
15
  from hf_space.history_jobs import Runner
16
  from hf_space.history_keepalive import WorkKeepAlive
@@ -23,11 +23,22 @@ LOG = logging.getLogger(__name__)
23
 
24
 
25
  def deployment_status():
 
26
  native = native_build.status()
27
  models = release_backend.model_status()
 
 
 
 
 
 
28
  # A public readiness check must not expose traceback text, runtime file
29
  # paths, private configuration or job identifiers.
30
- return {'native_build': {key: native[key] for key in ('stage', 'current') if key in native},
 
 
 
 
31
  'models': {key: models[key] for key in ('stage', 'startup_progress') if key in models}}
32
 
33
 
@@ -36,7 +47,7 @@ class Service:
36
  self._boot_started = time.monotonic()
37
  self.store = store if store is not None else Store()
38
  self.private_files = PrivateFileCache()
39
- self.runner = Runner(self.store, {'generate': self.generate, 'export': self.export,
40
  'recover': self.recover, 'foreground': self.foreground}, ready=self.ready,
41
  progress=self.progress)
42
  self.keepalive = WorkKeepAlive(self.active_work)
@@ -61,6 +72,18 @@ class Service:
61
  if self._started:
62
  return
63
  self._started = True
 
 
 
 
 
 
 
 
 
 
 
 
64
  # One small read-only HTTP bootstrap lets existing history cards appear
65
  # during model loading. It never traverses the Bucket mount, writes task
66
  # state, triggers model preparation or waits on the weight controller.
@@ -96,13 +119,24 @@ class Service:
96
  atexit.register(self.runner.stop)
97
  atexit.register(self.keepalive.stop)
98
 
 
 
 
 
 
 
 
 
 
 
99
  @staticmethod
100
  def ready(kind):
101
  state = deployment_status()
 
 
102
  native = state['native_build'].get('stage') == 'compiled'
103
- # Startup constructs the shared runtime and opens a CUDA context outside
104
- # the FIFO worker. Exports/recovery must wait for it too, avoiding GPU
105
- # overlap and contention with the initial weight staging/loading.
106
  return native and state['models'].get('stage') == 'ready_released'
107
 
108
  def generate(self, job, update):
@@ -112,12 +146,31 @@ class Service:
112
  input_path = self.store.resolve_file(job['owner'], job['id'], 'foreground.png' if prepared else 'input.png')
113
  with Image.open(input_path) as source:
114
  image = source.convert('RGBA')
115
- update(label='Generating geometry and reference materials')
116
- mesh, _asset, report = release_backend.generate(image, int(job['seed']), generation_token=token,
 
 
117
  **({'foreground_prepared': True} if prepared else {}))
118
- generation_progress.update(token, status='saving', label='Saving the 3D asset')
119
- update(label='Saving the 3D asset')
120
- identifier = ovoxel_results.persist(mesh, report)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  local = Path(mesh).parent
122
  files, metadata = ovoxel_results.local_manifest(local)
123
  # Prime the first actual view from local generation output. This is
 
10
 
11
  from PIL import Image
12
 
13
+ from hf_space import release_backend, native_build, generation_progress, extraction_progress, ovoxel_results, example_assets, geometry_results
14
  from hf_space.history_store import Store, NotFound
15
  from hf_space.history_jobs import Runner
16
  from hf_space.history_keepalive import WorkKeepAlive
 
23
 
24
 
25
  def deployment_status():
26
+ from hf_space.gpu_mode import enabled
27
  native = native_build.status()
28
  models = release_backend.model_status()
29
+ native_steps = 7 # CUDA setup, five native wheels, dependency validation.
30
+ completed = (native_steps if native.get('stage') == 'compiled' else
31
+ 1 + len(native.get('completed', [])) if native.get('stage') == 'compiling' else 0)
32
+ native_progress = {'label': native.get('current') or 'Preparing native dependencies',
33
+ 'completed': min(native_steps, completed), 'total': native_steps, 'unit': 'steps'}
34
+ foreground = release_backend.foreground_status() if enabled() else None
35
  # A public readiness check must not expose traceback text, runtime file
36
  # paths, private configuration or job identifiers.
37
+ return {'runtime_id': models.get('epoch'),
38
+ **({'gpu_mode': 'zerogpu', 'foreground': {
39
+ key: foreground[key] for key in ('stage', 'startup_progress') if key in foreground}} if enabled() else {}),
40
+ 'native_build': {**{key: native[key] for key in ('stage', 'current') if key in native},
41
+ 'startup_progress': native_progress},
42
  'models': {key: models[key] for key in ('stage', 'startup_progress') if key in models}}
43
 
44
 
 
47
  self._boot_started = time.monotonic()
48
  self.store = store if store is not None else Store()
49
  self.private_files = PrivateFileCache()
50
+ self.runner = Runner(self.store, {'generate': self.generate, 'texture': self.texture, 'export': self.export,
51
  'recover': self.recover, 'foreground': self.foreground}, ready=self.ready,
52
  progress=self.progress)
53
  self.keepalive = WorkKeepAlive(self.active_work)
 
72
  if self._started:
73
  return
74
  self._started = True
75
+ from hf_space.gpu_mode import enabled
76
+ if enabled():
77
+ # History is available independently of models. Opening the page,
78
+ # polling status, and restoring saved records never prepare weights.
79
+ def initialize_history():
80
+ try:
81
+ self.store.initialize()
82
+ except Exception:
83
+ LOG.exception('Could not initialize personal history')
84
+ threading.Thread(target=initialize_history, name='history-startup', daemon=True).start()
85
+ atexit.register(self.runner.stop)
86
+ return
87
  # One small read-only HTTP bootstrap lets existing history cards appear
88
  # during model loading. It never traverses the Bucket mount, writes task
89
  # state, triggers model preparation or waits on the weight controller.
 
119
  atexit.register(self.runner.stop)
120
  atexit.register(self.keepalive.stop)
121
 
122
+ @staticmethod
123
+ def prepare_models(kind='generate'):
124
+ """Start CPU preparation only after an explicit owner-checked task."""
125
+ from hf_space.gpu_mode import enabled
126
+ if enabled():
127
+ if kind == 'foreground':
128
+ release_backend.start_foreground_preparation()
129
+ else:
130
+ release_backend.start_preparation()
131
+
132
  @staticmethod
133
  def ready(kind):
134
  state = deployment_status()
135
+ if state.get('gpu_mode') == 'zerogpu' and kind == 'foreground':
136
+ return state['foreground'].get('stage') == 'ready_released'
137
  native = state['native_build'].get('stage') == 'compiled'
138
+ # GPU execution requires the CPU pool and native extensions. Page,
139
+ # history and task admission have an independent workspace gate.
 
140
  return native and state['models'].get('stage') == 'ready_released'
141
 
142
  def generate(self, job, update):
 
146
  input_path = self.store.resolve_file(job['owner'], job['id'], 'foreground.png' if prepared else 'input.png')
147
  with Image.open(input_path) as source:
148
  image = source.convert('RGBA')
149
+ split = job.get('workflow') == 'geometry_texture'
150
+ update(label='Generating geometry' if split else 'Generating geometry and reference materials')
151
+ generate = release_backend.generate_geometry if split else release_backend.generate
152
+ mesh, _asset, report = generate(image, int(job['seed']), generation_token=token,
153
  **({'foreground_prepared': True} if prepared else {}))
154
+ return self._publish_generated(mesh, report, job, token, update, geometry=split)
155
+
156
+ def texture(self, job, update):
157
+ """Resume from the saved white model; never regenerate its geometry."""
158
+ import tempfile
159
+ if job.get('workflow') != 'geometry_texture' or not job.get('geometry_asset_id'):
160
+ raise ValueError('A saved geometry result is required before generating textures.')
161
+ token = job['operation_id']
162
+ update(label='Loading saved geometry')
163
+ with tempfile.TemporaryDirectory(prefix='supratex-texture-input-') as temporary:
164
+ folder = geometry_results.restore(job['geometry_asset_id'], Path(temporary))
165
+ mesh, _asset, report = release_backend.generate_texture(
166
+ folder, int(job['seed']), generation_token=token)
167
+ return self._publish_generated(mesh, report, job, token, update)
168
+
169
+ def _publish_generated(self, mesh, report, job, token, update, *, geometry=False):
170
+ label = 'Saving geometry' if geometry else 'Saving the 3D asset'
171
+ generation_progress.update(token, status='saving', label=label)
172
+ update(label=label)
173
+ identifier = (geometry_results if geometry else ovoxel_results).persist(mesh, report)
174
  local = Path(mesh).parent
175
  files, metadata = ovoxel_results.local_manifest(local)
176
  # Prime the first actual view from local generation output. This is
hf_space/history_store.py CHANGED
@@ -26,7 +26,8 @@ from PIL import Image, ImageOps, UnidentifiedImageError
26
 
27
  ID = re.compile(r"^[0-9a-f]{32}$")
28
  FRAME = re.compile(r"^[a-z][a-z0-9_]{0,47}-[0-9]{2}\.webp$")
29
- KINDS = {"generate", "export", "recover", "foreground"}
 
30
  STATUSES = {"queued", "running", "complete", "failed", "interrupted"}
31
  MAX_UPLOAD_BYTES = 20 * 1024 * 1024
32
  MAX_PIXELS = 24_000_000
@@ -391,6 +392,15 @@ class Store:
391
  if not upload:
392
  if record.get("status") not in STATUSES or not isinstance(record.get("operations"), list):
393
  raise ValidationError("Invalid job state.")
 
 
 
 
 
 
 
 
 
394
  for operation in record["operations"]:
395
  _id(operation.get("id"))
396
  if operation.get("kind") not in KINDS or operation.get("status") not in STATUSES:
@@ -405,6 +415,8 @@ class Store:
405
  _key(alias)
406
  if (operation["kind"] == "foreground") != (record.get("purpose") == "foreground"):
407
  raise ValidationError("Invalid foreground task.")
 
 
408
  if record.get("purpose") == "foreground" and record.get("upload_id") != record["id"]:
409
  raise ValidationError("Invalid foreground upload.")
410
  asset_id = record.get("asset_id")
@@ -550,6 +562,8 @@ class Store:
550
  result["foreground"] = {"status": state["status"], "progress": state.get("progress", {})}
551
  if state.get("error"):
552
  result["foreground"]["error"] = state["error"]
 
 
553
  if state["status"] == "complete" and state.get("foreground_metadata"):
554
  result.setdefault("files", {})["foreground.png"] = deepcopy(state["files"]["foreground.png"])
555
  return result
@@ -685,10 +699,12 @@ class Store:
685
  "idempotency_key": key, "enqueue_order": sequence,
686
  "created_at": now, "updated_at": now, "progress": {}, "error": None}
687
 
688
- def submit(self, owner, upload_id, seed, idempotency_key):
689
  self._require_ready()
690
  _owner(owner)
691
  _key(idempotency_key)
 
 
692
  if type(seed) is not int or not 0 <= seed <= 2**31 - 1:
693
  raise ValidationError("Seed must be an integer between 0 and 2147483647.")
694
  with self._durable_mutation(("admission", owner)):
@@ -703,9 +719,7 @@ class Store:
703
  if not same_input or job["seed"] != seed:
704
  raise Conflict("This request key already belongs to another submission.")
705
  return self._snapshot(job)
706
- pending = sum(job["owner"] == owner and job["status"] in ("queued", "running")
707
- and job.get("purpose") != "foreground"
708
- for job in self._jobs.values())
709
  if pending >= self.max_pending_per_owner:
710
  raise LimitExceeded("You can have at most three unfinished generations.")
711
  foreground = self._jobs.get(upload_id)
@@ -741,6 +755,11 @@ class Store:
741
  "error": None, "export_status": "not_requested", "export_progress": {},
742
  "export_error": None, "recover_status": "not_requested", "recover_progress": {},
743
  "operations": [operation], "files": deepcopy(upload.get("files", {}))}
 
 
 
 
 
744
  if foreground:
745
  job.update(foreground_prepared=True, foreground_metadata=deepcopy(foreground["foreground_metadata"]))
746
  job["files"]["foreground.png"] = deepcopy(foreground["files"]["foreground.png"])
@@ -767,10 +786,18 @@ class Store:
767
  self._revision += 1
768
  self._job_revisions[job_id] = self._revision
769
 
 
 
 
 
 
770
  def _submit_operation(self, kind, owner, job_id, idempotency_key):
771
  self._require_ready()
772
  _key(idempotency_key)
773
- with self._durable_mutation(("job", job_id)):
 
 
 
774
  with self._lock:
775
  original = deepcopy(self._authorized(self._jobs, owner, job_id))
776
  previous = self._keys.get((owner, kind, idempotency_key))
@@ -780,10 +807,18 @@ class Store:
780
  return self._snapshot(original)
781
  if original["status"] != "complete" or not original.get("asset_id"):
782
  raise Conflict("Wait for generation to finish before requesting this operation.")
 
 
 
 
 
 
 
 
783
  existing = next((index for index in range(len(original["operations"]) - 1, -1, -1)
784
  if original["operations"][index]["kind"] == kind
785
  and (original["operations"][index]["status"] in ("queued", "running")
786
- or kind == "export" and original["operations"][index]["status"] == "complete")),
787
  None)
788
  if existing is not None:
789
  # Persist merged request keys too: retrying a second click
@@ -793,6 +828,13 @@ class Store:
793
  self._commit(job)
794
  with self._lock:
795
  return self._snapshot(job)
 
 
 
 
 
 
 
796
  job = deepcopy(original)
797
  operation = self._new_operation(kind, idempotency_key)
798
  job["operations"].append(operation)
@@ -804,6 +846,9 @@ class Store:
804
  def submit_export(self, owner, job_id, idempotency_key):
805
  return self._submit_operation("export", owner, job_id, idempotency_key)
806
 
 
 
 
807
  def submit_recover(self, owner, job_id, idempotency_key):
808
  return self._submit_operation("recover", owner, job_id, idempotency_key)
809
 
@@ -821,6 +866,11 @@ class Store:
821
  if operation["status"] == "queued"), key=lambda entry: entry[0])
822
 
823
  def _queue_state(self):
 
 
 
 
 
824
  running = sum(operation["status"] == "running" for record in self._jobs.values()
825
  for operation in record["operations"])
826
  positions = {}
@@ -843,9 +893,11 @@ class Store:
843
  keys = ("id", "upload_id", "status", "seed", "created_at", "updated_at",
844
  "asset_id", "progress", "error", "export_status", "export_progress",
845
  "export_error", "export_postprocess", "recover_status", "recover_progress", "recover_error", "preview",
846
- "foreground_prepared")
847
  result = {key: deepcopy(job[key]) for key in keys if key in job}
848
  result["display_ready"] = "display.webp" in job.get("files", {})
 
 
849
  return result
850
 
851
  def get_job(self, owner, job_id):
@@ -929,6 +981,16 @@ class Store:
929
  _, identifier, operation = queued[0]
930
  return {"job": self._snapshot(self._jobs[identifier]), "operation": deepcopy(operation)}
931
 
 
 
 
 
 
 
 
 
 
 
932
  def has_pending(self):
933
  """Whether this initialized process has any queued or running work."""
934
  with self._lock:
@@ -1017,14 +1079,20 @@ class Store:
1017
  if operation["kind"] != "export":
1018
  raise ValidationError("Only GLB exports can report texture postprocessing.")
1019
  job["export_postprocess"] = _export_postprocess(result["export_postprocess"])
1020
- if operation["kind"] == "generate":
 
1021
  try:
1022
  asset_id = _id(result.get("asset_id"))
1023
  except NotFound as invalid:
1024
  raise ValidationError("The generated asset identifier is invalid.") from invalid
1025
  if not (self.asset_root / asset_id / "ready.json").is_file():
1026
  raise ValidationError("The generated asset has not finished saving.")
 
 
 
1027
  job["asset_id"] = asset_id
 
 
1028
  elif "asset_id" in result and result["asset_id"] != job["asset_id"]:
1029
  raise ValidationError("A task cannot replace another asset.")
1030
  if operation["kind"] == "export" and not (self.asset_root / job["asset_id"] / "asset.glb").is_file():
@@ -1032,7 +1100,7 @@ class Store:
1032
  metadata = result.get("preview_metadata")
1033
  if metadata is not None:
1034
  metadata = _preview_metadata(metadata)
1035
- elif job.get("preview_metadata"):
1036
  metadata = _preview_metadata(job["preview_metadata"])
1037
  elif job.get("asset_id") and (self.asset_root / job["asset_id"] / "preview.json").is_file():
1038
  try:
@@ -1040,6 +1108,8 @@ class Store:
1040
  except ValueError as invalid:
1041
  raise ValidationError("The saved preview metadata is invalid.") from invalid
1042
  metadata = _preview_metadata(metadata)
 
 
1043
  if "preview" in result:
1044
  job["preview"] = _preview(result["preview"])
1045
  elif metadata is not None:
@@ -1056,6 +1126,15 @@ class Store:
1056
  # handlers report their local sizes while publishing.
1057
  manifest = self._capture_asset_files(job, metadata)
1058
  self._validate_asset_manifest(job, manifest, metadata or job.get("preview_metadata"))
 
 
 
 
 
 
 
 
 
1059
  job.setdefault("files", {}).update(manifest)
1060
  elapsed = operation.get("progress", {}).get("elapsed_seconds")
1061
  operation.update(status="complete", error=None)
 
26
 
27
  ID = re.compile(r"^[0-9a-f]{32}$")
28
  FRAME = re.compile(r"^[a-z][a-z0-9_]{0,47}-[0-9]{2}\.webp$")
29
+ KINDS = {"generate", "texture", "export", "recover", "foreground"}
30
+ GEOMETRY_TEXTURE = "geometry_texture"
31
  STATUSES = {"queued", "running", "complete", "failed", "interrupted"}
32
  MAX_UPLOAD_BYTES = 20 * 1024 * 1024
33
  MAX_PIXELS = 24_000_000
 
392
  if not upload:
393
  if record.get("status") not in STATUSES or not isinstance(record.get("operations"), list):
394
  raise ValidationError("Invalid job state.")
395
+ if record.get("workflow") not in (None, GEOMETRY_TEXTURE):
396
+ raise ValidationError("Invalid job workflow.")
397
+ if record.get("geometry_asset_id") is not None:
398
+ _id(record["geometry_asset_id"])
399
+ if record.get("workflow") == GEOMETRY_TEXTURE:
400
+ if record.get("texture_status", "not_requested") not in STATUSES | {"not_requested"}:
401
+ raise ValidationError("Invalid texture state.")
402
+ if record["status"] == "complete" and not record.get("geometry_asset_id"):
403
+ raise ValidationError("The geometry checkpoint is unavailable.")
404
  for operation in record["operations"]:
405
  _id(operation.get("id"))
406
  if operation.get("kind") not in KINDS or operation.get("status") not in STATUSES:
 
415
  _key(alias)
416
  if (operation["kind"] == "foreground") != (record.get("purpose") == "foreground"):
417
  raise ValidationError("Invalid foreground task.")
418
+ if operation["kind"] == "texture" and record.get("workflow") != GEOMETRY_TEXTURE:
419
+ raise ValidationError("Invalid texture workflow.")
420
  if record.get("purpose") == "foreground" and record.get("upload_id") != record["id"]:
421
  raise ValidationError("Invalid foreground upload.")
422
  asset_id = record.get("asset_id")
 
562
  result["foreground"] = {"status": state["status"], "progress": state.get("progress", {})}
563
  if state.get("error"):
564
  result["foreground"]["error"] = state["error"]
565
+ from hf_space.gpu_mode import pending_fields
566
+ result.update(pending_fields(foreground['operations']))
567
  if state["status"] == "complete" and state.get("foreground_metadata"):
568
  result.setdefault("files", {})["foreground.png"] = deepcopy(state["files"]["foreground.png"])
569
  return result
 
699
  "idempotency_key": key, "enqueue_order": sequence,
700
  "created_at": now, "updated_at": now, "progress": {}, "error": None}
701
 
702
+ def submit(self, owner, upload_id, seed, idempotency_key, *, workflow=GEOMETRY_TEXTURE):
703
  self._require_ready()
704
  _owner(owner)
705
  _key(idempotency_key)
706
+ if workflow not in (None, GEOMETRY_TEXTURE):
707
+ raise ValidationError("Invalid generation workflow.")
708
  if type(seed) is not int or not 0 <= seed <= 2**31 - 1:
709
  raise ValidationError("Seed must be an integer between 0 and 2147483647.")
710
  with self._durable_mutation(("admission", owner)):
 
719
  if not same_input or job["seed"] != seed:
720
  raise Conflict("This request key already belongs to another submission.")
721
  return self._snapshot(job)
722
+ pending = self._pending_generations(owner)
 
 
723
  if pending >= self.max_pending_per_owner:
724
  raise LimitExceeded("You can have at most three unfinished generations.")
725
  foreground = self._jobs.get(upload_id)
 
755
  "error": None, "export_status": "not_requested", "export_progress": {},
756
  "export_error": None, "recover_status": "not_requested", "recover_progress": {},
757
  "operations": [operation], "files": deepcopy(upload.get("files", {}))}
758
+ # Operator-managed background refreshes explicitly retain the full
759
+ # pipeline. HTTP submissions never accept a workflow override.
760
+ if workflow == GEOMETRY_TEXTURE:
761
+ job.update(workflow=workflow, geometry_asset_id=None, texture_status="not_requested",
762
+ texture_progress={}, texture_error=None)
763
  if foreground:
764
  job.update(foreground_prepared=True, foreground_metadata=deepcopy(foreground["foreground_metadata"]))
765
  job["files"]["foreground.png"] = deepcopy(foreground["files"]["foreground.png"])
 
786
  self._revision += 1
787
  self._job_revisions[job_id] = self._revision
788
 
789
+ def _pending_generations(self, owner):
790
+ return sum(job["owner"] == owner and any(
791
+ op["kind"] in ("generate", "texture") and op["status"] in ("queued", "running")
792
+ for op in job["operations"]) for job in self._jobs.values())
793
+
794
  def _submit_operation(self, kind, owner, job_id, idempotency_key):
795
  self._require_ready()
796
  _key(idempotency_key)
797
+ # Share admission ordering with new generations: a pending texture also
798
+ # occupies an unfinished-generation slot, even while its white preview
799
+ # remains complete and viewable.
800
+ with self._writer_for(("admission", owner)), self._durable_mutation(("job", job_id)):
801
  with self._lock:
802
  original = deepcopy(self._authorized(self._jobs, owner, job_id))
803
  previous = self._keys.get((owner, kind, idempotency_key))
 
807
  return self._snapshot(original)
808
  if original["status"] != "complete" or not original.get("asset_id"):
809
  raise Conflict("Wait for generation to finish before requesting this operation.")
810
+ if kind == "texture":
811
+ if (original.get("workflow") != GEOMETRY_TEXTURE
812
+ or not isinstance(original.get("geometry_asset_id"), str)
813
+ or not ID.fullmatch(original["geometry_asset_id"])):
814
+ raise Conflict("This generation has no separate geometry checkpoint to texture.")
815
+ elif kind == "export" and original.get("workflow") == GEOMETRY_TEXTURE:
816
+ if original.get("texture_status") != "complete":
817
+ raise Conflict("Generate the texture before exporting this model.")
818
  existing = next((index for index in range(len(original["operations"]) - 1, -1, -1)
819
  if original["operations"][index]["kind"] == kind
820
  and (original["operations"][index]["status"] in ("queued", "running")
821
+ or kind in ("export", "texture") and original["operations"][index]["status"] == "complete")),
822
  None)
823
  if existing is not None:
824
  # Persist merged request keys too: retrying a second click
 
828
  self._commit(job)
829
  with self._lock:
830
  return self._snapshot(job)
831
+ if any(op["status"] in ("queued", "running") and
832
+ (kind == "texture" or op["kind"] == "texture") for op in original["operations"]):
833
+ raise Conflict("Wait for the current model operation to finish before requesting another stage.")
834
+ if kind == "texture":
835
+ with self._lock:
836
+ if self._pending_generations(owner) >= self.max_pending_per_owner:
837
+ raise LimitExceeded("You can have at most three unfinished generations.")
838
  job = deepcopy(original)
839
  operation = self._new_operation(kind, idempotency_key)
840
  job["operations"].append(operation)
 
846
  def submit_export(self, owner, job_id, idempotency_key):
847
  return self._submit_operation("export", owner, job_id, idempotency_key)
848
 
849
+ def submit_texture(self, owner, job_id, idempotency_key):
850
+ return self._submit_operation("texture", owner, job_id, idempotency_key)
851
+
852
  def submit_recover(self, owner, job_id, idempotency_key):
853
  return self._submit_operation("recover", owner, job_id, idempotency_key)
854
 
 
866
  if operation["status"] == "queued"), key=lambda entry: entry[0])
867
 
868
  def _queue_state(self):
869
+ from hf_space.gpu_mode import enabled
870
+ if enabled():
871
+ # ZeroGPU requests run independently; a global FIFO rank would be
872
+ # misleading. The HF scheduler alone controls GPU availability.
873
+ return {}
874
  running = sum(operation["status"] == "running" for record in self._jobs.values()
875
  for operation in record["operations"])
876
  positions = {}
 
893
  keys = ("id", "upload_id", "status", "seed", "created_at", "updated_at",
894
  "asset_id", "progress", "error", "export_status", "export_progress",
895
  "export_error", "export_postprocess", "recover_status", "recover_progress", "recover_error", "preview",
896
+ "foreground_prepared", "workflow", "texture_status", "texture_progress", "texture_error")
897
  result = {key: deepcopy(job[key]) for key in keys if key in job}
898
  result["display_ready"] = "display.webp" in job.get("files", {})
899
+ from hf_space.gpu_mode import pending_fields
900
+ result.update(pending_fields(job.get('operations', [])))
901
  return result
902
 
903
  def get_job(self, owner, job_id):
 
981
  _, identifier, operation = queued[0]
982
  return {"job": self._snapshot(self._jobs[identifier]), "operation": deepcopy(operation)}
983
 
984
+ def get_operation(self, owner, operation_id):
985
+ """Look up an operation only for its verified owner, including cutouts."""
986
+ self._require_ready()
987
+ _owner(owner)
988
+ with self._lock:
989
+ job, index = self._operation(operation_id)
990
+ if job['owner'] != owner or job.get('deleted_at'):
991
+ raise NotFound('The task is not available.')
992
+ return {'job': self._snapshot(job), 'operation': deepcopy(job['operations'][index])}
993
+
994
  def has_pending(self):
995
  """Whether this initialized process has any queued or running work."""
996
  with self._lock:
 
1079
  if operation["kind"] != "export":
1080
  raise ValidationError("Only GLB exports can report texture postprocessing.")
1081
  job["export_postprocess"] = _export_postprocess(result["export_postprocess"])
1082
+ replacing_preview = operation["kind"] == "texture"
1083
+ if operation["kind"] in ("generate", "texture"):
1084
  try:
1085
  asset_id = _id(result.get("asset_id"))
1086
  except NotFound as invalid:
1087
  raise ValidationError("The generated asset identifier is invalid.") from invalid
1088
  if not (self.asset_root / asset_id / "ready.json").is_file():
1089
  raise ValidationError("The generated asset has not finished saving.")
1090
+ if replacing_preview and (job.get("workflow") != GEOMETRY_TEXTURE
1091
+ or asset_id == job.get("geometry_asset_id")):
1092
+ raise ValidationError("Texture output must preserve the saved geometry checkpoint.")
1093
  job["asset_id"] = asset_id
1094
+ if operation["kind"] == "generate" and job.get("workflow") == GEOMETRY_TEXTURE:
1095
+ job["geometry_asset_id"] = asset_id
1096
  elif "asset_id" in result and result["asset_id"] != job["asset_id"]:
1097
  raise ValidationError("A task cannot replace another asset.")
1098
  if operation["kind"] == "export" and not (self.asset_root / job["asset_id"] / "asset.glb").is_file():
 
1100
  metadata = result.get("preview_metadata")
1101
  if metadata is not None:
1102
  metadata = _preview_metadata(metadata)
1103
+ elif job.get("preview_metadata") and not replacing_preview:
1104
  metadata = _preview_metadata(job["preview_metadata"])
1105
  elif job.get("asset_id") and (self.asset_root / job["asset_id"] / "preview.json").is_file():
1106
  try:
 
1108
  except ValueError as invalid:
1109
  raise ValidationError("The saved preview metadata is invalid.") from invalid
1110
  metadata = _preview_metadata(metadata)
1111
+ if replacing_preview and metadata is None:
1112
+ raise ValidationError("The textured preview metadata is unavailable.")
1113
  if "preview" in result:
1114
  job["preview"] = _preview(result["preview"])
1115
  elif metadata is not None:
 
1126
  # handlers report their local sizes while publishing.
1127
  manifest = self._capture_asset_files(job, metadata)
1128
  self._validate_asset_manifest(job, manifest, metadata or job.get("preview_metadata"))
1129
+ if replacing_preview:
1130
+ frames = {f"frames/{mode}-{view:02d}.webp" for mode in metadata["modes"]
1131
+ for view in range(metadata["views"])}
1132
+ if not frames.issubset(manifest):
1133
+ raise ValidationError("The textured preview has not finished saving.")
1134
+ # The asset changes atomically with its descriptors. Keep
1135
+ # the owner's saved inputs; remove only obsolete model files.
1136
+ job["files"] = {name: value for name, value in job.get("files", {}).items()
1137
+ if name in PRIVATE_IMAGE_NAMES}
1138
  job.setdefault("files", {}).update(manifest)
1139
  elapsed = operation.get("progress", {}).get("elapsed_seconds")
1140
  operation.update(status="complete", error=None)
hf_space/history_ui.css CHANGED
@@ -7,15 +7,25 @@
7
  #st-history-header .st-history-heading p{font-size:12px;color:#9c9c9a;margin:5px 0 0;line-height:1.5}
8
  #st-resource-footer{width:auto!important;min-width:0!important;flex:none!important;margin:10px clamp(12px,1.2vw,24px) 12px!important;padding:0!important;border:0!important;background:transparent!important}
9
  #st-resource-footer .html-container{padding:0!important;border:0!important;background:transparent!important}
10
- #st-resource-footer .st-resource-notice{padding:9px 12px;border:1px solid #655536;border-left:3px solid #edc96b;border-radius:7px;background:#29251c;color:#e5d5ac;font-size:12px;line-height:1.5;font-family:Arial,sans-serif}
11
- #st-resource-footer .st-resource-notice strong{color:#f3d386;font-weight:600}
 
 
 
 
 
 
 
12
  #st-oauth-bootstrap{display:none!important}
13
  #st-app.st-history-app{height:var(--st-workspace-height,calc(var(--st-available-height,100dvh) - 190px));width:100%;padding:0 clamp(12px,1.2vw,24px);margin:0;background:transparent;font-family:Arial,sans-serif}
14
  #st-app.st-history-app [hidden]{display:none!important}
15
  #st-app.st-history-app #st-workspace{position:relative;display:grid!important;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr) clamp(112px,9vw,154px) clamp(112px,9vw,154px);gap:12px!important;height:100%;margin:0;min-height:0;align-items:stretch!important}
16
  #st-app.st-history-app .st-native-panel{display:flex!important;flex-direction:column!important;gap:9px!important;border:1px solid #373735;border-radius:15px;background:#191919;padding:12px!important;height:100%;min-width:0;min-height:0;overflow:hidden;box-shadow:none}
17
  #st-app.st-history-app #st-input-panel{grid-column:1;overflow:auto}
18
- #st-app.st-history-app #st-output-panel{grid-column:2;overflow:hidden}
 
 
 
19
  #st-app.st-history-app #st-history-panel{grid-column:3;contain:none}
20
  #st-app.st-history-app #st-examples-panel{grid-column:4;contain:none}
21
  #st-app.st-history-app h2,#st-app.st-history-app .st-panel-title{font-size:12px;font-weight:400;line-height:1.4;margin:0;color:#ccc;flex:0 0 auto}
@@ -31,7 +41,6 @@
31
  #st-app.st-history-app .st-upload-actions button{border:0;background:none;color:#dfc775;font-size:11px;padding:3px}
32
  #st-app.st-history-app #st-upload-status{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:#c3bcaa;line-height:1.5}
33
  #st-app.st-history-app #st-upload-label{flex:1 1 180px}
34
- #st-app.st-history-app #st-upload-meter{width:100%;height:4px;accent-color:#f2cd70;order:3}
35
  #st-app.st-history-app #st-upload-retry{border:1px solid #62583c;border-radius:5px;background:#28251e;color:#f2cd70;padding:4px 10px}
36
  #st-app.st-history-app #st-upload-status[data-state=complete]{color:#bcd5a8}
37
  #st-app.st-history-app #st-upload-status[data-state=failed]{color:#e0b6a7}
@@ -48,12 +57,6 @@
48
  #st-app.st-history-app #st-account-help{font-size:11px;line-height:1.6;color:#c3bcaa;margin:0}
49
  #st-app.st-history-app #st-cancel-signin{align-self:center;border:0;background:none;color:#c3bcaa;font-size:11px;padding:3px;text-decoration:underline;text-underline-offset:2px}
50
  #st-app.st-history-app #st-generate-control[data-notice=true] #st-gate-note{color:#f2cd70}
51
- #st-app.st-history-app #st-native-startup{padding:9px 11px;border:1px solid #494337;border-radius:8px;background:#211f1b}
52
- #st-app.st-history-app #st-native-startup strong{font-size:11px;font-weight:500;color:#e8d8ac}
53
- #st-app.st-history-app #st-native-startup small{display:block;font-size:10px;color:#aaa;overflow-wrap:anywhere}
54
- #st-app.st-history-app #st-native-startup progress{display:block;width:100%;height:4px;margin:7px 0;accent-color:#f2cd70}
55
- #st-app.st-history-app .st-startup-heading{display:flex;justify-content:space-between;gap:7px;flex-wrap:wrap}
56
- #st-app.st-history-app #st-startup-detail{font-size:10px;color:#bdb5a2}
57
  #st-app.st-history-app #st-viewer{position:relative;flex:1 1 0!important;display:flex;min-height:180px!important;overflow:hidden}
58
  #st-app.st-history-app .st-native-empty{display:grid;place-items:center;flex:1;width:100%;min-height:0;text-align:center;color:#999;font-size:13px;padding:20px}
59
  #st-app.st-history-app #st-model,#st-app.st-history-app #st-ovoxel{flex:1 1 0!important;width:100%;min-height:0!important;height:100%!important}
@@ -68,6 +71,26 @@
68
  #st-app.st-history-app #st-queue-history-link:focus-visible{outline:2px solid #f2cd70;outline-offset:3px;border-radius:2px}
69
  #st-app.st-history-app #st-job-progress progress{width:min(260px,80%);height:5px;accent-color:#f2cd70}
70
  #st-app.st-history-app #st-job-progress small{font-size:11px;color:#999}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  #st-app.st-history-app #st-export-note{font-size:11px;line-height:1.5;margin:0;flex:none;min-height:17px}
72
  #st-app.st-history-app #st-extract[data-action=download]{background:#e2c478!important;color:#241e10!important}
73
  #st-app.st-history-app #st-export-progress{flex:none;padding:10px 12px;border:1px solid #494337;border-radius:8px;background:#211f1b}
@@ -130,6 +153,7 @@
130
  #st-app.st-history-app :is(#st-input-panel,#st-output-panel,#st-history-panel,#st-examples-panel){grid-column:1;height:auto;min-width:0}
131
  #st-app.st-history-app #st-upload{height:350px!important;flex:none!important}
132
  #st-app.st-history-app #st-viewer{height:420px;flex:none!important}
 
133
  #st-app.st-history-app #st-history-panel,#st-app.st-history-app #st-examples-panel{height:300px;max-height:none}
134
  #st-app.st-history-app #st-history-list,#st-app.st-history-app #st-examples{grid-template-columns:repeat(auto-fill,minmax(95px,1fr))}
135
  #st-app.st-history-app #st-history-panel.expanded{inset:12px!important;width:calc(100vw - 24px)!important;height:calc(100dvh - 24px)!important;max-height:none!important}
 
7
  #st-history-header .st-history-heading p{font-size:12px;color:#9c9c9a;margin:5px 0 0;line-height:1.5}
8
  #st-resource-footer{width:auto!important;min-width:0!important;flex:none!important;margin:10px clamp(12px,1.2vw,24px) 12px!important;padding:0!important;border:0!important;background:transparent!important}
9
  #st-resource-footer .html-container{padding:0!important;border:0!important;background:transparent!important}
10
+ #st-resource-footer .st-resource-notice{padding:9px 12px;border:1px solid #655536;border-left:3px solid #edc96b;border-radius:7px;background:#29251c;color:#e5d5ac;font-size:12px;line-height:1.8;font-family:Arial,sans-serif}
11
+ #st-resource-footer strong{color:#f3d386;font-weight:600}
12
+ #st-resource-footer .st-plan-link{display:inline-flex;align-items:center;gap:4px;vertical-align:middle;white-space:nowrap;color:inherit;text-decoration:none}
13
+ #st-resource-footer .st-plan-link:hover{text-decoration:underline}
14
+ #st-resource-footer .st-plan-link:focus-visible{outline:2px solid #f2cd70;outline-offset:3px;border-radius:4px}
15
+ #st-resource-footer .st-hf-logo{display:inline-block;width:18px;height:17px;object-fit:contain}
16
+ #st-resource-footer .st-hf-free{display:inline-block;border:1px solid #ffe4a0;border-radius:6px;padding:0 7px;background:#f2cd70;color:#241f13;font-size:12px;font-weight:800;line-height:20px;letter-spacing:.04em}
17
+ /* HF LabelPro: official dark-theme colors and badge geometry from huggingface.co/pricing. */
18
+ #st-resource-footer .st-hf-pro{display:inline-block;transform:skewX(-12deg);border:1px solid #e5e7eb;border-radius:6px;padding:0 6px;background:linear-gradient(to bottom right,oklch(65.6% .241 354.308) 0%,oklch(69.6% .17 162.48) 50%,oklch(76.9% .188 70.08) 100%);color:#000;font-size:12px;font-weight:700;line-height:20px;box-shadow:0 10px 15px -3px oklch(69.6% .17 162.48 / .2),0 4px 6px -4px oklch(69.6% .17 162.48 / .2)}
19
  #st-oauth-bootstrap{display:none!important}
20
  #st-app.st-history-app{height:var(--st-workspace-height,calc(var(--st-available-height,100dvh) - 190px));width:100%;padding:0 clamp(12px,1.2vw,24px);margin:0;background:transparent;font-family:Arial,sans-serif}
21
  #st-app.st-history-app [hidden]{display:none!important}
22
  #st-app.st-history-app #st-workspace{position:relative;display:grid!important;grid-template-columns:minmax(260px,1fr) minmax(260px,1fr) clamp(112px,9vw,154px) clamp(112px,9vw,154px);gap:12px!important;height:100%;margin:0;min-height:0;align-items:stretch!important}
23
  #st-app.st-history-app .st-native-panel{display:flex!important;flex-direction:column!important;gap:9px!important;border:1px solid #373735;border-radius:15px;background:#191919;padding:12px!important;height:100%;min-width:0;min-height:0;overflow:hidden;box-shadow:none}
24
  #st-app.st-history-app #st-input-panel{grid-column:1;overflow:auto}
25
+ #st-app.st-history-app #st-input-panel[data-busy=true]{pointer-events:none;border-color:#494337}
26
+ #st-app.st-history-app #st-input-panel[data-busy=true] :is(button,input,summary){opacity:.46;cursor:not-allowed}
27
+ #st-app.st-history-app #st-examples button:disabled{opacity:.46;cursor:not-allowed}
28
+ #st-app.st-history-app #st-output-panel{grid-column:2;overflow:auto}
29
  #st-app.st-history-app #st-history-panel{grid-column:3;contain:none}
30
  #st-app.st-history-app #st-examples-panel{grid-column:4;contain:none}
31
  #st-app.st-history-app h2,#st-app.st-history-app .st-panel-title{font-size:12px;font-weight:400;line-height:1.4;margin:0;color:#ccc;flex:0 0 auto}
 
41
  #st-app.st-history-app .st-upload-actions button{border:0;background:none;color:#dfc775;font-size:11px;padding:3px}
42
  #st-app.st-history-app #st-upload-status{flex:none;display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:#c3bcaa;line-height:1.5}
43
  #st-app.st-history-app #st-upload-label{flex:1 1 180px}
 
44
  #st-app.st-history-app #st-upload-retry{border:1px solid #62583c;border-radius:5px;background:#28251e;color:#f2cd70;padding:4px 10px}
45
  #st-app.st-history-app #st-upload-status[data-state=complete]{color:#bcd5a8}
46
  #st-app.st-history-app #st-upload-status[data-state=failed]{color:#e0b6a7}
 
57
  #st-app.st-history-app #st-account-help{font-size:11px;line-height:1.6;color:#c3bcaa;margin:0}
58
  #st-app.st-history-app #st-cancel-signin{align-self:center;border:0;background:none;color:#c3bcaa;font-size:11px;padding:3px;text-decoration:underline;text-underline-offset:2px}
59
  #st-app.st-history-app #st-generate-control[data-notice=true] #st-gate-note{color:#f2cd70}
 
 
 
 
 
 
60
  #st-app.st-history-app #st-viewer{position:relative;flex:1 1 0!important;display:flex;min-height:180px!important;overflow:hidden}
61
  #st-app.st-history-app .st-native-empty{display:grid;place-items:center;flex:1;width:100%;min-height:0;text-align:center;color:#999;font-size:13px;padding:20px}
62
  #st-app.st-history-app #st-model,#st-app.st-history-app #st-ovoxel{flex:1 1 0!important;width:100%;min-height:0!important;height:100%!important}
 
71
  #st-app.st-history-app #st-queue-history-link:focus-visible{outline:2px solid #f2cd70;outline-offset:3px;border-radius:2px}
72
  #st-app.st-history-app #st-job-progress progress{width:min(260px,80%);height:5px;accent-color:#f2cd70}
73
  #st-app.st-history-app #st-job-progress small{font-size:11px;color:#999}
74
+ #st-app.st-history-app #st-job-progress[data-resumable=true]{gap:8px;padding:10px 22px 34px}
75
+ #st-app.st-history-app #st-resume-gpu{appearance:none;flex:none;min-height:44px;max-width:100%;margin:4px 0 0;padding:10px 18px;border:1px solid #ffe4a0;border-radius:9px;background:#f2cd70;color:#241f13;font-size:13px;font-weight:700;line-height:1.4;cursor:pointer}
76
+ #st-app.st-history-app #st-resume-gpu:hover{background:#ffe09a;border-color:#ffedbc}
77
+ #st-app.st-history-app #st-resume-gpu:focus-visible{outline:2px solid #ffe4a0;outline-offset:3px}
78
+ #st-app.st-history-app #st-texture-actions{flex:none;display:flex;flex-direction:column;align-items:center;gap:8px;padding:10px 12px;border:1px solid #494337;border-radius:9px;background:#211f1b;text-align:center}
79
+ #st-app.st-history-app #st-texture-note,#st-app.st-history-app #st-texture-detail{font-size:12px;line-height:1.5;color:#c8c1b1;margin:0}
80
+ #st-app.st-history-app #st-texture{appearance:none;min-height:44px;width:100%;padding:10px 18px;border:1px solid #ffe4a0;border-radius:9px;background:#f2cd70;color:#241f13;font-size:13px;font-weight:700;cursor:pointer}
81
+ #st-app.st-history-app #st-texture:hover{background:#ffe09a}
82
+ #st-app.st-history-app #st-texture:focus-visible{outline:2px solid #ffe4a0;outline-offset:3px}
83
+ #st-app.st-history-app #st-texture:disabled{opacity:.55;cursor:default}
84
+ #st-app.st-history-app #st-texture-progress{width:100%;display:flex;flex-direction:column;align-items:center;gap:7px}
85
+ #st-app.st-history-app #st-texture-title{font-size:13px;font-weight:600;color:#efe3bf}
86
+ #st-app.st-history-app #st-texture-progress progress{width:min(260px,90%);height:5px;accent-color:#f2cd70}
87
+ #st-app.st-history-app #st-texture-time{font-size:11px;color:#999}
88
+ #st-app.st-history-app #st-output-panel[data-texture-progress=true] #st-viewer{display:none}
89
+ #st-app.st-history-app #st-output-panel[data-texture-progress=true] #st-texture-actions{flex:1 1 0;min-height:180px;justify-content:center;padding:22px;border:0;background:transparent;gap:12px}
90
+ #st-app.st-history-app #st-output-panel[data-texture-progress=true] #st-texture-progress{gap:12px}
91
+ #st-app.st-history-app #st-output-panel[data-texture-progress=true] #st-texture-title{font-size:18px;font-weight:500}
92
+ #st-app.st-history-app #st-output-panel[data-texture-progress=true] #st-texture-detail{font-size:13px;line-height:1.6;color:#bbb}
93
+ #st-app.st-history-app #st-output-panel[data-texture-progress=true] #st-texture{width:min(280px,100%);margin-top:4px}
94
  #st-app.st-history-app #st-export-note{font-size:11px;line-height:1.5;margin:0;flex:none;min-height:17px}
95
  #st-app.st-history-app #st-extract[data-action=download]{background:#e2c478!important;color:#241e10!important}
96
  #st-app.st-history-app #st-export-progress{flex:none;padding:10px 12px;border:1px solid #494337;border-radius:8px;background:#211f1b}
 
153
  #st-app.st-history-app :is(#st-input-panel,#st-output-panel,#st-history-panel,#st-examples-panel){grid-column:1;height:auto;min-width:0}
154
  #st-app.st-history-app #st-upload{height:350px!important;flex:none!important}
155
  #st-app.st-history-app #st-viewer{height:420px;flex:none!important}
156
+ #st-app.st-history-app #st-output-panel[data-texture-progress=true] #st-texture-actions{flex:none;min-height:420px}
157
  #st-app.st-history-app #st-history-panel,#st-app.st-history-app #st-examples-panel{height:300px;max-height:none}
158
  #st-app.st-history-app #st-history-list,#st-app.st-history-app #st-examples{grid-template-columns:repeat(auto-fill,minmax(95px,1fr))}
159
  #st-app.st-history-app #st-history-panel.expanded{inset:12px!important;width:calc(100vw - 24px)!important;height:calc(100dvh - 24px)!important;max-height:none!important}
hf_space/history_ui.js CHANGED
@@ -4,30 +4,121 @@
4
  root.dataset.historyInstalled = 'true';
5
  const $ = id => document.getElementById(id);
6
  const pending = job => ['queued', 'running'].includes(job?.status) ||
7
- ['queued', 'running'].includes(job?.export_status) || ['queued', 'running'].includes(job?.recover_status);
 
 
8
  const terminal = state => ['native_build', 'models', 'workspace'].some(key =>
9
  /^(failed|blocked)/.test(state?.[key]?.stage || ''));
10
- const modelsReady = state => state?.native_build?.stage === 'compiled' && state?.models?.stage === 'ready_released';
11
- const ready = state => modelsReady(state) && (!state.workspace || state.workspace.stage === 'ready');
 
 
12
  const S = {identity: null, identityVersion: 0, deployment: null, jobs: new Map(), examples: new Map(),
13
  cursor: null, revision: null, historyLoaded: false, examplesLoaded: false, examplesPromise: null,
14
  historyError: '', historyFailures: 0, historyFailureSince: null,
15
  pageLoading: false, selected: null, selectionVersion: 0, previewLoaded: null,
16
  inputVersion: 0, source: null, uploadBusy: false, uploadState: null, submitBusy: false, expanded: false,
17
  jobsTimer: null, startupTimer: null, noticeTimer: null, exportTimer: null, previewController: null, stopped: false,
18
- refreshPromise: null, statusPromise: null, lastIdentityCheck: 0, submitKey: null,
 
19
  localURL: null, silentAttempted: false, silentFrame: null, silentTimer: null,
20
  authPopup: null, authTimer: null, authBootstrapTimer: null, authFlow: null, authVersion: 0, authBusy: false,
21
  authFallback: false, identityPromise: null, authChecking: true, autoAuthAttempted: false, authMessage: ''};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  // Gradio can finish its independent script callbacks in either order. Give
23
  // a late viewer installation only the current cache scope, never credentials.
24
  window.SupraPrivateAssetAccount = () => !S.stopped && S.identity?.authenticated ?
25
  String(S.identity.account_key || S.identity.username || '') : '';
26
  const statusNames = {queued: 'Queued', running: 'Generating', complete: 'Generation Complete', failed: 'Failed', interrupted: 'Interrupted'};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  const text = (id, value) => { $(id).textContent = value || ''; };
28
  const show = (id, visible) => { $(id).hidden = !visible; };
29
  const englishMessage = (value, fallback) => typeof value === 'string' && value.length <= 500 &&
30
  !/\p{Script=Han}/u.test(value) ? value : fallback;
 
 
31
  const loadingCardMessage = 'Please wait for the models to finish loading.';
32
  const workspaceMessage = 'Preparing your workspace. Please wait a moment.';
33
  let cardTip = null, cardTipTarget = null;
@@ -37,9 +128,9 @@
37
  }
38
  function gateCard(card) {
39
  const loading = !ready(S.deployment);
40
- card.disabled = !S.identity?.authenticated || loading;
41
  if (S.identity?.authenticated && loading && !terminal(S.deployment))
42
- card.title = modelsReady(S.deployment) ? workspaceMessage : loadingCardMessage;
43
  else card.removeAttribute('title');
44
  }
45
  function showCardTip(event) {
@@ -330,6 +421,9 @@
330
  updateGate(); syncAccount();
331
  }
332
  function clearPreview() {
 
 
 
333
  show('st-view-switch', false);
334
  clearTimeout(S.exportTimer); S.exportTimer = null;
335
  $('st-model').replaceChildren(); $('st-ovoxel').replaceChildren();
@@ -343,6 +437,7 @@
343
  function clearPrivate() {
344
  $('st-delete-dialog').close(); deleteTarget = null; deletedHistory.clear();
345
  ++S.identityVersion; ++S.selectionVersion; ++S.inputVersion;
 
346
  clearUploadPreview();
347
  clearHistoryThumbnails();
348
  S.previewController?.abort(); S.previewController = null;
@@ -351,6 +446,7 @@
351
  S.historyFailures = 0; S.historyFailureSince = null; S.selected = null; S.previewLoaded = null;
352
  S.refreshPromise = null; S.pageLoading = false;
353
  S.source = null; S.uploadBusy = false; S.submitBusy = false; S.submitKey = null;
 
354
  if (S.localURL) URL.revokeObjectURL(S.localURL);
355
  S.localURL = null;
356
  clearTimeout(S.jobsTimer); S.jobsTimer = null;
@@ -397,6 +493,12 @@
397
  error.status = response.status; error.code = data.code;
398
  throw error;
399
  }
 
 
 
 
 
 
400
  return data;
401
  }
402
  async function refreshIdentity() {
@@ -441,8 +543,17 @@
441
  updateGate();
442
  }
443
  function updateGate() {
444
- const signedIn = !!S.identity?.authenticated, loading = !ready(S.deployment), failed = terminal(S.deployment);
445
- const workspaceLoading = modelsReady(S.deployment) && loading;
 
 
 
 
 
 
 
 
 
446
  if (!loading && $('st-native-notice').textContent.startsWith('Models are loading in the background.')) {
447
  clearTimeout(S.noticeTimer); show('st-native-notice', false);
448
  }
@@ -452,46 +563,29 @@
452
  'Model loading failed. Please refresh and try again later.';
453
  else if (loading) reason = workspaceLoading ? workspaceMessage : 'Models are loading in the background. You can generate once loading is complete.';
454
  else if (S.authBusy) reason = 'Connecting your Huggingface account…';
455
- else if (S.uploadBusy || S.source?.foreground_required && !S.source.foreground_ready)
456
- reason = $('st-upload-label').textContent || 'Removing background…';
457
  else if (!S.source) reason = 'Choose an example or upload an image.';
458
- $('st-generate').disabled = !signedIn || loading || failed || !S.source || S.uploadBusy || S.submitBusy || S.authBusy ||
459
  !!(S.source?.foreground_required && !S.source.foreground_ready);
460
  for (const id of ['st-file-input', 'st-pick-image', 'st-change-image', 'st-clear-image', 'st-seed'])
461
- $(id).disabled = !signedIn || loading || failed;
462
  $('st-history-toggle').disabled = !signedIn;
463
  $('st-history-more').disabled = !signedIn || S.pageLoading;
464
- $('st-upload-retry').disabled = !signedIn || loading || failed || S.uploadBusy;
465
- $('st-examples').inert = !signedIn;
466
  $('st-examples').querySelectorAll('button[data-example]').forEach(gateCard);
467
  $('st-history-list').inert = !signedIn;
468
  $('st-history-list').querySelectorAll('button[data-job-id]').forEach(gateCard);
469
  if (!loading || failed || !signedIn) hideCardTip();
470
  $('st-output-panel').inert = !signedIn || loading || failed;
471
  show('st-cancel-signin', S.authBusy);
472
- text('st-generate', S.submitBusy ? 'Submitting…' : 'Generate 3D');
473
- const explanatory = loading || failed;
 
474
  show('st-generate-hit', explanatory);
475
  $('st-generate-hit').setAttribute('aria-label', reason);
476
  $('st-generate-hit').title = reason;
477
- text('st-gate-note', reason); show('st-gate-note', !!reason);
478
- show('st-native-startup', loading);
479
- if (loading) {
480
- const progress = workspaceLoading ? {} : S.deployment?.models?.startup_progress || {};
481
- const completed = Math.max(0, Number(progress.completed) || 0);
482
- const total = Math.max(0, Number(progress.total) || 0);
483
- const reconnecting = /^Reconnecting to model storage/.test(progress.label || '');
484
- $('st-native-startup').querySelector('strong').textContent = failed ?
485
- workspaceLoading ? 'Workspace preparation failed' : 'Model loading failed' :
486
- workspaceLoading ? 'Preparing your workspace…' : reconnecting ? 'Reconnecting to model storage…' : 'Loading models in the background';
487
- text('st-startup-detail', failed ? 'Please try again later' : total ?
488
- progress.unit === 'bytes' ? `${(completed / 1073741824).toFixed(1)} / ${(total / 1073741824).toFixed(1)} GB` :
489
- `${Math.min(completed, total)} / ${total} ${progress.unit === 'models' ? 'models' : 'stages'}` : 'Preparing…');
490
- text('st-startup-label', failed ? 'Service temporarily unavailable.' : 'Only required for the initial load.');
491
- show('st-startup-meter', !failed);
492
- if (total) $('st-startup-meter').value = Math.min(100, completed / total * 100);
493
- else $('st-startup-meter').removeAttribute('value');
494
- }
495
  requestAnimationFrame(() => { $('st-generate-hit').style.height = $('st-generate').offsetHeight + 'px'; });
496
  }
497
  async function refreshStatus() {
@@ -499,12 +593,17 @@
499
  S.statusPromise = (async () => {
500
  try {
501
  const wasReady = ready(S.deployment); S.deployment = await api('/status'); updateGate();
 
 
 
 
502
  if (!wasReady && ready(S.deployment)) void activateWorkspace();
 
503
  }
504
  catch (error) { if (!S.deployment) text('st-gate-note', 'Connecting to the service…'); }
505
  finally {
506
  S.statusPromise = null; clearTimeout(S.startupTimer);
507
- if (!S.stopped && !ready(S.deployment) && !terminal(S.deployment))
508
  S.startupTimer = setTimeout(refreshStatus, document.hidden ? 20000 : 3000);
509
  }
510
  })();
@@ -516,20 +615,25 @@
516
  }
517
  function historyStatus(job) {
518
  if (job.status !== 'complete') return {status: statusNames[job.status] ? job.status : 'running',
519
- label: statusNames[job.status] || 'Preparing'};
 
 
 
 
 
520
  // Generation completion makes the preview available. Export and recovery
521
  // remain separate operations, and only a downloadable GLB completes the result.
522
  const operations = [
523
- {status: job.export_status, labels: {queued: 'GLB Queued', running: 'Extracting GLB',
524
  failed: 'GLB Extraction Failed', interrupted: 'GLB Extraction Interrupted'}},
525
- {status: job.recover_status, labels: {queued: 'Preview Recovery Queued', running: 'Recovering Preview',
526
  failed: 'Preview Recovery Failed', interrupted: 'Preview Recovery Interrupted'}}
527
  ];
528
  const operation = operations.find(item => item.status === 'running') ||
529
  operations.find(item => item.status === 'queued') ||
530
  operations.find(item => ['failed', 'interrupted'].includes(item.status));
531
  if (operation) return {status: operation.status, label: operation.labels[operation.status]};
532
- return privateURL(job.download_url) ? {status: 'complete', label: 'Complete'} :
533
  {status: 'generation_complete', label: 'Generation Complete'};
534
  }
535
  function renderHistory() {
@@ -591,7 +695,7 @@
591
  ++S.selectionVersion; S.previewController?.abort(); S.previewController = null;
592
  S.selected = null; S.previewLoaded = null; clearPreview();
593
  // A newer upload can coexist with the previously selected result.
594
- if (job?.upload_id && S.source?.upload_id === job.upload_id) {
595
  ++S.inputVersion; clearUploadPreview(); S.source = null; S.submitKey = null;
596
  if (S.localURL) URL.revokeObjectURL(S.localURL); S.localURL = null;
597
  $('st-input-image').removeAttribute('src'); $('st-file-input').value = '';
@@ -675,39 +779,59 @@
675
  S.historyError = sustained ? (S.historyLoaded ?
676
  'History could not refresh. We will keep trying.' : 'History could not load. We will keep trying.') : '';
677
  }
678
- } finally { S.refreshPromise = null; S.pageLoading = false; renderHistory(); scheduleJobs(); }
679
  })();
680
  return S.refreshPromise;
681
  }
682
  function renderSelected() {
683
- if (!ready(S.deployment)) return;
684
  if (S.selected?.kind !== 'job') return;
685
  const job = S.jobs.get(S.selected.id); if (!job) return;
 
 
 
 
686
  if (job.status === 'complete') {
687
- if (S.previewLoaded !== previewKey(job)) void loadPreview(job);
688
- renderExport(job); return;
689
  }
 
 
690
  S.previewLoaded = null;
691
  $('st-model').replaceChildren(); $('st-ovoxel').replaceChildren();
692
  show('st-model', false); show('st-ovoxel', false); show('st-native-empty', false); show('st-job-progress', true);
693
  $('st-extract').disabled = true; show('st-download', false);
694
  show('st-export-progress', false); clearTimeout(S.exportTimer); S.exportTimer = null;
695
  const progress = job.progress || {};
696
- text('st-job-title', statusNames[job.status] || 'Preparing your model');
697
  const position = progress.queue_position;
698
- const detail = job.status === 'failed' || job.status === 'interrupted' ?
699
- (job.status === 'interrupted' ? 'This generation was interrupted. You can generate it again.' : 'This generation could not finish. You can try again.') :
 
 
 
 
 
 
 
 
 
 
 
700
  job.status === 'queued' && position !== undefined && position !== null ?
701
- `Waiting in queue · Position ${position}` : englishMessage(progress.label, 'Preparing your model…');
702
  text('st-job-detail', detail);
703
- show('st-queue-note', job.status === 'queued');
704
- $('st-job-progress').querySelector('progress').hidden = !['queued', 'running'].includes(job.status);
705
- text('st-job-time', progress.elapsed_seconds ? `${Math.floor(progress.elapsed_seconds)}s elapsed` : '');
 
 
 
706
  text('st-export-note', '');
707
  }
708
  function previewKey(job) {
709
- const view = privateURL(job.download_url) ? S.selected?.view || 'glb' : 'render';
710
- return `${view}:${job.id}`;
711
  }
712
  async function loadPreview(job) {
713
  const version = S.selectionVersion, inputVersion = S.inputVersion;
@@ -720,52 +844,141 @@
720
  const view = key.split(':')[0];
721
  const result = await api('/jobs/' + encodeURIComponent(job.id) + '/preview?view=' + view, {signal: controller.signal});
722
  if (version !== S.selectionVersion || S.selected?.id !== job.id || S.previewController !== controller) return;
 
 
 
723
  $('st-ovoxel').innerHTML = result.html || '';
724
- show('st-ovoxel', !!result.html); show('st-native-empty', !result.html);
725
  if (!result.html) text('st-native-empty', 'The saved preview is not available.');
726
  const input = privateURL(result.display_url || job.display_url || result.input_url || job.input_url);
727
  const original = privateURL(result.input_url || job.input_url);
728
- if (input && !S.source?.local_file && !S.source?.local_job)
729
  setInput(input, {upload_id: result.upload_id || job.upload_id, input_url: original}, result.seed ?? job.seed, inputVersion);
730
- const current = S.jobs.get(job.id) || job;
731
  if (result.download_url) current.download_url = result.download_url;
732
  const model = $('st-ovoxel').querySelector('.st-gallery-preview[data-model]');
733
- S.previewLoaded = result.view ? `${result.view}:${job.id}` :
734
  model && privateURL(model.dataset.model) === privateURL(current.download_url) ?
735
- `glb:${job.id}` : `render:${job.id}`;
736
- renderHistory(); renderExport(current);
737
  } catch (error) {
738
  if (quietError(error) || version !== S.selectionVersion || S.previewController !== controller) return;
739
- S.previewLoaded = null; text('st-native-empty', 'Could not load this preview. Select it again to retry.');
 
 
740
  notice(errorText(error));
741
  }
742
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  function renderExport(job) {
744
  clearTimeout(S.exportTimer); S.exportTimer = null;
 
 
 
 
 
 
745
  const exporting = ['queued', 'running'].includes(job.export_status);
 
 
746
  const url = privateURL(job.download_url);
747
  show('st-view-switch', !!url && job.status === 'complete');
748
  const view = previewKey(job).split(':')[0];
749
  $('st-view-switch').querySelectorAll('[data-result-view]').forEach(button =>
750
  button.setAttribute('aria-pressed', String(button.dataset.resultView === view)));
751
- $('st-extract').disabled = (!url && exporting) || job.status !== 'complete' || !S.identity?.authenticated || !ready(S.deployment);
752
  $('st-extract').dataset.action = url ? 'download' : 'extract';
753
- text('st-extract', url ? 'Download GLB' : exporting ?
754
- job.export_status === 'queued' ? 'GLB extraction queued…' : 'Extracting GLB…' : 'Extract GLB');
755
  show('st-download', false);
756
  if (url) $('st-download').href = url; else $('st-download').removeAttribute('href');
757
  show('st-export-progress', exporting && !url);
758
- text('st-export-note', url ? 'Your GLB is ready to download.' : exporting ?
 
759
  'You can return to My History while extraction continues.' :
760
- job.export_status === 'failed' ? 'Extraction failed. Click Extract GLB to try again.' : 'Extract and download GLB.');
761
  if (!exporting || url) return;
762
- const progress = job.export_progress || {}, queued = job.export_status === 'queued';
 
763
  const position = progress.queue_position;
764
- text('st-export-label', queued ? `Waiting for GPU${position ? ' · Queue position ' + position : ''}` :
 
765
  englishMessage(progress.label, 'Preparing your GLB…'));
766
  const completed = Math.max(0, Number(progress.completed) || 0), total = Math.max(0, Number(progress.total) || 0);
767
  const meter = $('st-export-meter'), details = [];
768
- if (total && !queued) {
 
 
 
769
  meter.value = Math.min(100, completed / total * 100);
770
  meter.setAttribute('aria-valuetext', `${Math.min(completed, total)} of ${total} ${progress.unit === 'bytes' ? 'bytes' : 'stages'} complete`);
771
  details.push(progress.unit === 'bytes' ?
@@ -773,13 +986,13 @@
773
  `${Math.min(completed, total)} / ${total} stages`);
774
  } else { meter.removeAttribute('value'); meter.removeAttribute('aria-valuetext'); }
775
  const transferred = Number(progress.bytes_completed), transferTotal = Number(progress.bytes_total);
776
- if (transferTotal > 0 && progress.unit !== 'bytes') details.push(
777
  `${(Math.max(0, transferred || 0) / 1048576).toFixed(1)} / ${(transferTotal / 1048576).toFixed(1)} MB transferred`);
778
  job._exportProgressSeenAt ||= Date.now();
779
  const elapsed = Math.max(0, Number(progress.elapsed_seconds) || 0) +
780
  (progress.clock === 'running' ? Math.floor((Date.now() - job._exportProgressSeenAt) / 1000) : 0);
781
  if (elapsed) details.push(`${Math.floor(elapsed)}s elapsed`);
782
- details.push(queued ? 'Extraction shares the generation queue.' : 'Stage durations vary with model complexity.');
783
  text('st-export-detail', details.join(' · '));
784
  S.exportTimer = setTimeout(() => {
785
  if (S.selected?.kind === 'job' && S.selected.id === job.id) renderExport(S.jobs.get(job.id) || job);
@@ -788,23 +1001,25 @@
788
  function selectJob(id, keepInput = false) {
789
  if (!S.identity?.authenticated || !ready(S.deployment)) return;
790
  const job = S.jobs.get(id); if (!job) return;
791
- ++S.selectionVersion; ++S.inputVersion; S.previewController?.abort();
 
 
792
  S.selected = {kind: 'job', id}; S.previewLoaded = null; clearPreview();
793
  const input = privateURL(job.display_url || job.input_url) || `/api/history/jobs/${encodeURIComponent(id)}/files/input.png`;
794
  const original = privateURL(job.input_url) || `/api/history/jobs/${encodeURIComponent(id)}/files/input.png`;
795
  const currentInput = keepInput && $('st-input-image').getAttribute('src');
796
- if (currentInput && S.source) {
797
  // A submission already has a visible reference, including examples.
798
  // Keep it while the newly saved private files become available.
799
  const source = {...S.source}; delete source.example;
800
  setInput(currentInput, {...source, local_job: id, upload_id: job.upload_id,
801
  input_url: original}, job.seed);
802
- } else setInput(input, {upload_id: job.upload_id, input_url: original}, job.seed);
803
  renderHistory(); renderSelected();
804
  if (S.expanded && matchMedia('(max-width:900px)').matches) expandHistory(false);
805
  }
806
  function selectExample(name) {
807
- if (!S.identity?.authenticated || !ready(S.deployment)) return;
808
  const example = S.examples.get(name); if (!example) return;
809
  ++S.selectionVersion; ++S.inputVersion; S.previewController?.abort();
810
  S.selected = {kind: 'example', id: name}; S.previewLoaded = null;
@@ -863,21 +1078,20 @@
863
  $('st-upload-status').dataset.state = phase;
864
  text('st-upload-label', label); show('st-upload-status', true);
865
  show('st-upload-retry', retry && (!!state.uploadId || state.retryAction === 'upload'));
866
- show('st-upload-meter', !['complete', 'failed'].includes(phase));
867
- $('st-upload-meter').removeAttribute('value'); updateGate();
868
  }
869
  function scheduleUploadStatus(state, delay = document.hidden ? 6000 : 1500) {
870
  clearTimeout(state.timer);
871
  if (currentUpload(state)) state.timer = setTimeout(() => void refreshUpload(state), delay);
872
  }
873
  function uploadReadFailed(state, error) {
874
- if (!currentUpload(state) || quietError(error)) return;
875
  if (state.resultURL) { URL.revokeObjectURL(state.resultURL); state.resultURL = null; }
876
  state.failures++;
877
  state.failureSince ??= Date.now();
878
  if (state.failures < 3 || Date.now() - state.failureSince < 30000) {
879
  S.uploadBusy = true;
880
- uploadLabel(state, 'checking', 'Checking background-removal progress…');
881
  scheduleUploadStatus(state, Math.min(8000, state.failures * 2000));
882
  } else {
883
  S.uploadBusy = false; state.retryAction = 'status';
@@ -888,9 +1102,20 @@
888
  if (!currentUpload(state)) return;
889
  if (!result.upload_id || state.uploadId && state.uploadId !== result.upload_id)
890
  throw new Error('The uploaded image could not be verified.');
 
 
 
 
 
 
 
 
 
 
 
891
  state.uploadId = result.upload_id;
 
892
  S.source = {...S.source, upload_id: result.upload_id, uploaded: true};
893
- const foreground = result.foreground;
894
  if (!foreground) {
895
  // Retain compatibility with saved uploads and the previous API version.
896
  S.source.foreground_required = false; S.uploadBusy = false;
@@ -898,53 +1123,74 @@
898
  }
899
  S.source.foreground_required = true; S.source.foreground_ready = false;
900
  if (['queued', 'running'].includes(foreground.status)) {
 
 
 
 
 
901
  state.failures = 0; state.failureSince = null; S.uploadBusy = true;
902
- const position = Number(foreground.progress?.queue_position);
903
- uploadLabel(state, foreground.status, foreground.status === 'queued' ?
904
- 'Waiting to remove background…' + (position > 0 ? ' · Queue position ' + position : '') : 'Removing background…');
905
  scheduleUploadStatus(state); return;
906
  }
907
  if (['failed', 'interrupted'].includes(foreground.status)) {
908
  S.uploadBusy = false; state.retryAction = 'foreground'; state.retryKey = null;
909
- uploadLabel(state, 'failed', 'Background removal failed. Please retry.', true); return;
910
  }
911
  if (foreground.status !== 'complete') throw new Error('Background removal status is unavailable.');
912
- S.uploadBusy = true; uploadLabel(state, 'loading', 'Loading background-removed image…');
913
- const url = privateURL(result.foreground_url);
914
- const expected = '/api/history/uploads/' + encodeURIComponent(state.uploadId) + '/files/foreground.png';
915
- if (!url || new URL(url).pathname !== expected) throw new Error('The background-removed image is unavailable.');
916
- const response = await fetch(url, {credentials: 'same-origin', cache: 'no-store',
917
- signal: AbortSignal.any([state.controller.signal, AbortSignal.timeout(30000)])});
918
- if (!response.ok) throw new Error('The background-removed image could not load.');
919
- const blob = await response.blob();
920
- if (!currentUpload(state)) return;
921
- if (!blob.size || blob.size > 8 * 1024 * 1024 || !blob.type.startsWith('image/'))
922
- throw new Error('The background-removed image is invalid.');
923
- state.resultURL = URL.createObjectURL(blob);
924
- const image = new Image(); image.src = state.resultURL;
925
- await image.decode();
926
- if (!currentUpload(state)) return;
927
- const previous = S.localURL;
928
- S.localURL = state.resultURL; state.resultURL = null;
929
- if (previous) URL.revokeObjectURL(previous);
930
- S.uploadBusy = false; state.failures = 0; state.failureSince = null; state.retryKey = null;
931
- setInput(S.localURL, {...S.source, foreground_required: true, foreground_ready: true,
932
- foreground_file: blob}, undefined, state.inputVersion);
933
- uploadLabel(state, 'complete', 'Background removed.');
 
 
 
 
 
 
934
  }
935
  async function refreshUpload(state) {
936
- if (!currentUpload(state) || !state.uploadId) return;
937
- try {
938
- const result = await api('/uploads/' + encodeURIComponent(state.uploadId), {signal: state.controller.signal});
939
- await applyUploadResult(result, state);
940
- } catch (error) { uploadReadFailed(state, error); }
 
 
 
 
 
941
  }
942
  async function retryUploadPreview() {
943
  const state = S.uploadState;
944
- if (!state || !currentUpload(state) || S.uploadBusy || !ready(S.deployment)) return;
945
  if (state.retryAction === 'upload') return upload(S.source?.local_file);
 
 
 
 
 
 
946
  state.failures = 0; state.failureSince = null; S.uploadBusy = true;
947
- uploadLabel(state, 'queued', 'Waiting to remove background…');
948
  if (state.retryAction !== 'foreground') return refreshUpload(state);
949
  state.retryKey ||= crypto.randomUUID();
950
  try {
@@ -954,7 +1200,7 @@
954
  } catch (error) { uploadReadFailed(state, error); }
955
  }
956
  async function upload(file) {
957
- if (!file) return;
958
  if (!S.identity?.authenticated || !ready(S.deployment) || S.stopped) return;
959
  if (file.size > 20 * 1024 * 1024) return notice('Please upload an image smaller than 20 MiB.');
960
  const serviceWaits = S.source?.local_file === file && S.uploadState?.phase === 'waiting_service' ?
@@ -968,7 +1214,7 @@
968
  S.localURL = URL.createObjectURL(file);
969
  setInput(S.localURL, {local_file: file, foreground_required: true, foreground_ready: false}, undefined, version);
970
  S.uploadBusy = true; S.submitKey = null; updateGate();
971
- uploadLabel(state, 'uploading', 'Uploading your image…');
972
  try {
973
  const form = new FormData(); form.append('file', file);
974
  const result = await api('/upload', {method: 'POST', body: form, signal: state.controller.signal});
@@ -982,20 +1228,20 @@
982
  S.uploadBusy = false; state.serviceWaits++; state.retryAction = 'upload';
983
  S.deployment = {...S.deployment, workspace: {stage: 'starting'}};
984
  uploadLabel(state, state.serviceWaits <= 3 ? 'waiting_service' : 'failed',
985
- state.serviceWaits <= 3 ? 'Waiting for the service. Your image is kept here.' :
986
  'The service is still preparing. Your image is kept here. Please retry when ready.', state.serviceWaits > 3);
987
  void refreshStatus();
988
  }
989
  else {
990
  S.uploadBusy = false;
991
  state.retryAction = 'upload';
992
- uploadLabel(state, 'failed', 'Upload could not finish. Your image is kept here. Please retry.', true);
993
- notice(errorText(error));
994
  }
995
  }
996
  } finally { if (currentUpload(state)) { $('st-file-input').value = ''; updateGate(); } }
997
  }
998
  function stageLocalFile(file, seed) {
 
999
  clearUploadPreview();
1000
  ++S.inputVersion; S.uploadBusy = false;
1001
  if (S.localURL) URL.revokeObjectURL(S.localURL);
@@ -1005,7 +1251,7 @@
1005
  if (S.identity?.authenticated && ready(S.deployment)) void upload(file);
1006
  }
1007
  async function submit() {
1008
- if ($('st-generate').disabled || S.submitBusy) return;
1009
  if (!S.identity?.authenticated) return connectAccount();
1010
  const source = {...S.source}, seed = Math.round(Number($('st-seed').value));
1011
  if (!Number.isFinite(seed)) return notice('Please enter a valid seed.');
@@ -1060,6 +1306,10 @@
1060
  async function extract() {
1061
  if (S.selected?.kind !== 'job' || $('st-extract').disabled) return;
1062
  const job = S.jobs.get(S.selected.id); if (!job) return;
 
 
 
 
1063
  const url = privateURL(job.download_url);
1064
  if (url) {
1065
  try {
@@ -1104,20 +1354,25 @@
1104
  root.querySelector('#st-generate-control').dataset.notice = 'true';
1105
  });
1106
  $('st-generate-hit').addEventListener('mouseleave', () => delete root.querySelector('#st-generate-control').dataset.notice);
1107
- const pick = () => { if (ready(S.deployment) && S.identity?.authenticated) $('st-file-input').click(); };
1108
  $('st-pick-image').addEventListener('click', pick); $('st-change-image').addEventListener('click', pick);
1109
  $('st-file-input').addEventListener('change', event => void upload(event.target.files[0]));
1110
  $('st-upload').addEventListener('dragover', event => event.preventDefault());
1111
  $('st-upload').addEventListener('drop', event => { event.preventDefault(); void upload(event.dataTransfer.files[0]); });
 
 
 
1112
  $('st-clear-image').addEventListener('click', () => {
1113
- if (!ready(S.deployment)) return;
1114
  clearUploadPreview();
1115
  ++S.inputVersion; S.source = null; S.uploadBusy = false; S.submitKey = null;
1116
  if (S.localURL) URL.revokeObjectURL(S.localURL); S.localURL = null;
1117
  $('st-input-image').removeAttribute('src'); show('st-input-image', false); show('st-upload-placeholder', true); show('st-clear-image', false); updateGate();
1118
  });
1119
  $('st-upload-retry').addEventListener('click', () => void retryUploadPreview());
1120
- $('st-seed').addEventListener('input', () => { S.submitKey = null; });
 
 
1121
  $('st-generate').addEventListener('click', () => void submit());
1122
  const cancelSignIn = () => accountConnectionFailed('Sign-in cancelled. Please sign in to continue.');
1123
  $('st-cancel-signin').addEventListener('click', cancelSignIn);
@@ -1125,13 +1380,14 @@
1125
  $('st-auth-signin').addEventListener('click', () => connectAccount(true));
1126
  $('st-auth-dialog').addEventListener('cancel', event => event.preventDefault());
1127
  $('st-extract').addEventListener('click', () => void extract());
 
1128
  $('st-view-switch').addEventListener('click', event => {
1129
  const button = event.target.closest('[data-result-view]');
1130
  if (!button || S.selected?.kind !== 'job') return;
1131
  const job = S.jobs.get(S.selected.id);
1132
  if (!job || job.status !== 'complete' || !privateURL(job.download_url)) return;
1133
  const view = button.dataset.resultView;
1134
- if (!['render', 'glb'].includes(view) || previewKey(job) === `${view}:${job.id}`) return;
1135
  S.selected.view = view;
1136
  ++S.selectionVersion; S.previewLoaded = null;
1137
  renderSelected();
@@ -1215,7 +1471,7 @@
1215
  }
1216
  document.addEventListener('visibilitychange', () => {
1217
  clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer);
1218
- if (document.hidden) { scheduleJobs(); if (!ready(S.deployment) && !terminal(S.deployment)) S.startupTimer = setTimeout(refreshStatus, 20000); }
1219
  else void resume();
1220
  });
1221
  window.addEventListener('focus', () => void resume());
 
4
  root.dataset.historyInstalled = 'true';
5
  const $ = id => document.getElementById(id);
6
  const pending = job => ['queued', 'running'].includes(job?.status) ||
7
+ ['queued', 'running'].includes(job?.texture_status) || ['queued', 'running'].includes(job?.export_status) || ['queued', 'running'].includes(job?.recover_status);
8
+ const stagedJob = job => job?.workflow === 'geometry_texture';
9
+ const texturesReady = job => !stagedJob(job) || job.texture_status === 'complete';
10
  const terminal = state => ['native_build', 'models', 'workspace'].some(key =>
11
  /^(failed|blocked)/.test(state?.[key]?.stage || ''));
12
+ const zeroGPU = state => state?.gpu_mode === 'zerogpu';
13
+ const modelsReady = window.SupraZeroGPU.modelsReady;
14
+ const ready = window.SupraZeroGPU.workspaceReady;
15
+ const {foregroundOperation, preparationState, operationReady, preparationError, formatProgress} = window.SupraZeroGPU;
16
  const S = {identity: null, identityVersion: 0, deployment: null, jobs: new Map(), examples: new Map(),
17
  cursor: null, revision: null, historyLoaded: false, examplesLoaded: false, examplesPromise: null,
18
  historyError: '', historyFailures: 0, historyFailureSince: null,
19
  pageLoading: false, selected: null, selectionVersion: 0, previewLoaded: null,
20
  inputVersion: 0, source: null, uploadBusy: false, uploadState: null, submitBusy: false, expanded: false,
21
  jobsTimer: null, startupTimer: null, noticeTimer: null, exportTimer: null, previewController: null, stopped: false,
22
+ refreshPromise: null, statusPromise: null, lastIdentityCheck: 0, submitKey: null, resumeOperation: null,
23
+ textureBusy: null, textureKeys: new Map(), textureErrors: new Map(),
24
  localURL: null, silentAttempted: false, silentFrame: null, silentTimer: null,
25
  authPopup: null, authTimer: null, authBootstrapTimer: null, authFlow: null, authVersion: 0, authBusy: false,
26
  authFallback: false, identityPromise: null, authChecking: true, autoAuthAttempted: false, authMessage: ''};
27
+ const gpuDispatcher = window.SupraZeroGPU.createDispatcher({
28
+ identity: () => S.identity, version: () => S.identityVersion, stopped: () => S.stopped,
29
+ ready: record => operationReady(S.deployment, record),
30
+ prepare: (record, signal) => api('/gpu/prepare', {method: 'POST', json: {operation_id: record.gpu_operation_id}, signal}),
31
+ onPending() { void refreshStatus(); renderSelected(); },
32
+ onError(record, message) {
33
+ const state = S.uploadState;
34
+ if (state && currentUpload(state) && foregroundOperation(record) && state.uploadId === record.upload_id) {
35
+ S.uploadBusy = false; state.retryAction = 'gpu'; state.gpuRecord = record;
36
+ clearTimeout(state.timer); uploadLabel(state, 'failed', message, true);
37
+ void refreshUpload(state);
38
+ } else notice(message);
39
+ renderSelected(); void refreshJobs();
40
+ },
41
+ onComplete(record) {
42
+ const state = S.uploadState;
43
+ if (state && currentUpload(state) && foregroundOperation(record) && state.uploadId === record.upload_id) void refreshUpload(state);
44
+ void refreshJobs();
45
+ }
46
+ });
47
+ const resumeGPU = document.createElement('button');
48
+ resumeGPU.id = 'st-resume-gpu'; resumeGPU.type = 'button'; resumeGPU.hidden = true;
49
+ resumeGPU.textContent = 'Resume task';
50
+ resumeGPU.addEventListener('click', async () => {
51
+ const job = S.selected?.kind === 'job' && S.jobs.get(S.selected.id);
52
+ if (!job || !S.identity?.authenticated || S.resumeOperation) return;
53
+ const identityVersion = S.identityVersion, operation = job.gpu_operation_id;
54
+ S.resumeOperation = operation; renderSelected();
55
+ try {
56
+ // Reconnect to the current instance before using its readiness/progress.
57
+ const deployment = await refreshStatus();
58
+ if (identityVersion !== S.identityVersion || S.stopped || !S.identity?.authenticated) return;
59
+ const latest = S.jobs.get(job.id);
60
+ if (!deployment || !ready(deployment)) {
61
+ notice('The service is reconnecting. Please try Resume task again.'); return;
62
+ }
63
+ if (latest?.gpu_operation_id === operation) void gpuDispatcher.retry(latest);
64
+ } finally {
65
+ S.resumeOperation = null; renderSelected();
66
+ }
67
+ });
68
+ $('st-job-progress').append(resumeGPU);
69
+ const needsGPUResume = record => record?.gpu_mode === 'zerogpu' && record.gpu_operation_id &&
70
+ (!gpuDispatcher.requested(record.gpu_operation_id) || !!gpuDispatcher.failure(record.gpu_operation_id));
71
+ const taskPreparing = record => record?.gpu_mode === 'zerogpu' && record.gpu_operation_id &&
72
+ gpuDispatcher.requested(record.gpu_operation_id) && !gpuDispatcher.failure(record.gpu_operation_id) && !operationReady(S.deployment, record);
73
+ const startupBlocked = () => zeroGPU(S.deployment) ? /^(failed|blocked)/.test(S.deployment.workspace?.stage || '') : terminal(S.deployment);
74
+ const pollStartup = () => !startupBlocked() && (!ready(S.deployment) || gpuDispatcher.pending(record =>
75
+ !operationReady(S.deployment, record) && !preparationError(S.deployment, record)));
76
+ function preparationLabel(record) {
77
+ const state = preparationState(S.deployment, record);
78
+ if (!foregroundOperation(record) && state === S.deployment?.native_build) return 'Preparing environment';
79
+ const subject = foregroundOperation(record) ? 'background-removal model' : 'model weights';
80
+ return (state.stage === 'staging_local_weights' ? 'Downloading ' : state.stage === 'loading_models' ? 'Loading ' : 'Preparing ') + subject;
81
+ }
82
+ function preparationText(record) {
83
+ const progress = preparationState(S.deployment, record).startup_progress || {};
84
+ return preparationLabel(record) + ' · ' + formatProgress(progress).percent + '%';
85
+ }
86
+ function preparationMeter(meter, record) {
87
+ const progress = preparationState(S.deployment, record).startup_progress || {};
88
+ meter.max = 100;
89
+ if (taskPreparing(record)) meter.value = formatProgress(progress).percent;
90
+ else meter.removeAttribute('value');
91
+ }
92
  // Gradio can finish its independent script callbacks in either order. Give
93
  // a late viewer installation only the current cache scope, never credentials.
94
  window.SupraPrivateAssetAccount = () => !S.stopped && S.identity?.authenticated ?
95
  String(S.identity.account_key || S.identity.username || '') : '';
96
  const statusNames = {queued: 'Queued', running: 'Generating', complete: 'Generation Complete', failed: 'Failed', interrupted: 'Interrupted'};
97
+ const zeroGPURecord = record => record?.gpu_mode === 'zerogpu' || !record?.gpu_mode && zeroGPU(S.deployment);
98
+ const leftInputBusy = () => S.submitBusy || !!S.textureBusy || !!S.resumeOperation || [...S.jobs.values()].some(job => {
99
+ const states = [job.status, job.texture_status];
100
+ if (states.includes('running')) return true;
101
+ return states.includes('queued') && (!zeroGPURecord(job) || !!job.gpu_operation_id &&
102
+ gpuDispatcher.requested(job.gpu_operation_id) && !gpuDispatcher.failure(job.gpu_operation_id));
103
+ });
104
+ const selectedGeometry = () => {
105
+ const job = S.selected?.kind === 'job' && S.jobs.get(S.selected.id);
106
+ return stagedJob(job) && job.status === 'complete' && S.source?.upload_id &&
107
+ S.source.upload_id === job.upload_id && Math.round(Number($('st-seed').value)) === Number(job.seed) ? job : null;
108
+ };
109
+ const textureFailure = job => (!['running', 'complete'].includes(job.texture_status) && S.textureErrors.get(job.id)) ||
110
+ (job.gpu_operation_kind === 'texture' && gpuDispatcher.failure(job.gpu_operation_id));
111
+ const textureProgressVisible = job => stagedJob(job) && job.status === 'complete' && !texturesReady(job) &&
112
+ (S.textureBusy === job.id || ['queued', 'running'].includes(job.texture_status) && !textureFailure(job));
113
+ const generationStatusName = job => stagedJob(job) ? ({queued: 'Preparing geometry', running: 'Generating geometry',
114
+ complete: 'Geometry ready', failed: 'Geometry failed', interrupted: 'Geometry interrupted'})[job.status] :
115
+ job.status === 'queued' && zeroGPURecord(job) ? 'Preparing' : statusNames[job.status];
116
  const text = (id, value) => { $(id).textContent = value || ''; };
117
  const show = (id, visible) => { $(id).hidden = !visible; };
118
  const englishMessage = (value, fallback) => typeof value === 'string' && value.length <= 500 &&
119
  !/\p{Script=Han}/u.test(value) ? value : fallback;
120
+ const generationProgressLabel = (value, fallback) => englishMessage(
121
+ value === 'Running the latest single-view pipeline' ? 'Starting generation…' : value, fallback);
122
  const loadingCardMessage = 'Please wait for the models to finish loading.';
123
  const workspaceMessage = 'Preparing your workspace. Please wait a moment.';
124
  let cardTip = null, cardTipTarget = null;
 
128
  }
129
  function gateCard(card) {
130
  const loading = !ready(S.deployment);
131
+ card.disabled = !S.identity?.authenticated || loading || card.dataset.example !== undefined && leftInputBusy();
132
  if (S.identity?.authenticated && loading && !terminal(S.deployment))
133
+ card.title = zeroGPU(S.deployment) || modelsReady(S.deployment) ? workspaceMessage : loadingCardMessage;
134
  else card.removeAttribute('title');
135
  }
136
  function showCardTip(event) {
 
421
  updateGate(); syncAccount();
422
  }
423
  function clearPreview() {
424
+ $('st-output-panel').dataset.textureProgress = 'false';
425
+ show('st-texture-actions', false);
426
+ show('st-extract', true); show('st-export-note', true);
427
  show('st-view-switch', false);
428
  clearTimeout(S.exportTimer); S.exportTimer = null;
429
  $('st-model').replaceChildren(); $('st-ovoxel').replaceChildren();
 
437
  function clearPrivate() {
438
  $('st-delete-dialog').close(); deleteTarget = null; deletedHistory.clear();
439
  ++S.identityVersion; ++S.selectionVersion; ++S.inputVersion;
440
+ gpuDispatcher.reset();
441
  clearUploadPreview();
442
  clearHistoryThumbnails();
443
  S.previewController?.abort(); S.previewController = null;
 
446
  S.historyFailures = 0; S.historyFailureSince = null; S.selected = null; S.previewLoaded = null;
447
  S.refreshPromise = null; S.pageLoading = false;
448
  S.source = null; S.uploadBusy = false; S.submitBusy = false; S.submitKey = null;
449
+ S.textureBusy = null; S.textureKeys.clear(); S.textureErrors.clear();
450
  if (S.localURL) URL.revokeObjectURL(S.localURL);
451
  S.localURL = null;
452
  clearTimeout(S.jobsTimer); S.jobsTimer = null;
 
493
  error.status = response.status; error.code = data.code;
494
  throw error;
495
  }
496
+ const textureJob = method === 'POST' && /^\/jobs\/([^/]+)\/texture$/.exec(path);
497
+ if (textureJob && data?.id !== decodeURIComponent(textureJob[1]))
498
+ throw new Error('The texture response did not match this saved model. Please try again.');
499
+ // Reading history never starts CPU preparation or spends GPU quota. Only
500
+ // the user's creation/retry POST establishes intent for this page session.
501
+ gpuDispatcher.observe(data, {requested: method === 'POST' && path !== '/gpu/prepare'});
502
  return data;
503
  }
504
  async function refreshIdentity() {
 
543
  updateGate();
544
  }
545
  function updateGate() {
546
+ const signedIn = !!S.identity?.authenticated, loading = !ready(S.deployment);
547
+ const inputBusy = leftInputBusy(), geometry = selectedGeometry();
548
+ $('st-input-panel').inert = inputBusy;
549
+ $('st-input-panel').dataset.busy = String(inputBusy);
550
+ $('st-input-panel').setAttribute('aria-disabled', String(inputBusy));
551
+ $('st-settings').inert = inputBusy;
552
+ $('st-settings').setAttribute('aria-disabled', String(inputBusy));
553
+ const imageFlow = S.uploadBusy || !!(S.uploadState && currentUpload(S.uploadState)) ||
554
+ !!(S.source?.foreground_required && !S.source.foreground_ready);
555
+ const failed = zeroGPU(S.deployment) ? /^(failed|blocked)/.test(S.deployment.workspace?.stage || '') : terminal(S.deployment);
556
+ const workspaceLoading = (zeroGPU(S.deployment) || modelsReady(S.deployment)) && loading;
557
  if (!loading && $('st-native-notice').textContent.startsWith('Models are loading in the background.')) {
558
  clearTimeout(S.noticeTimer); show('st-native-notice', false);
559
  }
 
563
  'Model loading failed. Please refresh and try again later.';
564
  else if (loading) reason = workspaceLoading ? workspaceMessage : 'Models are loading in the background. You can generate once loading is complete.';
565
  else if (S.authBusy) reason = 'Connecting your Huggingface account…';
566
+ else if (S.uploadBusy || S.source?.foreground_required && !S.source.foreground_ready) reason = '';
 
567
  else if (!S.source) reason = 'Choose an example or upload an image.';
568
+ $('st-generate').disabled = inputBusy || !!geometry || !signedIn || loading || failed || !S.source || S.uploadBusy || S.authBusy ||
569
  !!(S.source?.foreground_required && !S.source.foreground_ready);
570
  for (const id of ['st-file-input', 'st-pick-image', 'st-change-image', 'st-clear-image', 'st-seed'])
571
+ $(id).disabled = inputBusy || !signedIn || loading || failed;
572
  $('st-history-toggle').disabled = !signedIn;
573
  $('st-history-more').disabled = !signedIn || S.pageLoading;
574
+ $('st-upload-retry').disabled = inputBusy || !signedIn || loading || failed || S.uploadBusy;
575
+ $('st-examples').inert = !signedIn || inputBusy;
576
  $('st-examples').querySelectorAll('button[data-example]').forEach(gateCard);
577
  $('st-history-list').inert = !signedIn;
578
  $('st-history-list').querySelectorAll('button[data-job-id]').forEach(gateCard);
579
  if (!loading || failed || !signedIn) hideCardTip();
580
  $('st-output-panel').inert = !signedIn || loading || failed;
581
  show('st-cancel-signin', S.authBusy);
582
+ text('st-generate', S.submitBusy ? 'Submitting…' : geometry ?
583
+ texturesReady(geometry) ? 'Geometry generated' : 'Geometry ready' : 'Generate geometry');
584
+ const explanatory = (loading || failed) && !imageFlow;
585
  show('st-generate-hit', explanatory);
586
  $('st-generate-hit').setAttribute('aria-label', reason);
587
  $('st-generate-hit').title = reason;
588
+ text('st-gate-note', reason); show('st-gate-note', !!reason && !imageFlow);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  requestAnimationFrame(() => { $('st-generate-hit').style.height = $('st-generate').offsetHeight + 'px'; });
590
  }
591
  async function refreshStatus() {
 
593
  S.statusPromise = (async () => {
594
  try {
595
  const wasReady = ready(S.deployment); S.deployment = await api('/status'); updateGate();
596
+ gpuDispatcher.syncRuntime(S.deployment.runtime_id);
597
+ gpuDispatcher.advance(record => preparationError(S.deployment, record));
598
+ renderSelected();
599
+ if (S.uploadState && currentUpload(S.uploadState) && S.uploadState.uploadId) void refreshUpload(S.uploadState);
600
  if (!wasReady && ready(S.deployment)) void activateWorkspace();
601
+ return S.deployment;
602
  }
603
  catch (error) { if (!S.deployment) text('st-gate-note', 'Connecting to the service…'); }
604
  finally {
605
  S.statusPromise = null; clearTimeout(S.startupTimer);
606
+ if (!S.stopped && pollStartup())
607
  S.startupTimer = setTimeout(refreshStatus, document.hidden ? 20000 : 3000);
608
  }
609
  })();
 
615
  }
616
  function historyStatus(job) {
617
  if (job.status !== 'complete') return {status: statusNames[job.status] ? job.status : 'running',
618
+ label: generationStatusName(job) || 'Preparing'};
619
+ if (stagedJob(job) && !texturesReady(job)) {
620
+ const labels = {queued: 'Preparing textures', running: 'Generating textures', failed: 'Texture failed', interrupted: 'Texture interrupted'};
621
+ return {status: labels[job.texture_status] ? job.texture_status : 'generation_complete',
622
+ label: labels[job.texture_status] || 'Geometry ready'};
623
+ }
624
  // Generation completion makes the preview available. Export and recovery
625
  // remain separate operations, and only a downloadable GLB completes the result.
626
  const operations = [
627
+ {status: job.export_status, labels: {queued: zeroGPURecord(job) ? 'Preparing GLB' : 'GLB Queued', running: 'Extracting GLB',
628
  failed: 'GLB Extraction Failed', interrupted: 'GLB Extraction Interrupted'}},
629
+ {status: job.recover_status, labels: {queued: zeroGPURecord(job) ? 'Preparing Preview' : 'Preview Recovery Queued', running: 'Recovering Preview',
630
  failed: 'Preview Recovery Failed', interrupted: 'Preview Recovery Interrupted'}}
631
  ];
632
  const operation = operations.find(item => item.status === 'running') ||
633
  operations.find(item => item.status === 'queued') ||
634
  operations.find(item => ['failed', 'interrupted'].includes(item.status));
635
  if (operation) return {status: operation.status, label: operation.labels[operation.status]};
636
+ return stagedJob(job) || privateURL(job.download_url) ? {status: 'complete', label: 'Complete'} :
637
  {status: 'generation_complete', label: 'Generation Complete'};
638
  }
639
  function renderHistory() {
 
695
  ++S.selectionVersion; S.previewController?.abort(); S.previewController = null;
696
  S.selected = null; S.previewLoaded = null; clearPreview();
697
  // A newer upload can coexist with the previously selected result.
698
+ if (!leftInputBusy() && job?.upload_id && S.source?.upload_id === job.upload_id) {
699
  ++S.inputVersion; clearUploadPreview(); S.source = null; S.submitKey = null;
700
  if (S.localURL) URL.revokeObjectURL(S.localURL); S.localURL = null;
701
  $('st-input-image').removeAttribute('src'); $('st-file-input').value = '';
 
779
  S.historyError = sustained ? (S.historyLoaded ?
780
  'History could not refresh. We will keep trying.' : 'History could not load. We will keep trying.') : '';
781
  }
782
+ } finally { S.refreshPromise = null; S.pageLoading = false; renderHistory(); updateGate(); scheduleJobs(); }
783
  })();
784
  return S.refreshPromise;
785
  }
786
  function renderSelected() {
787
+ updateGate();
788
  if (S.selected?.kind !== 'job') return;
789
  const job = S.jobs.get(S.selected.id); if (!job) return;
790
+ if (!ready(S.deployment)) {
791
+ if (job.status === 'complete') renderTexture(job);
792
+ return;
793
+ }
794
  if (job.status === 'complete') {
795
+ if (!textureProgressVisible(job) && S.previewLoaded !== previewKey(job)) void loadPreview(job);
796
+ show('st-job-progress', false); renderTexture(job); renderExport(job); return;
797
  }
798
+ $('st-output-panel').dataset.textureProgress = 'false';
799
+ show('st-texture-actions', false); show('st-extract', !stagedJob(job)); show('st-export-note', !stagedJob(job));
800
  S.previewLoaded = null;
801
  $('st-model').replaceChildren(); $('st-ovoxel').replaceChildren();
802
  show('st-model', false); show('st-ovoxel', false); show('st-native-empty', false); show('st-job-progress', true);
803
  $('st-extract').disabled = true; show('st-download', false);
804
  show('st-export-progress', false); clearTimeout(S.exportTimer); S.exportTimer = null;
805
  const progress = job.progress || {};
806
+ text('st-job-title', generationStatusName(job) || 'Preparing your model');
807
  const position = progress.queue_position;
808
+ const gpuFailure = gpuDispatcher.failure(job.gpu_operation_id);
809
+ const resumeNeeded = needsGPUResume(job);
810
+ const resuming = !!S.resumeOperation && S.resumeOperation === job.gpu_operation_id;
811
+ $('st-job-progress').dataset.resumable = String(!!resumeNeeded);
812
+ show('st-resume-gpu', !!resumeNeeded);
813
+ resumeGPU.disabled = resuming;
814
+ text('st-resume-gpu', resuming ? 'Resuming…' : gpuFailure ? 'Retry task' : 'Resume task');
815
+ const detail = resuming ? 'Reconnecting to your task…' : gpuFailure ? gpuFailure : resumeNeeded ? 'Your task is saved. Resume when you are ready.' :
816
+ job.status === 'failed' || job.status === 'interrupted' ?
817
+ (job.status === 'interrupted' ? 'This generation was interrupted. You can generate it again.' :
818
+ englishMessage(job.error, 'This generation could not finish. You can try again.')) :
819
+ taskPreparing(job) ? preparationText(job) :
820
+ job.status === 'queued' && zeroGPURecord(job) ? (stagedJob(job) ? 'Starting geometry… Keep this page open.' : 'Starting generation… Keep this page open.') :
821
  job.status === 'queued' && position !== undefined && position !== null ?
822
+ `Waiting in queue · Position ${position}` : generationProgressLabel(progress.label, 'Preparing your model…');
823
  text('st-job-detail', detail);
824
+ show('st-queue-note', job.status === 'queued' && !zeroGPURecord(job));
825
+ $('st-job-progress').querySelector('progress').hidden = !!resumeNeeded || !['queued', 'running'].includes(job.status);
826
+ preparationMeter($('st-job-progress').querySelector('progress'), job);
827
+ const timing = !resumeNeeded && (taskPreparing(job) ? 'Only needed for the first generation after each Space startup.' :
828
+ progress.elapsed_seconds ? `${Math.floor(progress.elapsed_seconds)}s elapsed` : '');
829
+ text('st-job-time', timing); show('st-job-time', !!timing);
830
  text('st-export-note', '');
831
  }
832
  function previewKey(job) {
833
+ const view = texturesReady(job) && privateURL(job.download_url) ? S.selected?.view || 'glb' : 'render';
834
+ return `${view}:${job.id}:${job.asset_id || ''}`;
835
  }
836
  async function loadPreview(job) {
837
  const version = S.selectionVersion, inputVersion = S.inputVersion;
 
844
  const view = key.split(':')[0];
845
  const result = await api('/jobs/' + encodeURIComponent(job.id) + '/preview?view=' + view, {signal: controller.signal});
846
  if (version !== S.selectionVersion || S.selected?.id !== job.id || S.previewController !== controller) return;
847
+ const current = S.jobs.get(job.id) || job;
848
+ if (previewKey(current) !== key) { S.previewLoaded = null; renderSelected(); return; }
849
+ const textureProgress = textureProgressVisible(current);
850
  $('st-ovoxel').innerHTML = result.html || '';
851
+ show('st-ovoxel', !!result.html && !textureProgress); show('st-native-empty', !result.html && !textureProgress);
852
  if (!result.html) text('st-native-empty', 'The saved preview is not available.');
853
  const input = privateURL(result.display_url || job.display_url || result.input_url || job.input_url);
854
  const original = privateURL(result.input_url || job.input_url);
855
+ if (input && (!leftInputBusy() || !S.source) && !S.source?.local_file && !S.source?.local_job)
856
  setInput(input, {upload_id: result.upload_id || job.upload_id, input_url: original}, result.seed ?? job.seed, inputVersion);
 
857
  if (result.download_url) current.download_url = result.download_url;
858
  const model = $('st-ovoxel').querySelector('.st-gallery-preview[data-model]');
859
+ S.previewLoaded = result.view ? `${result.view}:${job.id}:${job.asset_id || ''}` :
860
  model && privateURL(model.dataset.model) === privateURL(current.download_url) ?
861
+ `glb:${job.id}:${job.asset_id || ''}` : `render:${job.id}:${job.asset_id || ''}`;
862
+ renderHistory(); renderTexture(current); renderExport(current);
863
  } catch (error) {
864
  if (quietError(error) || version !== S.selectionVersion || S.previewController !== controller) return;
865
+ S.previewLoaded = null;
866
+ if (textureProgressVisible(S.jobs.get(job.id) || job)) return;
867
+ text('st-native-empty', 'Could not load this preview. Select it again to retry.');
868
  notice(errorText(error));
869
  }
870
  }
871
+ function renderTexture(job) {
872
+ const visible = stagedJob(job) && job.status === 'complete' && !texturesReady(job);
873
+ const central = visible && textureProgressVisible(job);
874
+ $('st-output-panel').dataset.textureProgress = String(!!central);
875
+ show('st-texture-actions', visible);
876
+ if (!visible) return;
877
+ const previewReady = S.previewLoaded === previewKey(job) && !!$('st-ovoxel').innerHTML;
878
+ show('st-model', false); show('st-ovoxel', !central && previewReady);
879
+ show('st-native-empty', !central && !previewReady); show('st-job-progress', false);
880
+ const active = ['queued', 'running'].includes(job.texture_status);
881
+ const busy = S.textureBusy === job.id;
882
+ const gpuFailure = textureFailure(job);
883
+ const resumeNeeded = job.texture_status === 'queued' && job.gpu_operation_kind === 'texture' && needsGPUResume(job);
884
+ const failed = ['failed', 'interrupted'].includes(job.texture_status);
885
+ const preparing = active && taskPreparing(job);
886
+ const progress = job.texture_progress || {};
887
+ show('st-texture-note', !active && !busy && !failed && !gpuFailure);
888
+ show('st-texture-progress', active || busy || failed || !!gpuFailure);
889
+ text('st-texture-title', busy ? 'Preparing textures' : failed || gpuFailure ? 'Texture failed' :
890
+ job.texture_status === 'running' ? 'Generating textures' : 'Preparing textures');
891
+ text('st-texture-detail', busy ? 'Connecting to your task…' : gpuFailure || (resumeNeeded ?
892
+ 'Your geometry is ready. Resume textures when you are ready.' : failed ?
893
+ englishMessage(job.texture_error, 'Texture generation could not finish. Retry textures to continue.') : preparing ?
894
+ preparationText(job) : job.texture_status === 'running' ? generationProgressLabel(progress.label, 'Generating textures…') :
895
+ 'Starting textures… Keep this page open.'));
896
+ const meter = $('st-texture-progress').querySelector('progress');
897
+ meter.hidden = !busy && (!active || !!resumeNeeded || !!gpuFailure);
898
+ preparationMeter(meter, job);
899
+ const timing = !preparing && !resumeNeeded && !busy && progress.elapsed_seconds ? `${Math.floor(progress.elapsed_seconds)}s elapsed` : '';
900
+ text('st-texture-time', timing); show('st-texture-time', !!timing);
901
+ show('st-texture', !active || !!resumeNeeded || busy);
902
+ $('st-texture').disabled = busy || active && !resumeNeeded || !S.identity?.authenticated || !ready(S.deployment);
903
+ text('st-texture', busy ? 'Connecting…' : gpuFailure || failed ? 'Retry textures' : resumeNeeded ? 'Resume textures' : 'Generate textures');
904
+ }
905
+ async function startTexture() {
906
+ const job = S.selected?.kind === 'job' && S.jobs.get(S.selected.id);
907
+ if (!job || !stagedJob(job) || job.status !== 'complete' || texturesReady(job) ||
908
+ !S.identity?.authenticated || S.textureBusy || $('st-texture').disabled) return;
909
+ const identityVersion = S.identityVersion;
910
+ S.textureErrors.delete(job.id);
911
+ S.textureBusy = job.id; updateGate(); renderTexture(job);
912
+ try {
913
+ const deployment = await refreshStatus();
914
+ if (identityVersion !== S.identityVersion || S.stopped || !S.identity?.authenticated) return;
915
+ if (!deployment || !ready(deployment)) throw new Error('The service is reconnecting. Please try again.');
916
+ const current = S.jobs.get(job.id);
917
+ if (!current || texturesReady(current)) return;
918
+ if (current.texture_status === 'queued') {
919
+ if (current.gpu_operation_kind === 'texture' && needsGPUResume(current)) void gpuDispatcher.retry(current);
920
+ return;
921
+ }
922
+ if (current.texture_status === 'running') return;
923
+ if (!S.textureKeys.has(job.id)) S.textureKeys.set(job.id, crypto.randomUUID());
924
+ const updated = await api('/jobs/' + encodeURIComponent(job.id) + '/texture',
925
+ {method: 'POST', json: {idempotency_key: S.textureKeys.get(job.id)}});
926
+ if (identityVersion !== S.identityVersion || S.stopped) return;
927
+ S.textureKeys.delete(job.id); S.jobs.set(updated.id, updated);
928
+ renderHistory(); scheduleJobs();
929
+ } catch (error) {
930
+ if (identityVersion === S.identityVersion && !quietError(error)) {
931
+ const message = errorText(error); S.textureErrors.set(job.id, message);
932
+ if (S.selected?.kind !== 'job' || S.selected.id !== job.id) notice(message);
933
+ }
934
+ } finally {
935
+ if (identityVersion === S.identityVersion) {
936
+ S.textureBusy = null;
937
+ updateGate();
938
+ if (S.selected?.kind === 'job' && S.selected.id === job.id) renderSelected();
939
+ }
940
+ }
941
+ }
942
  function renderExport(job) {
943
  clearTimeout(S.exportTimer); S.exportTimer = null;
944
+ const allowed = texturesReady(job);
945
+ show('st-extract', allowed); show('st-export-note', allowed);
946
+ if (!allowed) {
947
+ $('st-extract').disabled = true; show('st-download', false); show('st-export-progress', false);
948
+ show('st-view-switch', false); text('st-export-note', ''); return;
949
+ }
950
  const exporting = ['queued', 'running'].includes(job.export_status);
951
+ const gpuFailure = job.export_status === 'queued' && gpuDispatcher.failure(job.gpu_operation_id);
952
+ const resumeNeeded = job.export_status === 'queued' && needsGPUResume(job);
953
  const url = privateURL(job.download_url);
954
  show('st-view-switch', !!url && job.status === 'complete');
955
  const view = previewKey(job).split(':')[0];
956
  $('st-view-switch').querySelectorAll('[data-result-view]').forEach(button =>
957
  button.setAttribute('aria-pressed', String(button.dataset.resultView === view)));
958
+ $('st-extract').disabled = (!url && exporting && !resumeNeeded) || job.status !== 'complete' || !S.identity?.authenticated || !ready(S.deployment);
959
  $('st-extract').dataset.action = url ? 'download' : 'extract';
960
+ text('st-extract', url ? 'Download GLB' : gpuFailure ? 'Retry extraction' : resumeNeeded ? 'Resume GLB extraction' : exporting ?
961
+ job.export_status === 'queued' ? zeroGPURecord(job) ? 'Preparing GLB…' : 'GLB extraction queued…' : 'Extracting GLB…' : 'Extract GLB');
962
  show('st-download', false);
963
  if (url) $('st-download').href = url; else $('st-download').removeAttribute('href');
964
  show('st-export-progress', exporting && !url);
965
+ text('st-export-note', url ? 'Your GLB is ready to download.' : gpuFailure ? gpuFailure : resumeNeeded ?
966
+ 'Your extraction request is saved. Resume it when you are ready.' : taskPreparing(job) ? preparationText(job) : exporting ?
967
  'You can return to My History while extraction continues.' :
968
+ job.export_status === 'failed' ? englishMessage(job.export_error, 'Extraction failed. Click Extract GLB to try again.') : 'Extract and download GLB.');
969
  if (!exporting || url) return;
970
+ const preparing = taskPreparing(job), queued = job.export_status === 'queued';
971
+ const progress = (preparing ? preparationState(S.deployment, job).startup_progress : job.export_progress) || {};
972
  const position = progress.queue_position;
973
+ text('st-export-label', preparing ? preparationText(job) : resumeNeeded ? 'Extraction paused' :
974
+ queued ? zeroGPURecord(job) ? 'Preparing GLB… Keep this page open.' : `Waiting for extraction${position ? ' · Queue position ' + position : ''}` :
975
  englishMessage(progress.label, 'Preparing your GLB…'));
976
  const completed = Math.max(0, Number(progress.completed) || 0), total = Math.max(0, Number(progress.total) || 0);
977
  const meter = $('st-export-meter'), details = [];
978
+ if (preparing) {
979
+ meter.value = formatProgress(progress).percent;
980
+ meter.setAttribute('aria-valuetext', formatProgress(progress).percent + '%');
981
+ } else if (total && !queued) {
982
  meter.value = Math.min(100, completed / total * 100);
983
  meter.setAttribute('aria-valuetext', `${Math.min(completed, total)} of ${total} ${progress.unit === 'bytes' ? 'bytes' : 'stages'} complete`);
984
  details.push(progress.unit === 'bytes' ?
 
986
  `${Math.min(completed, total)} / ${total} stages`);
987
  } else { meter.removeAttribute('value'); meter.removeAttribute('aria-valuetext'); }
988
  const transferred = Number(progress.bytes_completed), transferTotal = Number(progress.bytes_total);
989
+ if (!preparing && transferTotal > 0 && progress.unit !== 'bytes') details.push(
990
  `${(Math.max(0, transferred || 0) / 1048576).toFixed(1)} / ${(transferTotal / 1048576).toFixed(1)} MB transferred`);
991
  job._exportProgressSeenAt ||= Date.now();
992
  const elapsed = Math.max(0, Number(progress.elapsed_seconds) || 0) +
993
  (progress.clock === 'running' ? Math.floor((Date.now() - job._exportProgressSeenAt) / 1000) : 0);
994
  if (elapsed) details.push(`${Math.floor(elapsed)}s elapsed`);
995
+ if (!preparing && queued && !zeroGPURecord(job)) details.push('Extraction shares the generation queue.');
996
  text('st-export-detail', details.join(' · '));
997
  S.exportTimer = setTimeout(() => {
998
  if (S.selected?.kind === 'job' && S.selected.id === job.id) renderExport(S.jobs.get(job.id) || job);
 
1001
  function selectJob(id, keepInput = false) {
1002
  if (!S.identity?.authenticated || !ready(S.deployment)) return;
1003
  const job = S.jobs.get(id); if (!job) return;
1004
+ const preserveReference = leftInputBusy() && !!S.source;
1005
+ ++S.selectionVersion; if (!preserveReference) ++S.inputVersion;
1006
+ S.previewController?.abort();
1007
  S.selected = {kind: 'job', id}; S.previewLoaded = null; clearPreview();
1008
  const input = privateURL(job.display_url || job.input_url) || `/api/history/jobs/${encodeURIComponent(id)}/files/input.png`;
1009
  const original = privateURL(job.input_url) || `/api/history/jobs/${encodeURIComponent(id)}/files/input.png`;
1010
  const currentInput = keepInput && $('st-input-image').getAttribute('src');
1011
+ if (!preserveReference && currentInput && S.source) {
1012
  // A submission already has a visible reference, including examples.
1013
  // Keep it while the newly saved private files become available.
1014
  const source = {...S.source}; delete source.example;
1015
  setInput(currentInput, {...source, local_job: id, upload_id: job.upload_id,
1016
  input_url: original}, job.seed);
1017
+ } else if (!preserveReference) setInput(input, {upload_id: job.upload_id, input_url: original}, job.seed);
1018
  renderHistory(); renderSelected();
1019
  if (S.expanded && matchMedia('(max-width:900px)').matches) expandHistory(false);
1020
  }
1021
  function selectExample(name) {
1022
+ if (leftInputBusy() || !S.identity?.authenticated || !ready(S.deployment)) return;
1023
  const example = S.examples.get(name); if (!example) return;
1024
  ++S.selectionVersion; ++S.inputVersion; S.previewController?.abort();
1025
  S.selected = {kind: 'example', id: name}; S.previewLoaded = null;
 
1078
  $('st-upload-status').dataset.state = phase;
1079
  text('st-upload-label', label); show('st-upload-status', true);
1080
  show('st-upload-retry', retry && (!!state.uploadId || state.retryAction === 'upload'));
1081
+ updateGate();
 
1082
  }
1083
  function scheduleUploadStatus(state, delay = document.hidden ? 6000 : 1500) {
1084
  clearTimeout(state.timer);
1085
  if (currentUpload(state)) state.timer = setTimeout(() => void refreshUpload(state), delay);
1086
  }
1087
  function uploadReadFailed(state, error) {
1088
+ if (!currentUpload(state) || state.appliedResultKey || state.applyPromise || quietError(error)) return;
1089
  if (state.resultURL) { URL.revokeObjectURL(state.resultURL); state.resultURL = null; }
1090
  state.failures++;
1091
  state.failureSince ??= Date.now();
1092
  if (state.failures < 3 || Date.now() - state.failureSince < 30000) {
1093
  S.uploadBusy = true;
1094
+ uploadLabel(state, 'checking', 'Preparing image…');
1095
  scheduleUploadStatus(state, Math.min(8000, state.failures * 2000));
1096
  } else {
1097
  S.uploadBusy = false; state.retryAction = 'status';
 
1102
  if (!currentUpload(state)) return;
1103
  if (!result.upload_id || state.uploadId && state.uploadId !== result.upload_id)
1104
  throw new Error('The uploaded image could not be verified.');
1105
+ // One upload state belongs to one account and input version. Repeated
1106
+ // status/dispatcher notifications must not re-download its applied cutout.
1107
+ const foreground = result.foreground;
1108
+ const resultKey = foreground?.status === 'complete' ? JSON.stringify([
1109
+ result.upload_id, result.files?.['foreground.png']?.version || result.foreground_url
1110
+ ]) : null;
1111
+ if (state.appliedResultKey && (!resultKey || state.appliedResultKey === resultKey)) return;
1112
+ if (state.applyPromise) {
1113
+ await state.applyPromise;
1114
+ return applyUploadResult(result, state);
1115
+ }
1116
  state.uploadId = result.upload_id;
1117
+ state.gpuRecord = result;
1118
  S.source = {...S.source, upload_id: result.upload_id, uploaded: true};
 
1119
  if (!foreground) {
1120
  // Retain compatibility with saved uploads and the previous API version.
1121
  S.source.foreground_required = false; S.uploadBusy = false;
 
1123
  }
1124
  S.source.foreground_required = true; S.source.foreground_ready = false;
1125
  if (['queued', 'running'].includes(foreground.status)) {
1126
+ const gpuFailure = gpuDispatcher.failure(result.gpu_operation_id);
1127
+ if (gpuFailure) {
1128
+ S.uploadBusy = false; state.retryAction = 'gpu'; state.gpuRecord = result;
1129
+ clearTimeout(state.timer); uploadLabel(state, 'failed', gpuFailure, true); return;
1130
+ }
1131
  state.failures = 0; state.failureSince = null; S.uploadBusy = true;
1132
+ uploadLabel(state, foreground.status, taskPreparing(result) ? 'Loading model…' : foreground.status === 'queued' ?
1133
+ 'Removing background… Keep this page open.' : 'Removing background…');
 
1134
  scheduleUploadStatus(state); return;
1135
  }
1136
  if (['failed', 'interrupted'].includes(foreground.status)) {
1137
  S.uploadBusy = false; state.retryAction = 'foreground'; state.retryKey = null;
1138
+ uploadLabel(state, 'failed', englishMessage(foreground.error, 'Background removal failed. Please retry.'), true); return;
1139
  }
1140
  if (foreground.status !== 'complete') throw new Error('Background removal status is unavailable.');
1141
+ const applying = (async () => {
1142
+ S.uploadBusy = true; uploadLabel(state, 'loading', 'Loading image…');
1143
+ const url = privateURL(result.foreground_url);
1144
+ const expected = '/api/history/uploads/' + encodeURIComponent(state.uploadId) + '/files/foreground.png';
1145
+ if (!url || new URL(url).pathname !== expected) throw new Error('The background-removed image is unavailable.');
1146
+ const response = await fetch(url, {credentials: 'same-origin', cache: 'no-store',
1147
+ signal: AbortSignal.any([state.controller.signal, AbortSignal.timeout(30000)])});
1148
+ if (!response.ok) throw new Error('The background-removed image could not load.');
1149
+ const blob = await response.blob();
1150
+ if (!currentUpload(state)) return;
1151
+ if (!blob.size || blob.size > 8 * 1024 * 1024 || !blob.type.startsWith('image/'))
1152
+ throw new Error('The background-removed image is invalid.');
1153
+ state.resultURL = URL.createObjectURL(blob);
1154
+ const image = new Image(); image.src = state.resultURL;
1155
+ await image.decode();
1156
+ if (!currentUpload(state)) return;
1157
+ const previous = S.localURL;
1158
+ S.localURL = state.resultURL; state.resultURL = null;
1159
+ if (previous) URL.revokeObjectURL(previous);
1160
+ S.uploadBusy = false; state.failures = 0; state.failureSince = null; state.retryKey = null;
1161
+ state.appliedResultKey = resultKey; clearTimeout(state.timer); state.timer = null;
1162
+ setInput(S.localURL, {...S.source, foreground_required: true, foreground_ready: true,
1163
+ foreground_file: blob}, undefined, state.inputVersion);
1164
+ uploadLabel(state, 'complete', 'Background removed.');
1165
+ })();
1166
+ state.applyPromise = applying;
1167
+ try { await applying; }
1168
+ finally { if (state.applyPromise === applying) state.applyPromise = null; }
1169
  }
1170
  async function refreshUpload(state) {
1171
+ if (!currentUpload(state) || !state.uploadId || state.appliedResultKey) return;
1172
+ if (state.refreshPromise) return state.refreshPromise;
1173
+ state.refreshPromise = (async () => {
1174
+ try {
1175
+ const result = await api('/uploads/' + encodeURIComponent(state.uploadId), {signal: state.controller.signal});
1176
+ await applyUploadResult(result, state);
1177
+ } catch (error) { uploadReadFailed(state, error); }
1178
+ finally { state.refreshPromise = null; }
1179
+ })();
1180
+ return state.refreshPromise;
1181
  }
1182
  async function retryUploadPreview() {
1183
  const state = S.uploadState;
1184
+ if (leftInputBusy() || !state || !currentUpload(state) || S.uploadBusy || !ready(S.deployment)) return;
1185
  if (state.retryAction === 'upload') return upload(S.source?.local_file);
1186
+ state.appliedResultKey = null;
1187
+ if (state.retryAction === 'gpu' && state.gpuRecord) {
1188
+ S.uploadBusy = true;
1189
+ uploadLabel(state, 'queued', 'Preparing image…');
1190
+ void gpuDispatcher.retry(state.gpuRecord); scheduleUploadStatus(state); return;
1191
+ }
1192
  state.failures = 0; state.failureSince = null; S.uploadBusy = true;
1193
+ uploadLabel(state, 'queued', 'Preparing image…');
1194
  if (state.retryAction !== 'foreground') return refreshUpload(state);
1195
  state.retryKey ||= crypto.randomUUID();
1196
  try {
 
1200
  } catch (error) { uploadReadFailed(state, error); }
1201
  }
1202
  async function upload(file) {
1203
+ if (!file || leftInputBusy()) return;
1204
  if (!S.identity?.authenticated || !ready(S.deployment) || S.stopped) return;
1205
  if (file.size > 20 * 1024 * 1024) return notice('Please upload an image smaller than 20 MiB.');
1206
  const serviceWaits = S.source?.local_file === file && S.uploadState?.phase === 'waiting_service' ?
 
1214
  S.localURL = URL.createObjectURL(file);
1215
  setInput(S.localURL, {local_file: file, foreground_required: true, foreground_ready: false}, undefined, version);
1216
  S.uploadBusy = true; S.submitKey = null; updateGate();
1217
+ uploadLabel(state, 'uploading', 'Loading image…');
1218
  try {
1219
  const form = new FormData(); form.append('file', file);
1220
  const result = await api('/upload', {method: 'POST', body: form, signal: state.controller.signal});
 
1228
  S.uploadBusy = false; state.serviceWaits++; state.retryAction = 'upload';
1229
  S.deployment = {...S.deployment, workspace: {stage: 'starting'}};
1230
  uploadLabel(state, state.serviceWaits <= 3 ? 'waiting_service' : 'failed',
1231
+ state.serviceWaits <= 3 ? 'Preparing image…' :
1232
  'The service is still preparing. Your image is kept here. Please retry when ready.', state.serviceWaits > 3);
1233
  void refreshStatus();
1234
  }
1235
  else {
1236
  S.uploadBusy = false;
1237
  state.retryAction = 'upload';
1238
+ uploadLabel(state, 'failed', errorText(error), true);
 
1239
  }
1240
  }
1241
  } finally { if (currentUpload(state)) { $('st-file-input').value = ''; updateGate(); } }
1242
  }
1243
  function stageLocalFile(file, seed) {
1244
+ if (leftInputBusy()) return;
1245
  clearUploadPreview();
1246
  ++S.inputVersion; S.uploadBusy = false;
1247
  if (S.localURL) URL.revokeObjectURL(S.localURL);
 
1251
  if (S.identity?.authenticated && ready(S.deployment)) void upload(file);
1252
  }
1253
  async function submit() {
1254
+ if (leftInputBusy() || selectedGeometry() || $('st-generate').disabled) return;
1255
  if (!S.identity?.authenticated) return connectAccount();
1256
  const source = {...S.source}, seed = Math.round(Number($('st-seed').value));
1257
  if (!Number.isFinite(seed)) return notice('Please enter a valid seed.');
 
1306
  async function extract() {
1307
  if (S.selected?.kind !== 'job' || $('st-extract').disabled) return;
1308
  const job = S.jobs.get(S.selected.id); if (!job) return;
1309
+ if (!texturesReady(job)) return;
1310
+ if (job.export_status === 'queued' && needsGPUResume(job)) {
1311
+ void gpuDispatcher.retry(job); renderExport(job); return;
1312
+ }
1313
  const url = privateURL(job.download_url);
1314
  if (url) {
1315
  try {
 
1354
  root.querySelector('#st-generate-control').dataset.notice = 'true';
1355
  });
1356
  $('st-generate-hit').addEventListener('mouseleave', () => delete root.querySelector('#st-generate-control').dataset.notice);
1357
+ const pick = () => { if (!leftInputBusy() && ready(S.deployment) && S.identity?.authenticated) $('st-file-input').click(); };
1358
  $('st-pick-image').addEventListener('click', pick); $('st-change-image').addEventListener('click', pick);
1359
  $('st-file-input').addEventListener('change', event => void upload(event.target.files[0]));
1360
  $('st-upload').addEventListener('dragover', event => event.preventDefault());
1361
  $('st-upload').addEventListener('drop', event => { event.preventDefault(); void upload(event.dataTransfer.files[0]); });
1362
+ for (const eventName of ['dragover', 'drop']) document.addEventListener(eventName, event => {
1363
+ if (leftInputBusy() && Array.from(event.dataTransfer?.types || []).includes('Files')) event.preventDefault();
1364
+ }, true);
1365
  $('st-clear-image').addEventListener('click', () => {
1366
+ if (leftInputBusy() || !ready(S.deployment)) return;
1367
  clearUploadPreview();
1368
  ++S.inputVersion; S.source = null; S.uploadBusy = false; S.submitKey = null;
1369
  if (S.localURL) URL.revokeObjectURL(S.localURL); S.localURL = null;
1370
  $('st-input-image').removeAttribute('src'); show('st-input-image', false); show('st-upload-placeholder', true); show('st-clear-image', false); updateGate();
1371
  });
1372
  $('st-upload-retry').addEventListener('click', () => void retryUploadPreview());
1373
+ $('st-seed').addEventListener('input', () => {
1374
+ if (!leftInputBusy()) { S.submitKey = null; updateGate(); }
1375
+ });
1376
  $('st-generate').addEventListener('click', () => void submit());
1377
  const cancelSignIn = () => accountConnectionFailed('Sign-in cancelled. Please sign in to continue.');
1378
  $('st-cancel-signin').addEventListener('click', cancelSignIn);
 
1380
  $('st-auth-signin').addEventListener('click', () => connectAccount(true));
1381
  $('st-auth-dialog').addEventListener('cancel', event => event.preventDefault());
1382
  $('st-extract').addEventListener('click', () => void extract());
1383
+ $('st-texture').addEventListener('click', () => startTexture());
1384
  $('st-view-switch').addEventListener('click', event => {
1385
  const button = event.target.closest('[data-result-view]');
1386
  if (!button || S.selected?.kind !== 'job') return;
1387
  const job = S.jobs.get(S.selected.id);
1388
  if (!job || job.status !== 'complete' || !privateURL(job.download_url)) return;
1389
  const view = button.dataset.resultView;
1390
+ if (!['render', 'glb'].includes(view) || previewKey(job) === `${view}:${job.id}:${job.asset_id || ''}`) return;
1391
  S.selected.view = view;
1392
  ++S.selectionVersion; S.previewLoaded = null;
1393
  renderSelected();
 
1471
  }
1472
  document.addEventListener('visibilitychange', () => {
1473
  clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer);
1474
+ if (document.hidden) { scheduleJobs(); if (pollStartup()) S.startupTimer = setTimeout(refreshStatus, 20000); }
1475
  else void resume();
1476
  });
1477
  window.addEventListener('focus', () => void resume());
hf_space/history_ui.py CHANGED
@@ -1,4 +1,5 @@
1
  """Native, account-scoped UI. Private images never enter Gradio's file cache."""
 
2
  from pathlib import Path
3
  import gradio as gr
4
  from hf_space.gallery_preview import install_js as gallery_install_js
@@ -9,9 +10,20 @@ CSS = (DIRECTORY / "ui.css").read_text(encoding="utf-8") + "\n" + (
9
  DIRECTORY / "history_ui.css").read_text(encoding="utf-8")
10
  HEADER = ('<header class="st-history-heading"><h1>Supra-Tex</h1>'
11
  '<p>3D Generation with Super-Aligned Texture</p></header>')
12
- RESOURCE_NOTICE = ('<footer class="st-resource-notice" role="note">'
13
- 'This demo uses <strong>shared GPU resources</strong>. Requests may be queued, '
14
- 'and processing may take longer during busy periods.</footer>')
 
 
 
 
 
 
 
 
 
 
 
15
  # Keep Gradio's footer and settings controls in English on Chinese browsers.
16
  # Flat translation keys take precedence over Gradio's nested locale resources.
17
  ENGLISH_UI = {
@@ -64,15 +76,14 @@ MARKUP = """
64
  <input type="file" id="st-file-input" accept="image/*" hidden>
65
  <div class="st-upload-actions"><button type="button" id="st-change-image">Upload image</button><button type="button" id="st-clear-image" hidden>Clear</button></div>
66
  </div>
67
- <div id="st-upload-status" hidden role="status" aria-live="polite"><span id="st-upload-label"></span><progress id="st-upload-meter" max="100" aria-label="Background removal progress"></progress><button id="st-upload-retry" type="button" hidden>Retry</button></div>
68
  <details id="st-settings"><summary>Generation settings</summary><label for="st-seed">Seed<input id="st-seed" type="number" step="1" value="42"></label></details>
69
  <div id="st-generate-control">
70
- <button id="st-generate" type="button" disabled>Generate 3D</button>
71
  <button id="st-generate-hit" type="button" aria-describedby="st-gate-note" hidden></button>
72
  <p id="st-gate-note" role="status" aria-live="polite">Checking service…</p>
73
  <p id="st-account-help" role="status" hidden></p>
74
  <button id="st-cancel-signin" type="button" hidden>Cancel sign-in</button>
75
- <div id="st-native-startup" hidden><div class="st-startup-heading"><strong>Loading models in the background</strong><span id="st-startup-detail"></span></div><progress id="st-startup-meter" max="100" aria-label="Current loading stage progress"></progress><small id="st-startup-label"></small></div>
76
  </div>
77
  </section>
78
  <section id="st-output-panel" class="st-native-panel">
@@ -88,6 +99,11 @@ MARKUP = """
88
  <div id="st-ovoxel" hidden></div>
89
  <div id="st-job-progress" hidden role="status" aria-live="polite"><h3 id="st-job-title"></h3><p id="st-job-detail"></p><p id="st-queue-note" hidden>Your request is queued due to Hugging Face GPU resource limits. Wait times may be longer during busy periods. You can check your task’s progress and results in <a id="st-queue-history-link" href="#st-history-panel">My History</a>.</p><progress aria-label="Generation in progress"></progress><small id="st-job-time"></small></div>
90
  </div>
 
 
 
 
 
91
  <button id="st-extract" type="button" disabled>Extract GLB</button>
92
  <a id="st-download" hidden download="asset.glb" tabindex="-1" aria-hidden="true"></a>
93
  <div id="st-export-progress" hidden role="status" aria-live="polite"><strong id="st-export-label"></strong><progress id="st-export-meter" max="100" aria-label="GLB extraction stages"></progress><small id="st-export-detail"></small></div>
@@ -134,7 +150,8 @@ def build_ui(deployment_status=None):
134
  gr.LoginButton(visible=False, elem_id="st-oauth-bootstrap")
135
  gr.HTML(MARKUP, apply_default_css=False, elem_id="st-native-page")
136
  gr.HTML(RESOURCE_NOTICE, apply_default_css=False, elem_id="st-resource-footer")
137
- script = (DIRECTORY / "history_ui.js").read_text(encoding="utf-8")
 
138
  demo.load(None, js="() => {\n" + script + "\n}", api_visibility="private", queue=False)
139
  demo.load(None, js=gallery_install_js(), api_visibility="private", queue=False)
140
  for filename in ("ovoxel_preview.js", "viewport.js"):
 
1
  """Native, account-scoped UI. Private images never enter Gradio's file cache."""
2
+ import base64
3
  from pathlib import Path
4
  import gradio as gr
5
  from hf_space.gallery_preview import install_js as gallery_install_js
 
10
  DIRECTORY / "history_ui.css").read_text(encoding="utf-8")
11
  HEADER = ('<header class="st-history-heading"><h1>Supra-Tex</h1>'
12
  '<p>3D Generation with Super-Aligned Texture</p></header>')
13
+ # Official HF artwork and PRO badge treatment; FREE has no separate plan logo.
14
+ # Sources: https://huggingface.co/brand and https://huggingface.co/pricing
15
+ # Logo: https://huggingface.co/front/assets/huggingface_logo-noborder.svg
16
+ HF_LOGO = base64.b64encode((DIRECTORY / 'brand/huggingface-logo.svg').read_bytes()).decode('ascii')
17
+ RESOURCE_NOTICE = (
18
+ '<footer class="st-resource-notice" role="note">'
19
+ 'This demo uses <strong>your personal ZeroGPU quota</strong>. '
20
+ '<a class="st-plan-link" href="https://huggingface.co/pricing" target="_blank" rel="noopener noreferrer" aria-label="Hugging Face FREE account">'
21
+ f'<img class="st-hf-logo" src="data:image/svg+xml;base64,{HF_LOGO}" alt="" aria-hidden="true"><span class="st-hf-free">FREE</span></a>'
22
+ ' accounts have limited quota. We recommend a '
23
+ '<a class="st-plan-link" href="https://huggingface.co/pro" target="_blank" rel="noopener noreferrer" aria-label="Hugging Face PRO account">'
24
+ '<span class="st-hf-pro">PRO</span></a>'
25
+ ' account for a better experience.</footer>'
26
+ )
27
  # Keep Gradio's footer and settings controls in English on Chinese browsers.
28
  # Flat translation keys take precedence over Gradio's nested locale resources.
29
  ENGLISH_UI = {
 
76
  <input type="file" id="st-file-input" accept="image/*" hidden>
77
  <div class="st-upload-actions"><button type="button" id="st-change-image">Upload image</button><button type="button" id="st-clear-image" hidden>Clear</button></div>
78
  </div>
79
+ <div id="st-upload-status" hidden role="status" aria-live="polite"><span id="st-upload-label"></span><button id="st-upload-retry" type="button" hidden>Retry</button></div>
80
  <details id="st-settings"><summary>Generation settings</summary><label for="st-seed">Seed<input id="st-seed" type="number" step="1" value="42"></label></details>
81
  <div id="st-generate-control">
82
+ <button id="st-generate" type="button" disabled>Generate geometry</button>
83
  <button id="st-generate-hit" type="button" aria-describedby="st-gate-note" hidden></button>
84
  <p id="st-gate-note" role="status" aria-live="polite">Checking service…</p>
85
  <p id="st-account-help" role="status" hidden></p>
86
  <button id="st-cancel-signin" type="button" hidden>Cancel sign-in</button>
 
87
  </div>
88
  </section>
89
  <section id="st-output-panel" class="st-native-panel">
 
99
  <div id="st-ovoxel" hidden></div>
100
  <div id="st-job-progress" hidden role="status" aria-live="polite"><h3 id="st-job-title"></h3><p id="st-job-detail"></p><p id="st-queue-note" hidden>Your request is queued due to Hugging Face GPU resource limits. Wait times may be longer during busy periods. You can check your task’s progress and results in <a id="st-queue-history-link" href="#st-history-panel">My History</a>.</p><progress aria-label="Generation in progress"></progress><small id="st-job-time"></small></div>
101
  </div>
102
+ <div id="st-texture-actions" hidden>
103
+ <p id="st-texture-note" role="status">Geometry ready. Generate textures when you are ready.</p>
104
+ <div id="st-texture-progress" hidden role="status" aria-live="polite"><strong id="st-texture-title"></strong><p id="st-texture-detail"></p><progress max="100" aria-label="Texture generation progress"></progress><small id="st-texture-time" hidden></small></div>
105
+ <button id="st-texture" type="button">Generate textures</button>
106
+ </div>
107
  <button id="st-extract" type="button" disabled>Extract GLB</button>
108
  <a id="st-download" hidden download="asset.glb" tabindex="-1" aria-hidden="true"></a>
109
  <div id="st-export-progress" hidden role="status" aria-live="polite"><strong id="st-export-label"></strong><progress id="st-export-meter" max="100" aria-label="GLB extraction stages"></progress><small id="st-export-detail"></small></div>
 
150
  gr.LoginButton(visible=False, elem_id="st-oauth-bootstrap")
151
  gr.HTML(MARKUP, apply_default_css=False, elem_id="st-native-page")
152
  gr.HTML(RESOURCE_NOTICE, apply_default_css=False, elem_id="st-resource-footer")
153
+ script = (DIRECTORY / "zerogpu_client.js").read_text(encoding="utf-8") + "\n" + (
154
+ DIRECTORY / "history_ui.js").read_text(encoding="utf-8")
155
  demo.load(None, js="() => {\n" + script + "\n}", api_visibility="private", queue=False)
156
  demo.load(None, js=gallery_install_js(), api_visibility="private", queue=False)
157
  for filename in ("ovoxel_preview.js", "viewport.js"):
hf_space/native_build.py CHANGED
@@ -7,24 +7,47 @@ import importlib.metadata
7
  import copy
8
  import json
9
  import os
10
- from pathlib import Path
11
  import shutil
12
  import site
13
  import subprocess
14
  import sys
15
  import threading
 
16
  import time
17
  import traceback
18
  import uuid
19
  import zipfile
20
 
21
  from scripts.install_runtime import O_VOXEL_FLAGS, SOURCES
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  ROOT = Path(__file__).resolve().parents[1]
24
- WORK = Path('/tmp/supratex-build-cu128')
25
  STATE = Path(os.environ.get('SUPRATEX_NATIVE_STATE', str(WORK / 'status.json')))
26
  LOG = STATE.parent / 'build.log'
27
- CACHE = Path('/data/native-wheels/torch280-cu128-cp310-sm89-v1')
28
  LOCK = threading.Lock()
29
  _persist_lock = threading.Lock()
30
  _start_lock = threading.Lock()
@@ -45,6 +68,78 @@ CUDA_PARTS = (
45
  ('cuda_cudart/linux-x86_64/cuda_cudart-linux-x86_64-12.8.90-archive.tar.xz', '8d566b5fe745c46842dc16945cf36686227536decd2302c372be86da37faca68'),
46
  ('cuda_cccl/linux-x86_64/cuda_cccl-linux-x86_64-12.8.90-archive.tar.xz', '0740e9e01e4f15e17c5ab8d68bba4f8ec0eb6b84edccba4ac45112d2d2174e4b'),
47
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
 
50
  def status():
@@ -82,13 +177,23 @@ def run(args, env=None, cwd=None):
82
  process.wait(timeout=5)
83
  raise
84
  if code:
85
- raise RuntimeError(f'Command failed (exit {process.returncode}): {args[0]} {args[1:4]}')
 
 
 
 
 
 
 
 
 
 
86
 
87
 
88
  def runtime_environment():
89
  """Publish only known settings, without waiting on storage or importing Torch."""
90
  env = os.environ.copy()
91
- env.update(CUDA_HOME=str(WORK / 'cuda-12.8'), BUILD_TARGET='cuda', TORCH_CUDA_ARCH_LIST='8.9+PTX',
92
  MAX_JOBS='2', CMAKE_BUILD_PARALLEL_LEVEL='2', ATTN_BACKEND='sdpa', SPARSE_ATTN_BACKEND='sdpa',
93
  TORCH_COMPILE_DISABLE='1', SUPRATEX_RUNTIME_PROFILE='torch28',
94
  FLEX_GEMM_USE_AUTOTUNE_CACHE='0', FLEX_GEMM_AUTOSAVE_AUTOTUNE_CACHE='0',
@@ -179,11 +284,12 @@ def copy_atomic(source, target):
179
  def build():
180
  try:
181
  WORK.mkdir(parents=True, exist_ok=True)
 
182
  import torch
183
  assert torch.__version__ == '2.8.0+cu128', torch.__version__
184
  assert torch.version.cuda == '12.8', torch.version.cuda
185
  update(stage='preparing_cuda', torch=torch.__version__, cuda=torch.version.cuda,
186
- python=sys.version.split()[0], architecture='8.9+PTX', completed=[])
187
  env = configure_cuda()
188
  run([Path(env['CUDA_HOME']) / 'bin/nvcc', '--version'], env)
189
  persistent = CACHE
@@ -223,7 +329,7 @@ def build():
223
  run(['git', '-C', checkout, 'remote', 'add', 'origin', url], env)
224
  run(['git', '-C', checkout, 'fetch', '--depth=1', 'origin', revision], env)
225
  run(['git', '-C', checkout, 'checkout', '--detach', revision], env)
226
- run(['git', '-C', checkout, 'submodule', 'update', '--init', '--recursive', '--depth=1'], env)
227
  build_env = env.copy()
228
  build_env['NVCC_APPEND_FLAGS'] = O_VOXEL_FLAGS if subdir == 'o-voxel' else '--extended-lambda' if name == 'CuMesh' else ''
229
  run([*pip, 'wheel', '-v', '--no-build-isolation', '--no-deps', '--wheel-dir', wheels, checkout / subdir], build_env)
 
7
  import copy
8
  import json
9
  import os
10
+ from pathlib import Path, PurePosixPath
11
  import shutil
12
  import site
13
  import subprocess
14
  import sys
15
  import threading
16
+ import tarfile
17
  import time
18
  import traceback
19
  import uuid
20
  import zipfile
21
 
22
  from scripts.install_runtime import O_VOXEL_FLAGS, SOURCES
23
+ from hf_space.gpu_mode import enabled as zero_gpu_enabled
24
+
25
+
26
+ def cuda_architectures():
27
+ if not zero_gpu_enabled():
28
+ return '8.9+PTX'
29
+ # The scheduler advertises the CUDA architecture without acquiring a GPU.
30
+ # Cover both shared fleets if an older image does not publish these values.
31
+ major = os.environ.get('ZEROGPU_CUDA_CAPABILITY_MAJOR')
32
+ minor = os.environ.get('ZEROGPU_CUDA_CAPABILITY_MINOR', '0')
33
+ if major is None:
34
+ return '9.0;12.0+PTX'
35
+ if major not in ('8', '9', '10', '12') or minor not in ('0', '6', '9'):
36
+ raise RuntimeError('Unsupported advertised ZeroGPU CUDA architecture')
37
+ return f'{major}.{minor}+PTX'
38
+
39
+
40
+ def wheel_cache_key():
41
+ if not zero_gpu_enabled():
42
+ return 'torch280-cu128-cp310-sm89-v1'
43
+ arch = cuda_architectures().replace('.', '').replace(';', '-').replace('+', '-')
44
+ return f'torch280-cu128-cp310-zero-sm{arch}-v1'
45
 
46
  ROOT = Path(__file__).resolve().parents[1]
47
+ WORK = Path('/tmp/supratex-build-cu128-zero' if zero_gpu_enabled() else '/tmp/supratex-build-cu128')
48
  STATE = Path(os.environ.get('SUPRATEX_NATIVE_STATE', str(WORK / 'status.json')))
49
  LOG = STATE.parent / 'build.log'
50
+ CACHE = Path('/data/native-wheels') / wheel_cache_key()
51
  LOCK = threading.Lock()
52
  _persist_lock = threading.Lock()
53
  _start_lock = threading.Lock()
 
68
  ('cuda_cudart/linux-x86_64/cuda_cudart-linux-x86_64-12.8.90-archive.tar.xz', '8d566b5fe745c46842dc16945cf36686227536decd2302c372be86da37faca68'),
69
  ('cuda_cccl/linux-x86_64/cuda_cccl-linux-x86_64-12.8.90-archive.tar.xz', '0740e9e01e4f15e17c5ab8d68bba4f8ec0eb6b84edccba4ac45112d2d2174e4b'),
70
  )
71
+ EIGEN_REVISION = '21e4582d1739107337a03460c81412981130373e'
72
+ EIGEN_SHA256 = '399bbf2c2239b286f21edfebfe1430a689c0995acd32e8017701346f6d35be7a'
73
+ EIGEN_ARCHIVE = ROOT / 'deployment' / 'vendor' / f'eigen-{EIGEN_REVISION}.tar.gz'
74
+ EIGEN_TRELLIS_REVISION = '5565d240c4a494caaf9ece7a554542b76ffa36d3'
75
+
76
+
77
+ def startup_resources(tmp=Path('/tmp'), cgroup=Path('/sys/fs/cgroup')):
78
+ """Small capacity diagnostics, without listing environment or credentials."""
79
+ disk = shutil.disk_usage(tmp)
80
+ limits = []
81
+ for path in (cgroup / 'memory.max', cgroup / 'memory' / 'memory.limit_in_bytes'):
82
+ try:
83
+ value = path.read_text().strip()
84
+ if value.isdecimal() and 0 < int(value) < 2**60:
85
+ limits.append(int(value))
86
+ except OSError:
87
+ pass
88
+ return {'tmp_disk_total_bytes': disk.total, 'tmp_disk_free_bytes': disk.free,
89
+ 'cgroup_memory_limit_bytes': min(limits) if limits else None}
90
+
91
+
92
+ def prepare_eigen(checkout, archive=None):
93
+ """Unpack the exact TRELLIS submodule from a checksum-pinned local archive."""
94
+ checkout = Path(checkout).absolute()
95
+ archive = Path(archive) if archive is not None else EIGEN_ARCHIVE
96
+ if hashlib.sha256(archive.read_bytes()).hexdigest() != EIGEN_SHA256:
97
+ raise RuntimeError('Vendored Eigen archive checksum mismatch')
98
+ target = checkout / 'o-voxel' / 'third_party' / 'eigen'
99
+ if any(path.is_symlink() for path in (checkout, target, *target.parents)
100
+ if path == checkout or checkout in path.parents):
101
+ raise RuntimeError('Eigen extraction path must not contain symlinks')
102
+ boundary = target.resolve()
103
+ if not boundary.is_relative_to(checkout.resolve()):
104
+ raise RuntimeError('Eigen extraction escaped its checkout')
105
+ with tarfile.open(archive, 'r:gz') as source:
106
+ entries = []
107
+ for member in source.getmembers():
108
+ relative = PurePosixPath(member.name)
109
+ if (relative.is_absolute() or not relative.parts or
110
+ relative.parts[0] != f'eigen-{EIGEN_REVISION}' or
111
+ '..' in relative.parts or '\\' in member.name or ':' in member.name or
112
+ not (member.isdir() or member.isfile())):
113
+ raise RuntimeError('Unsafe member in vendored Eigen archive')
114
+ destination = target.joinpath(*relative.parts[1:])
115
+ if (not destination.resolve().is_relative_to(boundary) or
116
+ any(path.is_symlink() for path in (destination, *destination.parents)
117
+ if path == target or target in path.parents)):
118
+ raise RuntimeError('Unsafe destination for vendored Eigen archive')
119
+ if destination.exists() and destination.is_file() and destination.stat().st_nlink > 1:
120
+ raise RuntimeError('Eigen extraction must not overwrite a hard-linked file')
121
+ entries.append((member, destination))
122
+ # Validate every member before writing any headers. Tar links, special
123
+ # files and traversal paths are never materialized.
124
+ for member, destination in entries:
125
+ if member.isdir():
126
+ destination.mkdir(parents=True, exist_ok=True)
127
+ else:
128
+ destination.parent.mkdir(parents=True, exist_ok=True)
129
+ with source.extractfile(member) as reader, destination.open('wb') as writer:
130
+ shutil.copyfileobj(reader, writer)
131
+ if not (target / 'Eigen' / 'Core').is_file() or not (target / 'COPYING.MPL2').is_file():
132
+ raise RuntimeError('Vendored Eigen headers or license are missing')
133
+ return target
134
+
135
+
136
+ def prepare_submodules(name, revision, checkout, env):
137
+ if name == 'TRELLIS.2':
138
+ if revision != EIGEN_TRELLIS_REVISION:
139
+ raise RuntimeError('Review the vendored Eigen revision before updating TRELLIS.2')
140
+ prepare_eigen(checkout)
141
+ else:
142
+ run(['git', '-C', checkout, 'submodule', 'update', '--init', '--recursive', '--depth=1'], env)
143
 
144
 
145
  def status():
 
177
  process.wait(timeout=5)
178
  raise
179
  if code:
180
+ # Read only a bounded tail even when verbose compiler output is
181
+ # enormous. Public status responses never include this error text.
182
+ tail = ''
183
+ try:
184
+ with LOG.open('rb') as output:
185
+ output.seek(0, os.SEEK_END)
186
+ output.seek(max(0, output.tell() - 16000))
187
+ tail = output.read(16000).decode('utf-8', errors='replace')[-4000:]
188
+ except OSError:
189
+ pass
190
+ raise RuntimeError(f'Command failed (exit {process.returncode}): {args[0]} {args[1:4]}\n{tail}')
191
 
192
 
193
  def runtime_environment():
194
  """Publish only known settings, without waiting on storage or importing Torch."""
195
  env = os.environ.copy()
196
+ env.update(CUDA_HOME=str(WORK / 'cuda-12.8'), BUILD_TARGET='cuda', TORCH_CUDA_ARCH_LIST=cuda_architectures(),
197
  MAX_JOBS='2', CMAKE_BUILD_PARALLEL_LEVEL='2', ATTN_BACKEND='sdpa', SPARSE_ATTN_BACKEND='sdpa',
198
  TORCH_COMPILE_DISABLE='1', SUPRATEX_RUNTIME_PROFILE='torch28',
199
  FLEX_GEMM_USE_AUTOTUNE_CACHE='0', FLEX_GEMM_AUTOSAVE_AUTOTUNE_CACHE='0',
 
284
  def build():
285
  try:
286
  WORK.mkdir(parents=True, exist_ok=True)
287
+ update(stage='preparing_cuda', resources=startup_resources())
288
  import torch
289
  assert torch.__version__ == '2.8.0+cu128', torch.__version__
290
  assert torch.version.cuda == '12.8', torch.version.cuda
291
  update(stage='preparing_cuda', torch=torch.__version__, cuda=torch.version.cuda,
292
+ python=sys.version.split()[0], architecture=cuda_architectures(), completed=[])
293
  env = configure_cuda()
294
  run([Path(env['CUDA_HOME']) / 'bin/nvcc', '--version'], env)
295
  persistent = CACHE
 
329
  run(['git', '-C', checkout, 'remote', 'add', 'origin', url], env)
330
  run(['git', '-C', checkout, 'fetch', '--depth=1', 'origin', revision], env)
331
  run(['git', '-C', checkout, 'checkout', '--detach', revision], env)
332
+ prepare_submodules(name, revision, checkout, env)
333
  build_env = env.copy()
334
  build_env['NVCC_APPEND_FLAGS'] = O_VOXEL_FLAGS if subdir == 'o-voxel' else '--extended-lambda' if name == 'CuMesh' else ''
335
  run([*pip, 'wheel', '-v', '--no-build-isolation', '--no-deps', '--wheel-dir', wheels, checkout / subdir], build_env)
hf_space/ovoxel_results.py CHANGED
@@ -35,7 +35,8 @@ MAX_PREVIEW_BYTES = 64*1024*1024
35
  _preview_lock = threading.Lock()
36
  LOG = logging.getLogger(__name__)
37
  REFERENCE_FILES = ('camera.json', 'condition_rgba.png', 'albedo.png', 'mra.png')
38
- REFERENCE_OPTIONAL_FILES = ('condition_rgb.png',)
 
39
  REFERENCE_DIRECTORY = 'reference-input'
40
  LEGACY_REPROJECTION_WARNING = (
41
  'Reference-view reprojection is unavailable for this older result because its original '
@@ -72,6 +73,7 @@ def persist(source, report):
72
  reference = source.parent/'asset_work'/'input'
73
  reference_saved = False
74
  if reference.is_dir():
 
75
  _persist_reference_inputs(reference, destination)
76
  reference_saved = True
77
  views = destination/'views'
@@ -99,6 +101,39 @@ def _file_digest(path):
99
  return digest.hexdigest()
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  def _validate_reference_inputs(folder, bindings=None):
103
  """Validate the exact observed maps and camera; never substitute raw RGB."""
104
  from PIL import Image
@@ -130,6 +165,7 @@ def _validate_reference_inputs(folder, bindings=None):
130
  with Image.open(folder/'condition_rgb.png') as rgb, Image.open(folder/'condition_rgba.png') as rgba:
131
  if rgb.size != rgba.size:
132
  raise ValueError('The saved reference RGB and RGBA dimensions differ')
 
133
  actual = {name: _file_digest(folder/name) for name in names}
134
  if bindings is not None and actual != bindings:
135
  raise ValueError('The saved reference inputs changed or failed integrity validation')
@@ -409,11 +445,23 @@ def _extract(identifier, token, *, stage, stages, prepare_download=None):
409
  local_output = work/'asset.glb'
410
  # Export-time padding precedes native file writing; continuous observed
411
  # gutters follow reprojection. Keep these two real boundaries separate.
412
- with observe_stages(lambda name: stage('export_gutter' if name == 'gutter' else name)):
413
- _export_local(source, local_output, gutter=reference is None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
414
  if reference is not None:
415
- from supratex.inference.reprojection import run_front_view
416
- local_output = run_front_view(local_output, reference, work/'reproject', stage=stage)
417
  postprocess = {'reprojection':'applied', 'gutter':'observation_guided',
418
  'postprocess_warning':None}
419
  else:
 
35
  _preview_lock = threading.Lock()
36
  LOG = logging.getLogger(__name__)
37
  REFERENCE_FILES = ('camera.json', 'condition_rgba.png', 'albedo.png', 'mra.png')
38
+ OBSERVATION_FILES = ('observation_keep.npy', 'observation_admission.json')
39
+ REFERENCE_OPTIONAL_FILES = ('condition_rgb.png', *OBSERVATION_FILES)
40
  REFERENCE_DIRECTORY = 'reference-input'
41
  LEGACY_REPROJECTION_WARNING = (
42
  'Reference-view reprojection is unavailable for this older result because its original '
 
73
  reference = source.parent/'asset_work'/'input'
74
  reference_saved = False
75
  if reference.is_dir():
76
+ _stage_observation_filter(source, reference)
77
  _persist_reference_inputs(reference, destination)
78
  reference_saved = True
79
  views = destination/'views'
 
101
  return digest.hexdigest()
102
 
103
 
104
+ def _observation_keep(folder):
105
+ """Reuse the exact admission mask from completion, including after a restart."""
106
+ import numpy as np
107
+ paths = [Path(folder)/name for name in OBSERVATION_FILES]
108
+ present = [path.is_file() for path in paths]
109
+ if not any(present):
110
+ return None
111
+ if not all(present) or any(path.is_symlink() for path in paths):
112
+ raise ValueError('The saved observation filter is incomplete')
113
+ if paths[0].stat().st_size > 2*1024*1024 or paths[1].stat().st_size > 64*1024:
114
+ raise ValueError('The saved observation filter is invalid')
115
+ keep = np.load(paths[0], allow_pickle=False)
116
+ if not isinstance(keep, np.ndarray) or keep.dtype != np.bool_ or keep.shape != (1024, 1024):
117
+ raise ValueError('The saved observation filter mask is invalid')
118
+ admission = json.loads(paths[1].read_text(encoding='utf-8'))
119
+ if not isinstance(admission, dict):
120
+ raise ValueError('The saved observation filter metadata is invalid')
121
+ return keep
122
+
123
+
124
+ def _stage_observation_filter(source, reference):
125
+ """Bind completion sidecars to the observations used by deferred GLB export."""
126
+ sidecars = [source.with_suffix('.'+name) for name in OBSERVATION_FILES]
127
+ present = [path.is_file() for path in sidecars]
128
+ if not any(present) and not (reference/'image_depth.npy').is_file():
129
+ return # Pre-upgrade completions have no image-depth gate.
130
+ if not all(present) or any(path.is_symlink() for path in sidecars):
131
+ raise ValueError('Completion did not save its observation filter')
132
+ for source_path, name in zip(sidecars, OBSERVATION_FILES):
133
+ shutil.copyfile(source_path, reference/name)
134
+ _observation_keep(reference)
135
+
136
+
137
  def _validate_reference_inputs(folder, bindings=None):
138
  """Validate the exact observed maps and camera; never substitute raw RGB."""
139
  from PIL import Image
 
165
  with Image.open(folder/'condition_rgb.png') as rgb, Image.open(folder/'condition_rgba.png') as rgba:
166
  if rgb.size != rgba.size:
167
  raise ValueError('The saved reference RGB and RGBA dimensions differ')
168
+ _observation_keep(folder)
169
  actual = {name: _file_digest(folder/name) for name in names}
170
  if bindings is not None and actual != bindings:
171
  raise ValueError('The saved reference inputs changed or failed integrity validation')
 
445
  local_output = work/'asset.glb'
446
  # Export-time padding precedes native file writing; continuous observed
447
  # gutters follow reprojection. Keep these two real boundaries separate.
448
+ from hf_space.gpu_mode import enabled as zero_gpu_enabled
449
+ if zero_gpu_enabled():
450
+ from hf_space.zerogpu_runtime import export
451
+ offset = time.monotonic() - started
452
+ result, gpu_stages = export(str(source), str(local_output),
453
+ str(reference) if reference is not None else None, str(work/'reproject'), token)
454
+ local_output = Path(result)
455
+ stages.extend(dict(event, start_seconds=round(offset+event['start_seconds'], 3))
456
+ for event in gpu_stages)
457
+ else:
458
+ with observe_stages(lambda name: stage('export_gutter' if name == 'gutter' else name)):
459
+ _export_local(source, local_output, gutter=reference is None)
460
+ if reference is not None:
461
+ from supratex.inference.reprojection import run_front_view
462
+ local_output = run_front_view(local_output, reference, work/'reproject', stage=stage,
463
+ keep=_observation_keep(reference))
464
  if reference is not None:
 
 
465
  postprocess = {'reprojection':'applied', 'gutter':'observation_guided',
466
  'postprocess_warning':None}
467
  else:
hf_space/preview_recovery.py CHANGED
@@ -8,6 +8,17 @@ import time
8
 
9
 
10
  def recover(identifier):
 
 
 
 
 
 
 
 
 
 
 
11
  import numpy as np
12
  import torch
13
  from hf_space import ovoxel_results
@@ -18,7 +29,7 @@ def recover(identifier):
18
  target = ovoxel_results.directory(identifier)
19
  source = Path('/data/generated/ovoxel-recovery-v1')/identifier
20
  if (target/'ready.json').is_file():
21
- return ovoxel_results.preview_html(identifier), identifier
22
  report = json.loads((source/'report.json').read_text())
23
  backup = json.loads((source/'backup.json').read_text())
24
  if report.get('weight_origin')!='released' or report.get('test_only') is not False:
@@ -66,4 +77,4 @@ def recover(identifier):
66
  shutil.copyfile(source/'timings.jsonl',target/'original-failed-timings.jsonl')
67
  (target/'ready.json').write_text(json.dumps({'complete':True,'identifier':identifier,
68
  'project_revision':report['project_revision'],'output_kind':'final_ovoxel','recovered_preview':True}))
69
- return ovoxel_results.preview_html(identifier),identifier
 
8
 
9
 
10
  def recover(identifier):
11
+ from hf_space.gpu_mode import enabled
12
+ if enabled():
13
+ from hf_space.zerogpu_runtime import recover as gpu_recover
14
+ from hf_space import ovoxel_results
15
+ if not (ovoxel_results.directory(identifier)/'ready.json').is_file():
16
+ gpu_recover(identifier)
17
+ return ovoxel_results.preview_html(identifier), identifier
18
+ return _recover(identifier)
19
+
20
+
21
+ def _recover(identifier, *, render_html=True):
22
  import numpy as np
23
  import torch
24
  from hf_space import ovoxel_results
 
29
  target = ovoxel_results.directory(identifier)
30
  source = Path('/data/generated/ovoxel-recovery-v1')/identifier
31
  if (target/'ready.json').is_file():
32
+ return (ovoxel_results.preview_html(identifier) if render_html else None), identifier
33
  report = json.loads((source/'report.json').read_text())
34
  backup = json.loads((source/'backup.json').read_text())
35
  if report.get('weight_origin')!='released' or report.get('test_only') is not False:
 
77
  shutil.copyfile(source/'timings.jsonl',target/'original-failed-timings.jsonl')
78
  (target/'ready.json').write_text(json.dumps({'complete':True,'identifier':identifier,
79
  'project_revision':report['project_revision'],'output_kind':'final_ovoxel','recovered_preview':True}))
80
+ return (ovoxel_results.preview_html(identifier) if render_html else None),identifier
hf_space/release_backend.py CHANGED
@@ -1,5 +1,6 @@
1
  """Prepare the pinned release and one reusable model worker on every startup."""
2
  from pathlib import Path
 
3
  import atexit
4
  import copy
5
  import json
@@ -12,16 +13,20 @@ import traceback
12
  import uuid
13
 
14
  from hf_space import native_build, generation_progress
 
15
 
16
  ROOT=Path(__file__).resolve().parents[1]
17
  JOBS_ROOT=Path('/tmp/supratex-jobs')
18
  MODEL_REVISION='1bc02eb969a8ed0f1418c2bbe1e409a8481f834c'
19
  CODE_REVISION='e4dfbbde6a8d76e25deef5aba5d836f03994967d'
 
20
  CHECKPOINT='denoiser_step0030000.safetensors'
21
  MODEL_ROOT=Path('/data/models')
22
  STATE=Path('/tmp/supratex-release-preparation.json')
23
  _lock=threading.Lock()
24
  _worker=None
 
 
25
  _epoch=os.environ.get('SUPRATEX_STARTUP_EPOCH') or uuid.uuid4().hex
26
  # Generated binding identities include resolved cache paths. Never reuse a
27
  # previous process's prepare marker after a cache/transport upgrade.
@@ -33,6 +38,7 @@ _generation_lock=threading.Lock()
33
  _state_lock=threading.Lock()
34
  _persist_lock=threading.Lock()
35
  _snapshot={'stage':'pending','epoch':_epoch}
 
36
  _staging_process=None
37
  _runtime_monitor=None
38
  _shutdown=threading.Event()
@@ -52,6 +58,80 @@ def model_status():
52
  with _state_lock:
53
  return copy.deepcopy(_snapshot)
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  def update(**changes):
56
  global _snapshot
57
  with _persist_lock:
@@ -87,10 +167,10 @@ def _stage_models():
87
  # longer rehashes or copies large checkpoints through Bucket FUSE.
88
  sources.extend(download(operation,MODEL_ROOT,inventory_only=True))
89
  from hf_space.weight_cache import mirror_models,mirror_project,register_sources
 
90
  update(stage='staging_local_weights',startup_progress={
91
- 'label':'Preparing local weight files','completed':0,'total':0})
92
  project_root=MODEL_ROOT/'project'/revision
93
- total=sum(record['size'] for record in sources)
94
  copied={}
95
  def file_progress(source,completed,size):
96
  copied[str(source)]=completed
@@ -115,7 +195,7 @@ def _stage_models():
115
  project_revision=revision,code_revision=CODE_REVISION,weight_origin='released',
116
  actual_training_steps=30000,random_weights_present=False,
117
  checkpoint=CHECKPOINT,model_loading='startup_pool_with_stage_offload',
118
- startup_progress={'label':'Initializing model runtime','completed':0,'total':0})
119
  return config
120
  except BaseException:
121
  update(stage='failed',error=traceback.format_exc())
@@ -191,7 +271,8 @@ def prepare_models():
191
  from hf_space.startup_guard import StartupCleanupError
192
  _startup_deadline=time.monotonic()+STARTUP_TOTAL_SECONDS
193
  try:
194
- update(stage='waiting_native_build',startup_progress={'label':'Preparing runtime','completed':0,'total':0})
 
195
  native_build.start()
196
  deadline=time.monotonic()+_remaining(NATIVE_WAIT_SECONDS)
197
  while native_build.status().get('stage') not in ('compiled','failed'):
@@ -204,10 +285,11 @@ def prepare_models():
204
  try:
205
  _remaining(STARTUP_TOTAL_SECONDS)
206
  update(stage='staging_local_weights',attempt=attempt,attempts=STARTUP_ATTEMPTS,
207
- error=None,startup_progress={'label':'Preparing local weight files','completed':0,'total':0})
 
208
  config=_stage_attempt(attempt)
209
  update(stage='loading_models',startup_progress={
210
- 'label':'Initializing model runtime','completed':0,'total':0})
211
  launch_runtime(config)
212
  update(stage='ready_released',error=None)
213
  _start_runtime_monitor()
@@ -220,11 +302,11 @@ def prepare_models():
220
  if attempt==STARTUP_ATTEMPTS:
221
  raise
222
  update(stage='retrying',error=error,startup_progress={
223
- 'label':'Retrying model startup','completed':0,'total':0})
224
  time.sleep(min(STARTUP_BACKOFF_SECONDS*attempt,_remaining(STARTUP_TOTAL_SECONDS)))
225
  except BaseException:
226
  update(stage='failed',error=traceback.format_exc()[-8000:],startup_progress={
227
- 'label':'Model startup failed. Please try again later.','completed':0,'total':0})
228
  traceback.print_exc()
229
 
230
  def start_preparation():
@@ -239,6 +321,10 @@ def start_preparation():
239
 
240
  def stop_runtime():
241
  global _runtime_process
 
 
 
 
242
  process=_runtime_process
243
  if process is not None:
244
  from hf_space.startup_guard import stop_process
@@ -260,6 +346,17 @@ atexit.register(stop_preparation)
260
 
261
  def launch_runtime(config):
262
  global _runtime_process
 
 
 
 
 
 
 
 
 
 
 
263
  from hf_space.startup_guard import process_options
264
  if _runtime_process is not None:
265
  stop_runtime()
@@ -302,6 +399,9 @@ def launch_runtime(config):
302
 
303
  def _start_runtime_monitor():
304
  global _runtime_monitor
 
 
 
305
  process=_runtime_process
306
  def monitor():
307
  last=None
@@ -335,14 +435,27 @@ def ensure_prepared(generation_token=None):
335
 
336
 
337
  def run_resident(job, seed, timeout_seconds, generation_token, *, operation='generate', foreground_prepared=False):
338
- with _generation_lock:
339
- if model_status().get('stage')!='ready_released':
 
 
 
 
 
 
340
  raise RuntimeError('Background model loading is incomplete')
 
 
 
 
 
 
 
 
 
341
  process=_runtime_process
342
  if process is None or process.poll() is not None:
343
  raise RuntimeError('The preloaded model worker is unavailable')
344
- if operation not in ('generate','remove_background') or type(foreground_prepared) is not bool:
345
- raise ValueError('Invalid internal model operation')
346
  request={'epoch':_epoch,'job':str(job),'seed':int(seed),
347
  'operation':operation,'foreground_prepared':foreground_prepared}
348
  process.stdin.write(json.dumps(request)+'\n')
@@ -365,7 +478,7 @@ def run_resident(job, seed, timeout_seconds, generation_token, *, operation='gen
365
  generation_progress.update(generation_token,label={
366
  'frontend':'Generating geometry and reference materials',
367
  'completion':'Completing surface materials and rendering o-voxel previews',
368
- }.get(stage,'Running the latest single-view pipeline'))
369
  time.sleep(.5)
370
  response=json.loads(response_path.read_text())
371
  if response.get('epoch')!=_epoch:
@@ -380,7 +493,7 @@ def run_resident(job, seed, timeout_seconds, generation_token, *, operation='gen
380
 
381
  def remove_background(image,timeout_seconds=120):
382
  """Run the queued cutout in the one startup-loaded, serial model worker."""
383
- if model_status().get('stage')!='ready_released':
384
  raise RuntimeError('Model files are not ready')
385
  job=JOBS_ROOT/uuid.uuid4().hex
386
  job.mkdir(parents=True)
@@ -415,8 +528,8 @@ def generate(image,seed,progress=None,timeout_seconds=1800,generation_token=None
415
  output=job/'asset.npz'
416
  started=time.monotonic()
417
  generation_progress.update(generation_token,status='running',job=identifier,
418
- generation_started_unix=time.time(),label='Running the latest single-view pipeline')
419
- if progress:progress(.05,'Running the latest single-view pipeline')
420
  resident_models=run_resident(job,seed,timeout_seconds,generation_token,foreground_prepared=foreground_prepared)
421
  receipt_path=job/'asset_work/run.json'
422
  receipt=json.loads(receipt_path.read_text()) if receipt_path.exists() else {}
@@ -438,7 +551,8 @@ def generate(image,seed,progress=None,timeout_seconds=1800,generation_token=None
438
  report={'status':'complete','asset_id':identifier,'code_revision':CODE_REVISION,
439
  'project_revision':MODEL_REVISION,'checkpoint':CHECKPOINT,'weight_origin':'released',
440
  'elapsed_seconds':time.monotonic()-started,'output_kind':'final_ovoxel','backview':False,
441
- 'refine':False,'model_loading':'startup_pool_with_stage_offload','resident_models':resident_models,'run':receipt,
 
442
  'preview':preview,'voxel_count':voxel_count,'automatic_glb_export':False,
443
  'foreground_prepared':foreground_prepared}
444
  (job/'report.json').write_text(json.dumps(report,indent=2))
@@ -448,6 +562,90 @@ def generate(image,seed,progress=None,timeout_seconds=1800,generation_token=None
448
  return str(output),str(output),report
449
 
450
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
451
  if __name__=='__main__':
452
  if len(sys.argv)!=3 or sys.argv[1]!='--stage':
453
  raise SystemExit('This module is an internal model preparation worker')
 
1
  """Prepare the pinned release and one reusable model worker on every startup."""
2
  from pathlib import Path
3
+ from contextlib import nullcontext
4
  import atexit
5
  import copy
6
  import json
 
13
  import uuid
14
 
15
  from hf_space import native_build, generation_progress
16
+ from hf_space.gpu_mode import enabled as zero_gpu_enabled
17
 
18
  ROOT=Path(__file__).resolve().parents[1]
19
  JOBS_ROOT=Path('/tmp/supratex-jobs')
20
  MODEL_REVISION='1bc02eb969a8ed0f1418c2bbe1e409a8481f834c'
21
  CODE_REVISION='e4dfbbde6a8d76e25deef5aba5d836f03994967d'
22
+ TEXTURE_FILTER_REVISION='cee4435652d0f23737bd28d5704e0fae9830b411'
23
  CHECKPOINT='denoiser_step0030000.safetensors'
24
  MODEL_ROOT=Path('/data/models')
25
  STATE=Path('/tmp/supratex-release-preparation.json')
26
  _lock=threading.Lock()
27
  _worker=None
28
+ _foreground_worker=None
29
+ _foreground_started=False
30
  _epoch=os.environ.get('SUPRATEX_STARTUP_EPOCH') or uuid.uuid4().hex
31
  # Generated binding identities include resolved cache paths. Never reuse a
32
  # previous process's prepare marker after a cache/transport upgrade.
 
38
  _state_lock=threading.Lock()
39
  _persist_lock=threading.Lock()
40
  _snapshot={'stage':'pending','epoch':_epoch}
41
+ _foreground_snapshot={'stage':'pending','epoch':_epoch}
42
  _staging_process=None
43
  _runtime_monitor=None
44
  _shutdown=threading.Event()
 
58
  with _state_lock:
59
  return copy.deepcopy(_snapshot)
60
 
61
+
62
+ def foreground_status():
63
+ if not zero_gpu_enabled():
64
+ return model_status()
65
+ with _state_lock:
66
+ return copy.deepcopy(_foreground_snapshot)
67
+
68
+
69
+ def _update_foreground(**changes):
70
+ global _foreground_snapshot
71
+ with _state_lock:
72
+ _foreground_snapshot = _foreground_snapshot | changes | {'updated_unix': time.time()}
73
+ print('[foreground-prepare] ' + json.dumps(changes), flush=True)
74
+
75
+
76
+ def _stage_foreground_models():
77
+ """Verify and mirror only the pinned RMBG group, sharing existing blobs."""
78
+ from hf_space.download_models import source_inventory
79
+ from hf_space.weight_cache import register_sources, _mirror
80
+ spec = json.loads((ROOT/'configs/pipeline.json').read_text())['external_models']['rmbg2']
81
+ operation = dict(spec, kind='hub', name='rmbg2', destination='external/rmbg2')
82
+ sources = source_inventory(operation, MODEL_ROOT)
83
+ total = sum(record['size'] for record in sources)
84
+ _update_foreground(startup_progress={'label':'Preparing background-removal model',
85
+ 'completed':0, 'total':total, 'unit':'bytes'})
86
+ copied = {}
87
+ def progress(source, completed, size):
88
+ copied[str(source)] = completed
89
+ _update_foreground(startup_progress={'label':'Preparing background-removal model',
90
+ 'completed':sum(copied.values()), 'total':total, 'unit':'bytes'})
91
+ def retry(source, attempt, attempts):
92
+ _update_foreground(startup_progress={'label':f'Reconnecting to model storage (attempt {attempt} of {attempts})',
93
+ 'completed':sum(copied.values()), 'total':total, 'unit':'bytes'})
94
+ register_sources(sources, retry=retry)
95
+ destination = Path('/tmp/supratex-foreground-models-v1')
96
+ _mirror(MODEL_ROOT/'external/rmbg2', destination, progress)
97
+ return destination
98
+
99
+
100
+ def prepare_foreground_models():
101
+ """CPU-only, independent of native compilation and the 18-model pipeline."""
102
+ try:
103
+ _update_foreground(stage='staging_local_weights', error=None, startup_progress={
104
+ 'label':'Checking background-removal model files', 'completed':0, 'total':1, 'unit':'steps'})
105
+ model_path = _stage_foreground_models()
106
+ _update_foreground(stage='loading_models', startup_progress={
107
+ 'label':'Loading background-removal model', 'completed':0, 'total':1, 'unit':'models'})
108
+ from hf_space import zerogpu_runtime
109
+ def notify(label, completed, total):
110
+ _update_foreground(startup_progress={'label':label, 'completed':completed,
111
+ 'total':total, 'unit':'models'})
112
+ summary = zerogpu_runtime.preload_foreground(model_path, notify)
113
+ _update_foreground(stage='ready_released', error=None, resident_models=summary, startup_progress={
114
+ 'label':'Background-removal model ready', 'completed':1, 'total':1, 'unit':'models'})
115
+ except BaseException:
116
+ _update_foreground(stage='failed', error=traceback.format_exc()[-8000:], startup_progress={
117
+ 'label':'Background-removal model preparation failed. Please try again later.',
118
+ 'completed':0, 'total':1, 'unit':'steps'})
119
+ traceback.print_exc()
120
+
121
+
122
+ def start_foreground_preparation():
123
+ global _foreground_worker, _foreground_started
124
+ if not zero_gpu_enabled():
125
+ return start_preparation()
126
+ with _lock:
127
+ if not _foreground_started:
128
+ _foreground_started = True
129
+ _foreground_worker = threading.Thread(target=prepare_foreground_models, daemon=True,
130
+ name='foreground-model-prepare')
131
+ _foreground_worker.start()
132
+ return _foreground_worker
133
+
134
+
135
  def update(**changes):
136
  global _snapshot
137
  with _persist_lock:
 
167
  # longer rehashes or copies large checkpoints through Bucket FUSE.
168
  sources.extend(download(operation,MODEL_ROOT,inventory_only=True))
169
  from hf_space.weight_cache import mirror_models,mirror_project,register_sources
170
+ total=sum(record['size'] for record in sources)
171
  update(stage='staging_local_weights',startup_progress={
172
+ 'label':'Preparing local weight files','completed':0,'total':total,'unit':'bytes'})
173
  project_root=MODEL_ROOT/'project'/revision
 
174
  copied={}
175
  def file_progress(source,completed,size):
176
  copied[str(source)]=completed
 
195
  project_revision=revision,code_revision=CODE_REVISION,weight_origin='released',
196
  actual_training_steps=30000,random_weights_present=False,
197
  checkpoint=CHECKPOINT,model_loading='startup_pool_with_stage_offload',
198
+ startup_progress={'label':'Initializing model runtime','completed':0,'total':1,'unit':'steps'})
199
  return config
200
  except BaseException:
201
  update(stage='failed',error=traceback.format_exc())
 
271
  from hf_space.startup_guard import StartupCleanupError
272
  _startup_deadline=time.monotonic()+STARTUP_TOTAL_SECONDS
273
  try:
274
+ update(stage='waiting_native_build',startup_progress={
275
+ 'label':'Preparing runtime','completed':0,'total':1,'unit':'steps'})
276
  native_build.start()
277
  deadline=time.monotonic()+_remaining(NATIVE_WAIT_SECONDS)
278
  while native_build.status().get('stage') not in ('compiled','failed'):
 
285
  try:
286
  _remaining(STARTUP_TOTAL_SECONDS)
287
  update(stage='staging_local_weights',attempt=attempt,attempts=STARTUP_ATTEMPTS,
288
+ error=None,startup_progress={
289
+ 'label':'Checking model files','completed':0,'total':1,'unit':'steps'})
290
  config=_stage_attempt(attempt)
291
  update(stage='loading_models',startup_progress={
292
+ 'label':'Initializing model runtime','completed':0,'total':1,'unit':'steps'})
293
  launch_runtime(config)
294
  update(stage='ready_released',error=None)
295
  _start_runtime_monitor()
 
302
  if attempt==STARTUP_ATTEMPTS:
303
  raise
304
  update(stage='retrying',error=error,startup_progress={
305
+ 'label':'Retrying model startup','completed':0,'total':1,'unit':'steps'})
306
  time.sleep(min(STARTUP_BACKOFF_SECONDS*attempt,_remaining(STARTUP_TOTAL_SECONDS)))
307
  except BaseException:
308
  update(stage='failed',error=traceback.format_exc()[-8000:],startup_progress={
309
+ 'label':'Model startup failed. Please try again later.','completed':0,'total':1,'unit':'steps'})
310
  traceback.print_exc()
311
 
312
  def start_preparation():
 
321
 
322
  def stop_runtime():
323
  global _runtime_process
324
+ if zero_gpu_enabled():
325
+ runtime = sys.modules.get('hf_space.zerogpu_runtime')
326
+ if runtime is not None:
327
+ runtime.clear()
328
  process=_runtime_process
329
  if process is not None:
330
  from hf_space.startup_guard import stop_process
 
346
 
347
  def launch_runtime(config):
348
  global _runtime_process
349
+ if zero_gpu_enabled():
350
+ from hf_space import zerogpu_runtime
351
+ def notify(label, completed, total):
352
+ update(startup_progress={'label': label, 'completed': completed,
353
+ 'total': total, 'unit': 'models'})
354
+ summary = zerogpu_runtime.preload(config, notify)
355
+ update(resident_models=summary, model_loading='cpu_pool_with_per_user_zerogpu',
356
+ startup_progress={'label': 'Models ready',
357
+ 'completed': summary['loaded_models'],
358
+ 'total': summary['loaded_models'], 'unit': 'models'})
359
+ return
360
  from hf_space.startup_guard import process_options
361
  if _runtime_process is not None:
362
  stop_runtime()
 
399
 
400
  def _start_runtime_monitor():
401
  global _runtime_monitor
402
+ if zero_gpu_enabled():
403
+ # There is deliberately no resident CUDA subprocess to monitor.
404
+ return
405
  process=_runtime_process
406
  def monitor():
407
  last=None
 
435
 
436
 
437
  def run_resident(job, seed, timeout_seconds, generation_token, *, operation='generate', foreground_prepared=False):
438
+ zero_gpu = zero_gpu_enabled()
439
+ # Each ZeroGPU lease owns an isolated fork. Only the dedicated runtime's
440
+ # single stdin/result protocol needs a process-wide execution lock.
441
+ with nullcontext() if zero_gpu else _generation_lock:
442
+ if operation not in ('generate','remove_background','generate_geometry','generate_texture') or type(foreground_prepared) is not bool:
443
+ raise ValueError('Invalid internal model operation')
444
+ state = foreground_status() if zero_gpu and operation == 'remove_background' else model_status()
445
+ if state.get('stage')!='ready_released':
446
  raise RuntimeError('Background model loading is incomplete')
447
+ if zero_gpu:
448
+ from hf_space import zerogpu_runtime
449
+ if operation == 'remove_background':
450
+ return zerogpu_runtime.foreground(str(job))
451
+ if operation == 'generate_geometry':
452
+ return zerogpu_runtime.geometry(str(job), int(seed), foreground_prepared)
453
+ if operation == 'generate_texture':
454
+ return zerogpu_runtime.texture(str(job), int(seed))
455
+ return zerogpu_runtime.generate(str(job), int(seed), foreground_prepared)
456
  process=_runtime_process
457
  if process is None or process.poll() is not None:
458
  raise RuntimeError('The preloaded model worker is unavailable')
 
 
459
  request={'epoch':_epoch,'job':str(job),'seed':int(seed),
460
  'operation':operation,'foreground_prepared':foreground_prepared}
461
  process.stdin.write(json.dumps(request)+'\n')
 
478
  generation_progress.update(generation_token,label={
479
  'frontend':'Generating geometry and reference materials',
480
  'completion':'Completing surface materials and rendering o-voxel previews',
481
+ }.get(stage,'Starting generation…'))
482
  time.sleep(.5)
483
  response=json.loads(response_path.read_text())
484
  if response.get('epoch')!=_epoch:
 
493
 
494
  def remove_background(image,timeout_seconds=120):
495
  """Run the queued cutout in the one startup-loaded, serial model worker."""
496
+ if foreground_status().get('stage')!='ready_released':
497
  raise RuntimeError('Model files are not ready')
498
  job=JOBS_ROOT/uuid.uuid4().hex
499
  job.mkdir(parents=True)
 
528
  output=job/'asset.npz'
529
  started=time.monotonic()
530
  generation_progress.update(generation_token,status='running',job=identifier,
531
+ generation_started_unix=time.time(),label='Starting generation…')
532
+ if progress:progress(.05,'Starting generation…')
533
  resident_models=run_resident(job,seed,timeout_seconds,generation_token,foreground_prepared=foreground_prepared)
534
  receipt_path=job/'asset_work/run.json'
535
  receipt=json.loads(receipt_path.read_text()) if receipt_path.exists() else {}
 
551
  report={'status':'complete','asset_id':identifier,'code_revision':CODE_REVISION,
552
  'project_revision':MODEL_REVISION,'checkpoint':CHECKPOINT,'weight_origin':'released',
553
  'elapsed_seconds':time.monotonic()-started,'output_kind':'final_ovoxel','backview':False,
554
+ 'refine':False,'model_loading':('cpu_pool_with_per_user_zerogpu' if zero_gpu_enabled()
555
+ else 'startup_pool_with_stage_offload'),'resident_models':resident_models,'run':receipt,
556
  'preview':preview,'voxel_count':voxel_count,'automatic_glb_export':False,
557
  'foreground_prepared':foreground_prepared}
558
  (job/'report.json').write_text(json.dumps(report,indent=2))
 
562
  return str(output),str(output),report
563
 
564
 
565
+ def _split_job(generation_token, label):
566
+ if model_status().get('stage') != 'ready_released':
567
+ raise RuntimeError('Model files are not ready')
568
+ job = JOBS_ROOT / uuid.uuid4().hex
569
+ job.mkdir(parents=True)
570
+ generation_progress.update(generation_token, status='running', job=job.name,
571
+ generation_started_unix=time.time(), label=label)
572
+ return job
573
+
574
+
575
+ def _split_result(job, started, resident_models, *, geometry=False):
576
+ import numpy as np
577
+ result = json.loads((job / 'stage-result.json').read_text(encoding='utf-8'))
578
+ expected = 'geometry' if geometry else 'final_ovoxel'
579
+ if result.get('status') != 'complete' or result.get('output_kind') != expected:
580
+ raise RuntimeError('The generation stage did not complete')
581
+ output = job / ('geometry.npz' if geometry else 'asset.npz')
582
+ with np.load(output, allow_pickle=False) as asset:
583
+ required = {'vertices', 'faces'} if geometry else {'vertices','faces','coords','attrs','resolution','origin','voxel_size'}
584
+ if not required <= set(asset.files) or not len(asset['vertices']) or not len(asset['faces']):
585
+ raise RuntimeError('The generated geometry is incomplete')
586
+ if not geometry and (not len(asset['coords']) or asset['attrs'].shape[1] != 6
587
+ or not np.isfinite(asset['attrs']).all()):
588
+ raise RuntimeError('The generated material is incomplete')
589
+ face_count = len(asset['faces'])
590
+ voxel_count = None if geometry else len(asset['coords'])
591
+ if geometry:
592
+ from hf_space.split_pipeline import CHECKPOINT_DIRECTORY, validate_checkpoint
593
+ validate_checkpoint(job / CHECKPOINT_DIRECTORY)
594
+ preview = json.loads((job / 'preview.json').read_text(encoding='utf-8'))
595
+ expected_frames = {f'{mode}-{index:02d}.webp' for mode in preview['modes']
596
+ for index in range(preview['views'])}
597
+ frames = list((job / 'views').glob('*.webp'))
598
+ if not expected_frames or {path.name for path in frames} != expected_frames or any(not p.stat().st_size for p in frames):
599
+ raise RuntimeError('The preview render set is incomplete')
600
+ if geometry and (preview['modes'] != ['clay'] or preview.get('default_mode') != 'clay'):
601
+ raise RuntimeError('The geometry preview must contain clay views only')
602
+ if list(job.rglob('*.glb')) or preview.get('automatic_glb_export') is not False:
603
+ raise RuntimeError('Generation must not export a mesh before selection')
604
+ report = {'status':'complete', 'asset_id':job.name, 'code_revision':CODE_REVISION,
605
+ 'project_revision':MODEL_REVISION, 'checkpoint':CHECKPOINT, 'weight_origin':'released',
606
+ 'elapsed_seconds':time.monotonic()-started, 'output_kind':expected,
607
+ 'backview':False, 'refine':False, 'automatic_glb_export':False,
608
+ 'model_loading':('cpu_pool_with_per_user_zerogpu' if zero_gpu_enabled()
609
+ else 'startup_pool_with_stage_offload'),
610
+ 'resident_models':resident_models, 'run':result, 'preview':preview,
611
+ 'face_count':face_count, 'geometry_reused':not geometry}
612
+ if voxel_count is not None:
613
+ report['voxel_count'] = voxel_count
614
+ admission = output.with_suffix('.observation_admission.json')
615
+ report['depth_pre_filter'] = {
616
+ 'code_revision': TEXTURE_FILTER_REVISION,
617
+ 'admission': json.loads(admission.read_text(encoding='utf-8')) if admission.is_file() else None,
618
+ }
619
+ (job / 'report.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
620
+ if not (job / 'timings.jsonl').exists():
621
+ (job / 'timings.jsonl').write_text('', encoding='utf-8')
622
+ return str(output), str(output), report
623
+
624
+
625
+ def generate_geometry(image, seed, generation_token=None, *, foreground_prepared=False, timeout_seconds=1800):
626
+ if type(foreground_prepared) is not bool:
627
+ raise ValueError('Prepared foreground must be an internal Boolean')
628
+ started = time.monotonic()
629
+ job = _split_job(generation_token, 'Generating geometry')
630
+ image.save(job / 'input.png')
631
+ summary = run_resident(job, int(seed), timeout_seconds, generation_token,
632
+ operation='generate_geometry', foreground_prepared=foreground_prepared)
633
+ return _split_result(job, started, summary, geometry=True)
634
+
635
+
636
+ def generate_texture(geometry_folder, seed, generation_token=None, *, timeout_seconds=1800):
637
+ import shutil
638
+ from hf_space.split_pipeline import CHECKPOINT_DIRECTORY, copy_checkpoint
639
+ geometry_folder = Path(geometry_folder)
640
+ started = time.monotonic()
641
+ job = _split_job(generation_token, 'Generating materials')
642
+ # Copy from the verified durable checkpoint before acquiring a GPU lease.
643
+ copy_checkpoint(geometry_folder / CHECKPOINT_DIRECTORY, job / CHECKPOINT_DIRECTORY, seed=seed)
644
+ shutil.copyfile(geometry_folder / 'input.png', job / 'input.png')
645
+ summary = run_resident(job, int(seed), timeout_seconds, generation_token, operation='generate_texture')
646
+ return _split_result(job, started, summary)
647
+
648
+
649
  if __name__=='__main__':
650
  if len(sys.argv)!=3 or sys.argv[1]!='--stage':
651
  raise SystemExit('This module is an internal model preparation worker')
hf_space/resident_pipeline.py CHANGED
@@ -12,6 +12,32 @@ from hf_space.model_pool import ModelPool
12
  ROOT = Path(__file__).resolve().parents[1]
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  class ResidentPipeline:
16
  def __init__(self, config):
17
  self.config_path = str(config)
@@ -23,29 +49,31 @@ class ResidentPipeline:
23
  self.material_step = None
24
  self.material_processor = None
25
 
26
- def prepare(self, notify):
27
  import torch
28
  from scripts.run_example import read_config
29
  from supratex.inference.runtime import configure_inference_runtime
30
  # Startup constructors run on host tensors; no model forward is called.
31
  config = read_config(Path(self.config))
32
  self.config = config
33
- os.environ.update(TORCH_HOME=config['torch_home'], HF_HUB_OFFLINE='1',
34
- TRANSFORMERS_OFFLINE='1', TOKENIZERS_PARALLELISM='false',
35
  ATTN_BACKEND='sdpa', SPARSE_ATTN_BACKEND='sdpa',
36
  SUPRATEX_RUNTIME_PROFILE='torch28', SUPRATEX_LOW_VRAM='1')
37
- # The Space has eight CPU cores. Bounded constructor threads leave
38
- # headroom for uploads and avoid oversubscribing the container quota.
39
- torch.set_num_threads(4)
40
- torch.set_num_interop_threads(1)
41
- if not torch.cuda.is_available():
 
 
42
  raise RuntimeError('The GPU is unavailable; startup models cannot become ready')
43
  for path in (ROOT, ROOT/'deployment/runtime/moge', ROOT/'deployment/runtime/utils3d',
44
  Path(config['geometry_package']), Path(config['qwen_runtime']),
45
  Path(config['qwen_deps']) if config.get('qwen_deps') else None):
46
  if path is not None and str(path) not in sys.path:
47
  sys.path.insert(0, str(path))
48
- configure_inference_runtime(attention='sdpa', low_vram=True, device='cuda',
 
49
  material_backend='qwen-image')
50
  total = 18
51
  done = 0
@@ -129,10 +157,11 @@ class ResidentPipeline:
129
  build_completion_model(c, load_completion_state_dict(checkpoint, torch_module=torch), assign_weights=True).eval())
130
  # Establish a working CUDA context before enabling Generate. Constructors
131
  # above load host weights; they do not need neural warm-up forwards.
132
- probe = torch.zeros(1, device='cuda')
133
- torch.cuda.synchronize()
134
- del probe
135
- torch.cuda.empty_cache()
 
136
  self.pool.sealed = True
137
  notify('Models ready', total, total)
138
  print('[resident-models] '+json.dumps(self.pool.summary()), flush=True)
 
12
  ROOT = Path(__file__).resolve().parents[1]
13
 
14
 
15
+ def _configure_preload_threads(torch, *, cpu_only):
16
+ # spaces 0.51.3 already sets the process-wide inter-op count at import.
17
+ # PyTorch permits that setter only once, even before the pool is consumed.
18
+ # Intra-op is adjustable and stays serial for the later ZeroGPU fork.
19
+ torch.set_num_threads(1 if cpu_only else 4)
20
+ if not cpu_only and torch.get_num_interop_threads() != 1:
21
+ torch.set_num_interop_threads(1)
22
+
23
+
24
+ class ForegroundResident:
25
+ """The standalone cutout task needs only RMBG, without native 3D modules."""
26
+ def __init__(self, model_path):
27
+ self.config = {'rmbg_model': str(model_path)}
28
+ self.pool = ModelPool(directory='/tmp/supratex-foreground-state-v1')
29
+
30
+ def prepare(self, notify):
31
+ import torch
32
+ from transformers import AutoModelForImageSegmentation
33
+ _configure_preload_threads(torch, cpu_only=True)
34
+ notify('Loading background-removal model', 0, 1)
35
+ self.pool.add('background', AutoModelForImageSegmentation.from_pretrained(
36
+ self.config['rmbg_model'], trust_remote_code=True, local_files_only=True).eval())
37
+ self.pool.sealed = True
38
+ notify('Background-removal model ready', 1, 1)
39
+
40
+
41
  class ResidentPipeline:
42
  def __init__(self, config):
43
  self.config_path = str(config)
 
49
  self.material_step = None
50
  self.material_processor = None
51
 
52
+ def prepare(self, notify, *, cpu_only=False):
53
  import torch
54
  from scripts.run_example import read_config
55
  from supratex.inference.runtime import configure_inference_runtime
56
  # Startup constructors run on host tensors; no model forward is called.
57
  config = read_config(Path(self.config))
58
  self.config = config
59
+ os.environ.update(TORCH_HOME=config['torch_home'], TOKENIZERS_PARALLELISM='false',
 
60
  ATTN_BACKEND='sdpa', SPARSE_ATTN_BACKEND='sdpa',
61
  SUPRATEX_RUNTIME_PROFILE='torch28', SUPRATEX_LOW_VRAM='1')
62
+ # The isolated dedicated-GPU worker can globally disable Hub access.
63
+ # ZeroGPU preloads in the HTTP process, which still serves OAuth and
64
+ # Bucket requests; local model paths and local_files_only are sufficient.
65
+ if not cpu_only:
66
+ os.environ.update(HF_HUB_OFFLINE='1', TRANSFORMERS_OFFLINE='1')
67
+ _configure_preload_threads(torch, cpu_only=cpu_only)
68
+ if not cpu_only and not torch.cuda.is_available():
69
  raise RuntimeError('The GPU is unavailable; startup models cannot become ready')
70
  for path in (ROOT, ROOT/'deployment/runtime/moge', ROOT/'deployment/runtime/utils3d',
71
  Path(config['geometry_package']), Path(config['qwen_runtime']),
72
  Path(config['qwen_deps']) if config.get('qwen_deps') else None):
73
  if path is not None and str(path) not in sys.path:
74
  sys.path.insert(0, str(path))
75
+ configure_inference_runtime(attention='sdpa', low_vram=True,
76
+ device='cpu' if cpu_only else 'cuda',
77
  material_backend='qwen-image')
78
  total = 18
79
  done = 0
 
157
  build_completion_model(c, load_completion_state_dict(checkpoint, torch_module=torch), assign_weights=True).eval())
158
  # Establish a working CUDA context before enabling Generate. Constructors
159
  # above load host weights; they do not need neural warm-up forwards.
160
+ if not cpu_only:
161
+ probe = torch.zeros(1, device='cuda')
162
+ torch.cuda.synchronize()
163
+ del probe
164
+ torch.cuda.empty_cache()
165
  self.pool.sealed = True
166
  notify('Models ready', total, total)
167
  print('[resident-models] '+json.dumps(self.pool.summary()), flush=True)
hf_space/runtime_worker.py CHANGED
@@ -25,7 +25,8 @@ def atomic_json(path, value):
25
  temporary.replace(path)
26
 
27
 
28
- def serve(config_path, status_path, epoch, stream, *, factory=None, run_job=None, run_foreground=None):
 
29
  """Injectable protocol boundary; production models stay in this process."""
30
  def status(stage, **fields):
31
  atomic_json(status_path, {"epoch": epoch, "stage": stage,
@@ -68,7 +69,7 @@ def serve(config_path, status_path, epoch, stream, *, factory=None, run_job=None
68
  if request.get("epoch") != epoch:
69
  raise ValueError("Job belongs to another runtime")
70
  operation = request.get('operation', 'generate')
71
- if operation not in ('generate', 'remove_background'):
72
  raise ValueError('Unknown internal model operation')
73
  foreground_prepared = request.get('foreground_prepared', False)
74
  if type(foreground_prepared) is not bool:
@@ -81,6 +82,11 @@ def serve(config_path, status_path, epoch, stream, *, factory=None, run_job=None
81
  try:
82
  if operation == 'remove_background':
83
  (run_foreground or execute_foreground)(resident, job)
 
 
 
 
 
84
  else:
85
  options = {'foreground_prepared': True} if foreground_prepared else {}
86
  (run_job or execute)(resident, job, int(request["seed"]), **options)
@@ -121,7 +127,7 @@ def execute_foreground(resident, job):
121
  atomic_json(job / 'foreground.json', metadata)
122
 
123
 
124
- def execute(resident, job, seed, *, foreground_prepared=False):
125
  from scripts.run_example import parse_args, build_command
126
  arguments, extra = parse_args([
127
  "--config", str(resident.config_path), "--input", str(job / "input.png"),
@@ -134,10 +140,29 @@ def execute(resident, job, seed, *, foreground_prepared=False):
134
  spec = importlib.util.spec_from_file_location("_supratex_resident_infer", ROOT / "infer.py")
135
  module = importlib.util.module_from_spec(spec)
136
  spec.loader.exec_module(module)
137
- module.run(module.parse_args(command[2:]), resident=resident,
 
 
 
 
 
138
  foreground_prepared=foreground_prepared)
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  def main():
142
  parser = argparse.ArgumentParser()
143
  parser.add_argument("--config", required=True)
 
25
  temporary.replace(path)
26
 
27
 
28
+ def serve(config_path, status_path, epoch, stream, *, factory=None, run_job=None, run_foreground=None,
29
+ run_geometry=None, run_texture=None):
30
  """Injectable protocol boundary; production models stay in this process."""
31
  def status(stage, **fields):
32
  atomic_json(status_path, {"epoch": epoch, "stage": stage,
 
69
  if request.get("epoch") != epoch:
70
  raise ValueError("Job belongs to another runtime")
71
  operation = request.get('operation', 'generate')
72
+ if operation not in ('generate', 'remove_background', 'generate_geometry', 'generate_texture'):
73
  raise ValueError('Unknown internal model operation')
74
  foreground_prepared = request.get('foreground_prepared', False)
75
  if type(foreground_prepared) is not bool:
 
82
  try:
83
  if operation == 'remove_background':
84
  (run_foreground or execute_foreground)(resident, job)
85
+ elif operation == 'generate_geometry':
86
+ (run_geometry or execute_geometry)(resident, job, int(request['seed']),
87
+ foreground_prepared=foreground_prepared)
88
+ elif operation == 'generate_texture':
89
+ (run_texture or execute_texture)(resident, job, int(request['seed']))
90
  else:
91
  options = {'foreground_prepared': True} if foreground_prepared else {}
92
  (run_job or execute)(resident, job, int(request["seed"]), **options)
 
127
  atomic_json(job / 'foreground.json', metadata)
128
 
129
 
130
+ def _arguments(resident, job, seed):
131
  from scripts.run_example import parse_args, build_command
132
  arguments, extra = parse_args([
133
  "--config", str(resident.config_path), "--input", str(job / "input.png"),
 
140
  spec = importlib.util.spec_from_file_location("_supratex_resident_infer", ROOT / "infer.py")
141
  module = importlib.util.module_from_spec(spec)
142
  spec.loader.exec_module(module)
143
+ return module, module.parse_args(command[2:])
144
+
145
+
146
+ def execute(resident, job, seed, *, foreground_prepared=False):
147
+ module, arguments = _arguments(resident, job, seed)
148
+ module.run(arguments, resident=resident,
149
  foreground_prepared=foreground_prepared)
150
 
151
 
152
+ def execute_geometry(resident, job, seed, *, foreground_prepared=False):
153
+ from hf_space.split_pipeline import run_geometry
154
+ _module, arguments = _arguments(resident, job, seed)
155
+ result = run_geometry(arguments, resident=resident, foreground_prepared=foreground_prepared)
156
+ atomic_json(job / 'stage-result.json', dict(result, status='complete'))
157
+
158
+
159
+ def execute_texture(resident, job, seed):
160
+ from hf_space.split_pipeline import run_texture
161
+ _module, arguments = _arguments(resident, job, seed)
162
+ result = run_texture(arguments, resident=resident)
163
+ atomic_json(job / 'stage-result.json', dict(result, status='complete'))
164
+
165
+
166
  def main():
167
  parser = argparse.ArgumentParser()
168
  parser.add_argument("--config", required=True)
hf_space/split_pipeline.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Two explicit inference requests sharing an immutable geometry checkpoint.
2
+
3
+ The checkpoint contains the exact prepared condition, optimized camera and mesh.
4
+ Texture generation consumes those files without invoking the image/geometry
5
+ frontend. The history layer owns durable copies and their content checksums.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+ import shutil
12
+
13
+ from hf_space.timing import span
14
+
15
+ CHECKPOINT_DIRECTORY = 'geometry-checkpoint'
16
+ CHECKPOINT_FORMAT = 'supratex.geometry-checkpoint.v1'
17
+ CHECKPOINT_FILES = ('condition_rgba.png', 'condition_rgb.png', 'camera.json',
18
+ 'geometry.pt', 'geometry_receipt.json', 'checkpoint.json')
19
+ OPTIONAL_CHECKPOINT_FILES = ('image_depth.npy',)
20
+
21
+
22
+ def validate_checkpoint(folder, *, seed=None):
23
+ folder = Path(folder)
24
+ if folder.is_symlink() or not folder.is_dir():
25
+ raise ValueError('The saved geometry checkpoint is unavailable')
26
+ for name in CHECKPOINT_FILES:
27
+ path = folder / name
28
+ if path.is_symlink() or not path.is_file() or path.stat().st_size <= 0:
29
+ raise ValueError('The saved geometry checkpoint is incomplete')
30
+ metadata = json.loads((folder / 'checkpoint.json').read_text(encoding='utf-8'))
31
+ declared = metadata.get('files')
32
+ if (metadata.get('format') != CHECKPOINT_FORMAT
33
+ or type(metadata.get('seed')) is not int
34
+ or type(metadata.get('remove_floaters_applied')) is not bool
35
+ or declared not in (list(CHECKPOINT_FILES[:-1]),
36
+ [*CHECKPOINT_FILES[:-1], *OPTIONAL_CHECKPOINT_FILES])):
37
+ raise ValueError('The saved geometry checkpoint has an unsupported format')
38
+ for name in OPTIONAL_CHECKPOINT_FILES:
39
+ path = folder / name
40
+ if name in declared:
41
+ if path.is_symlink() or not path.is_file() or path.stat().st_size <= 0:
42
+ raise ValueError('The saved geometry checkpoint is incomplete')
43
+ elif path.exists() or path.is_symlink():
44
+ raise ValueError('The saved geometry checkpoint contains undeclared depth')
45
+ if seed is not None and metadata['seed'] != int(seed):
46
+ raise ValueError('The saved geometry belongs to a different seed')
47
+ return metadata
48
+
49
+
50
+ def copy_checkpoint(source, destination, *, seed=None):
51
+ """Copy only the bounded, relative checkpoint contract into a fresh folder."""
52
+ metadata = validate_checkpoint(source, seed=seed)
53
+ destination = Path(destination)
54
+ destination.mkdir(parents=True, exist_ok=False)
55
+ for name in (*metadata['files'], 'checkpoint.json'):
56
+ shutil.copyfile(Path(source) / name, destination / name)
57
+ return metadata
58
+
59
+
60
+ def _frontend_options(args):
61
+ return dict(moge_model=args.moge_model, rmbg_model=args.rmbg_model,
62
+ use_input_alpha=args.use_input_alpha, resolution_level=args.resolution_level,
63
+ geometry_package=args.geometry_package.expanduser().resolve(), geometry_model=args.geometry_model,
64
+ geometry_revision=args.geometry_revision, geometry_seed=args.seed,
65
+ geometry_initial_mesh_scale=args.geometry_initial_mesh_scale,
66
+ geometry_scale_search=args.geometry_scale_search, geometry_target_padding=args.geometry_target_padding,
67
+ geometry_max_iterations=args.geometry_max_iterations,
68
+ material_backend=args.material_backend, material_base=args.material_base,
69
+ qwen_runtime=args.qwen_runtime, qwen_deps=args.qwen_deps,
70
+ material_model=args.material_model, revision=args.material_revision,
71
+ cache_dir=args.cache_dir, local_files_only=args.local_files_only,
72
+ export_image_depth=not (getattr(args, 'no_filter', False) or getattr(args, 'no_image_depth', False)))
73
+
74
+
75
+ def run_geometry(args, *, resident, foreground_prepared=False):
76
+ """Run foreground/camera/geometry once; never predict material maps."""
77
+ import numpy as np
78
+ import torch
79
+ from PIL import Image
80
+ from supratex.frontend.pipeline import run as frontend
81
+ from supratex.inference.runtime import configure_inference_runtime
82
+ from hf_space.geometry_preview import render_geometry
83
+
84
+ job = Path(args.output).parent
85
+ checkpoint = job / CHECKPOINT_DIRECTORY
86
+ configure_inference_runtime(attention='sdpa', low_vram=True, material_backend='qwen-image')
87
+ frontend(Path(args.input), checkpoint, **_frontend_options(args), resident=resident,
88
+ foreground_prepared=foreground_prepared, geometry_only=True)
89
+ geometry = torch.load(checkpoint / 'geometry.pt', map_location='cpu', weights_only=True)
90
+ vertices, faces = geometry['verts'].float().contiguous(), geometry['faces'].long().contiguous()
91
+ camera = json.loads((checkpoint / 'camera.json').read_text(encoding='utf-8'))
92
+ cleanup = None
93
+ if args.remove_floaters:
94
+ from supratex.inference.geometry_cleanup import remove_floaters
95
+ with span('geometry.cleanup'), Image.open(checkpoint / 'condition_rgba.png') as image:
96
+ vertices, faces, cleanup = remove_floaters(vertices, faces, camera,
97
+ np.asarray(image.getchannel('A')))
98
+ if (vertices.ndim != 2 or vertices.shape[1] != 3 or faces.ndim != 2 or faces.shape[1] != 3
99
+ or not len(vertices) or not len(faces) or not torch.isfinite(vertices).all()
100
+ or faces.min() < 0 or faces.max() >= len(vertices)):
101
+ raise ValueError('Geometry generation returned an invalid mesh')
102
+ vertices, faces = vertices.cpu().contiguous(), faces.cpu().contiguous()
103
+ with span('geometry.save'):
104
+ # Apply the original completion cleanup once, before displaying the mesh.
105
+ # Stage two must use this exact topology, without repeating cleanup.
106
+ torch.save(dict(geometry, verts=vertices, faces=faces), checkpoint / 'geometry.pt')
107
+ np.savez(job / 'geometry.npz', vertices=vertices.numpy(), faces=faces.numpy())
108
+ metadata = {'format': CHECKPOINT_FORMAT, 'seed': int(args.seed),
109
+ 'remove_floaters_applied': bool(args.remove_floaters),
110
+ 'files': list(CHECKPOINT_FILES[:-1])}
111
+ metadata['files'].extend(name for name in OPTIONAL_CHECKPOINT_FILES if (checkpoint / name).is_file())
112
+ (checkpoint / 'checkpoint.json').write_text(json.dumps(metadata), encoding='utf-8')
113
+ (job / 'input-camera.json').write_text(json.dumps(camera), encoding='utf-8')
114
+ render_geometry(job, vertices, faces, camera)
115
+ return {'output_kind': 'geometry', 'face_count': len(faces), 'vertex_count': len(vertices),
116
+ 'geometry_checkpoint': CHECKPOINT_DIRECTORY, 'geometry_cleanup': cleanup,
117
+ 'foreground_prepared': bool(foreground_prepared)}
118
+
119
+
120
+ def run_texture(args, *, resident):
121
+ """Resume from a saved geometry handoff; there is no geometry code path."""
122
+ from supratex.frontend.pipeline import predict_material_maps
123
+ from supratex.inference import run_completion
124
+ from supratex.inference.runtime import configure_inference_runtime
125
+
126
+ job = Path(args.output).parent
127
+ handoff = job / 'asset_work' / 'input'
128
+ copy_checkpoint(job / CHECKPOINT_DIRECTORY, handoff, seed=args.seed)
129
+ configure_inference_runtime(attention='sdpa', low_vram=True, material_backend='qwen-image')
130
+ with span('frontend.material'):
131
+ albedo, mra = predict_material_maps(
132
+ handoff / 'condition_rgba.png', handoff / 'condition_rgb.png', handoff / 'material',
133
+ material_backend='qwen-image', material_base=args.material_base,
134
+ material_model=args.material_model, qwen_runtime=args.qwen_runtime,
135
+ qwen_deps=args.qwen_deps, revision=args.material_revision,
136
+ cache_dir=args.cache_dir, local_files_only=True, resident=resident)
137
+ shutil.copyfile(albedo, handoff / 'albedo.png')
138
+ shutil.copyfile(mra, handoff / 'mra.png')
139
+ with span('texture.completion'):
140
+ run_completion(input_dir=handoff, output=Path(args.output), model_config=args.model_config,
141
+ checkpoint=args.checkpoint, trellis_root=args.trellis_root, seed=int(args.seed),
142
+ texture_size=args.texture_size, decimation_target=args.decimation_target,
143
+ texture_format=args.texture_format, gutter=args.gutter,
144
+ remove_floaters=False, preview_only=True, resident=resident)
145
+ return {'output_kind': 'final_ovoxel', 'geometry_reused': True,
146
+ 'geometry_checkpoint': CHECKPOINT_DIRECTORY}
hf_space/vendor/gradio-client-2.1.0.js ADDED
@@ -0,0 +1,1940 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const We = "host", Te = "queue/data", Ze = "queue/join", ye = "upload", Ke = "login", V = "config", Ve = "info", Ye = "runtime", Qe = "sleeptime", Xe = "heartbeat", et = "component_server", tt = "reset", st = "cancel", nt = "app_id", Ae = "This application is currently busy. Please try again. ", L = "Connection errored out. ", N = "Could not resolve app config. ", ot = "Could not get space status. ", it = "Could not get API info. ", le = "Space metadata could not be loaded. ", rt = "Invalid URL. A full URL path is required.", at = "Not authorized to access this space. ", xe = "Invalid credentials. Could not login. ", ct = "Login credentials are required to access this space.", ut = "File system access is only available in Node.js environments", Ce = "Root URL not found in client config", lt = "Error uploading file";
2
+ async function be(e, s, t) {
3
+ try {
4
+ return (await (await fetch(`https://huggingface.co/api/spaces/${e}/jwt`, {
5
+ headers: {
6
+ Authorization: `Bearer ${s}`,
7
+ ...t ? { Cookie: t } : {}
8
+ }
9
+ })).json()).token || !1;
10
+ } catch {
11
+ return !1;
12
+ }
13
+ }
14
+ function pt(e) {
15
+ let s = {};
16
+ return e.forEach(({ api_name: t, id: n }) => {
17
+ t && (s[t] = n);
18
+ }), s;
19
+ }
20
+ async function dt(e) {
21
+ const s = this.options.token ? { Authorization: `Bearer ${this.options.token}` } : {};
22
+ if (s["Content-Type"] = "application/json", typeof window < "u" && window.gradio_config && location.origin !== "http://localhost:9876") {
23
+ if (window.gradio_config.current_page && (e = e.substring(0, e.lastIndexOf("/"))), window.gradio_config.dev_mode || typeof window < "u" && window?.BUILD_MODE === "dev") {
24
+ let t = ce(
25
+ e,
26
+ this.deep_link ? V + "?deep_link=" + this.deep_link : V
27
+ );
28
+ const n = await this.fetch(t, {
29
+ headers: s,
30
+ credentials: "include"
31
+ }), o = await ve(n, !!this.options.auth);
32
+ o.root = e || o.root, window.gradio_config = {
33
+ ...o,
34
+ current_page: window.gradio_config.current_page
35
+ };
36
+ }
37
+ return { ...window.gradio_config };
38
+ } else if (e) {
39
+ let t = ce(
40
+ e,
41
+ this.deep_link ? V + "?deep_link=" + this.deep_link : V
42
+ );
43
+ const n = await this.fetch(t, {
44
+ headers: s,
45
+ credentials: "include"
46
+ }), o = await ve(n, !!this.options.auth);
47
+ return o.root || (o.root = e), o;
48
+ }
49
+ throw new Error(N);
50
+ }
51
+ async function ve(e, s) {
52
+ if (e?.status === 401 && !s) {
53
+ const n = (await e.json())?.detail?.auth_message;
54
+ throw new Error(n || ct);
55
+ } else if (e?.status === 401 && s)
56
+ throw new Error(xe);
57
+ if (e?.status === 200) {
58
+ let t = await e.json();
59
+ return t.dependencies?.forEach((n, o) => {
60
+ n.id === void 0 && (n.id = o);
61
+ }), t;
62
+ } else if (e?.status === 401)
63
+ throw new Error(at);
64
+ throw new Error(N);
65
+ }
66
+ async function ht() {
67
+ const { http_protocol: e, host: s } = await de(
68
+ this.app_reference,
69
+ this.options.token
70
+ );
71
+ try {
72
+ if (this.options.auth) {
73
+ const t = await De(
74
+ e,
75
+ s,
76
+ this.options.auth,
77
+ this.fetch,
78
+ this.options.token
79
+ );
80
+ t && this.set_cookies(t);
81
+ }
82
+ } catch (t) {
83
+ throw Error(t.message);
84
+ }
85
+ }
86
+ async function De(e, s, t, n, o) {
87
+ const r = new FormData();
88
+ r.append("username", t?.[0]), r.append("password", t?.[1]);
89
+ let i = {};
90
+ o && (i.Authorization = `Bearer ${o}`);
91
+ const a = await n(`${e}//${s}/${Ke}`, {
92
+ headers: i,
93
+ method: "POST",
94
+ body: r,
95
+ credentials: "include"
96
+ });
97
+ if (a.status === 200)
98
+ return a.headers.get("set-cookie");
99
+ throw a.status === 401 ? new Error(xe) : new Error(le);
100
+ }
101
+ function ae(e) {
102
+ if (e.startsWith("http")) {
103
+ const { protocol: s, host: t, pathname: n } = new URL(e);
104
+ return {
105
+ ws_protocol: s === "https:" ? "wss" : "ws",
106
+ http_protocol: s,
107
+ host: t + (n !== "/" ? n : "")
108
+ };
109
+ }
110
+ return {
111
+ ws_protocol: "wss",
112
+ http_protocol: "https:",
113
+ host: new URL(e).host
114
+ };
115
+ }
116
+ const Ne = (e) => {
117
+ let s = [];
118
+ return e.split(/,(?=\s*[^\s=;]+=[^\s=;]+)/).forEach((n) => {
119
+ const [o, r] = n.split(";")[0].split("=");
120
+ o && r && s.push(`${o.trim()}=${r.trim()}`);
121
+ }), s;
122
+ }, pe = /^[a-zA-Z0-9_\-\.]+\/[a-zA-Z0-9_\-\.]+$/, ft = /.*hf\.space\/{0,1}.*$/;
123
+ async function de(e, s) {
124
+ const t = {};
125
+ s && (t.Authorization = `Bearer ${s}`);
126
+ const n = e.trim().replace(/\/$/, "");
127
+ if (pe.test(n))
128
+ try {
129
+ const r = (await (await fetch(
130
+ `https://huggingface.co/api/spaces/${n}/${We}`,
131
+ { headers: t }
132
+ )).json()).host;
133
+ return {
134
+ space_id: e,
135
+ ...ae(r)
136
+ };
137
+ } catch {
138
+ throw new Error(le);
139
+ }
140
+ if (ft.test(n)) {
141
+ const { ws_protocol: o, http_protocol: r, host: i } = ae(n);
142
+ return {
143
+ space_id: i.split("/")[0].replace(".hf.space", ""),
144
+ ws_protocol: o,
145
+ http_protocol: r,
146
+ host: i
147
+ };
148
+ }
149
+ return {
150
+ space_id: !1,
151
+ ...ae(n)
152
+ };
153
+ }
154
+ const ce = (...e) => {
155
+ try {
156
+ return e.reduce((s, t) => (s = s.replace(/\/+$/, ""), t = t.replace(/^\/+/, ""), new URL(t, s + "/").toString()));
157
+ } catch {
158
+ throw new Error(rt);
159
+ }
160
+ };
161
+ function _t(e, s, t) {
162
+ const n = {
163
+ named_endpoints: {},
164
+ unnamed_endpoints: {}
165
+ };
166
+ return Object.keys(e).forEach((o) => {
167
+ (o === "named_endpoints" || o === "unnamed_endpoints") && (n[o] = {}, Object.entries(e[o]).forEach(
168
+ ([r, { parameters: i, returns: a }]) => {
169
+ const u = s.dependencies.find(
170
+ (c) => c.api_name === r || c.api_name === r.replace("/", "")
171
+ )?.id || t[r.replace("/", "")] || -1, l = u !== -1 ? s.dependencies.find((c) => c.id == u)?.types : { generator: !1, cancel: !1 };
172
+ if (u !== -1 && s.dependencies.find((c) => c.id == u)?.inputs?.length !== i.length) {
173
+ const c = s.dependencies.find((m) => m.id == u).inputs.map(
174
+ (m) => s.components.find((w) => w.id === m)?.type
175
+ );
176
+ try {
177
+ c.forEach((m, w) => {
178
+ if (m === "state") {
179
+ const P = {
180
+ component: "state",
181
+ example: null,
182
+ parameter_default: null,
183
+ parameter_has_default: !0,
184
+ parameter_name: null,
185
+ hidden: !0
186
+ };
187
+ i.splice(w, 0, P);
188
+ }
189
+ });
190
+ } catch (m) {
191
+ console.error(m);
192
+ }
193
+ }
194
+ const p = (c, m, w, P) => ({
195
+ ...c,
196
+ description: gt(c?.type, w),
197
+ type: mt(c?.type, m, w, P) || ""
198
+ });
199
+ n[o][r] = {
200
+ parameters: i.map(
201
+ (c) => p(c, c?.component, c?.serializer, "parameter")
202
+ ),
203
+ returns: a.map(
204
+ (c) => p(c, c?.component, c?.serializer, "return")
205
+ ),
206
+ type: l
207
+ };
208
+ }
209
+ ));
210
+ }), n;
211
+ }
212
+ function mt(e, s, t, n) {
213
+ if (s === "Api") return e.type;
214
+ switch (e?.type) {
215
+ case "string":
216
+ return "string";
217
+ case "boolean":
218
+ return "boolean";
219
+ case "number":
220
+ return "number";
221
+ }
222
+ if (t === "JSONSerializable" || t === "StringSerializable")
223
+ return "any";
224
+ if (t === "ListStringSerializable")
225
+ return "string[]";
226
+ if (s === "Image")
227
+ return n === "parameter" ? "Blob | File | Buffer" : "string";
228
+ if (t === "FileSerializable")
229
+ return e?.type === "array" ? n === "parameter" ? "(Blob | File | Buffer)[]" : "{ name: string; data: string; size?: number; is_file?: boolean; orig_name?: string}[]" : n === "parameter" ? "Blob | File | Buffer" : "{ name: string; data: string; size?: number; is_file?: boolean; orig_name?: string}";
230
+ if (t === "GallerySerializable")
231
+ return n === "parameter" ? "[(Blob | File | Buffer), (string | null)][]" : "[{ name: string; data: string; size?: number; is_file?: boolean; orig_name?: string}, (string | null))][]";
232
+ }
233
+ function gt(e, s) {
234
+ return s === "GallerySerializable" ? "array of [file, label] tuples" : s === "ListStringSerializable" ? "array of strings" : s === "FileSerializable" ? "array of files or single file" : e?.description;
235
+ }
236
+ function Ee(e, s) {
237
+ switch (e.msg) {
238
+ case "send_data":
239
+ return { type: "data" };
240
+ case "send_hash":
241
+ return { type: "hash" };
242
+ case "queue_full":
243
+ return {
244
+ type: "update",
245
+ status: {
246
+ queue: !0,
247
+ message: Ae,
248
+ stage: "error",
249
+ code: e.code,
250
+ success: e.success
251
+ }
252
+ };
253
+ case "heartbeat":
254
+ return {
255
+ type: "heartbeat"
256
+ };
257
+ case "unexpected_error":
258
+ return {
259
+ type: "unexpected_error",
260
+ status: {
261
+ queue: !0,
262
+ message: e.message,
263
+ session_not_found: e.session_not_found,
264
+ stage: "error",
265
+ success: !1
266
+ }
267
+ };
268
+ case "broken_connection":
269
+ return {
270
+ type: "broken_connection",
271
+ status: {
272
+ queue: !0,
273
+ message: e.message,
274
+ stage: "error",
275
+ success: !1
276
+ }
277
+ };
278
+ case "estimation":
279
+ return {
280
+ type: "update",
281
+ status: {
282
+ queue: !0,
283
+ stage: s || "pending",
284
+ code: e.code,
285
+ size: e.queue_size,
286
+ position: e.rank,
287
+ eta: e.rank_eta,
288
+ success: e.success
289
+ }
290
+ };
291
+ case "progress":
292
+ return {
293
+ type: "update",
294
+ status: {
295
+ queue: !0,
296
+ stage: "pending",
297
+ code: e.code,
298
+ progress_data: e.progress_data,
299
+ success: e.success
300
+ }
301
+ };
302
+ case "log":
303
+ return { type: "log", data: e };
304
+ case "process_generating":
305
+ return {
306
+ type: "generating",
307
+ status: {
308
+ queue: !0,
309
+ message: e.success ? null : e.output.error,
310
+ stage: e.success ? "generating" : "error",
311
+ code: e.code,
312
+ progress_data: e.progress_data,
313
+ eta: e.average_duration,
314
+ changed_state_ids: e.success ? e.output.changed_state_ids : void 0
315
+ },
316
+ data: e.success ? e.output : null
317
+ };
318
+ case "process_streaming":
319
+ return {
320
+ type: "streaming",
321
+ status: {
322
+ queue: !0,
323
+ message: e.output.error,
324
+ stage: "streaming",
325
+ time_limit: e.time_limit,
326
+ code: e.code,
327
+ progress_data: e.progress_data,
328
+ eta: e.eta
329
+ },
330
+ data: e.output
331
+ };
332
+ case "process_completed":
333
+ return "error" in e.output ? {
334
+ type: "update",
335
+ status: {
336
+ queue: !0,
337
+ title: e.output.title ?? "Error",
338
+ message: e.output.error ?? "An error occurred",
339
+ visible: e.output.visible,
340
+ duration: e.output.duration,
341
+ stage: "error",
342
+ code: e.code,
343
+ success: e.success
344
+ }
345
+ } : {
346
+ type: "complete",
347
+ status: {
348
+ queue: !0,
349
+ message: e.success ? void 0 : e.output.error,
350
+ stage: e.success ? "complete" : "error",
351
+ code: e.code,
352
+ progress_data: e.progress_data,
353
+ changed_state_ids: e.success ? e.output.changed_state_ids : void 0
354
+ },
355
+ data: e.success ? e.output : null
356
+ };
357
+ case "process_starts":
358
+ return {
359
+ type: "update",
360
+ status: {
361
+ queue: !0,
362
+ stage: "pending",
363
+ code: e.code,
364
+ size: e.rank,
365
+ position: 0,
366
+ success: e.success,
367
+ eta: e.eta
368
+ },
369
+ original_msg: "process_starts"
370
+ };
371
+ }
372
+ return { type: "none", status: { stage: "error", queue: !0 } };
373
+ }
374
+ const wt = (e = [], s) => {
375
+ const t = s ? s.parameters : [];
376
+ if (Array.isArray(e))
377
+ return s && t.length > 0 && e.length > t.length && console.warn("Too many arguments provided for the endpoint."), e;
378
+ const n = [], o = Object.keys(e);
379
+ return t.forEach((r, i) => {
380
+ if (e.hasOwnProperty(r.parameter_name))
381
+ n[i] = e[r.parameter_name];
382
+ else if (r.parameter_has_default)
383
+ n[i] = r.parameter_default;
384
+ else
385
+ throw new Error(
386
+ `No value provided for required parameter: ${r.parameter_name}`
387
+ );
388
+ }), o.forEach((r) => {
389
+ if (!t.some((i) => i.parameter_name === r))
390
+ throw new Error(
391
+ `Parameter \`${r}\` is not a valid keyword argument. Please refer to the API for usage.`
392
+ );
393
+ }), n.forEach((r, i) => {
394
+ if (r === void 0 && !t[i].parameter_has_default)
395
+ throw new Error(
396
+ `No value provided for required parameter: ${t[i].parameter_name}`
397
+ );
398
+ }), n;
399
+ };
400
+ async function yt() {
401
+ if (this.api_info) return this.api_info;
402
+ const { token: e } = this.options, { config: s } = this, t = { "Content-Type": "application/json" };
403
+ if (e && (t.Authorization = `Bearer ${e}`), !!s)
404
+ try {
405
+ let n, o;
406
+ if (typeof window < "u" && window.gradio_api_info)
407
+ o = window.gradio_api_info;
408
+ else {
409
+ const r = ce(s.root, this.api_prefix, Ve);
410
+ if (n = await this.fetch(r, {
411
+ headers: t,
412
+ credentials: "include"
413
+ }), !n.ok)
414
+ throw new Error(L);
415
+ o = await n.json();
416
+ }
417
+ return "api" in o && (o = o.api), o.named_endpoints["/predict"] && !o.unnamed_endpoints[0] && (o.unnamed_endpoints[0] = o.named_endpoints["/predict"]), _t(o, s, this.api_map);
418
+ } catch (n) {
419
+ throw new Error("Could not get API info. " + n.message);
420
+ }
421
+ }
422
+ async function bt(e, s, t) {
423
+ const n = {};
424
+ this?.options?.token && (n.Authorization = `Bearer ${this.options.token}`);
425
+ const o = 1e3, r = [];
426
+ let i;
427
+ for (let a = 0; a < s.length; a += o) {
428
+ const u = s.slice(a, a + o), l = new FormData();
429
+ u.forEach((c) => {
430
+ l.append("files", c);
431
+ });
432
+ try {
433
+ const c = t ? `${e}${this.api_prefix}/${ye}?upload_id=${t}` : `${e}${this.api_prefix}/${ye}`;
434
+ i = await this.fetch(c, {
435
+ method: "POST",
436
+ body: l,
437
+ headers: n,
438
+ credentials: "include"
439
+ });
440
+ } catch (c) {
441
+ throw new Error(L + c.message);
442
+ }
443
+ if (!i.ok) {
444
+ const c = await i.text();
445
+ return { error: `HTTP ${i.status}: ${c}` };
446
+ }
447
+ const p = await i.json();
448
+ p && r.push(...p);
449
+ }
450
+ return { files: r };
451
+ }
452
+ const vt = {
453
+ radix: 1e3,
454
+ unit: ["b", "kb", "Mb", "Gb", "Tb", "Pb", "Eb", "Zb", "Yb"]
455
+ }, Et = {
456
+ radix: 1024,
457
+ unit: ["b", "Kib", "Mib", "Gib", "Tib", "Pib", "Eib", "Zib", "Yib"]
458
+ }, St = {
459
+ radix: 1024,
460
+ unit: ["b", "Kb", "Mb", "Gb", "Tb", "Pb", "Eb", "Zb", "Yb"]
461
+ }, Se = {
462
+ si: vt,
463
+ iec: Et,
464
+ jedec: St
465
+ };
466
+ function $t(e, s = 1, t = "jedec") {
467
+ e = Math.abs(e);
468
+ const { radix: n, unit: o } = Se[t] || Se.jedec;
469
+ let r = 0;
470
+ for (; e >= n; )
471
+ e /= n, ++r;
472
+ return `${e.toFixed(s)} ${o[r]}`;
473
+ }
474
+ async function kt(e, s, t, n) {
475
+ let o = (Array.isArray(e) ? e : [e]).map(
476
+ (i) => i.blob
477
+ );
478
+ const r = o.filter(
479
+ (i) => i.size > (n ?? 1 / 0)
480
+ );
481
+ if (r.length)
482
+ throw new Error(
483
+ `File(s) exceed the maximum allowed size of ${$t(n || 1 / 0)}: ${r.map((i) => `"${i.name}"`).join(", ")}`
484
+ );
485
+ return await Promise.all(
486
+ await this.upload_files(s, o, t).then(
487
+ async (i) => {
488
+ if (i.error)
489
+ throw new Error(i.error);
490
+ return i.files ? i.files.map((a, u) => new X({
491
+ ...e[u],
492
+ path: a,
493
+ url: `${s}${this.api_prefix}/file=${a}`
494
+ })) : [];
495
+ }
496
+ )
497
+ );
498
+ }
499
+ async function Xt(e, s) {
500
+ return e.map(
501
+ (t) => new X({
502
+ path: t.name,
503
+ orig_name: t.name,
504
+ blob: t,
505
+ size: t.size,
506
+ mime_type: t.type,
507
+ is_stream: s
508
+ })
509
+ );
510
+ }
511
+ class X {
512
+ path;
513
+ url;
514
+ orig_name;
515
+ size;
516
+ blob;
517
+ is_stream;
518
+ mime_type;
519
+ alt_text;
520
+ b64;
521
+ meta = { _type: "gradio.FileData" };
522
+ constructor({
523
+ path: s,
524
+ url: t,
525
+ orig_name: n,
526
+ size: o,
527
+ blob: r,
528
+ is_stream: i,
529
+ mime_type: a,
530
+ alt_text: u,
531
+ b64: l
532
+ }) {
533
+ this.path = s, this.url = t, this.orig_name = n, this.size = o, this.blob = t ? void 0 : r, this.is_stream = i, this.mime_type = a, this.alt_text = u, this.b64 = l;
534
+ }
535
+ }
536
+ class Le {
537
+ type;
538
+ command;
539
+ meta;
540
+ fileData;
541
+ constructor(s, t) {
542
+ this.type = "command", this.command = s, this.meta = t;
543
+ }
544
+ }
545
+ const Rt = typeof process < "u" && process.versions && process.versions.node;
546
+ function $e(e, s, t) {
547
+ for (; t.length > 1; ) {
548
+ const o = t.shift();
549
+ if (typeof o == "string" || typeof o == "number")
550
+ e = e[o];
551
+ else
552
+ throw new Error("Invalid key type");
553
+ }
554
+ const n = t.shift();
555
+ if (typeof n == "string" || typeof n == "number")
556
+ e[n] = s;
557
+ else
558
+ throw new Error("Invalid key type");
559
+ }
560
+ async function ue(e, s = void 0, t = [], n = !1, o = void 0) {
561
+ if (Array.isArray(e)) {
562
+ let r = [];
563
+ return await Promise.all(
564
+ e.map(async (i, a) => {
565
+ let u = t.slice();
566
+ u.push(String(a));
567
+ const l = await ue(
568
+ e[a],
569
+ n ? o?.parameters[a]?.component || void 0 : s,
570
+ u,
571
+ !1,
572
+ o
573
+ );
574
+ r = r.concat(l);
575
+ })
576
+ ), r;
577
+ } else {
578
+ if (globalThis.Buffer && e instanceof globalThis.Buffer || e instanceof Blob)
579
+ return [
580
+ {
581
+ path: t,
582
+ blob: new Blob([e]),
583
+ type: s
584
+ }
585
+ ];
586
+ if (typeof e == "object" && e !== null) {
587
+ let r = [];
588
+ for (const i of Object.keys(e)) {
589
+ const a = [...t, i], u = e[i];
590
+ r = r.concat(
591
+ await ue(
592
+ u,
593
+ void 0,
594
+ a,
595
+ !1,
596
+ o
597
+ )
598
+ );
599
+ }
600
+ return r;
601
+ }
602
+ }
603
+ return [];
604
+ }
605
+ function Ot(e, s) {
606
+ let t = s?.dependencies?.find((n) => n.id == e)?.queue;
607
+ return t != null ? !t : !s.enable_queue;
608
+ }
609
+ function Tt(e, s) {
610
+ return new Promise((t, n) => {
611
+ const o = new MessageChannel();
612
+ o.port1.onmessage = (({ data: r }) => {
613
+ o.port1.close(), t(r);
614
+ }), window.parent.postMessage(e, s, [o.port2]);
615
+ });
616
+ }
617
+ function es(e) {
618
+ if (typeof e == "string") {
619
+ if (e.startsWith("http://") || e.startsWith("https://"))
620
+ return {
621
+ path: e,
622
+ url: e,
623
+ orig_name: e.split("/").pop() ?? "unknown",
624
+ meta: { _type: "gradio.FileData" }
625
+ };
626
+ if (Rt)
627
+ return new Le("upload_file", {
628
+ path: e,
629
+ name: e,
630
+ orig_path: e
631
+ });
632
+ } else {
633
+ if (typeof File < "u" && e instanceof File)
634
+ return new Blob([e]);
635
+ if (e instanceof Buffer)
636
+ return new Blob([e]);
637
+ if (e instanceof Blob)
638
+ return e;
639
+ }
640
+ throw new Error(
641
+ "Invalid input: must be a URL, File, Blob, or Buffer object."
642
+ );
643
+ }
644
+ function Y(e, s, t, n, o = !1) {
645
+ if (n === "input" && !o)
646
+ throw new Error("Invalid code path. Cannot skip state inputs for input.");
647
+ if (n === "output" && o)
648
+ return e;
649
+ let r = [], i = 0;
650
+ const a = n === "input" ? s.inputs : s.outputs;
651
+ for (let u = 0; u < a.length; u++) {
652
+ const l = a[u];
653
+ if (t.find((c) => c.id === l)?.type === "state") {
654
+ if (o)
655
+ if (e.length === a.length) {
656
+ const c = e[i];
657
+ r.push(c), i++;
658
+ } else
659
+ r.push(null);
660
+ else {
661
+ i++;
662
+ continue;
663
+ }
664
+ continue;
665
+ } else {
666
+ const c = e[i];
667
+ r.push(c), i++;
668
+ }
669
+ }
670
+ return r;
671
+ }
672
+ async function At(e, s, t) {
673
+ const n = this;
674
+ await xt(n, s);
675
+ const o = await ue(
676
+ s,
677
+ void 0,
678
+ [],
679
+ !0,
680
+ t
681
+ );
682
+ return (await Promise.all(
683
+ o.map(async ({ path: i, blob: a, type: u }) => {
684
+ if (!a) return { path: i, type: u };
685
+ const l = await n.upload_files(e, [a]), p = l.files && l.files[0];
686
+ return {
687
+ path: i,
688
+ file_url: p,
689
+ type: u,
690
+ name: typeof File < "u" && a instanceof File ? a?.name : void 0
691
+ };
692
+ })
693
+ )).forEach(({ path: i, file_url: a, type: u, name: l }) => {
694
+ if (u === "Gallery")
695
+ $e(s, a, i);
696
+ else if (a) {
697
+ const p = new X({ path: a, orig_name: l });
698
+ $e(s, p, i);
699
+ }
700
+ }), s;
701
+ }
702
+ async function xt(e, s) {
703
+ if (!(e.config?.root || e.config?.root_url))
704
+ throw new Error(Ce);
705
+ await Pe(e, s);
706
+ }
707
+ async function Pe(e, s, t = []) {
708
+ for (const n in s)
709
+ s[n] instanceof Le ? await Ct(e, s, n) : typeof s[n] == "object" && s[n] !== null && await Pe(e, s[n], [...t, n]);
710
+ }
711
+ async function Ct(e, s, t) {
712
+ let n = s[t];
713
+ const o = e.config?.root || e.config?.root_url;
714
+ if (!o)
715
+ throw new Error(Ce);
716
+ try {
717
+ let r, i;
718
+ if (typeof process < "u" && process.versions && process.versions.node) {
719
+ const p = await import("./__vite-browser-external-DYxpcVy9.js");
720
+ i = (await import("./__vite-browser-external-DYxpcVy9.js")).resolve(process.cwd(), n.meta.path), r = await p.readFile(i);
721
+ } else
722
+ throw new Error(ut);
723
+ const a = new Blob([r], {
724
+ type: "application/octet-stream"
725
+ }), u = await e.upload_files(o, [a]), l = u.files && u.files[0];
726
+ if (l) {
727
+ const p = new X({
728
+ path: l,
729
+ orig_name: n.meta.name || ""
730
+ });
731
+ s[t] = p;
732
+ }
733
+ } catch (r) {
734
+ console.error(lt, r);
735
+ }
736
+ }
737
+ async function Dt(e, s, t) {
738
+ const n = { "Content-Type": "application/json" };
739
+ this.options.token && (n.Authorization = `Bearer ${this.options.token}`);
740
+ try {
741
+ var o = await this.fetch(e, {
742
+ method: "POST",
743
+ body: JSON.stringify(s),
744
+ headers: { ...n, ...t },
745
+ credentials: "include"
746
+ });
747
+ } catch {
748
+ return [{ error: L }, 500];
749
+ }
750
+ let r, i;
751
+ try {
752
+ r = await o.json(), i = o.status;
753
+ } catch (a) {
754
+ r = { error: `Could not parse server response: ${a}` }, i = 500;
755
+ }
756
+ return [r, i];
757
+ }
758
+ async function Nt(e, s = {}) {
759
+ let t = !1, n = !1;
760
+ if (!this.config)
761
+ throw new Error("Could not resolve app config");
762
+ if (typeof e == "number")
763
+ this.config.dependencies.find((o) => o.id == e);
764
+ else {
765
+ const o = e.replace(/^\//, "");
766
+ this.config.dependencies.find(
767
+ (r) => r.id == this.api_map[o]
768
+ );
769
+ }
770
+ return new Promise(async (o, r) => {
771
+ const i = this.submit(e, s, null, null, !0);
772
+ let a;
773
+ for await (const u of i)
774
+ u.type === "data" && (n && o(a), t = !0, a = u), u.type === "status" && (u.stage === "error" && r(u), u.stage === "complete" && (n = !0, t && o(a)));
775
+ });
776
+ }
777
+ async function H(e, s, t) {
778
+ let n = s === "subdomain" ? `https://huggingface.co/api/spaces/by-subdomain/${e}` : `https://huggingface.co/api/spaces/${e}`, o, r;
779
+ try {
780
+ if (o = await fetch(n), r = o.status, r !== 200)
781
+ throw new Error();
782
+ o = await o.json();
783
+ } catch {
784
+ t({
785
+ status: "error",
786
+ load_status: "error",
787
+ message: ot,
788
+ detail: "NOT_FOUND"
789
+ });
790
+ return;
791
+ }
792
+ if (!o || r !== 200) return;
793
+ const {
794
+ runtime: { stage: i },
795
+ id: a
796
+ } = o;
797
+ switch (i) {
798
+ case "STOPPED":
799
+ case "SLEEPING":
800
+ t({
801
+ status: "sleeping",
802
+ load_status: "pending",
803
+ message: "Space is asleep. Waking it up...",
804
+ detail: i
805
+ }), setTimeout(() => {
806
+ H(e, s, t);
807
+ }, 1e3);
808
+ break;
809
+ case "PAUSED":
810
+ t({
811
+ status: "paused",
812
+ load_status: "error",
813
+ message: "This space has been paused by the author. If you would like to try this demo, consider duplicating the space.",
814
+ detail: i,
815
+ discussions_enabled: await ke(a)
816
+ });
817
+ break;
818
+ case "RUNNING":
819
+ case "RUNNING_BUILDING":
820
+ t({
821
+ status: "running",
822
+ load_status: "complete",
823
+ message: "Space is running.",
824
+ detail: i
825
+ });
826
+ break;
827
+ case "BUILDING":
828
+ t({
829
+ status: "building",
830
+ load_status: "pending",
831
+ message: "Space is building...",
832
+ detail: i
833
+ }), setTimeout(() => {
834
+ H(e, s, t);
835
+ }, 1e3);
836
+ break;
837
+ case "APP_STARTING":
838
+ t({
839
+ status: "starting",
840
+ load_status: "pending",
841
+ message: "Space is starting...",
842
+ detail: i
843
+ }), setTimeout(() => {
844
+ H(e, s, t);
845
+ }, 1e3);
846
+ break;
847
+ default:
848
+ t({
849
+ status: "space_error",
850
+ load_status: "error",
851
+ message: "This space is experiencing an issue.",
852
+ detail: i,
853
+ discussions_enabled: await ke(a)
854
+ });
855
+ break;
856
+ }
857
+ }
858
+ const je = async (e, s) => {
859
+ let t = 0;
860
+ const n = 12, o = 5e3;
861
+ return new Promise((r) => {
862
+ H(
863
+ e,
864
+ pe.test(e) ? "space_name" : "subdomain",
865
+ (i) => {
866
+ s(i), i.status === "running" || i.status === "error" || i.status === "paused" || i.status === "space_error" ? r() : (i.status === "sleeping" || i.status === "building") && (t < n ? (t++, setTimeout(() => {
867
+ je(e, s).then(r);
868
+ }, o)) : r());
869
+ }
870
+ );
871
+ });
872
+ }, Lt = /^(?=[^]*\b[dD]iscussions{0,1}\b)(?=[^]*\b[dD]isabled\b)[^]*$/;
873
+ async function ke(e) {
874
+ try {
875
+ const s = await fetch(
876
+ `https://huggingface.co/api/spaces/${e}/discussions`,
877
+ {
878
+ method: "HEAD"
879
+ }
880
+ ), t = s.headers.get("x-error-message");
881
+ return !(!s.ok || t && Lt.test(t));
882
+ } catch {
883
+ return !1;
884
+ }
885
+ }
886
+ async function Pt(e, s) {
887
+ const t = {};
888
+ s && (t.Authorization = `Bearer ${s}`);
889
+ try {
890
+ const n = await fetch(
891
+ `https://huggingface.co/api/spaces/${e}/${Ye}`,
892
+ { headers: t }
893
+ );
894
+ if (n.status !== 200)
895
+ throw new Error("Space hardware could not be obtained.");
896
+ const { hardware: o } = await n.json();
897
+ return o.current;
898
+ } catch (n) {
899
+ throw new Error(n.message);
900
+ }
901
+ }
902
+ async function jt(e, s, t) {
903
+ const n = {};
904
+ t && (n.Authorization = `Bearer ${t}`);
905
+ const o = {
906
+ seconds: s
907
+ };
908
+ try {
909
+ const r = await fetch(
910
+ `https://huggingface.co/api/spaces/${e}/${Qe}`,
911
+ {
912
+ method: "POST",
913
+ headers: { "Content-Type": "application/json", ...n },
914
+ body: JSON.stringify(o)
915
+ }
916
+ );
917
+ if (r.status !== 200)
918
+ throw new Error(
919
+ "Could not set sleep timeout on duplicated Space. Please visit *ADD HF LINK TO SETTINGS* to set a timeout manually to reduce billing charges."
920
+ );
921
+ return await r.json();
922
+ } catch (r) {
923
+ throw new Error(r.message);
924
+ }
925
+ }
926
+ const Re = [
927
+ "cpu-basic",
928
+ "cpu-upgrade",
929
+ "cpu-xl",
930
+ "t4-small",
931
+ "t4-medium",
932
+ "a10g-small",
933
+ "a10g-large",
934
+ "a10g-largex2",
935
+ "a10g-largex4",
936
+ "a100-large",
937
+ "zero-a10g",
938
+ "h100",
939
+ "h100x8"
940
+ ];
941
+ async function It(e, s) {
942
+ const { token: t, private: n, hardware: o, timeout: r, auth: i } = s;
943
+ if (o && !Re.includes(o))
944
+ throw new Error(
945
+ `Invalid hardware type provided. Valid types are: ${Re.map((g) => `"${g}"`).join(",")}.`
946
+ );
947
+ const { http_protocol: a, host: u } = await de(e, t);
948
+ let l = null;
949
+ if (i) {
950
+ const g = await De(
951
+ a,
952
+ u,
953
+ i,
954
+ fetch
955
+ );
956
+ g && (l = Ne(g));
957
+ }
958
+ const p = {
959
+ Authorization: `Bearer ${t}`,
960
+ "Content-Type": "application/json",
961
+ ...l ? { Cookie: l.join("; ") } : {}
962
+ }, c = (await (await fetch("https://huggingface.co/api/whoami-v2", {
963
+ headers: p
964
+ })).json()).name, m = e.split("/")[1], w = {
965
+ repository: `${c}/${m}`
966
+ };
967
+ n && (w.private = !0);
968
+ let P;
969
+ try {
970
+ o || (P = await Pt(e, t));
971
+ } catch (g) {
972
+ throw Error(le + g.message);
973
+ }
974
+ const f = o || P || "cpu-basic";
975
+ w.hardware = f;
976
+ try {
977
+ const g = await fetch(
978
+ `https://huggingface.co/api/spaces/${e}/duplicate`,
979
+ {
980
+ method: "POST",
981
+ headers: p,
982
+ body: JSON.stringify(w)
983
+ }
984
+ );
985
+ if (g.status === 409)
986
+ try {
987
+ return await Q.connect(`${c}/${m}`, s);
988
+ } catch (z) {
989
+ throw console.error("Failed to connect Client instance:", z), z;
990
+ }
991
+ else if (g.status !== 200)
992
+ throw new Error(g.statusText);
993
+ const J = await g.json();
994
+ return await jt(`${c}/${m}`, r || 300, t), await Q.connect(
995
+ Ut(J.url),
996
+ s
997
+ );
998
+ } catch (g) {
999
+ throw new Error(g);
1000
+ }
1001
+ }
1002
+ function Ut(e) {
1003
+ const s = /https:\/\/huggingface.co\/spaces\/([^/]+\/[^/]+)/, t = e.match(s);
1004
+ if (t)
1005
+ return t[1];
1006
+ }
1007
+ class qt extends TransformStream {
1008
+ #e = "";
1009
+ /** Constructs a new instance. */
1010
+ constructor(s = { allowCR: !1 }) {
1011
+ super({
1012
+ transform: (t, n) => {
1013
+ for (t = this.#e + t; ; ) {
1014
+ const o = t.indexOf(`
1015
+ `), r = s.allowCR ? t.indexOf("\r") : -1;
1016
+ if (r !== -1 && r !== t.length - 1 && (o === -1 || o - 1 > r)) {
1017
+ n.enqueue(t.slice(0, r)), t = t.slice(r + 1);
1018
+ continue;
1019
+ }
1020
+ if (o === -1)
1021
+ break;
1022
+ const i = t[o - 1] === "\r" ? o - 1 : o;
1023
+ n.enqueue(t.slice(0, i)), t = t.slice(o + 1);
1024
+ }
1025
+ this.#e = t;
1026
+ },
1027
+ flush: (t) => {
1028
+ if (this.#e === "")
1029
+ return;
1030
+ const n = s.allowCR && this.#e.endsWith("\r") ? this.#e.slice(0, -1) : this.#e;
1031
+ t.enqueue(n);
1032
+ }
1033
+ });
1034
+ }
1035
+ }
1036
+ function zt(e) {
1037
+ let s = new TextDecoderStream(), t = new qt({ allowCR: !0 });
1038
+ return e.pipeThrough(s).pipeThrough(t);
1039
+ }
1040
+ function Bt(e) {
1041
+ let t = /[:]\s*/.exec(e), n = t && t.index;
1042
+ if (n)
1043
+ return [
1044
+ e.substring(0, n),
1045
+ e.substring(n + t[0].length)
1046
+ ];
1047
+ }
1048
+ function Oe(e, s, t) {
1049
+ e.get(s) || e.set(s, t);
1050
+ }
1051
+ async function* Ft(e, s) {
1052
+ if (!e.body)
1053
+ return;
1054
+ let t = zt(e.body), n, o = t.getReader(), r;
1055
+ for (; ; ) {
1056
+ if (s && s.aborted)
1057
+ return o.cancel();
1058
+ if (n = await o.read(), n.done)
1059
+ return;
1060
+ if (!n.value) {
1061
+ r && (yield r), r = void 0;
1062
+ continue;
1063
+ }
1064
+ let [i, a] = Bt(n.value) || [];
1065
+ i === "data" ? (r ||= {}, r[i] = r[i] ? r[i] + `
1066
+ ` + a : a) : i === "event" ? (r ||= {}, r[i] = a) : i === "id" ? (r ||= {}, r[i] = String(+a) === a ? +a : a) : i === "retry" && (r ||= {}, r[i] = +a || void 0);
1067
+ }
1068
+ }
1069
+ async function Gt(e, s) {
1070
+ let t = new Request(e, s);
1071
+ Oe(t.headers, "Accept", "text/event-stream"), Oe(t.headers, "Content-Type", "application/json");
1072
+ let n = await fetch(t);
1073
+ if (!n.ok)
1074
+ throw n;
1075
+ return Ft(n, t.signal);
1076
+ }
1077
+ async function Mt() {
1078
+ let {
1079
+ event_callbacks: e,
1080
+ unclosed_events: s,
1081
+ pending_stream_messages: t,
1082
+ stream_status: n,
1083
+ config: o,
1084
+ jwt: r
1085
+ } = this;
1086
+ const i = this;
1087
+ if (!o)
1088
+ throw new Error("Could not resolve app config");
1089
+ n.open = !0;
1090
+ let a = null, u = new URLSearchParams({
1091
+ session_hash: this.session_hash
1092
+ }).toString(), l = new URL(`${o.root}${this.api_prefix}/${Te}?${u}`);
1093
+ if (r && l.searchParams.set("__sign", r), a = this.stream(l), !a) {
1094
+ console.warn("Cannot connect to SSE endpoint: " + l.toString());
1095
+ return;
1096
+ }
1097
+ a.onmessage = async function(p) {
1098
+ let c = JSON.parse(p.data);
1099
+ if (c.msg === "close_stream") {
1100
+ he(n, i.abort_controller);
1101
+ return;
1102
+ }
1103
+ const m = c.event_id;
1104
+ if (!m)
1105
+ await Promise.all(
1106
+ Object.keys(e).map(
1107
+ (w) => e[w](c)
1108
+ )
1109
+ );
1110
+ else if (e[m] && o) {
1111
+ c.msg === "process_completed" && ["sse", "sse_v1", "sse_v2", "sse_v2.1", "sse_v3"].includes(
1112
+ o.protocol
1113
+ ) && s.delete(m);
1114
+ let w = e[m];
1115
+ typeof window < "u" && typeof document < "u" ? setTimeout(w, 0, c) : w(c);
1116
+ } else
1117
+ t[m] || (t[m] = []), t[m].push(c);
1118
+ }, a.onerror = async function(p) {
1119
+ console.error(p), await Promise.all(
1120
+ Object.keys(e).map(
1121
+ (c) => e[c]({
1122
+ msg: "broken_connection",
1123
+ message: L
1124
+ })
1125
+ )
1126
+ );
1127
+ };
1128
+ }
1129
+ function he(e, s) {
1130
+ e && (e.open = !1, s?.abort());
1131
+ }
1132
+ function Ht(e, s, t) {
1133
+ !e[s] ? (e[s] = [], t.data.forEach((o, r) => {
1134
+ e[s][r] = o;
1135
+ })) : t.data.forEach((o, r) => {
1136
+ let i = Jt(e[s][r], o);
1137
+ e[s][r] = i, t.data[r] = i;
1138
+ });
1139
+ }
1140
+ function Jt(e, s) {
1141
+ return s.forEach(([t, n, o]) => {
1142
+ e = Wt(e, n, t, o);
1143
+ }), e;
1144
+ }
1145
+ function Wt(e, s, t, n) {
1146
+ if (s.length === 0) {
1147
+ if (t === "replace")
1148
+ return n;
1149
+ if (t === "append")
1150
+ return e + n;
1151
+ throw new Error(`Unsupported action: ${t}`);
1152
+ }
1153
+ let o = e;
1154
+ for (let i = 0; i < s.length - 1; i++)
1155
+ o = o[s[i]];
1156
+ const r = s[s.length - 1];
1157
+ switch (t) {
1158
+ case "replace":
1159
+ o[r] = n;
1160
+ break;
1161
+ case "append":
1162
+ o[r] += n;
1163
+ break;
1164
+ case "add":
1165
+ Array.isArray(o) ? o.splice(Number(r), 0, n) : o[r] = n;
1166
+ break;
1167
+ case "delete":
1168
+ Array.isArray(o) ? o.splice(Number(r), 1) : delete o[r];
1169
+ break;
1170
+ default:
1171
+ throw new Error(`Unknown action: ${t}`);
1172
+ }
1173
+ return e;
1174
+ }
1175
+ function Zt(e, s = {}) {
1176
+ const t = {
1177
+ close: () => {
1178
+ console.warn("Method not implemented.");
1179
+ },
1180
+ onerror: null,
1181
+ onmessage: null,
1182
+ onopen: null,
1183
+ readyState: 0,
1184
+ url: e.toString(),
1185
+ withCredentials: !1,
1186
+ CONNECTING: 0,
1187
+ OPEN: 1,
1188
+ CLOSED: 2,
1189
+ addEventListener: () => {
1190
+ throw new Error("Method not implemented.");
1191
+ },
1192
+ dispatchEvent: () => {
1193
+ throw new Error("Method not implemented.");
1194
+ },
1195
+ removeEventListener: () => {
1196
+ throw new Error("Method not implemented.");
1197
+ }
1198
+ };
1199
+ return Gt(e, s).then(async (n) => {
1200
+ t.readyState = t.OPEN;
1201
+ try {
1202
+ for await (const o of n)
1203
+ t.onmessage && t.onmessage(o);
1204
+ t.readyState = t.CLOSED;
1205
+ } catch (o) {
1206
+ t.onerror && t.onerror(o), t.readyState = t.CLOSED;
1207
+ }
1208
+ }).catch((n) => {
1209
+ console.error(n), t.onerror && t.onerror(n), t.readyState = t.CLOSED;
1210
+ }), t;
1211
+ }
1212
+ function Kt(e, s = {}, t, n, o, r) {
1213
+ try {
1214
+ let i = function(h) {
1215
+ (o || ze[h.type]) && p(h);
1216
+ }, a = function() {
1217
+ for (Me = !0; G.length > 0; )
1218
+ G.shift()({
1219
+ value: void 0,
1220
+ done: !0
1221
+ });
1222
+ }, u = function(h) {
1223
+ G.length > 0 ? G.shift()(h) : ie.push(h);
1224
+ }, l = function(h) {
1225
+ u(Vt(h)), a();
1226
+ }, p = function(h) {
1227
+ u({ value: h, done: !1 });
1228
+ }, c = function() {
1229
+ return ie.length > 0 ? Promise.resolve(ie.shift()) : new Promise((h) => G.push(h));
1230
+ };
1231
+ const { token: m } = this.options, {
1232
+ fetch: w,
1233
+ app_reference: P,
1234
+ config: f,
1235
+ session_hash: g,
1236
+ api_info: J,
1237
+ api_map: z,
1238
+ stream_status: ee,
1239
+ pending_stream_messages: te,
1240
+ pending_diff_streams: se,
1241
+ event_callbacks: ne,
1242
+ unclosed_events: Ie,
1243
+ post_data: oe,
1244
+ options: W,
1245
+ api_prefix: T
1246
+ } = this, fe = r || { "x-gradio-user": "api" }, Ue = this;
1247
+ if (!J) throw new Error("No API found");
1248
+ if (!f) throw new Error("Could not resolve app config");
1249
+ let { fn_index: d, endpoint_info: _e, dependency: B } = Yt(
1250
+ J,
1251
+ e,
1252
+ z,
1253
+ f
1254
+ ), qe = wt(s, _e), U, A = f.protocol ?? "ws";
1255
+ if (A === "ws")
1256
+ throw new Error("WebSocket protocol is not supported in this version");
1257
+ let q = "", Qt = () => q;
1258
+ const _ = typeof e == "number" ? "/predict" : e;
1259
+ let Z, b = null, x = !1, me = {}, F = typeof window < "u" && typeof document < "u" ? new URLSearchParams(window.location.search).toString() : "";
1260
+ const ze = W?.events?.reduce(
1261
+ (h, R) => (h[R] = !0, h),
1262
+ {}
1263
+ ) || {};
1264
+ async function Be() {
1265
+ let h = {}, R = {};
1266
+ h = { event_id: b }, R = { event_id: b, session_hash: g, fn_index: d };
1267
+ try {
1268
+ if (!f)
1269
+ throw new Error("Could not resolve app config");
1270
+ "event_id" in R && await w(`${f.root}${T}/${st}`, {
1271
+ headers: { "Content-Type": "application/json" },
1272
+ method: "POST",
1273
+ body: JSON.stringify(R)
1274
+ }), await w(`${f.root}${T}/${tt}`, {
1275
+ headers: { "Content-Type": "application/json" },
1276
+ method: "POST",
1277
+ body: JSON.stringify(h)
1278
+ });
1279
+ } catch {
1280
+ console.warn(
1281
+ "The `/reset` endpoint could not be called. Subsequent endpoint results may be unreliable."
1282
+ );
1283
+ }
1284
+ }
1285
+ const Fe = async (h) => {
1286
+ await this._resolve_heartbeat(h);
1287
+ };
1288
+ async function ge(h) {
1289
+ if (!f) return;
1290
+ let R = h.render_id;
1291
+ f.components = [
1292
+ ...f.components.filter((v) => v.props.rendered_in !== R),
1293
+ ...h.components
1294
+ ], f.dependencies = [
1295
+ ...f.dependencies.filter((v) => v.rendered_in !== R),
1296
+ ...h.dependencies
1297
+ ];
1298
+ const K = f.components.some((v) => v.type === "state"), y = f.dependencies.some(
1299
+ (v) => v.targets.some((j) => j[1] === "unload")
1300
+ );
1301
+ f.connect_heartbeat = K || y, await Fe(f), i({
1302
+ type: "render",
1303
+ data: h,
1304
+ endpoint: _,
1305
+ fn_index: d
1306
+ });
1307
+ }
1308
+ const Ge = this.handle_blob(
1309
+ f.root,
1310
+ qe,
1311
+ _e
1312
+ ).then(async (h) => {
1313
+ if (Z = {
1314
+ data: Y(
1315
+ h,
1316
+ B,
1317
+ f.components,
1318
+ "input",
1319
+ !0
1320
+ ) || [],
1321
+ event_data: t,
1322
+ fn_index: d,
1323
+ trigger_id: n
1324
+ }, Ot(d, f))
1325
+ i({
1326
+ type: "status",
1327
+ endpoint: _,
1328
+ stage: "pending",
1329
+ queue: !1,
1330
+ fn_index: d,
1331
+ time: /* @__PURE__ */ new Date()
1332
+ }), oe(
1333
+ `${f.root}${T}/run${_.startsWith("/") ? _ : `/${_}`}${F ? "?" + F : ""}`,
1334
+ {
1335
+ ...Z,
1336
+ session_hash: g
1337
+ },
1338
+ fe
1339
+ ).then(async ([y, v]) => {
1340
+ const j = y.data;
1341
+ if (v == 200)
1342
+ i({
1343
+ type: "data",
1344
+ endpoint: _,
1345
+ fn_index: d,
1346
+ data: Y(
1347
+ j,
1348
+ B,
1349
+ f.components,
1350
+ "output",
1351
+ W.with_null_state
1352
+ ),
1353
+ time: /* @__PURE__ */ new Date(),
1354
+ event_data: t,
1355
+ trigger_id: n
1356
+ }), y.render_config && await ge(y.render_config), i({
1357
+ type: "status",
1358
+ endpoint: _,
1359
+ fn_index: d,
1360
+ stage: "complete",
1361
+ eta: y.average_duration,
1362
+ queue: !1,
1363
+ time: /* @__PURE__ */ new Date()
1364
+ });
1365
+ else {
1366
+ const O = y?.error === L;
1367
+ i({
1368
+ type: "status",
1369
+ stage: "error",
1370
+ endpoint: _,
1371
+ fn_index: d,
1372
+ message: y.error,
1373
+ broken: O,
1374
+ queue: !1,
1375
+ time: /* @__PURE__ */ new Date()
1376
+ });
1377
+ }
1378
+ }).catch((y) => {
1379
+ i({
1380
+ type: "status",
1381
+ stage: "error",
1382
+ message: y.message,
1383
+ endpoint: _,
1384
+ fn_index: d,
1385
+ queue: !1,
1386
+ time: /* @__PURE__ */ new Date()
1387
+ });
1388
+ });
1389
+ else if (A == "sse") {
1390
+ i({
1391
+ type: "status",
1392
+ stage: "pending",
1393
+ queue: !0,
1394
+ endpoint: _,
1395
+ fn_index: d,
1396
+ time: /* @__PURE__ */ new Date()
1397
+ });
1398
+ var K = new URLSearchParams({
1399
+ fn_index: d.toString(),
1400
+ session_hash: g
1401
+ }).toString();
1402
+ let y = new URL(
1403
+ `${f.root}${T}/${Te}?${F ? F + "&" : ""}${K}`
1404
+ );
1405
+ if (this.jwt && y.searchParams.set("__sign", this.jwt), U = this.stream(y), !U)
1406
+ return Promise.reject(
1407
+ new Error("Cannot connect to SSE endpoint: " + y.toString())
1408
+ );
1409
+ U.onmessage = async function(v) {
1410
+ const j = JSON.parse(v.data), { type: O, status: C, data: D } = Ee(
1411
+ j,
1412
+ me[d]
1413
+ );
1414
+ if (O === "update" && C && !x)
1415
+ i({
1416
+ type: "status",
1417
+ endpoint: _,
1418
+ fn_index: d,
1419
+ time: /* @__PURE__ */ new Date(),
1420
+ ...C
1421
+ }), C.stage === "error" && (U?.close(), a());
1422
+ else if (O === "data") {
1423
+ let [k, I] = await oe(
1424
+ `${f.root}${T}/queue/data`,
1425
+ {
1426
+ ...Z,
1427
+ session_hash: g,
1428
+ event_id: b
1429
+ }
1430
+ );
1431
+ I !== 200 && (i({
1432
+ type: "status",
1433
+ stage: "error",
1434
+ message: L,
1435
+ queue: !0,
1436
+ endpoint: _,
1437
+ fn_index: d,
1438
+ time: /* @__PURE__ */ new Date()
1439
+ }), U?.close(), a());
1440
+ } else O === "complete" ? x = C : O === "log" ? i({
1441
+ type: "log",
1442
+ title: D.title,
1443
+ log: D.log,
1444
+ level: D.level,
1445
+ endpoint: _,
1446
+ duration: D.duration,
1447
+ visible: D.visible,
1448
+ fn_index: d
1449
+ }) : (O === "generating" || O === "streaming") && i({
1450
+ type: "status",
1451
+ time: /* @__PURE__ */ new Date(),
1452
+ ...C,
1453
+ stage: C?.stage,
1454
+ queue: !0,
1455
+ endpoint: _,
1456
+ fn_index: d
1457
+ });
1458
+ D && (i({
1459
+ type: "data",
1460
+ time: /* @__PURE__ */ new Date(),
1461
+ data: Y(
1462
+ D.data,
1463
+ B,
1464
+ f.components,
1465
+ "output",
1466
+ W.with_null_state
1467
+ ),
1468
+ endpoint: _,
1469
+ fn_index: d,
1470
+ event_data: t,
1471
+ trigger_id: n
1472
+ }), x && (i({
1473
+ type: "status",
1474
+ time: /* @__PURE__ */ new Date(),
1475
+ ...x,
1476
+ stage: C?.stage,
1477
+ queue: !0,
1478
+ endpoint: _,
1479
+ fn_index: d
1480
+ }), U?.close(), a()));
1481
+ };
1482
+ } else if (A == "sse_v1" || A == "sse_v2" || A == "sse_v2.1" || A == "sse_v3") {
1483
+ i({
1484
+ type: "status",
1485
+ stage: "pending",
1486
+ queue: !0,
1487
+ endpoint: _,
1488
+ fn_index: d,
1489
+ time: /* @__PURE__ */ new Date()
1490
+ });
1491
+ let y = "";
1492
+ typeof window < "u" && typeof document < "u" && (y = window?.location?.hostname);
1493
+ const j = y.includes(".dev.") ? `https://moon-${y.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co";
1494
+ return (typeof window < "u" && typeof document < "u" && window.parent != window && window.supports_zerogpu_headers ? Tt("zerogpu-headers", j) : Promise.resolve(null)).then((k) => {
1495
+ const I = { ...fe, ...k || {} };
1496
+ return oe(
1497
+ `${f.root}${T}/${Ze}?${F}`,
1498
+ {
1499
+ ...Z,
1500
+ session_hash: g
1501
+ },
1502
+ I
1503
+ );
1504
+ }).then(async ([k, I]) => {
1505
+ if (k.event_id && (q = k.event_id), I === 503)
1506
+ i({
1507
+ type: "status",
1508
+ stage: "error",
1509
+ message: Ae,
1510
+ queue: !0,
1511
+ endpoint: _,
1512
+ fn_index: d,
1513
+ time: /* @__PURE__ */ new Date(),
1514
+ visible: !0
1515
+ });
1516
+ else if (I === 422)
1517
+ i({
1518
+ type: "status",
1519
+ stage: "error",
1520
+ message: k.detail,
1521
+ queue: !0,
1522
+ endpoint: _,
1523
+ fn_index: d,
1524
+ code: "validation_error",
1525
+ time: /* @__PURE__ */ new Date(),
1526
+ visible: !0
1527
+ }), a();
1528
+ else if (I !== 200) {
1529
+ const M = k?.error === L;
1530
+ i({
1531
+ type: "status",
1532
+ stage: "error",
1533
+ broken: M,
1534
+ message: M ? L : k.detail || k.error,
1535
+ queue: !0,
1536
+ endpoint: _,
1537
+ fn_index: d,
1538
+ time: /* @__PURE__ */ new Date(),
1539
+ visible: !0
1540
+ });
1541
+ } else {
1542
+ b = k.event_id, q = b;
1543
+ let M = async function(re) {
1544
+ try {
1545
+ const { type: E, status: S, data: $, original_msg: He } = Ee(
1546
+ re,
1547
+ me[d]
1548
+ );
1549
+ if (E == "heartbeat")
1550
+ return;
1551
+ if (E === "update" && S && !x)
1552
+ i({
1553
+ type: "status",
1554
+ endpoint: _,
1555
+ fn_index: d,
1556
+ time: /* @__PURE__ */ new Date(),
1557
+ original_msg: He,
1558
+ ...S
1559
+ });
1560
+ else if (E === "complete")
1561
+ x = S;
1562
+ else if (E == "unexpected_error" || E == "broken_connection") {
1563
+ console.error("Unexpected error", S?.message);
1564
+ const Je = E === "broken_connection";
1565
+ i({
1566
+ type: "status",
1567
+ stage: "error",
1568
+ message: S?.message || "An Unexpected Error Occurred!",
1569
+ queue: !0,
1570
+ endpoint: _,
1571
+ broken: Je,
1572
+ session_not_found: S?.session_not_found,
1573
+ fn_index: d,
1574
+ time: /* @__PURE__ */ new Date()
1575
+ });
1576
+ } else if (E === "log") {
1577
+ i({
1578
+ type: "log",
1579
+ title: $.title,
1580
+ log: $.log,
1581
+ level: $.level,
1582
+ endpoint: _,
1583
+ duration: $.duration,
1584
+ visible: $.visible,
1585
+ fn_index: d
1586
+ });
1587
+ return;
1588
+ } else (E === "generating" || E === "streaming") && (i({
1589
+ type: "status",
1590
+ time: /* @__PURE__ */ new Date(),
1591
+ ...S,
1592
+ stage: S?.stage,
1593
+ queue: !0,
1594
+ endpoint: _,
1595
+ fn_index: d
1596
+ }), $ && B.connection !== "stream" && ["sse_v2", "sse_v2.1", "sse_v3"].includes(A) && Ht(se, b, $));
1597
+ $ && (i({
1598
+ type: "data",
1599
+ time: /* @__PURE__ */ new Date(),
1600
+ data: Y(
1601
+ $.data,
1602
+ B,
1603
+ f.components,
1604
+ "output",
1605
+ W.with_null_state
1606
+ ),
1607
+ endpoint: _,
1608
+ fn_index: d
1609
+ }), $.render_config && await ge($.render_config), x && (i({
1610
+ type: "status",
1611
+ time: /* @__PURE__ */ new Date(),
1612
+ ...x,
1613
+ stage: S?.stage,
1614
+ queue: !0,
1615
+ endpoint: _,
1616
+ fn_index: d
1617
+ }), a())), (S?.stage === "complete" || S?.stage === "error") && (ne[b] && delete ne[b], b in se && delete se[b]);
1618
+ } catch (E) {
1619
+ console.error("Unexpected client exception", E), i({
1620
+ type: "status",
1621
+ stage: "error",
1622
+ message: "An Unexpected Error Occurred!",
1623
+ queue: !0,
1624
+ endpoint: _,
1625
+ fn_index: d,
1626
+ time: /* @__PURE__ */ new Date()
1627
+ }), ["sse_v2", "sse_v2.1", "sse_v3"].includes(A) && (he(ee, Ue.abort_controller), ee.open = !1, a());
1628
+ }
1629
+ };
1630
+ b in te && (te[b].forEach((re) => M(re)), delete te[b]), ne[b] = M, Ie.add(b), ee.open || await this.open_stream();
1631
+ }
1632
+ });
1633
+ }
1634
+ });
1635
+ let Me = !1;
1636
+ const ie = [], G = [], we = {
1637
+ [Symbol.asyncIterator]: () => we,
1638
+ next: c,
1639
+ throw: async (h) => (l(h), c()),
1640
+ return: async () => (a(), { value: void 0, done: !0 }),
1641
+ cancel: Be,
1642
+ send_chunk: (h) => {
1643
+ this.post_data(`${f.root}${T}/stream/${q}`, {
1644
+ ...h,
1645
+ session_hash: this.session_hash
1646
+ });
1647
+ },
1648
+ close_stream: () => {
1649
+ this.post_data(
1650
+ `${f.root}${T}/stream/${q}/close`,
1651
+ {}
1652
+ ), a();
1653
+ },
1654
+ event_id: () => q,
1655
+ wait_for_id: async () => (await Ge, b)
1656
+ };
1657
+ return we;
1658
+ } catch (i) {
1659
+ throw console.error("Submit function encountered an error:", i), i;
1660
+ }
1661
+ }
1662
+ function Vt(e) {
1663
+ return {
1664
+ then: (s, t) => t(e)
1665
+ };
1666
+ }
1667
+ function Yt(e, s, t, n) {
1668
+ let o, r, i;
1669
+ if (typeof s == "number")
1670
+ o = s, r = e.unnamed_endpoints[o], i = n.dependencies.find((a) => a.id == s);
1671
+ else {
1672
+ const a = s.replace(/^\//, "");
1673
+ o = t[a], r = e.named_endpoints[s.trim()], i = n.dependencies.find(
1674
+ (u) => u.id == t[a]
1675
+ );
1676
+ }
1677
+ if (typeof o != "number")
1678
+ throw new Error(
1679
+ "There is no endpoint matching that name of fn_index matching that number."
1680
+ );
1681
+ return { fn_index: o, endpoint_info: r, dependency: i };
1682
+ }
1683
+ class Q {
1684
+ app_reference;
1685
+ options;
1686
+ deep_link = null;
1687
+ config;
1688
+ api_prefix = "";
1689
+ api_info;
1690
+ api_map = {};
1691
+ session_hash = Math.random().toString(36).substring(2);
1692
+ jwt = !1;
1693
+ last_status = {};
1694
+ cookies = null;
1695
+ // streaming
1696
+ stream_status = { open: !1 };
1697
+ closed = !1;
1698
+ pending_stream_messages = {};
1699
+ pending_diff_streams = {};
1700
+ event_callbacks = {};
1701
+ unclosed_events = /* @__PURE__ */ new Set();
1702
+ heartbeat_event = null;
1703
+ abort_controller = null;
1704
+ stream_instance = null;
1705
+ current_payload;
1706
+ get_url_config(s = null) {
1707
+ if (!this.config)
1708
+ throw new Error(N);
1709
+ s === null && (s = window.location.href);
1710
+ const t = (i) => i.replace(/^\/+|\/+$/g, "");
1711
+ let n = t(new URL(this.config.root).pathname), o = t(new URL(s).pathname), r;
1712
+ return o.startsWith(n) ? r = t(o.substring(n.length)) : r = "", this.get_page_config(r);
1713
+ }
1714
+ get_page_config(s) {
1715
+ if (!this.config)
1716
+ throw new Error(N);
1717
+ let t = this.config;
1718
+ return s in t.page || (s = ""), {
1719
+ ...t,
1720
+ current_page: s,
1721
+ layout: t.page[s].layout,
1722
+ components: t.components.filter(
1723
+ (n) => t.page[s].components.includes(n.id)
1724
+ ),
1725
+ dependencies: this.config.dependencies.filter(
1726
+ (n) => t.page[s].dependencies.includes(n.id)
1727
+ )
1728
+ };
1729
+ }
1730
+ fetch(s, t) {
1731
+ const n = new Headers(t?.headers || {});
1732
+ return this && this.cookies && n.append("Cookie", this.cookies), this && this.options.headers && new Headers(this.options.headers).forEach((r, i) => {
1733
+ n.append(i, r);
1734
+ }), fetch(s, { ...t, headers: n });
1735
+ }
1736
+ stream(s) {
1737
+ const t = new Headers();
1738
+ return this && this.cookies && t.append("Cookie", this.cookies), this && this.options.headers && new Headers(this.options.headers).forEach((o, r) => {
1739
+ t.append(r, o);
1740
+ }), this && this.options.token && t.append("Authorization", `Bearer ${this.options.token}`), this.abort_controller = new AbortController(), this.stream_instance = Zt(s.toString(), {
1741
+ credentials: "include",
1742
+ headers: t,
1743
+ signal: this.abort_controller.signal
1744
+ }), this.stream_instance;
1745
+ }
1746
+ view_api;
1747
+ upload_files;
1748
+ upload;
1749
+ handle_blob;
1750
+ post_data;
1751
+ submit;
1752
+ predict;
1753
+ open_stream;
1754
+ resolve_config;
1755
+ resolve_cookies;
1756
+ constructor(s, t = { events: ["data"] }) {
1757
+ this.app_reference = s, this.deep_link = t.query_params?.deep_link || null, t.events || (t.events = ["data"]), this.options = t, this.current_payload = {}, t.cookies && (this.cookies = t.cookies), this.view_api = yt.bind(this), this.upload_files = bt.bind(this), this.handle_blob = At.bind(this), this.post_data = Dt.bind(this), this.submit = Kt.bind(this), this.predict = Nt.bind(this), this.open_stream = Mt.bind(this), this.resolve_config = dt.bind(this), this.resolve_cookies = ht.bind(this), this.upload = kt.bind(this), this.fetch = this.fetch.bind(this), this.handle_space_success = this.handle_space_success.bind(this), this.stream = this.stream.bind(this);
1758
+ }
1759
+ async init() {
1760
+ this.options.auth && await this.resolve_cookies(), await this._resolve_config().then(
1761
+ ({ config: s }) => this._resolve_heartbeat(s)
1762
+ ), this.api_info = await this.view_api(), this.api_map = pt(this.config?.dependencies || []);
1763
+ }
1764
+ async _resolve_heartbeat(s) {
1765
+ if (s && (this.config = s, this.api_prefix = s.api_prefix || "", this.config && this.config.connect_heartbeat && this.config.space_id && this.options.token && (this.jwt = await be(
1766
+ this.config.space_id,
1767
+ this.options.token,
1768
+ this.cookies
1769
+ ))), s.space_id && this.options.token && (this.jwt = await be(s.space_id, this.options.token)), this.config && this.config.connect_heartbeat) {
1770
+ const t = new URL(
1771
+ `${this.config.root}${this.api_prefix}/${Xe}/${this.session_hash}`
1772
+ );
1773
+ this.jwt && t.searchParams.set("__sign", this.jwt), this.heartbeat_event || (this.heartbeat_event = this.stream(t));
1774
+ }
1775
+ }
1776
+ static async connect(s, t = {
1777
+ events: ["data"]
1778
+ }) {
1779
+ const n = new this(s, t);
1780
+ return t.session_hash && (n.session_hash = t.session_hash), await n.init(), n;
1781
+ }
1782
+ async reconnect() {
1783
+ const s = new URL(
1784
+ `${this.config.root}${this.api_prefix}/${nt}`
1785
+ );
1786
+ let t;
1787
+ try {
1788
+ const n = await this.fetch(s);
1789
+ if (!n.ok)
1790
+ throw new Error();
1791
+ t = (await n.json()).app_id;
1792
+ } catch {
1793
+ return "broken";
1794
+ }
1795
+ return t !== this.config.app_id ? "changed" : "connected";
1796
+ }
1797
+ close() {
1798
+ this.closed = !0, he(this.stream_status, this.abort_controller);
1799
+ }
1800
+ set_current_payload(s) {
1801
+ this.current_payload = s;
1802
+ }
1803
+ static async duplicate(s, t = {
1804
+ events: ["data"]
1805
+ }) {
1806
+ return It(s, t);
1807
+ }
1808
+ async _resolve_config() {
1809
+ const { http_protocol: s, host: t, space_id: n } = await de(
1810
+ this.app_reference,
1811
+ this.options.token
1812
+ ), { status_callback: o } = this.options;
1813
+ n && o && await je(n, o);
1814
+ let r;
1815
+ try {
1816
+ let i = `${s}//${t}`;
1817
+ if (r = await this.resolve_config(i), !r)
1818
+ throw new Error(N);
1819
+ return this.config_success(r);
1820
+ } catch (i) {
1821
+ if (n && o)
1822
+ H(
1823
+ n,
1824
+ pe.test(n) ? "space_name" : "subdomain",
1825
+ this.handle_space_success
1826
+ );
1827
+ else
1828
+ throw o && o({
1829
+ status: "error",
1830
+ message: "Could not load this space.",
1831
+ load_status: "error",
1832
+ detail: "NOT_FOUND"
1833
+ }), Error(i);
1834
+ }
1835
+ }
1836
+ async config_success(s) {
1837
+ if (this.config = s, this.api_prefix = s.api_prefix || "", this.config.auth_required)
1838
+ return this.prepare_return_obj();
1839
+ try {
1840
+ this.api_info = await this.view_api();
1841
+ } catch (t) {
1842
+ console.error(it + t.message);
1843
+ }
1844
+ return this.prepare_return_obj();
1845
+ }
1846
+ async handle_space_success(s) {
1847
+ if (!this)
1848
+ throw new Error(N);
1849
+ const { status_callback: t } = this.options;
1850
+ if (t && t(s), s.status === "running")
1851
+ try {
1852
+ if (this.config = await this._resolve_config(), this.api_prefix = this?.config?.api_prefix || "", !this.config)
1853
+ throw new Error(N);
1854
+ return await this.config_success(this.config);
1855
+ } catch (n) {
1856
+ throw t && t({
1857
+ status: "error",
1858
+ message: "Could not load this space.",
1859
+ load_status: "error",
1860
+ detail: "NOT_FOUND"
1861
+ }), n;
1862
+ }
1863
+ }
1864
+ async component_server(s, t, n) {
1865
+ if (!this.config)
1866
+ throw new Error(N);
1867
+ const o = {}, { token: r } = this.options, { session_hash: i } = this;
1868
+ r && (o.Authorization = `Bearer ${this.options.token}`);
1869
+ let a, u = this.config.components.find(
1870
+ (p) => p.id === s
1871
+ );
1872
+ u?.props?.root_url ? a = u.props.root_url : a = this.config.root;
1873
+ let l;
1874
+ if (typeof n == "object" && n !== null && "binary" in n) {
1875
+ const p = n;
1876
+ l = new FormData();
1877
+ for (const c in p.data)
1878
+ c !== "binary" && l.append(c, p.data[c]);
1879
+ l.set("component_id", s.toString()), l.set("fn_name", t), l.set("session_hash", i);
1880
+ } else
1881
+ l = JSON.stringify({
1882
+ data: n,
1883
+ component_id: s,
1884
+ fn_name: t,
1885
+ session_hash: i
1886
+ }), o["Content-Type"] = "application/json";
1887
+ r && (o.Authorization = `Bearer ${r}`);
1888
+ try {
1889
+ const p = await this.fetch(
1890
+ `${a}${this.api_prefix}/${et}/`,
1891
+ {
1892
+ method: "POST",
1893
+ body: l,
1894
+ headers: o,
1895
+ credentials: "include"
1896
+ }
1897
+ );
1898
+ if (!p.ok)
1899
+ throw new Error(
1900
+ "Could not connect to component server: " + p.statusText
1901
+ );
1902
+ return await p.json();
1903
+ } catch (p) {
1904
+ console.warn(p);
1905
+ }
1906
+ }
1907
+ set_cookies(s) {
1908
+ this.cookies = Ne(s).join("; ");
1909
+ }
1910
+ prepare_return_obj() {
1911
+ return {
1912
+ config: this.config,
1913
+ predict: this.predict,
1914
+ submit: this.submit,
1915
+ view_api: this.view_api,
1916
+ component_server: this.component_server
1917
+ };
1918
+ }
1919
+ }
1920
+ async function ts(e, s = {
1921
+ events: ["data"]
1922
+ }) {
1923
+ return await Q.connect(e, s);
1924
+ }
1925
+ async function ss(e, s) {
1926
+ return await Q.duplicate(e, s);
1927
+ }
1928
+ export {
1929
+ Q as Client,
1930
+ X as FileData,
1931
+ ct as MISSING_CREDENTIALS_MSG,
1932
+ ts as client,
1933
+ ss as duplicate,
1934
+ es as handle_file,
1935
+ Nt as predict,
1936
+ Xt as prepare_files,
1937
+ Kt as submit,
1938
+ kt as upload,
1939
+ bt as upload_files
1940
+ };
hf_space/vendor/gradio-client-LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
hf_space/vendor/gradio-client-source.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "package": "@gradio/client",
3
+ "version": "2.1.0",
4
+ "matching_gradio": "6.10.0",
5
+ "source": "https://cdn.jsdelivr.net/npm/@gradio/client@2.1.0/dist/browser.js",
6
+ "sha256": "2a88bfa88b6c3ac52241287a36bf0a6325996d815abe0218a4be037f5067b3ea",
7
+ "license_file": "gradio-client-LICENSE",
8
+ "modified": false
9
+ }
hf_space/viewport.js CHANGED
@@ -36,8 +36,8 @@
36
  // Leave room for HF's wrapper. There is no fixed minimum desktop height.
37
  const height = Math.max(1, Math.floor(available) - (embedded ? 16 : 0));
38
  document.documentElement.style.setProperty('--st-available-height', height + 'px');
39
- // Measure the header's actual position, the resource notice (including
40
- // its margins), and Gradio's footer. Never budget using iframe height,
41
  // which grows with its content and would create a resize feedback loop.
42
  const top = root.getBoundingClientRect().top + window.scrollY;
43
  const tail = outerHeight(resourceFooter()) + gradioFooters().reduce((sum, node) => sum + outerHeight(node), 0);
 
36
  // Leave room for HF's wrapper. There is no fixed minimum desktop height.
37
  const height = Math.max(1, Math.floor(available) - (embedded ? 16 : 0));
38
  document.documentElement.style.setProperty('--st-available-height', height + 'px');
39
+ // Measure the header's position, plan notice, and Gradio's footer.
40
+ // Never budget using iframe height,
41
  // which grows with its content and would create a resize feedback loop.
42
  const top = root.getBoundingClientRect().top + window.scrollY;
43
  const tail = outerHeight(resourceFooter()) + gradioFooters().reduce((sum, node) => sum + outerHeight(node), 0);
hf_space/zerogpu_client.js ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Request-scoped ZeroGPU dispatch. HF quota headers stay in memory for one call. */
2
+ (() => {
3
+ const fallback = 'The GPU connection was interrupted. Refresh the page to reconnect your saved request.';
4
+ const identityError = () => new Error('Hugging Face could not verify your personal ZeroGPU quota.');
5
+ const quotaToken = headers => headers.get('x-zerogpu-token')?.trim() || headers.get('x-ip-token')?.trim();
6
+ const modelsReady = state => state?.native_build?.stage === 'compiled' && state?.models?.stage === 'ready_released';
7
+ const workspaceReady = state => state?.gpu_mode === 'zerogpu' ? state.workspace?.stage === 'ready' :
8
+ modelsReady(state) && (!state.workspace || state.workspace.stage === 'ready');
9
+ const foregroundOperation = record => record?.gpu_operation_kind === 'foreground' || !!record?.foreground;
10
+ const preparationState = (state, record) => (foregroundOperation(record) ? state?.foreground :
11
+ state?.native_build && state.native_build.stage !== 'compiled' ? state.native_build : state?.models) || {};
12
+ const operationReady = (state, record) => workspaceReady(state) && (foregroundOperation(record) ?
13
+ state?.foreground?.stage === 'ready_released' : modelsReady(state));
14
+ const preparationError = (state, record) => {
15
+ const failed = value => /^(failed|blocked)/.test(value?.stage || '');
16
+ return foregroundOperation(record) ? failed(state?.foreground) ? 'Background-removal model preparation failed' : null :
17
+ failed(state?.native_build) || failed(state?.models) ? 'Model preparation failed' : null;
18
+ };
19
+ function formatProgress(progress = {}) {
20
+ const count = value => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
21
+ const total = count(progress.total), completed = Math.min(count(progress.completed), total);
22
+ const percent = total ? Math.round(completed / total * 1000) / 10 : 0;
23
+ let quantity = '';
24
+ if (total) {
25
+ const divisor = total >= 1e9 ? 1e9 : 1e6;
26
+ quantity = progress.unit === 'bytes' ? `${(completed / divisor).toFixed(1)} / ${(total / divisor).toFixed(1)} ${divisor === 1e9 ? 'GB' : 'MB'}` :
27
+ `${completed} / ${total} ${progress.unit === 'models' ? 'models' : 'steps'}`;
28
+ }
29
+ return {percent, text: `${percent}%${quantity ? ' · ' + quantity : ''}`};
30
+ }
31
+ function safeMessage(error) {
32
+ const value = typeof error === 'string' ? error : error?.message;
33
+ if (typeof value !== 'string') return fallback;
34
+ // Do not display arbitrary backend exception text, paths, or credentials.
35
+ // A missing/invalid identity is not depleted quota. In particular, HF may
36
+ // return an IP-fallback token even while the account has its full allowance.
37
+ if (/could not verify|cannot verify|unable to verify|verification|sign.in|login|log in|authenticated|session.*expir/i.test(value))
38
+ return 'Hugging Face could not verify your account for ZeroGPU. Sign in on Hugging Face, then refresh this Space. This does not mean your quota is exhausted.';
39
+ if (/background-removal model preparation failed/i.test(value))
40
+ return 'The background-removal model could not be prepared. Please retry the task.';
41
+ if (/model preparation failed/i.test(value))
42
+ return 'Models could not be prepared for this task. Please retry the task.';
43
+ if (/quota[^.\n]*(?:exhausted|exceeded|insufficient)|(?:exhausted|exceeded|insufficient|not enough)[^.\n]*quota|(?:daily GPU|GPU usage)[^.\n]*(?:exceeded|exhausted|limit reached)/i.test(value))
44
+ return 'Your available Hugging Face GPU quota is insufficient for this task. Check your personal quota before trying again.';
45
+ if (/duration|timeout|timed out|time limit/i.test(value))
46
+ return 'This task exceeded its GPU time limit. Please try again.';
47
+ if (/busy|queue.*full|no.*GPU.*available/i.test(value))
48
+ return 'ZeroGPU is busy. Please try again shortly.';
49
+ if (/quota/i.test(value))
50
+ return 'Hugging Face could not process this GPU quota request. Refresh this Space and try again; your quota may still be available.';
51
+ return fallback;
52
+ }
53
+
54
+ function waitForVisible(signal) {
55
+ signal.throwIfAborted();
56
+ if (!document.hidden) return Promise.resolve();
57
+ return new Promise((resolve, reject) => {
58
+ const finish = error => {
59
+ document.removeEventListener('visibilitychange', visible);
60
+ signal.removeEventListener('abort', cancelled);
61
+ error ? reject(error) : resolve();
62
+ };
63
+ const visible = () => { if (!document.hidden) finish(); };
64
+ const cancelled = () => finish(signal.reason);
65
+ document.addEventListener('visibilitychange', visible);
66
+ signal.addEventListener('abort', cancelled, {once: true});
67
+ });
68
+ }
69
+
70
+ async function requestQuotaHeaders(signal) {
71
+ if (typeof window === 'undefined' || window.parent === window) throw identityError();
72
+ const origin = 'https://huggingface.co';
73
+ // An app OAuth cookie does not establish the embedding HF page's identity.
74
+ // Ask that page explicitly, only while this document is visible.
75
+ while (true) {
76
+ await waitForVisible(signal);
77
+ signal.throwIfAborted();
78
+ await new Promise((resolve, reject) => {
79
+ const finish = error => {
80
+ clearTimeout(timer); window.removeEventListener('message', supported);
81
+ signal.removeEventListener('abort', cancelled);
82
+ error ? reject(error) : resolve();
83
+ };
84
+ const supported = event => {
85
+ if (event.source === window.parent && event.origin === origin && event.data === 'supports-zerogpu-headers') finish();
86
+ };
87
+ const cancelled = () => finish(signal.reason);
88
+ const timer = setTimeout(() => finish(identityError()), 10000);
89
+ window.addEventListener('message', supported);
90
+ signal.addEventListener('abort', cancelled, {once: true});
91
+ try { window.parent.postMessage('supports-zerogpu-headers', origin); }
92
+ catch { finish(identityError()); }
93
+ });
94
+ if (document.hidden) continue;
95
+ signal.throwIfAborted();
96
+ const headers = await new Promise((resolve, reject) => {
97
+ const channel = new MessageChannel();
98
+ const finish = (error, value) => {
99
+ clearTimeout(timer); channel.port1.close(); channel.port2.close();
100
+ signal.removeEventListener('abort', cancelled);
101
+ error ? reject(error) : resolve(value);
102
+ };
103
+ const cancelled = () => finish(signal.reason);
104
+ const timer = setTimeout(() => finish(identityError()), 10000);
105
+ channel.port1.onmessage = ({data}) => {
106
+ try {
107
+ const values = new Headers(data || {});
108
+ // HF returns X-ZeroGPU-Token/UUID here; its proxy derives the
109
+ // x-ip-token seen by Python. Older parents may return x-ip-token.
110
+ if (!quotaToken(values)) return document.hidden ? finish(null, null) : finish(identityError());
111
+ values.set('x-gradio-user', 'app');
112
+ finish(null, Object.fromEntries(values));
113
+ } catch { finish(identityError()); }
114
+ };
115
+ signal.addEventListener('abort', cancelled, {once: true});
116
+ try { window.parent.postMessage('zerogpu-headers', origin, [channel.port2]); }
117
+ catch { finish(identityError()); }
118
+ });
119
+ // HF can omit the token if visibility changed mid-request. The null
120
+ // marker retries even if visibility returns before this continuation.
121
+ if (headers && !document.hidden) return headers;
122
+ }
123
+ }
124
+
125
+ function createDispatcher({identity, version, stopped, ready = () => true, prepare = async () => {},
126
+ quotaHeaders = requestQuotaHeaders, onPending, onError, onComplete,
127
+ connect = async options => {
128
+ const {Client} = await import('/gradio_api/file=hf_space/vendor/gradio-client-2.1.0.js');
129
+ return Client.connect(location.origin, options);
130
+ }}) {
131
+ const records = new Map(), clients = new Set();
132
+ let epoch = 0, runtimeId = null;
133
+ const current = (generation, accountVersion) =>
134
+ generation === epoch && accountVersion === version() && !stopped() && identity()?.authenticated;
135
+
136
+ function valid(record) {
137
+ const id = record?.gpu_operation_id;
138
+ return record?.gpu_mode === 'zerogpu' && typeof id === 'string' && /^[a-f0-9]{32}$/.test(id) &&
139
+ identity()?.authenticated && !stopped();
140
+ }
141
+ function fail(entry, error) {
142
+ if (!current(entry.generation, entry.accountVersion)) return;
143
+ entry.phase = 'failed'; entry.failure = safeMessage(error);
144
+ onError?.(entry.record, entry.failure);
145
+ }
146
+ function start(entry) {
147
+ if (entry.phase !== 'waiting' || !ready(entry.record) || !current(entry.generation, entry.accountVersion)) return entry.promise;
148
+ entry.phase = 'connecting';
149
+ return entry.promise = (async () => {
150
+ let connection;
151
+ try {
152
+ let values = new Headers(await quotaHeaders(entry.controller.signal) || {});
153
+ if (!quotaToken(values)) throw identityError();
154
+ if (!current(entry.generation, entry.accountVersion)) return;
155
+ const csrf = identity()?.csrf_token;
156
+ if (!csrf) throw new Error('Sign in again');
157
+ // Each operation owns its client, including its in-memory headers.
158
+ // Do not put x-gradio-user in options: Client.fetch appends them.
159
+ connection = Promise.resolve(connect({events: ['data', 'status'], headers: {'X-CSRF-Token': csrf}}));
160
+ clients.add(connection);
161
+ const client = await connection;
162
+ if (!current(entry.generation, entry.accountVersion)) return;
163
+ if (typeof document !== 'undefined' && document.hidden) {
164
+ values = new Headers(await quotaHeaders(entry.controller.signal) || {});
165
+ if (!quotaToken(values)) throw identityError();
166
+ if (!current(entry.generation, entry.accountVersion)) return;
167
+ }
168
+ values.set('x-gradio-user', 'app');
169
+ const headers = Object.fromEntries(values);
170
+ const originalFetch = client.fetch;
171
+ if (originalFetch) client.fetch = async (url, options) => {
172
+ const target = new URL(url, location.origin);
173
+ if (target.origin === location.origin && target.pathname.endsWith('/queue/join')) {
174
+ // Client 2.1.0 repeats the iframe handshake and merges its result
175
+ // over submit's headers. Recheck visibility and obtain fresh
176
+ // headers at the actual send, after that second handshake.
177
+ try {
178
+ const fresh = new Headers(await quotaHeaders(entry.controller.signal) || {});
179
+ if (!quotaToken(fresh)) throw identityError();
180
+ entry.controller.signal.throwIfAborted();
181
+ if (!current(entry.generation, entry.accountVersion)) throw identityError();
182
+ const locked = new Headers(options?.headers);
183
+ for (const name of ['x-zerogpu-token', 'x-zerogpu-uuid', 'x-ip-token']) locked.delete(name);
184
+ for (const [name, value] of fresh) locked.set(name, value);
185
+ locked.set('x-gradio-user', 'app');
186
+ return originalFetch.call(client, url, {...options, headers: locked});
187
+ } catch (error) {
188
+ // post_data catches fetch exceptions; retain only in memory
189
+ // so its generic transport error keeps the right UI category.
190
+ entry.headerError = error; throw error;
191
+ }
192
+ }
193
+ return originalFetch.call(client, url, options);
194
+ };
195
+ const submission = client.submit('/run_gpu_operation', {operation_id: entry.record.gpu_operation_id},
196
+ undefined, undefined, undefined, headers);
197
+ entry.phase = 'running';
198
+ let result = null;
199
+ for await (const event of submission) {
200
+ if (!current(entry.generation, entry.accountVersion)) return;
201
+ if (event.type === 'status' && event.stage === 'error') throw entry.headerError || event;
202
+ if (event.type === 'data') {
203
+ result = event.data?.[0] ?? event.data;
204
+ if (result?.status === 'queued') throw new Error('ZeroGPU is busy');
205
+ if (result?.status === 'failed' || result?.status === 'error')
206
+ throw new Error(result.error || result.message || 'GPU task failed');
207
+ }
208
+ }
209
+ if (current(entry.generation, entry.accountVersion)) {
210
+ entry.phase = 'complete'; onComplete?.(entry.record, result);
211
+ }
212
+ } catch (error) { fail(entry, error); }
213
+ finally {
214
+ if (connection && clients.delete(connection)) connection.then(client => client.close?.()).catch(() => {});
215
+ }
216
+ })();
217
+ }
218
+ function prepareEntry(entry) {
219
+ const attempt = {};
220
+ entry.preparation = attempt;
221
+ entry.controller.abort(); entry.controller = new AbortController();
222
+ entry.phase = 'preparing';
223
+ const active = () => entry.preparation === attempt &&
224
+ records.get(entry.record.gpu_operation_id) === entry && current(entry.generation, entry.accountVersion);
225
+ entry.promise = (async () => {
226
+ try {
227
+ // Only explicit user intent reaches this authenticated CPU-only POST.
228
+ await prepare(entry.record, entry.controller.signal);
229
+ if (!active() || entry.phase === 'failed') return;
230
+ entry.phase = 'waiting'; onPending?.(entry.record);
231
+ if (ready(entry.record)) await start(entry);
232
+ } catch (error) { if (active()) fail(entry, error); }
233
+ })();
234
+ return entry.promise;
235
+ }
236
+ function dispatch(record) {
237
+ if (!valid(record)) return null;
238
+ const id = record.gpu_operation_id;
239
+ if (records.has(id)) return start(records.get(id));
240
+ const entry = {record, generation: epoch, accountVersion: version(), controller: new AbortController(),
241
+ phase: 'preparing', failure: '', promise: null};
242
+ records.set(id, entry);
243
+ return prepareEntry(entry);
244
+ }
245
+
246
+ return {
247
+ observe(response, {requested = false} = {}) {
248
+ for (const record of [response, ...(response?.items || [])]) {
249
+ if (!valid(record)) continue;
250
+ const entry = records.get(record.gpu_operation_id);
251
+ if (entry) { entry.record = record; start(entry); }
252
+ else if (requested) dispatch(record);
253
+ }
254
+ },
255
+ dispatch,
256
+ syncRuntime(value) {
257
+ if (typeof value !== 'string' || !value || value === runtimeId) return;
258
+ const previous = runtimeId; runtimeId = value;
259
+ if (previous === null) return;
260
+ // A Space restart loses its CPU preparation controllers. Re-arm only
261
+ // requests explicitly started in this page and not yet sent to a GPU.
262
+ // History reads and already submitted GPU work must never be replayed.
263
+ for (const entry of records.values()) {
264
+ if (['preparing', 'waiting'].includes(entry.phase) && current(entry.generation, entry.accountVersion))
265
+ void prepareEntry(entry);
266
+ }
267
+ },
268
+ advance(error) {
269
+ for (const entry of records.values()) {
270
+ const failure = typeof error === 'function' ? error(entry.record) : error;
271
+ if (failure && ['preparing', 'waiting'].includes(entry.phase)) fail(entry, failure);
272
+ else start(entry);
273
+ }
274
+ },
275
+ requested(id) { return records.has(id); },
276
+ pending(predicate = () => true) {
277
+ return [...records.values()].some(entry => ['preparing', 'waiting', 'connecting', 'running'].includes(entry.phase) && predicate(entry.record));
278
+ },
279
+ failure(id) { return records.get(id)?.failure || ''; },
280
+ retry(record) {
281
+ if (records.get(record?.gpu_operation_id)?.failure) records.delete(record.gpu_operation_id);
282
+ return dispatch(record);
283
+ },
284
+ reset() {
285
+ epoch++;
286
+ for (const entry of records.values()) entry.controller.abort();
287
+ records.clear();
288
+ for (const client of clients) Promise.resolve(client).then(value => value.close?.()).catch(() => {});
289
+ clients.clear();
290
+ }
291
+ };
292
+ }
293
+ globalThis.SupraZeroGPU = Object.freeze({createDispatcher, requestQuotaHeaders, modelsReady, workspaceReady,
294
+ foregroundOperation, preparationState, operationReady, preparationError, formatProgress});
295
+ })();
hf_space/zerogpu_jobs.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authenticated Gradio entry to durable operations; no stored quota tokens."""
2
+ import base64
3
+ import json
4
+ import logging
5
+
6
+ import gradio as gr
7
+ from fastapi import HTTPException
8
+
9
+ from hf_space.history_auth import require_mutation
10
+ from hf_space.history_store import HistoryError, NotFound
11
+
12
+ LOG = logging.getLogger(__name__)
13
+
14
+
15
+ def _has_quota_error(token):
16
+ # Match spaces 0.51.3's jwt_payload parser and truthy error check. This
17
+ # unverified payload can only reject a known IP-quota fallback; it cannot
18
+ # establish identity. Unknown token formats remain HF's responsibility.
19
+ try:
20
+ _, payload, _ = token.split('.')
21
+ value = json.loads(base64.urlsafe_b64decode(payload + '=='))
22
+ except (ValueError, TypeError):
23
+ return False
24
+ return isinstance(value, dict) and bool(value.get('error'))
25
+
26
+
27
+ def public_gpu_error(error, fallback):
28
+ # Never persist scheduler tracebacks, request headers or arbitrary exceptions.
29
+ message = str(error).lower()
30
+ if any(value in message for value in ('verify', 'verification', 'authenticate', 'sign in', 'login', 'invalid token', 'expired token')):
31
+ return 'Hugging Face could not verify your personal ZeroGPU session. Keep the Space page visible and reconnect your account.'
32
+ if any(value in message for value in ('quota exceeded', 'quota exhausted', 'quota is insufficient', 'insufficient quota', 'daily gpu limit')):
33
+ return 'Your Hugging Face ZeroGPU quota is insufficient. Wait for it to reset, then try again.'
34
+ if 'duration' in message or 'timed out' in message or 'timeout' in message:
35
+ return 'The ZeroGPU time limit was reached. Please try again with your available GPU quota.'
36
+ if 'zerogpu' in message or 'gpu task aborted' in message:
37
+ return 'ZeroGPU is unavailable for this request. Please try again shortly.'
38
+ return fallback
39
+
40
+
41
+ def register(demo, service):
42
+ def run_gpu_operation(operation_id: str, request: gr.Request):
43
+ try:
44
+ if request is None or request.request is None:
45
+ raise HTTPException(401, 'Sign in with Hugging Face before using the GPU.')
46
+ identity = require_mutation(request.request)
47
+ # HF validates this opaque token when scheduling. OAuth alone does
48
+ # not identify the account whose GPU allowance will be consumed.
49
+ quota_token = dict(request.headers).get('x-ip-token')
50
+ if not quota_token:
51
+ raise HTTPException(403, 'Open this Space on Hugging Face and sign in to use your own ZeroGPU quota.')
52
+ if _has_quota_error(quota_token):
53
+ # Diagnostic booleans only: never log credentials or claims.
54
+ LOG.warning('ZeroGPU identity rejected: browser_quota_header=%s, browser_session_header=%s, app_request=%s',
55
+ bool(request.headers.get('x-zerogpu-token')),
56
+ bool(request.headers.get('x-zerogpu-uuid')),
57
+ request.headers.get('x-gradio-user') == 'app')
58
+ raise HTTPException(403, 'Hugging Face could not verify your personal ZeroGPU quota. Sign in on Hugging Face, then refresh this Space.')
59
+ pending = service.store.get_operation(identity.owner, operation_id)
60
+ if not service.ready(pending['operation']['kind']):
61
+ raise HTTPException(503, 'Models are loading. Please try again when loading is complete.')
62
+ service.runner.run_once(operation_id=operation_id, owner=identity.owner)
63
+ current = service.store.get_operation(identity.owner, operation_id)['operation']
64
+ return {'operation_id': operation_id, 'status': current['status'], 'error': current.get('error')}
65
+ except HTTPException as error:
66
+ raise gr.Error(error.detail) from None
67
+ except (NotFound, HistoryError):
68
+ raise gr.Error('This task is not available for your account.') from None
69
+
70
+ with demo:
71
+ operation = gr.Textbox(visible=False)
72
+ result = gr.JSON(visible=False)
73
+ trigger = gr.Button(visible=False)
74
+ trigger.click(run_gpu_operation, operation, result,
75
+ api_name='run_gpu_operation', api_visibility='public',
76
+ concurrency_limit=None,
77
+ show_progress='hidden')
78
+ return run_gpu_operation
hf_space/zerogpu_runtime.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPU leases for inference; the web process owns only immutable CPU weights.
2
+
3
+ Only primitive paths, seeds and progress identifiers cross the ZeroGPU process
4
+ boundary. History stores, locks, callbacks and authentication stay in the web
5
+ process. The caller must invoke these from its authenticated Gradio request.
6
+ """
7
+ from contextlib import redirect_stderr, redirect_stdout
8
+ import os
9
+ from pathlib import Path
10
+ import time
11
+ import traceback
12
+
13
+ from hf_space.gpu_mode import enabled
14
+
15
+ _resident = None
16
+ _foreground_resident = None
17
+ _mounted_started = False
18
+
19
+
20
+ def start_mounted_app():
21
+ """Run the SDK's launch hook for our mounted Gradio/FastAPI application.
22
+
23
+ spaces 0.51.3 hooks Blocks.launch(), which a uvicorn-mounted app never calls.
24
+ All lease functions are registered before this hook; subsequent preload is
25
+ CPU-only, so no post-pack emulated CUDA tensors are introduced at startup.
26
+ """
27
+ global _mounted_started
28
+ if enabled() and not _mounted_started:
29
+ from spaces.zero import startup
30
+ startup()
31
+ _mounted_started = True
32
+
33
+
34
+ def _gpu(setting, default):
35
+ seconds = int(os.environ.get(setting, str(default)))
36
+ if not 1 <= seconds <= 1200:
37
+ raise ValueError(setting + ' must be between 1 and 1200 seconds')
38
+ if enabled():
39
+ import spaces
40
+ return spaces.GPU(duration=seconds)
41
+ return lambda function: function
42
+
43
+
44
+ def preload(config, notify, *, factory=None):
45
+ """Prepare host tensors before ZeroGPU forks its first GPU request."""
46
+ global _resident
47
+ if factory is None:
48
+ from hf_space.resident_pipeline import ResidentPipeline
49
+ factory = ResidentPipeline
50
+ candidate = factory(config)
51
+ candidate.prepare(notify, cpu_only=True)
52
+ if not candidate.pool.sealed:
53
+ raise RuntimeError('The CPU model pool was not sealed')
54
+ _resident = candidate
55
+ return candidate.pool.summary()
56
+
57
+
58
+ def clear():
59
+ global _resident
60
+ _resident = None
61
+
62
+
63
+ def preload_foreground(model_path, notify, *, factory=None):
64
+ """Load RMBG alone before the foreground wrapper forks its first worker."""
65
+ global _foreground_resident
66
+ if factory is None:
67
+ from hf_space.resident_pipeline import ForegroundResident
68
+ factory = ForegroundResident
69
+ candidate = factory(model_path)
70
+ candidate.prepare(notify)
71
+ if not candidate.pool.sealed:
72
+ raise RuntimeError('The foreground CPU model pool was not sealed')
73
+ _foreground_resident = candidate
74
+ return candidate.pool.summary()
75
+
76
+
77
+ def _run(job_path, seed, foreground_prepared=False, *, foreground=False, stage='generate'):
78
+ resident = _foreground_resident if foreground else _resident
79
+ if resident is None:
80
+ raise RuntimeError('The CPU model pool is not ready')
81
+ from hf_space import runtime_worker
82
+ job = Path(job_path)
83
+ with (job / 'inference.log').open('w', encoding='utf-8', buffering=1) as log:
84
+ with redirect_stdout(log), redirect_stderr(log):
85
+ try:
86
+ if foreground:
87
+ runtime_worker.execute_foreground(resident, job)
88
+ elif stage == 'geometry':
89
+ runtime_worker.execute_geometry(resident, job, seed,
90
+ foreground_prepared=foreground_prepared)
91
+ elif stage == 'texture':
92
+ runtime_worker.execute_texture(resident, job, seed)
93
+ else:
94
+ runtime_worker.execute(resident, job, seed,
95
+ foreground_prepared=foreground_prepared)
96
+ except BaseException:
97
+ traceback.print_exc()
98
+ raise
99
+ finally:
100
+ resident.pool.restore_all()
101
+ return resident.pool.summary()
102
+
103
+
104
+ @_gpu('SUPRATEX_ZERO_GENERATE_SECONDS', 180)
105
+ def generate(job_path, seed, foreground_prepared=False):
106
+ return _run(job_path, seed, foreground_prepared)
107
+
108
+
109
+ @_gpu('SUPRATEX_ZERO_GEOMETRY_SECONDS', 180)
110
+ def geometry(job_path, seed, foreground_prepared=False):
111
+ return _run(job_path, seed, foreground_prepared, stage='geometry')
112
+
113
+
114
+ @_gpu('SUPRATEX_ZERO_TEXTURE_SECONDS', 180)
115
+ def texture(job_path, seed):
116
+ return _run(job_path, seed, stage='texture')
117
+
118
+
119
+ @_gpu('SUPRATEX_ZERO_FOREGROUND_SECONDS', 60)
120
+ def foreground(job_path):
121
+ return _run(job_path, 0, foreground=True)
122
+
123
+
124
+ @_gpu('SUPRATEX_ZERO_EXPORT_SECONDS', 180)
125
+ def export(source, output, reference, work, token):
126
+ from hf_space import extraction_progress, ovoxel_results
127
+ from hf_space.timing import observe_stages
128
+ events = []
129
+ began = time.monotonic()
130
+
131
+ def stage(name):
132
+ if name in extraction_progress.STAGES:
133
+ extraction_progress.stage(token, name)
134
+ events.append({'stage': name, 'start_seconds': round(time.monotonic() - began, 3)})
135
+
136
+ with observe_stages(lambda name: stage('export_gutter' if name == 'gutter' else name)):
137
+ ovoxel_results._export_local(Path(source), Path(output), gutter=reference is None)
138
+ if reference is not None:
139
+ from supratex.inference.reprojection import run_front_view
140
+ output = str(run_front_view(Path(output), Path(reference), Path(work), stage=stage,
141
+ keep=ovoxel_results._observation_keep(Path(reference))))
142
+ return str(output), events
143
+
144
+
145
+ @_gpu('SUPRATEX_ZERO_RECOVER_SECONDS', 120)
146
+ def recover(identifier):
147
+ from hf_space.preview_recovery import _recover
148
+ _recover(identifier, render_html=False)
149
+ return identifier
infer.py CHANGED
@@ -116,6 +116,8 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
116
  parser.add_argument("--moge-model", help="local MoGe-2 checkpoint/directory or HF repo ID")
117
  parser.add_argument("--rmbg-model", help="local RMBG-2 directory or HF repo ID; required for RGB input")
118
  parser.add_argument("--use-input-alpha", action="store_true", help="require and reuse foreground RGBA alpha")
 
 
119
  parser.add_argument("--resolution-level", type=int, default=9, help="MoGe-2 resolution level")
120
  parser.add_argument("--geometry-package", type=Path, help="native or paper geometry source checkout; see docs/geometry.md")
121
  parser.add_argument("--geometry-model", help="local geometry weights directory or HF repo ID")
@@ -289,13 +291,19 @@ def run(args: argparse.Namespace, *, resident=None, foreground_prepared: bool =
289
  from supratex.inference import run_completion
290
  work.mkdir(parents=True, exist_ok=True)
291
  handoff = work / "input"
 
 
 
 
292
  receipt = {"status": "running", "stage": "frontend", "input": str(image), "output": str(output),
293
  "work_dir": str(work), "seed": args.seed, "gutter": args.gutter,
294
  "material_backend": args.material_backend, "material_seed": 42,
295
  "runtime": runtime,
296
  "model_config": str(config), "checkpoint": str(checkpoint), "dual_view": args.dual_view,
297
  "refine": args.refine, "remove_floaters": args.remove_floaters, "preview_only": args.preview_only,
298
- "foreground_prepared": foreground_prepared}
 
 
299
  def record() -> None:
300
  temporary = work / ".run.json.tmp"
301
  temporary.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
@@ -305,6 +313,7 @@ def run(args: argparse.Namespace, *, resident=None, foreground_prepared: bool =
305
  print(f"[1/2] Foreground, MoGe-2, geometry, material maps -> {handoff}", flush=True)
306
  result = run_frontend(image, handoff, moge_model=args.moge_model, rmbg_model=args.rmbg_model,
307
  use_input_alpha=args.use_input_alpha, resolution_level=args.resolution_level,
 
308
  foreground_prepared=foreground_prepared,
309
  geometry_package=args.geometry_package.expanduser().resolve(), geometry_model=args.geometry_model,
310
  geometry_revision=args.geometry_revision, geometry_seed=args.seed,
 
116
  parser.add_argument("--moge-model", help="local MoGe-2 checkpoint/directory or HF repo ID")
117
  parser.add_argument("--rmbg-model", help="local RMBG-2 directory or HF repo ID; required for RGB input")
118
  parser.add_argument("--use-input-alpha", action="store_true", help="require and reuse foreground RGBA alpha")
119
+ parser.add_argument("--no-filter", "--no-image-depth", dest="no_filter", action="store_true",
120
+ help="disable the default single-view image/geometry depth pre-filter; dual-view does not use it")
121
  parser.add_argument("--resolution-level", type=int, default=9, help="MoGe-2 resolution level")
122
  parser.add_argument("--geometry-package", type=Path, help="native or paper geometry source checkout; see docs/geometry.md")
123
  parser.add_argument("--geometry-model", help="local geometry weights directory or HF repo ID")
 
291
  from supratex.inference import run_completion
292
  work.mkdir(parents=True, exist_ok=True)
293
  handoff = work / "input"
294
+ filter_requested = not getattr(args, 'no_filter', False)
295
+ filter_enabled = filter_requested and not args.dual_view
296
+ filter_disabled_reason = ('user_disabled' if not filter_requested else
297
+ 'dual_view_unsupported' if args.dual_view else None)
298
  receipt = {"status": "running", "stage": "frontend", "input": str(image), "output": str(output),
299
  "work_dir": str(work), "seed": args.seed, "gutter": args.gutter,
300
  "material_backend": args.material_backend, "material_seed": 42,
301
  "runtime": runtime,
302
  "model_config": str(config), "checkpoint": str(checkpoint), "dual_view": args.dual_view,
303
  "refine": args.refine, "remove_floaters": args.remove_floaters, "preview_only": args.preview_only,
304
+ "foreground_prepared": foreground_prepared,
305
+ "image_depth_filter": {"requested": filter_requested, "enabled": filter_enabled,
306
+ "disabled_reason": filter_disabled_reason}}
307
  def record() -> None:
308
  temporary = work / ".run.json.tmp"
309
  temporary.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8")
 
313
  print(f"[1/2] Foreground, MoGe-2, geometry, material maps -> {handoff}", flush=True)
314
  result = run_frontend(image, handoff, moge_model=args.moge_model, rmbg_model=args.rmbg_model,
315
  use_input_alpha=args.use_input_alpha, resolution_level=args.resolution_level,
316
+ export_image_depth=filter_enabled,
317
  foreground_prepared=foreground_prepared,
318
  geometry_package=args.geometry_package.expanduser().resolve(), geometry_model=args.geometry_model,
319
  geometry_revision=args.geometry_revision, geometry_seed=args.seed,
requirements.txt CHANGED
@@ -3,7 +3,7 @@
3
  torch==2.8.0
4
  torchvision==0.23.0
5
  gradio[oauth]==6.10.0
6
- spaces
7
  # Recorded from the working Python 3.10 / PyTorch 2.6.0 CUDA 12.4 environment.
8
  # Install torch/torchvision and compiled extensions separately; see DEPENDENCIES.md.
9
  numpy==2.2.6
 
3
  torch==2.8.0
4
  torchvision==0.23.0
5
  gradio[oauth]==6.10.0
6
+ spaces==0.51.3
7
  # Recorded from the working Python 3.10 / PyTorch 2.6.0 CUDA 12.4 environment.
8
  # Install torch/torchvision and compiled extensions separately; see DEPENDENCIES.md.
9
  numpy==2.2.6
supratex/frontend/camera.py CHANGED
@@ -29,10 +29,37 @@ def camera_from_intrinsics(intrinsics: np.ndarray, width: int, height: int, *, r
29
  "moge_resolution_level": int(resolution_level)}
30
 
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  def estimate(image: Path, model_path: Path, module_name: str = "moge.model.v2", resolution_level: int = 9,
33
- device: str = "cuda", *, resident=None) -> dict:
 
34
  if not 0 <= resolution_level <= 9:
35
  raise ValueError("resolution_level must be in [0, 9]")
 
 
 
 
36
  module = importlib.import_module(module_name)
37
  model_cls = getattr(module, "MoGeModel", None)
38
  if model_cls is None:
@@ -49,8 +76,14 @@ def estimate(image: Path, model_path: Path, module_name: str = "moge.model.v2",
49
  tensor = torch.from_numpy(np.asarray(rgb, dtype=np.float32) / 255.0).permute(2, 0, 1).to(device)
50
  with torch.inference_mode():
51
  result = model.infer(tensor, resolution_level=resolution_level, force_projection=True, apply_mask=True)
52
- return camera_from_intrinsics(result["intrinsics"].squeeze().float().cpu().numpy(), width, height,
53
- resolution_level=resolution_level)
 
 
 
 
 
 
54
  finally:
55
  model.cpu()
56
  del tensor, result, model
@@ -67,9 +100,11 @@ def main() -> None:
67
  help="MoGe-2 detail level from 0 (fastest) to 9 (highest).")
68
  parser.add_argument("--device", default="cuda")
69
  parser.add_argument("--output", type=Path, required=True)
 
70
  args = parser.parse_args()
71
  args.output.parent.mkdir(parents=True, exist_ok=True)
72
- args.output.write_text(json.dumps(estimate(args.image, args.model, args.module, args.resolution_level, args.device), indent=2) + "\n")
 
73
 
74
 
75
  if __name__ == "__main__":
 
29
  "moge_resolution_level": int(resolution_level)}
30
 
31
 
32
+ def _image_depth(result: dict, width: int, height: int) -> np.ndarray:
33
+ """Copy MoGe camera-z, rejecting outputs that changed the source pixel grid."""
34
+ value = result.get("depth")
35
+ if value is None:
36
+ raise ValueError("MoGe did not return an image depth map")
37
+ depth = value.detach().float().cpu().numpy()
38
+ if depth.shape == (1, height, width):
39
+ depth = depth[0]
40
+ if depth.shape != (height, width):
41
+ raise ValueError("MoGe depth must preserve the input image pixel grid")
42
+ valid = np.isfinite(depth) & (depth > 0)
43
+ mask = result.get("mask")
44
+ if mask is not None:
45
+ mask = mask.detach().cpu().numpy()
46
+ if mask.shape == (1, height, width):
47
+ mask = mask[0]
48
+ if mask.shape != depth.shape:
49
+ raise ValueError("MoGe valid mask must match the image depth grid")
50
+ valid &= np.isfinite(mask) & (mask > 0)
51
+ return np.where(valid, depth, np.nan).astype(np.float32)
52
+
53
+
54
  def estimate(image: Path, model_path: Path, module_name: str = "moge.model.v2", resolution_level: int = 9,
55
+ device: str = "cuda", *, depth_output: Path | None = None, resident=None) -> dict:
56
+ """Estimate camera and optional image-aligned depth with the same model call."""
57
  if not 0 <= resolution_level <= 9:
58
  raise ValueError("resolution_level must be in [0, 9]")
59
+ if depth_output is not None:
60
+ depth_output = Path(depth_output)
61
+ if depth_output.exists():
62
+ raise FileExistsError(f"image depth output must be new: {depth_output}")
63
  module = importlib.import_module(module_name)
64
  model_cls = getattr(module, "MoGeModel", None)
65
  if model_cls is None:
 
76
  tensor = torch.from_numpy(np.asarray(rgb, dtype=np.float32) / 255.0).permute(2, 0, 1).to(device)
77
  with torch.inference_mode():
78
  result = model.infer(tensor, resolution_level=resolution_level, force_projection=True, apply_mask=True)
79
+ camera = camera_from_intrinsics(result["intrinsics"].squeeze().float().cpu().numpy(), width, height,
80
+ resolution_level=resolution_level)
81
+ if depth_output is not None:
82
+ depth = _image_depth(result, width, height)
83
+ depth_output.parent.mkdir(parents=True, exist_ok=True)
84
+ with depth_output.open("xb") as stream:
85
+ np.save(stream, depth, allow_pickle=False)
86
+ return camera
87
  finally:
88
  model.cpu()
89
  del tensor, result, model
 
100
  help="MoGe-2 detail level from 0 (fastest) to 9 (highest).")
101
  parser.add_argument("--device", default="cuda")
102
  parser.add_argument("--output", type=Path, required=True)
103
+ parser.add_argument("--depth-output", type=Path, help="save image-aligned camera-z as .npy; invalid pixels become NaN")
104
  args = parser.parse_args()
105
  args.output.parent.mkdir(parents=True, exist_ok=True)
106
+ args.output.write_text(json.dumps(estimate(args.image, args.model, args.module, args.resolution_level, args.device,
107
+ depth_output=args.depth_output), indent=2) + "\n")
108
 
109
 
110
  if __name__ == "__main__":
supratex/frontend/pipeline.py CHANGED
@@ -112,12 +112,15 @@ def run(input_image: Path, output: Path, *, moge_model: str | Path,
112
  geometry_scale_search: bool = False, geometry_target_padding: int = 3, geometry_max_iterations: int = 2,
113
  geometry_seed: int = 42, record_material_provenance: bool = False,
114
  attention: str = "auto", low_vram: bool | None = None,
115
- parallel_frontend: bool = False, resident=None, foreground_prepared: bool = False) -> dict[str, str]:
 
116
  """Build the condition, canonical camera, and material-map handoff in one directory."""
117
  if resident is not None and (parallel_frontend or material_backend != "qwen-image"):
118
  raise ValueError("Resident frontend supports sequential Qwen generation only")
119
  if type(foreground_prepared) is not bool or (foreground_prepared and resident is None):
120
  raise ValueError('Prepared foreground is reserved for the trusted resident pipeline')
 
 
121
  if record_material_provenance and firered_manifest is not None:
122
  raise ValueError("material provenance does not support a custom FireRed manifest; "
123
  "omit it to bind the current condition and use matching prompts for both views")
@@ -167,14 +170,20 @@ def run(input_image: Path, output: Path, *, moge_model: str | Path,
167
  rmbg_model = resolve_model_dir(rmbg_model, cache_dir=cache_dir, local_files_only=local_files_only)
168
  output.mkdir(parents=True, exist_ok=True)
169
  resident_options = {"resident": resident} if resident is not None else {}
 
170
  estimator = RMBG2(rmbg_model, device=device, **resident_options) if rmbg_model and not foreground_prepared else None
171
  try:
172
- rgba, rgb, foreground_source = prepare_image(input_image, output_dir=output, use_input_alpha=use_input_alpha,
173
- background_model=estimator, foreground_prepared=foreground_prepared)
 
174
  finally:
175
  if estimator is not None:
176
  estimator.close()
177
- camera = estimate(rgb, moge_model, moge_module, resolution_level, device, **resident_options)
 
 
 
 
178
  camera["foreground_source"] = foreground_source
179
  camera_path = output / "camera.json"
180
  camera_path.write_text(json.dumps(camera, indent=2) + "\n", encoding="utf-8")
@@ -196,7 +205,18 @@ def run(input_image: Path, output: Path, *, moge_model: str | Path,
196
  executor.shutdown(wait=False)
197
  print(f"Frontend: geometry on GPU {gpus[0]}, material on GPU {gpus[1]} in parallel", flush=True)
198
  else:
199
- geometry_result = geometry_call()
 
 
 
 
 
 
 
 
 
 
 
200
  material_dir = output / "material"
201
  try:
202
  albedo, mra = predict_material_maps(
@@ -228,6 +248,8 @@ def run(input_image: Path, output: Path, *, moge_model: str | Path,
228
  result = {"condition_rgba": str(rgba), "condition_rgb": str(rgb), "camera": str(camera_path),
229
  "albedo": str(final_albedo), "mra": str(final_mra), "foreground_source": foreground_source,
230
  "material_backend": material_backend}
 
 
231
  if geometry_result:
232
  result.update(geometry=str(geometry_path), geometry_receipt=geometry_result["receipt"])
233
  return result
@@ -267,6 +289,8 @@ def main() -> None:
267
  parser.add_argument("--material-checkpoint-manifest", type=Path)
268
  parser.add_argument("--key", default="input")
269
  parser.add_argument("--record-material-provenance", action="store_true", help="write material bindings for optional dual-view inference")
 
 
270
  parser.add_argument("--device", default="cuda")
271
  parser.add_argument("--attention", choices=("auto", "flash_attn_3", "flash_attn", "sdpa", "xformers"), default="auto")
272
  parser.add_argument("--low-vram", action=argparse.BooleanOptionalAction, default=None,
@@ -290,6 +314,7 @@ def main() -> None:
290
  rmbg_model=args.rmbg_model, use_input_alpha=args.use_input_alpha, device=args.device,
291
  moge_module=args.moge_module, resolution_level=args.resolution_level, key=args.key,
292
  record_material_provenance=args.record_material_provenance,
 
293
  attention=args.attention, low_vram=args.low_vram), indent=2))
294
 
295
 
 
112
  geometry_scale_search: bool = False, geometry_target_padding: int = 3, geometry_max_iterations: int = 2,
113
  geometry_seed: int = 42, record_material_provenance: bool = False,
114
  attention: str = "auto", low_vram: bool | None = None,
115
+ parallel_frontend: bool = False, resident=None, foreground_prepared: bool = False,
116
+ geometry_only: bool = False, export_image_depth: bool = True) -> dict[str, str]:
117
  """Build the condition, canonical camera, and material-map handoff in one directory."""
118
  if resident is not None and (parallel_frontend or material_backend != "qwen-image"):
119
  raise ValueError("Resident frontend supports sequential Qwen generation only")
120
  if type(foreground_prepared) is not bool or (foreground_prepared and resident is None):
121
  raise ValueError('Prepared foreground is reserved for the trusted resident pipeline')
122
+ if geometry_only and (geometry_package is None or parallel_frontend):
123
+ raise ValueError('Geometry-only inference requires sequential geometry generation')
124
  if record_material_provenance and firered_manifest is not None:
125
  raise ValueError("material provenance does not support a custom FireRed manifest; "
126
  "omit it to bind the current condition and use matching prompts for both views")
 
170
  rmbg_model = resolve_model_dir(rmbg_model, cache_dir=cache_dir, local_files_only=local_files_only)
171
  output.mkdir(parents=True, exist_ok=True)
172
  resident_options = {"resident": resident} if resident is not None else {}
173
+ from hf_space.timing import span
174
  estimator = RMBG2(rmbg_model, device=device, **resident_options) if rmbg_model and not foreground_prepared else None
175
  try:
176
+ with span('frontend.preprocess'):
177
+ rgba, rgb, foreground_source = prepare_image(input_image, output_dir=output, use_input_alpha=use_input_alpha,
178
+ background_model=estimator, foreground_prepared=foreground_prepared)
179
  finally:
180
  if estimator is not None:
181
  estimator.close()
182
+ # Depth gating is single-view only; a dual-view front packet must omit it.
183
+ depth_path = output / "image_depth.npy" if export_image_depth and not record_material_provenance else None
184
+ with span('frontend.camera'):
185
+ camera = estimate(rgb, moge_model, moge_module, resolution_level, device,
186
+ depth_output=depth_path, **resident_options)
187
  camera["foreground_source"] = foreground_source
188
  camera_path = output / "camera.json"
189
  camera_path.write_text(json.dumps(camera, indent=2) + "\n", encoding="utf-8")
 
205
  executor.shutdown(wait=False)
206
  print(f"Frontend: geometry on GPU {gpus[0]}, material on GPU {gpus[1]} in parallel", flush=True)
207
  else:
208
+ with span('frontend.geometry'):
209
+ geometry_result = geometry_call()
210
+ if geometry_only:
211
+ geometry_path = output / 'geometry.pt'
212
+ geometry_path.write_bytes(Path(geometry_result['geometry']).read_bytes())
213
+ camera_path.write_bytes(Path(geometry_result['camera']).read_bytes())
214
+ (output / 'geometry_receipt.json').write_bytes(Path(geometry_result['receipt']).read_bytes())
215
+ result = {'condition_rgba': str(rgba), 'condition_rgb': str(rgb), 'camera': str(camera_path),
216
+ 'geometry': str(geometry_path), 'foreground_source': foreground_source}
217
+ if depth_path is not None:
218
+ result['image_depth'] = str(depth_path)
219
+ return result
220
  material_dir = output / "material"
221
  try:
222
  albedo, mra = predict_material_maps(
 
248
  result = {"condition_rgba": str(rgba), "condition_rgb": str(rgb), "camera": str(camera_path),
249
  "albedo": str(final_albedo), "mra": str(final_mra), "foreground_source": foreground_source,
250
  "material_backend": material_backend}
251
+ if depth_path is not None:
252
+ result["image_depth"] = str(depth_path)
253
  if geometry_result:
254
  result.update(geometry=str(geometry_path), geometry_receipt=geometry_result["receipt"])
255
  return result
 
289
  parser.add_argument("--material-checkpoint-manifest", type=Path)
290
  parser.add_argument("--key", default="input")
291
  parser.add_argument("--record-material-provenance", action="store_true", help="write material bindings for optional dual-view inference")
292
+ parser.add_argument("--no-filter", "--no-image-depth", dest="no_image_depth", action="store_true",
293
+ help="disable the default depth pre-filter by omitting image_depth.npy (implied by --record-material-provenance)")
294
  parser.add_argument("--device", default="cuda")
295
  parser.add_argument("--attention", choices=("auto", "flash_attn_3", "flash_attn", "sdpa", "xformers"), default="auto")
296
  parser.add_argument("--low-vram", action=argparse.BooleanOptionalAction, default=None,
 
314
  rmbg_model=args.rmbg_model, use_input_alpha=args.use_input_alpha, device=args.device,
315
  moge_module=args.moge_module, resolution_level=args.resolution_level, key=args.key,
316
  record_material_provenance=args.record_material_provenance,
317
+ export_image_depth=not args.no_image_depth,
318
  attention=args.attention, low_vram=args.low_vram), indent=2))
319
 
320
 
supratex/inference/image_depth.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conservative image/geometry depth consistency for source-pixel admission.
2
+
3
+ An auxiliary depth map supplies evidence about image-to-surface correspondence;
4
+ it is not ground truth. Invalid or degenerate fits leave the existing admission
5
+ rules unchanged and report why the additional gate was disabled.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ import numpy as np
12
+
13
+ IMAGE_DEPTH_GAP = 0.02
14
+ IMAGE_DEPTH_HIGHPASS = 41
15
+ IMAGE_DEPTH_BOUNDARY_RADIUS = 16
16
+ MIN_FIT_PIXELS = 1000
17
+
18
+
19
+ def image_depth_disagreement(depth: np.ndarray, image_depth: np.ndarray,
20
+ gap: float = IMAGE_DEPTH_GAP,
21
+ highpass: int = IMAGE_DEPTH_HIGHPASS,
22
+ boundary_radius: int = IMAGE_DEPTH_BOUNDARY_RADIUS,
23
+ stats: dict | None = None) -> np.ndarray:
24
+ """Return suspicious source pixels, using camera-z depths on the same grid.
25
+
26
+ ``gap`` is an absolute canonical length (unit object cube). ``highpass``
27
+ and ``boundary_radius`` are pixels of the observation grid, normally 1024.
28
+ The latter is a square/Chebyshev radius. Pixels without two finite positive
29
+ depths are never rejected. A source pixel is rejected only when its local
30
+ residual exceeds ``gap`` near a depth edge in the fitted image-depth map.
31
+ ``stats`` receives JSON-safe fit diagnostics, including disabled reasons.
32
+ """
33
+ from scipy.ndimage import maximum_filter, median_filter, minimum_filter
34
+
35
+ depth = np.asarray(depth)
36
+ image_depth = np.asarray(image_depth)
37
+ if depth.ndim != 2 or image_depth.shape != depth.shape:
38
+ raise ValueError("geometry and image depths must be matching 2D maps")
39
+ if not math.isfinite(gap) or gap <= 0:
40
+ raise ValueError("image-depth gap must be finite and positive")
41
+ if not isinstance(highpass, int) or highpass < 1 or highpass % 2 != 1:
42
+ raise ValueError("image-depth highpass must be a positive odd size")
43
+ if not isinstance(boundary_radius, int) or boundary_radius < 0:
44
+ raise ValueError("image-depth boundary radius must be nonnegative")
45
+
46
+ valid_image = np.isfinite(image_depth) & (image_depth > 0)
47
+ both = np.isfinite(depth) & (depth > 0) & valid_image
48
+ reject = np.zeros(depth.shape, dtype=bool)
49
+ report = {"status": "disabled", "reason": "insufficient_valid_depth",
50
+ "valid_pixels": int(both.sum()), "gap": float(gap),
51
+ "highpass": highpass, "boundary_radius": boundary_radius,
52
+ "rejected_pixels": 0}
53
+
54
+ def disabled(reason: str) -> np.ndarray:
55
+ report["reason"] = reason
56
+ if stats is not None:
57
+ stats.update(report)
58
+ return reject
59
+
60
+ if report["valid_pixels"] < MIN_FIT_PIXELS:
61
+ return disabled("insufficient_valid_depth")
62
+ x, y = image_depth[both].astype(np.float64), depth[both].astype(np.float64)
63
+ keep = np.ones(x.size, dtype=bool)
64
+ for iteration in range(3):
65
+ count = int(keep.sum())
66
+ if count < MIN_FIT_PIXELS:
67
+ return disabled("insufficient_fit_inliers")
68
+ xx, yy = x[keep], y[keep]
69
+ # Center the fit to avoid an ill-conditioned [large-depth, 1] matrix.
70
+ # A single depth layer cannot identify both scale and offset.
71
+ xmean, ymean = float(xx.mean()), float(yy.mean())
72
+ span = float(np.ptp(xx))
73
+ if span <= 1e-6 * max(float(np.max(np.abs(xx))), 1.0):
74
+ return disabled("degenerate_image_depth_span")
75
+ dx, dy = xx - xmean, yy - ymean
76
+ variance = float(np.mean(dx * dx))
77
+ if variance <= np.finfo(np.float64).eps * max(xmean * xmean, 1.0):
78
+ return disabled("degenerate_image_depth_variance")
79
+ a = float(np.mean(dx * dy) / variance)
80
+ b = ymean - a * xmean
81
+ if not math.isfinite(a) or not math.isfinite(b) or a <= 0:
82
+ return disabled("invalid_affine_fit")
83
+ residual = np.abs(y - (a * x + b))
84
+ if not np.isfinite(residual).all():
85
+ return disabled("nonfinite_affine_residual")
86
+ report.update(fit_iterations=iteration + 1, fit_pixels=count,
87
+ scale=a, offset=b,
88
+ median_absolute_residual=float(np.median(residual)))
89
+ if iteration < 2:
90
+ # Include percentile ties. Strict '<' can discard every pixel of
91
+ # an exactly aligned plane, or all of one of two depth layers.
92
+ threshold = float(np.percentile(residual, 80))
93
+ tolerance = 64 * np.finfo(np.float64).eps * max(float(np.max(np.abs(y))), 1.0)
94
+ keep = residual <= threshold + tolerance
95
+
96
+ fitted = np.full(depth.shape, np.nan, dtype=np.float32)
97
+ fitted[valid_image] = (a * image_depth[valid_image].astype(np.float64) + b).astype(np.float32)
98
+ if not np.isfinite(fitted[both]).all() or not (fitted[both] > 0).all():
99
+ return disabled("invalid_fitted_depth")
100
+ full = np.zeros(depth.shape, dtype=np.float32)
101
+ full[both] = depth[both] - fitted[both]
102
+ smooth = median_filter(full, size=highpass)
103
+ maximum = maximum_filter(np.where(valid_image, fitted, -np.inf), size=3,
104
+ mode="constant", cval=-np.inf)
105
+ minimum = minimum_filter(np.where(valid_image, fitted, np.inf), size=3,
106
+ mode="constant", cval=np.inf)
107
+ with np.errstate(invalid="ignore"):
108
+ boundary = valid_image & np.isfinite(maximum) & np.isfinite(minimum) & ((maximum - minimum) > gap)
109
+ near = maximum_filter(boundary, size=2 * boundary_radius + 1, mode="constant", cval=0)
110
+ reject[both] = (np.abs((full - smooth)[both]) > gap) & near[both]
111
+ report.update(status="applied", reason=None, rejected_pixels=int(reject.sum()),
112
+ boundary_pixels=int(boundary.sum()))
113
+ if stats is not None:
114
+ stats.update(report)
115
+ return reject
supratex/inference/native_observation.py CHANGED
@@ -11,6 +11,8 @@ from typing import Any
11
  import numpy as np
12
  import torch
13
 
 
 
14
  RESOLUTION = 1024
15
  LATENT_RESOLUTION = 64
16
 
@@ -233,10 +235,17 @@ def _admitted_pixels(depth: np.ndarray, missed: np.ndarray, focal: tuple[float,
233
 
234
 
235
  def pixel_first_occupied(camera: dict[str, Any], occupancy: np.ndarray, trusted: torch.Tensor, depth_edge_rtol: float = 0.04,
236
- stats: dict | None = None) -> torch.Tensor:
237
- """Map trusted camera pixels to their first occupied volume cell."""
 
 
 
 
 
 
238
  if "extrinsic" in camera:
239
- return pixel_first_occupied_cv(camera, occupancy, trusted, depth_edge_rtol, stats=stats)
 
240
  if trusted.shape != (RESOLUTION, RESOLUTION):
241
  raise ValueError("trusted alpha mask must be 1024 by 1024")
242
  if "camera_angle_x" not in camera or "distance" not in camera:
@@ -261,6 +270,12 @@ def pixel_first_occupied(camera: dict[str, Any], occupancy: np.ndarray, trusted:
261
  admitted = _admitted_pixels(depth, missed, (focal, focal), depth_edge_rtol, stats=stats)
262
  retained = hit_mask.copy()
263
  retained[hit_mask] &= admitted[y[hit_mask], x[hit_mask]]
 
 
 
 
 
 
264
  hit = owners[retained]
265
  if not hit.size:
266
  raise RuntimeError("observation admission removed every first-hit observation")
@@ -292,6 +307,53 @@ def camera_rays(camera: dict[str, Any], ys: np.ndarray, xs: np.ndarray) -> tuple
292
  return eye, directions, rotation
293
 
294
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  def pixel_hits_cv(camera: dict[str, Any], occupancy: np.ndarray, mask: np.ndarray) -> np.ndarray:
296
  """Return the raw first-hit silhouette, without trusted-mask depth filtering."""
297
  if mask.shape != (RESOLUTION, RESOLUTION) or mask.dtype != np.bool_:
@@ -305,7 +367,8 @@ def pixel_hits_cv(camera: dict[str, Any], occupancy: np.ndarray, mask: np.ndarra
305
 
306
 
307
  def pixel_first_occupied_cv(camera: dict[str, Any], occupancy: np.ndarray, trusted: torch.Tensor,
308
- depth_edge_rtol: float = .04, stats: dict | None = None) -> torch.Tensor:
 
309
  """Apply the production first-hit admission rules to a full CV camera."""
310
  if trusted.shape != (RESOLUTION, RESOLUTION) or trusted.dtype != torch.bool:
311
  raise ValueError("trusted alpha must be a boolean observation-resolution image")
@@ -330,6 +393,12 @@ def pixel_first_occupied_cv(camera: dict[str, Any], occupancy: np.ndarray, trust
330
  intrinsic = np.asarray(camera.get("projection_intrinsic", camera.get("intrinsic")), dtype=np.float64)
331
  focal = (float(intrinsic[0, 0]) * RESOLUTION, float(intrinsic[1, 1]) * RESOLUTION)
332
  retained = hits & _admitted_pixels(depth, missed, focal, depth_edge_rtol, stats=stats)[ys, xs]
 
 
 
 
 
 
333
  kept = owners[retained].astype(np.int64, copy=False)
334
  if not kept.size:
335
  raise RuntimeError("observation admission removed every first-hit observation")
 
11
  import numpy as np
12
  import torch
13
 
14
+ from .image_depth import image_depth_disagreement
15
+
16
  RESOLUTION = 1024
17
  LATENT_RESOLUTION = 64
18
 
 
235
 
236
 
237
  def pixel_first_occupied(camera: dict[str, Any], occupancy: np.ndarray, trusted: torch.Tensor, depth_edge_rtol: float = 0.04,
238
+ stats: dict | None = None, image_depth: np.ndarray | None = None,
239
+ depth_gate_out: dict | None = None) -> torch.Tensor:
240
+ """Map trusted pixels to first occupied cells, optionally excluding depth conflicts.
241
+
242
+ With image depth, ``depth_gate_out['keep_mask']`` contains only the depth
243
+ gate's keep mask for material sampling and reprojection. Alpha and geometry
244
+ admission remain separate. Diagnostics are written to ``stats['image_depth']``.
245
+ """
246
  if "extrinsic" in camera:
247
+ return pixel_first_occupied_cv(camera, occupancy, trusted, depth_edge_rtol, stats=stats,
248
+ image_depth=image_depth, depth_gate_out=depth_gate_out)
249
  if trusted.shape != (RESOLUTION, RESOLUTION):
250
  raise ValueError("trusted alpha mask must be 1024 by 1024")
251
  if "camera_angle_x" not in camera or "distance" not in camera:
 
270
  admitted = _admitted_pixels(depth, missed, (focal, focal), depth_edge_rtol, stats=stats)
271
  retained = hit_mask.copy()
272
  retained[hit_mask] &= admitted[y[hit_mask], x[hit_mask]]
273
+ if image_depth is not None:
274
+ mismatch = _image_depth_mask(camera, occupancy, image_depth, stats, depth_gate_out)
275
+ rejected = retained & mismatch[y, x]
276
+ retained &= ~mismatch[y, x]
277
+ if stats is not None:
278
+ stats.update(rejected_image_depth=int(rejected.sum()), admitted=int(retained.sum()))
279
  hit = owners[retained]
280
  if not hit.size:
281
  raise RuntimeError("observation admission removed every first-hit observation")
 
307
  return eye, directions, rotation
308
 
309
 
310
+ def first_hit_depth(camera: dict[str, Any], occupancy: np.ndarray) -> np.ndarray:
311
+ """Return full-grid first-hit camera-z for canonical and complete CV cameras.
312
+
313
+ The canonical camera looks down world -z; its positive view depth is
314
+ ``eye_z - world_z``. CV cameras instead use the z component after their
315
+ world-to-camera rotation. Neither convention uses Euclidean ray length.
316
+ """
317
+ ys, xs = np.mgrid[0:RESOLUTION, 0:RESOLUTION]
318
+ ys, xs = ys.ravel(), xs.ravel()
319
+ if "extrinsic" in camera:
320
+ eye, directions, rotation = camera_rays(camera, ys, xs)
321
+ else:
322
+ angle = float(camera["camera_angle_x"])
323
+ distance = float(camera["distance"]) * float(camera.get("mesh_scale", 1.0))
324
+ if not math.isfinite(angle) or not 0 < angle < math.pi or not math.isfinite(distance) or distance <= 0:
325
+ raise ValueError("canonical camera requires a finite FOV in (0, pi) and positive effective distance")
326
+ # Keep the arithmetic order of the canonical admission ray builder.
327
+ focal = 16.0 / math.tan(angle / 2.0) * RESOLUTION / 32.0
328
+ eye = np.array([0., 0., distance], dtype=np.float64)
329
+ directions = np.column_stack(((xs + .5 - RESOLUTION / 2) / focal,
330
+ -(ys + .5 - RESOLUTION / 2) / focal,
331
+ -np.ones(xs.size))).astype(np.float64)
332
+ rotation = np.diag([1., -1., -1.])
333
+ owners, reasons, _ = _dda_batch_kernel()(eye, directions, occupancy, -.5, .5, RESOLUTION)
334
+ hits = reasons == 0
335
+ centers = (owners[hits].astype(np.float64) + .5) / RESOLUTION - .5
336
+ values = ((centers - eye) @ rotation.T)[:, 2]
337
+ if not np.isfinite(values).all() or not (values > 0).all():
338
+ raise RuntimeError("first-hit owners must lie in front of the camera")
339
+ result = np.full((RESOLUTION, RESOLUTION), np.nan, dtype=np.float32)
340
+ result[ys[hits], xs[hits]] = values.astype(np.float32)
341
+ return result
342
+
343
+
344
+ def _image_depth_mask(camera: dict[str, Any], occupancy: np.ndarray, image_depth: np.ndarray,
345
+ stats: dict | None, depth_gate_out: dict | None) -> np.ndarray:
346
+ if np.asarray(image_depth).shape != (RESOLUTION, RESOLUTION):
347
+ raise ValueError("image depth must be an observation-resolution map")
348
+ diagnostic: dict[str, Any] = {}
349
+ mismatch = image_depth_disagreement(first_hit_depth(camera, occupancy), image_depth, stats=diagnostic)
350
+ if stats is not None:
351
+ stats["image_depth"] = diagnostic
352
+ if depth_gate_out is not None:
353
+ depth_gate_out["keep_mask"] = ~mismatch
354
+ return mismatch
355
+
356
+
357
  def pixel_hits_cv(camera: dict[str, Any], occupancy: np.ndarray, mask: np.ndarray) -> np.ndarray:
358
  """Return the raw first-hit silhouette, without trusted-mask depth filtering."""
359
  if mask.shape != (RESOLUTION, RESOLUTION) or mask.dtype != np.bool_:
 
367
 
368
 
369
  def pixel_first_occupied_cv(camera: dict[str, Any], occupancy: np.ndarray, trusted: torch.Tensor,
370
+ depth_edge_rtol: float = .04, stats: dict | None = None,
371
+ image_depth: np.ndarray | None = None, depth_gate_out: dict | None = None) -> torch.Tensor:
372
  """Apply the production first-hit admission rules to a full CV camera."""
373
  if trusted.shape != (RESOLUTION, RESOLUTION) or trusted.dtype != torch.bool:
374
  raise ValueError("trusted alpha must be a boolean observation-resolution image")
 
393
  intrinsic = np.asarray(camera.get("projection_intrinsic", camera.get("intrinsic")), dtype=np.float64)
394
  focal = (float(intrinsic[0, 0]) * RESOLUTION, float(intrinsic[1, 1]) * RESOLUTION)
395
  retained = hits & _admitted_pixels(depth, missed, focal, depth_edge_rtol, stats=stats)[ys, xs]
396
+ if image_depth is not None:
397
+ mismatch = _image_depth_mask(camera, occupancy, image_depth, stats, depth_gate_out)
398
+ rejected = retained & mismatch[ys, xs]
399
+ retained &= ~mismatch[ys, xs]
400
+ if stats is not None:
401
+ stats.update(rejected_image_depth=int(rejected.sum()), admitted=int(retained.sum()))
402
  kept = owners[retained].astype(np.int64, copy=False)
403
  if not kept.size:
404
  raise RuntimeError("observation admission removed every first-hit observation")
supratex/inference/pipeline.py CHANGED
@@ -196,6 +196,17 @@ def _load_case(input_dir: str | Path, torch: Any, image: Any, remove_floaters: b
196
  raise ValueError("camera.json must be valid JSON") from error
197
  if "camera_angle_x" not in camera or "distance" not in camera:
198
  raise ValueError("camera.json must provide canonical camera_angle_x and distance")
 
 
 
 
 
 
 
 
 
 
 
199
  if remove_floaters:
200
  import numpy as np
201
  from .geometry_cleanup import remove_floaters as clean
@@ -203,7 +214,8 @@ def _load_case(input_dir: str | Path, torch: Any, image: Any, remove_floaters: b
203
  vertices, faces = vertices.contiguous(), faces.contiguous()
204
  print(f"Floaters: removed {report['removed_components']} of {report['components']} components "
205
  f"({report['removed_faces']} of {report['faces']} faces)", flush=True)
206
- return {"vertices": vertices, "faces": faces, "rgba": rgba, "albedo": albedo, "mra": mra, "camera": camera}
 
207
 
208
 
209
  def _runtime() -> dict[str, Any]:
@@ -266,7 +278,8 @@ def _alignment_report(uv: Any, alpha: Any, admission: Mapping[str, int], np: Any
266
  rejected = [f"missed geometry {admission['trusted_rays'] - admission['first_hits']}",
267
  f"depth edge {admission['rejected_depth_edge']}"]
268
  rejected += [f"{label} {admission[key]}" for key, label in (("rejected_silhouette", "silhouette margin"),
269
- ("rejected_grazing", "grazing")) if admission[key]]
 
270
  print(f"Observation: silhouette IoU {report['silhouette_iou']:.3f} "
271
  f"(geometry inside image {report['geometry_inside_image']:.3f}, image covered {report['image_covered_by_geometry']:.3f}); "
272
  f"admitted {admission['admitted']} of {admission['trusted_rays']} trusted pixels ({', '.join(rejected)})", flush=True)
@@ -276,7 +289,21 @@ def _alignment_report(uv: Any, alpha: Any, admission: Mapping[str, int], np: Any
276
  return report
277
 
278
 
279
- def _partial(surface: Any, shape: Any, case: Mapping[str, Any], external: Any, projection: Any, encoder: Any, normalization: Mapping[str, Any], runtime: Mapping[str, Any], device: str) -> Any:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  torch, F, np, image, image_filter, sparse = runtime["torch"], runtime["F"], runtime["np"], runtime["Image"], runtime["ImageFilter"], runtime["sparse"]
281
  coords = surface.coords[:, 1:].long(); points = (coords.float() + .5) / RESOLUTION - .5
282
  camera = (torch.cat((points, torch.ones_like(points[:, :1])), dim=1) @ external.T)[:, :3]
@@ -289,13 +316,35 @@ def _partial(surface: Any, shape: Any, case: Mapping[str, Any], external: Any, p
289
  mask = image.fromarray(np.where(alpha >= 254, 255, 0).astype(np.uint8)).resize((RESOLUTION, RESOLUTION), image.Resampling.NEAREST).filter(image_filter.MinFilter(5))
290
  trusted = torch.from_numpy(np.asarray(mask, dtype=np.uint8).copy() == 255)
291
  bits, ordered, order = native_observation.build_occupancy_index(coords)
292
- admission: dict[str, int] = {}
293
- owners = native_observation.pixel_first_occupied(dict(case["camera"]), bits, trusted, stats=admission)
 
 
 
 
 
 
294
  _alignment_report(uv, alpha, admission, np, image, image_filter)
 
 
 
 
 
 
295
  visible = native_observation.owner_rows(coords, owners, ordered, order)
 
 
 
 
 
 
 
 
 
296
  if not bool(visible.any()): raise RuntimeError("the trusted alpha gate leaves no observed material surface")
297
  with torch.no_grad():
298
- return native_observation.encode_partial(encoder, coords, visible, sampled(case["albedo"]), sampled(case["mra"]), shape.coords[:, 1:], dict(normalization), sparse, device)
 
299
 
300
 
301
  def _export(output: Path, representation: Any, texture_size: int, decimation_target: int, texture_format: str, runtime: Mapping[str, Any], *, gutter: bool = False, low_vram: bool = False) -> None:
@@ -372,6 +421,31 @@ def _completion_stage(model: Any, torch: Any, device: str, *, layer_offload: boo
372
  torch.cuda.empty_cache()
373
 
374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  def _run_completion_low_vram(*, case: Mapping[str, Any], back_case: Mapping[str, Any] | None,
376
  ckpts: Path, config: Mapping[str, Any], dataset: Mapping[str, Any],
377
  checkpoint_path: Path, partial_stats: Mapping[str, Any], resolved: Any,
@@ -390,15 +464,15 @@ def _run_completion_low_vram(*, case: Mapping[str, Any], back_case: Mapping[str,
390
  del shape_encoder
391
  texture_encoder = (resident.completion_model("texture_encoder") if resident is not None else
392
  _load_vae(models, _stem(ckpts / "tex_enc_next_dc_f16c32_fp16", "material encoder")))
393
- dual_stats = {}
394
  with _completion_stage(texture_encoder, torch, device):
395
  if back_case is None:
396
  external, projection = _camera(case["camera"], device, torch, runtime["utils3d"])
397
- observation = _partial(surface, shape, case, external, projection, texture_encoder, partial_stats, runtime, device)
398
  del external, projection
399
  else:
400
  from .dual_view import encode_two_views
401
- observation, dual_stats = encode_two_views(surface, shape, case, back_case, texture_encoder,
402
  partial_stats, runtime, device)
403
  del texture_encoder, surface
404
  torch.cuda.empty_cache()
@@ -448,12 +522,13 @@ def _run_completion_low_vram(*, case: Mapping[str, Any], back_case: Mapping[str,
448
  torch.cuda.empty_cache()
449
  representation = runtime["MeshWithVoxel"](case["vertices"], case["faces"], [-.5, -.5, -.5], 1. / RESOLUTION, decoded_cpu.coords[:, 1:].int(), decoded_cpu.feats.float().clamp(0, 1), torch.Size([1, 6, RESOLUTION, RESOLUTION, RESOLUTION]), layout=LAYOUT)
450
  del decoded_cpu
 
451
  if preview_only:
452
  from hf_space.ovoxel_preview import save_and_render
453
  save_and_render(Path(output), representation.to(device))
454
  else:
455
  _export(Path(output), representation, texture_size, decimation_target, texture_format, runtime, gutter=gutter, low_vram=True)
456
- return dual_stats
457
 
458
 
459
  def _run_completion(*, input_dir: str | Path, output: str | Path, model_config: str | Path, checkpoint: str | Path, trellis_root: str | Path, seed: int = 42, device: str = "cuda", texture_size: int = 4096, decimation_target: int = 500000, texture_format: str = "png", gutter: bool = False, back_case: Mapping[str, Any] | None = None, remove_floaters: bool = False, preview_only: bool = False, resident=None) -> dict[str, Any]:
@@ -470,6 +545,9 @@ def _run_completion(*, input_dir: str | Path, output: str | Path, model_config:
470
  runtime = _runtime(); torch, models = runtime["torch"], runtime["models"]
471
  if not torch.cuda.is_available(): raise RuntimeError("CUDA is unavailable")
472
  case = _load_case(input_dir, torch, runtime["Image"], remove_floaters); ckpts = _completion_vae_root(trellis_root)
 
 
 
473
  if os.environ.get("SUPRATEX_LOW_VRAM", "0") == "1":
474
  return _run_completion_low_vram(case=case, back_case=back_case, ckpts=ckpts, config=config,
475
  dataset=dataset, checkpoint_path=checkpoint_path, partial_stats=partial_stats,
@@ -482,12 +560,12 @@ def _run_completion(*, input_dir: str | Path, output: str | Path, model_config:
482
  external, projection = _camera(case["camera"], device, torch, runtime["utils3d"])
483
  shape, surface = _shape(case["vertices"], case["faces"], shape_encoder, runtime, device)
484
  shape_encoder.to("cpu"); torch.cuda.empty_cache()
485
- dual_stats = {}
486
  if back_case is None:
487
- observation = _partial(surface, shape, case, external, projection, texture_encoder, partial_stats, runtime, device)
488
  else:
489
  from .dual_view import encode_two_views
490
- observation, dual_stats = encode_two_views(surface, shape, case, back_case, texture_encoder,
491
  partial_stats, runtime, device)
492
  texture_encoder.to("cpu"); torch.cuda.empty_cache()
493
  from supratex.pipelines.observation_guided_completion import build_completion_model, sample_material_latents
@@ -513,12 +591,13 @@ def _run_completion(*, input_dir: str | Path, output: str | Path, model_config:
513
  with torch.no_grad(): _, guide = shape_decoder(shape, return_subs=True); decoded = material_decoder(generated, guide_subs=guide) * .5 + .5
514
  if decoded.feats.shape[1] != 6: raise RuntimeError("material decoder must emit six PBR channels")
515
  representation = runtime["MeshWithVoxel"](case["vertices"].to(device), case["faces"].to(device), [-.5, -.5, -.5], 1. / RESOLUTION, decoded.coords[:, 1:].int(), decoded.feats.float().clamp(0, 1), torch.Size([1, 6, RESOLUTION, RESOLUTION, RESOLUTION]), layout=LAYOUT)
 
516
  if preview_only:
517
  from hf_space.ovoxel_preview import save_and_render
518
  save_and_render(Path(output), representation.to(device))
519
  else:
520
  _export(Path(output), representation, texture_size, decimation_target, texture_format, runtime, gutter=gutter)
521
- return dual_stats
522
 
523
 
524
  def _require_reprojection_atlas(texture_size: int, texture_format: str) -> None:
@@ -585,7 +664,7 @@ def _run_front_reprojection(arguments: dict[str, Any], work_dir: str | Path | No
585
  write_json(work / "receipt.json", receipt)
586
  try:
587
  native = work / "preds/mesh.glb"
588
- _run_completion(**{**arguments, "input_dir": source, "output": native, "gutter": False})
589
  # Reprojection runs in separate CUDA workers; release cached blocks first.
590
  import gc
591
  import torch
@@ -595,7 +674,13 @@ def _run_front_reprojection(arguments: dict[str, Any], work_dir: str | Path | No
595
  write_json(work / "receipt.json", receipt)
596
  from .reprojection import run_front_view
597
  try:
598
- final = run_front_view(native, source, work / "reproject")
 
 
 
 
 
 
599
  except Exception as error:
600
  _publish_unrefined(native, output, arguments["gutter"], error)
601
  receipt.update(status="valid", stage="complete", refine="skipped",
@@ -620,7 +705,7 @@ def run_completion(*, input_dir: str | Path, output: str | Path, model_config: s
620
  decimation_target: int = 500000, texture_format: str = "png", gutter: bool = False,
621
  back_view_dir: str | Path | None = None, work_dir: str | Path | None = None,
622
  refine: bool = False, remove_floaters: bool = False, preview_only: bool = False,
623
- resident=None) -> None:
624
  """Complete one view by default, optionally refining it, or publish a refined dual-view result."""
625
  arguments = dict(input_dir=input_dir, output=output, model_config=model_config,
626
  checkpoint=checkpoint, trellis_root=trellis_root, seed=seed, device=device,
@@ -632,6 +717,10 @@ def run_completion(*, input_dir: str | Path, output: str | Path, model_config: s
632
  arguments["resident"] = resident
633
  if preview_only and (back_view_dir is not None or refine):
634
  raise ValueError("o-voxel preview must precede single-view mesh export")
 
 
 
 
635
  # Dual-view refines both views itself, so --refine needs no separate pass there.
636
  if refine and back_view_dir is None:
637
  _run_front_reprojection(arguments, work_dir)
@@ -639,8 +728,7 @@ def run_completion(*, input_dir: str | Path, output: str | Path, model_config: s
639
  if back_view_dir is None:
640
  if work_dir is not None:
641
  raise ValueError("completion --work-dir is reserved for dual-view inference or --refine")
642
- _run_completion(**arguments)
643
- return
644
  from .dual_view import CAMERA_PROTOCOL, PROTOCOL, digest, load_back_case, write_json
645
 
646
  output = Path(output).expanduser().resolve()
 
196
  raise ValueError("camera.json must be valid JSON") from error
197
  if "camera_angle_x" not in camera or "distance" not in camera:
198
  raise ValueError("camera.json must provide canonical camera_angle_x and distance")
199
+ image_depth = None
200
+ if (root / "image_depth.npy").is_file():
201
+ import numpy as np
202
+ raw = np.load(root / "image_depth.npy", allow_pickle=False)
203
+ if raw.shape != rgba.size[::-1]:
204
+ raise ValueError("image_depth.npy must match the condition_rgba.png pixel grid")
205
+ if raw.dtype.kind not in "fiu":
206
+ raise ValueError("image_depth.npy must contain numeric camera-z depths")
207
+ raw = raw.astype(np.float32)
208
+ raw[~np.isfinite(raw) | (raw <= 0)] = np.nan
209
+ image_depth = np.asarray(image.fromarray(raw).resize((RESOLUTION, RESOLUTION), image.Resampling.NEAREST), dtype=np.float32)
210
  if remove_floaters:
211
  import numpy as np
212
  from .geometry_cleanup import remove_floaters as clean
 
214
  vertices, faces = vertices.contiguous(), faces.contiguous()
215
  print(f"Floaters: removed {report['removed_components']} of {report['components']} components "
216
  f"({report['removed_faces']} of {report['faces']} faces)", flush=True)
217
+ return {"vertices": vertices, "faces": faces, "rgba": rgba, "albedo": albedo, "mra": mra, "camera": camera,
218
+ "image_depth": image_depth}
219
 
220
 
221
  def _runtime() -> dict[str, Any]:
 
278
  rejected = [f"missed geometry {admission['trusted_rays'] - admission['first_hits']}",
279
  f"depth edge {admission['rejected_depth_edge']}"]
280
  rejected += [f"{label} {admission[key]}" for key, label in (("rejected_silhouette", "silhouette margin"),
281
+ ("rejected_grazing", "grazing"),
282
+ ("rejected_image_depth", "image-depth mismatch")) if admission.get(key)]
283
  print(f"Observation: silhouette IoU {report['silhouette_iou']:.3f} "
284
  f"(geometry inside image {report['geometry_inside_image']:.3f}, image covered {report['image_covered_by_geometry']:.3f}); "
285
  f"admitted {admission['admitted']} of {admission['trusted_rays']} trusted pixels ({', '.join(rejected)})", flush=True)
 
289
  return report
290
 
291
 
292
+ def _source_footprint_safe(keep: Any, grid: Any, runtime: Mapping[str, Any], device: str) -> Any:
293
+ """Reject material samples with nonzero weight in a depth-mismatch pixel.
294
+
295
+ Use the actual material-sampling grid and interpolation conventions, rather
296
+ than a separately rounded CPU projection of voxel centres.
297
+ """
298
+ torch, F, np = runtime["torch"], runtime["F"], runtime["np"]
299
+ unsafe = torch.from_numpy(np.asarray(~keep, dtype=np.float32)).to(device)[None, None]
300
+ contribution = F.grid_sample(unsafe, grid, mode="bilinear", align_corners=False,
301
+ padding_mode="border")[0, 0, 0]
302
+ return contribution <= 1e-6
303
+
304
+
305
+ def _partial(surface: Any, shape: Any, case: Mapping[str, Any], external: Any, projection: Any, encoder: Any, normalization: Mapping[str, Any], runtime: Mapping[str, Any], device: str) -> tuple[Any, dict[str, Any]]:
306
+ """Encode observations and return the depth-gate handoff for front refine."""
307
  torch, F, np, image, image_filter, sparse = runtime["torch"], runtime["F"], runtime["np"], runtime["Image"], runtime["ImageFilter"], runtime["sparse"]
308
  coords = surface.coords[:, 1:].long(); points = (coords.float() + .5) / RESOLUTION - .5
309
  camera = (torch.cat((points, torch.ones_like(points[:, :1])), dim=1) @ external.T)[:, :3]
 
316
  mask = image.fromarray(np.where(alpha >= 254, 255, 0).astype(np.uint8)).resize((RESOLUTION, RESOLUTION), image.Resampling.NEAREST).filter(image_filter.MinFilter(5))
317
  trusted = torch.from_numpy(np.asarray(mask, dtype=np.uint8).copy() == 255)
318
  bits, ordered, order = native_observation.build_occupancy_index(coords)
319
+ admission: dict[str, Any] = {}
320
+ depth_gate: dict[str, Any] = {}
321
+ completion_metadata: dict[str, Any] = {}
322
+ if case.get("image_depth") is None:
323
+ owners = native_observation.pixel_first_occupied(dict(case["camera"]), bits, trusted, stats=admission)
324
+ else:
325
+ owners = native_observation.pixel_first_occupied(dict(case["camera"]), bits, trusted, stats=admission,
326
+ image_depth=case["image_depth"], depth_gate_out=depth_gate)
327
  _alignment_report(uv, alpha, admission, np, image, image_filter)
328
+ if admission.get("image_depth"):
329
+ diagnostic = admission["image_depth"]
330
+ detail = f" ({diagnostic['reason']})" if diagnostic.get("reason") else ""
331
+ print(f"Image depth gate: {diagnostic['status']}{detail}; "
332
+ f"{diagnostic['valid_pixels']} paired depth pixels, "
333
+ f"{diagnostic['rejected_pixels']} mismatch pixels", flush=True)
334
  visible = native_observation.owner_rows(coords, owners, ordered, order)
335
+ if "keep_mask" in depth_gate:
336
+ depth_keep = np.asarray(depth_gate["keep_mask"], dtype=bool)
337
+ safe = _source_footprint_safe(depth_keep, grid, runtime, device)
338
+ admission["rejected_image_depth_footprint_owners"] = int((visible & ~safe).sum())
339
+ visible &= safe
340
+ admission["observed_voxels"] = int(visible.sum())
341
+ completion_metadata = {"observation_keep": depth_keep, "observation_admission": admission}
342
+ print(f"Observation image depth: excluded {admission['rejected_image_depth_footprint_owners']} "
343
+ f"additional voxel samples crossing the mismatch mask; {admission['observed_voxels']} observed voxels", flush=True)
344
  if not bool(visible.any()): raise RuntimeError("the trusted alpha gate leaves no observed material surface")
345
  with torch.no_grad():
346
+ observation = native_observation.encode_partial(encoder, coords, visible, sampled(case["albedo"]), sampled(case["mra"]), shape.coords[:, 1:], dict(normalization), sparse, device)
347
+ return observation, completion_metadata
348
 
349
 
350
  def _export(output: Path, representation: Any, texture_size: int, decimation_target: int, texture_format: str, runtime: Mapping[str, Any], *, gutter: bool = False, low_vram: bool = False) -> None:
 
421
  torch.cuda.empty_cache()
422
 
423
 
424
+ def _save_observation_metadata(output: str | Path, metadata: Mapping[str, Any]) -> None:
425
+ """Keep the actual completion gate for later preview recovery and export.
426
+
427
+ The sidecars belong to this result, never to the immutable geometry input.
428
+ Legacy inputs without image depth deliberately produce no sidecars.
429
+ """
430
+ keep = metadata.get("observation_keep")
431
+ if keep is None:
432
+ return
433
+ import numpy as np
434
+ keep = np.asarray(keep)
435
+ if keep.shape != (RESOLUTION, RESOLUTION) or keep.dtype != np.bool_:
436
+ raise ValueError("observation keep must be a boolean observation-resolution mask")
437
+ admission = metadata.get("observation_admission")
438
+ if not isinstance(admission, dict):
439
+ raise ValueError("observation admission must contain the depth-gate diagnostics")
440
+ payload = json.dumps(admission, indent=2, allow_nan=False) + "\n"
441
+ output = Path(output)
442
+ output.parent.mkdir(parents=True, exist_ok=True)
443
+ with output.with_suffix(".observation_keep.npy").open("xb") as stream:
444
+ np.save(stream, keep, allow_pickle=False)
445
+ with output.with_suffix(".observation_admission.json").open("x", encoding="utf-8") as stream:
446
+ stream.write(payload)
447
+
448
+
449
  def _run_completion_low_vram(*, case: Mapping[str, Any], back_case: Mapping[str, Any] | None,
450
  ckpts: Path, config: Mapping[str, Any], dataset: Mapping[str, Any],
451
  checkpoint_path: Path, partial_stats: Mapping[str, Any], resolved: Any,
 
464
  del shape_encoder
465
  texture_encoder = (resident.completion_model("texture_encoder") if resident is not None else
466
  _load_vae(models, _stem(ckpts / "tex_enc_next_dc_f16c32_fp16", "material encoder")))
467
+ completion_metadata = {}
468
  with _completion_stage(texture_encoder, torch, device):
469
  if back_case is None:
470
  external, projection = _camera(case["camera"], device, torch, runtime["utils3d"])
471
+ observation, completion_metadata = _partial(surface, shape, case, external, projection, texture_encoder, partial_stats, runtime, device)
472
  del external, projection
473
  else:
474
  from .dual_view import encode_two_views
475
+ observation, completion_metadata = encode_two_views(surface, shape, case, back_case, texture_encoder,
476
  partial_stats, runtime, device)
477
  del texture_encoder, surface
478
  torch.cuda.empty_cache()
 
522
  torch.cuda.empty_cache()
523
  representation = runtime["MeshWithVoxel"](case["vertices"], case["faces"], [-.5, -.5, -.5], 1. / RESOLUTION, decoded_cpu.coords[:, 1:].int(), decoded_cpu.feats.float().clamp(0, 1), torch.Size([1, 6, RESOLUTION, RESOLUTION, RESOLUTION]), layout=LAYOUT)
524
  del decoded_cpu
525
+ _save_observation_metadata(output, completion_metadata)
526
  if preview_only:
527
  from hf_space.ovoxel_preview import save_and_render
528
  save_and_render(Path(output), representation.to(device))
529
  else:
530
  _export(Path(output), representation, texture_size, decimation_target, texture_format, runtime, gutter=gutter, low_vram=True)
531
+ return completion_metadata
532
 
533
 
534
  def _run_completion(*, input_dir: str | Path, output: str | Path, model_config: str | Path, checkpoint: str | Path, trellis_root: str | Path, seed: int = 42, device: str = "cuda", texture_size: int = 4096, decimation_target: int = 500000, texture_format: str = "png", gutter: bool = False, back_case: Mapping[str, Any] | None = None, remove_floaters: bool = False, preview_only: bool = False, resident=None) -> dict[str, Any]:
 
545
  runtime = _runtime(); torch, models = runtime["torch"], runtime["models"]
546
  if not torch.cuda.is_available(): raise RuntimeError("CUDA is unavailable")
547
  case = _load_case(input_dir, torch, runtime["Image"], remove_floaters); ckpts = _completion_vae_root(trellis_root)
548
+ if back_case is not None and (case.get("image_depth") is not None or back_case.get("image_depth") is not None):
549
+ raise ValueError("image_depth.npy consistency gating currently supports single-view completion only; "
550
+ "remove it or prepare the front view with --no-image-depth")
551
  if os.environ.get("SUPRATEX_LOW_VRAM", "0") == "1":
552
  return _run_completion_low_vram(case=case, back_case=back_case, ckpts=ckpts, config=config,
553
  dataset=dataset, checkpoint_path=checkpoint_path, partial_stats=partial_stats,
 
560
  external, projection = _camera(case["camera"], device, torch, runtime["utils3d"])
561
  shape, surface = _shape(case["vertices"], case["faces"], shape_encoder, runtime, device)
562
  shape_encoder.to("cpu"); torch.cuda.empty_cache()
563
+ completion_metadata = {}
564
  if back_case is None:
565
+ observation, completion_metadata = _partial(surface, shape, case, external, projection, texture_encoder, partial_stats, runtime, device)
566
  else:
567
  from .dual_view import encode_two_views
568
+ observation, completion_metadata = encode_two_views(surface, shape, case, back_case, texture_encoder,
569
  partial_stats, runtime, device)
570
  texture_encoder.to("cpu"); torch.cuda.empty_cache()
571
  from supratex.pipelines.observation_guided_completion import build_completion_model, sample_material_latents
 
591
  with torch.no_grad(): _, guide = shape_decoder(shape, return_subs=True); decoded = material_decoder(generated, guide_subs=guide) * .5 + .5
592
  if decoded.feats.shape[1] != 6: raise RuntimeError("material decoder must emit six PBR channels")
593
  representation = runtime["MeshWithVoxel"](case["vertices"].to(device), case["faces"].to(device), [-.5, -.5, -.5], 1. / RESOLUTION, decoded.coords[:, 1:].int(), decoded.feats.float().clamp(0, 1), torch.Size([1, 6, RESOLUTION, RESOLUTION, RESOLUTION]), layout=LAYOUT)
594
+ _save_observation_metadata(output, completion_metadata)
595
  if preview_only:
596
  from hf_space.ovoxel_preview import save_and_render
597
  save_and_render(Path(output), representation.to(device))
598
  else:
599
  _export(Path(output), representation, texture_size, decimation_target, texture_format, runtime, gutter=gutter)
600
+ return completion_metadata
601
 
602
 
603
  def _require_reprojection_atlas(texture_size: int, texture_format: str) -> None:
 
664
  write_json(work / "receipt.json", receipt)
665
  try:
666
  native = work / "preds/mesh.glb"
667
+ completion = _run_completion(**{**arguments, "input_dir": source, "output": native, "gutter": False})
668
  # Reprojection runs in separate CUDA workers; release cached blocks first.
669
  import gc
670
  import torch
 
674
  write_json(work / "receipt.json", receipt)
675
  from .reprojection import run_front_view
676
  try:
677
+ keep = (completion or {}).get("observation_keep")
678
+ if keep is None:
679
+ final = run_front_view(native, source, work / "reproject")
680
+ else:
681
+ receipt["image_depth_admission"] = completion.get("observation_admission", {})
682
+ receipt["reprojection_excluded_source_pixels"] = int((~keep).sum())
683
+ final = run_front_view(native, source, work / "reproject", keep=keep)
684
  except Exception as error:
685
  _publish_unrefined(native, output, arguments["gutter"], error)
686
  receipt.update(status="valid", stage="complete", refine="skipped",
 
705
  decimation_target: int = 500000, texture_format: str = "png", gutter: bool = False,
706
  back_view_dir: str | Path | None = None, work_dir: str | Path | None = None,
707
  refine: bool = False, remove_floaters: bool = False, preview_only: bool = False,
708
+ resident=None) -> dict[str, Any] | None:
709
  """Complete one view by default, optionally refining it, or publish a refined dual-view result."""
710
  arguments = dict(input_dir=input_dir, output=output, model_config=model_config,
711
  checkpoint=checkpoint, trellis_root=trellis_root, seed=seed, device=device,
 
717
  arguments["resident"] = resident
718
  if preview_only and (back_view_dir is not None or refine):
719
  raise ValueError("o-voxel preview must precede single-view mesh export")
720
+ if back_view_dir is not None and any((Path(root) / "image_depth.npy").is_file()
721
+ for root in (input_dir, back_view_dir)):
722
+ raise ValueError("image_depth.npy consistency gating currently supports single-view completion only; "
723
+ "remove it or prepare the front view with --no-image-depth")
724
  # Dual-view refines both views itself, so --refine needs no separate pass there.
725
  if refine and back_view_dir is None:
726
  _run_front_reprojection(arguments, work_dir)
 
728
  if back_view_dir is None:
729
  if work_dir is not None:
730
  raise ValueError("completion --work-dir is reserved for dual-view inference or --refine")
731
+ return _run_completion(**arguments)
 
732
  from .dual_view import CAMERA_PROTOCOL, PROTOCOL, digest, load_back_case, write_json
733
 
734
  output = Path(output).expanduser().resolve()
supratex/inference/reprojection/__init__.py CHANGED
@@ -32,7 +32,7 @@ PROFILE = "supratex.dual_view.reproject_continuous"
32
  WORKER_TIMEOUT_SECONDS = 1800
33
 
34
 
35
- def _prepare_inputs(source: Path, destination: Path, camera: dict[str, Any]) -> dict[str, Any]:
36
  """Align observation alpha and RGB once, preserving native albedo/MRA maps."""
37
  from PIL import Image
38
 
@@ -47,6 +47,11 @@ def _prepare_inputs(source: Path, destination: Path, camera: dict[str, Any]) ->
47
  resized = rgb.convert("RGB").resize((1024, 1024), Image.Resampling.LANCZOS)
48
  alpha = rgba.convert("RGBA").getchannel("A").point(lambda value: 255 if value >= 254 else 0)
49
  alpha = alpha.resize((1024, 1024), Image.Resampling.NEAREST)
 
 
 
 
 
50
  resized.save(destination / "condition_rgb.png")
51
  aligned = resized.copy()
52
  aligned.putalpha(alpha)
@@ -61,6 +66,27 @@ def _prepare_inputs(source: Path, destination: Path, camera: dict[str, Any]) ->
61
 
62
 
63
  def _command(module: str, arguments: list[str], log: Path) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  command = [sys.executable, "-m", f"supratex.inference.reprojection.{module}", *arguments]
65
  # The web process adds bundled runtimes such as utils3d to sys.path after
66
  # startup. Python subprocesses do not inherit those additions automatically.
@@ -87,13 +113,14 @@ def _command(module: str, arguments: list[str], log: Path) -> None:
87
  raise RuntimeError(f"reprojection worker failed with exit code {result}; see {log}")
88
 
89
 
90
- def _run_side(baseline: Path, source: Path, output: Path, camera: dict[str, Any], *, stage=None) -> Path:
91
  """Publish one view only after the projection and continuous candidates pass."""
92
  output.mkdir(parents=True)
93
  baseline_binding = bind_file(baseline)
94
  code = {path.name: bind_file(path) for path in Path(__file__).parent.glob("*.py")}
95
  inputs = output / "inputs_1024"
96
- source_bindings = _prepare_inputs(source, inputs, camera)
 
97
  input_bindings = {path.name: bind_file(path) for path in inputs.iterdir() if path.is_file()}
98
  renderer = Path(__file__).with_name("_renderer.py").resolve()
99
  project, continuous = output / "project", output / "continuous"
@@ -201,13 +228,17 @@ def _validate_side(output: Path) -> dict[str, Any]:
201
  return receipt
202
 
203
 
204
- def run_front_view(native_glb: Path, input_dir: Path, output_dir: Path, *, stage=None) -> Path:
205
  """Reproject the single reference view onto a single-view result.
206
 
207
  This is the front half of dual-view texturing with the canonical reference
208
  camera from ``camera.json``. Inputs are staged under ``output_dir`` so their
209
  bindings stay valid; a missing ``condition_rgb.png`` is derived from the
210
  RGBA observation with black premultiplication, as the frontend writes it.
 
 
 
 
211
  """
212
  import numpy as np
213
  from PIL import Image
@@ -217,6 +248,10 @@ def run_front_view(native_glb: Path, input_dir: Path, output_dir: Path, *, stage
217
  if output.exists():
218
  raise FileExistsError(f"front reprojection requires a new destination: {output}")
219
  camera = read_json(source / "camera.json")
 
 
 
 
220
  try:
221
  camera = {"camera_angle_x": float(camera["camera_angle_x"]), "distance": float(camera["distance"]),
222
  "mesh_scale": float(camera.get("mesh_scale", 1.0))}
@@ -232,6 +267,8 @@ def run_front_view(native_glb: Path, input_dir: Path, output_dir: Path, *, stage
232
  with Image.open(source / "condition_rgba.png") as image:
233
  rgba = np.asarray(image.convert("RGBA"), dtype=np.float32) / 255.
234
  Image.fromarray((np.clip(rgba[..., :3] * rgba[..., 3:4], 0, 1) * 255).astype(np.uint8)).save(staged / "condition_rgb.png")
 
 
235
  return _run_side(native, staged, output / "front", camera, stage=stage)
236
 
237
 
 
32
  WORKER_TIMEOUT_SECONDS = 1800
33
 
34
 
35
+ def _prepare_inputs(source: Path, destination: Path, camera: dict[str, Any], keep: Any = None) -> dict[str, Any]:
36
  """Align observation alpha and RGB once, preserving native albedo/MRA maps."""
37
  from PIL import Image
38
 
 
47
  resized = rgb.convert("RGB").resize((1024, 1024), Image.Resampling.LANCZOS)
48
  alpha = rgba.convert("RGBA").getchannel("A").point(lambda value: 255 if value >= 254 else 0)
49
  alpha = alpha.resize((1024, 1024), Image.Resampling.NEAREST)
50
+ if keep is not None:
51
+ import numpy as np
52
+ # Apply at the actual projection grid. A 1024 -> small source ->
53
+ # 1024 round trip could otherwise resurrect thin rejected strips.
54
+ alpha = Image.fromarray(np.where(keep, np.asarray(alpha), 0).astype(np.uint8))
55
  resized.save(destination / "condition_rgb.png")
56
  aligned = resized.copy()
57
  aligned.putalpha(alpha)
 
66
 
67
 
68
  def _command(module: str, arguments: list[str], log: Path) -> None:
69
+ from hf_space.gpu_mode import enabled as zero_gpu_enabled
70
+ if zero_gpu_enabled():
71
+ # ZeroGPU's allocated process owns the CUDA lease. A new interpreter
72
+ # would lose it; call each existing stage in that process instead.
73
+ from contextlib import redirect_stderr, redirect_stdout
74
+ import gc
75
+ import importlib
76
+ if module not in ('_project', '_continuous'):
77
+ raise ValueError('Unknown reprojection stage')
78
+ previous = sys.argv
79
+ try:
80
+ sys.argv = [module, *arguments]
81
+ with log.open('x', encoding='utf-8') as stream:
82
+ with redirect_stdout(stream), redirect_stderr(stream):
83
+ importlib.import_module(f'supratex.inference.reprojection.{module}').main()
84
+ finally:
85
+ sys.argv = previous
86
+ gc.collect()
87
+ import torch
88
+ torch.cuda.empty_cache()
89
+ return
90
  command = [sys.executable, "-m", f"supratex.inference.reprojection.{module}", *arguments]
91
  # The web process adds bundled runtimes such as utils3d to sys.path after
92
  # startup. Python subprocesses do not inherit those additions automatically.
 
113
  raise RuntimeError(f"reprojection worker failed with exit code {result}; see {log}")
114
 
115
 
116
+ def _run_side(baseline: Path, source: Path, output: Path, camera: dict[str, Any], keep: Any = None, *, stage=None) -> Path:
117
  """Publish one view only after the projection and continuous candidates pass."""
118
  output.mkdir(parents=True)
119
  baseline_binding = bind_file(baseline)
120
  code = {path.name: bind_file(path) for path in Path(__file__).parent.glob("*.py")}
121
  inputs = output / "inputs_1024"
122
+ source_bindings = (_prepare_inputs(source, inputs, camera) if keep is None else
123
+ _prepare_inputs(source, inputs, camera, keep=keep))
124
  input_bindings = {path.name: bind_file(path) for path in inputs.iterdir() if path.is_file()}
125
  renderer = Path(__file__).with_name("_renderer.py").resolve()
126
  project, continuous = output / "project", output / "continuous"
 
228
  return receipt
229
 
230
 
231
+ def run_front_view(native_glb: Path, input_dir: Path, output_dir: Path, keep: Any = None, *, stage=None) -> Path:
232
  """Reproject the single reference view onto a single-view result.
233
 
234
  This is the front half of dual-view texturing with the canonical reference
235
  camera from ``camera.json``. Inputs are staged under ``output_dir`` so their
236
  bindings stay valid; a missing ``condition_rgb.png`` is derived from the
237
  RGBA observation with black premultiplication, as the frontend writes it.
238
+ Optional ``keep`` excludes source pixels identified as image/geometry depth
239
+ mismatches. It changes only staged projection trust, not source RGB, source
240
+ files, or the final mesh's opacity. Other observation-admission filters are
241
+ not passed through this mask.
242
  """
243
  import numpy as np
244
  from PIL import Image
 
248
  if output.exists():
249
  raise FileExistsError(f"front reprojection requires a new destination: {output}")
250
  camera = read_json(source / "camera.json")
251
+ if keep is not None:
252
+ keep = np.asarray(keep)
253
+ if keep.shape != (1024, 1024) or keep.dtype != np.bool_:
254
+ raise ValueError("reprojection keep must be a boolean 1024 by 1024 source-image mask")
255
  try:
256
  camera = {"camera_angle_x": float(camera["camera_angle_x"]), "distance": float(camera["distance"]),
257
  "mesh_scale": float(camera.get("mesh_scale", 1.0))}
 
267
  with Image.open(source / "condition_rgba.png") as image:
268
  rgba = np.asarray(image.convert("RGBA"), dtype=np.float32) / 255.
269
  Image.fromarray((np.clip(rgba[..., :3] * rgba[..., 3:4], 0, 1) * 255).astype(np.uint8)).save(staged / "condition_rgb.png")
270
+ if keep is not None:
271
+ return _run_side(native, staged, output / "front", camera, keep=keep, stage=stage)
272
  return _run_side(native, staged, output / "front", camera, stage=stage)
273
 
274
 
tests/test_depth_prefilter.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Depth filtering changes material observations, never the saved geometry."""
2
+ import importlib.util
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+ from types import ModuleType, SimpleNamespace
7
+ from unittest.mock import Mock
8
+
9
+ import numpy as np
10
+ from PIL import Image, ImageFilter
11
+ import pytest
12
+
13
+ torch = pytest.importorskip('torch')
14
+ pytest.importorskip('scipy')
15
+
16
+
17
+ @pytest.fixture
18
+ def core():
19
+ # Avoid the root package's unrelated eager CUDA-extension imports while
20
+ # exercising the real NumPy/SciPy/Torch inference functions on the CPU.
21
+ name = '_depth_prefilter_test'
22
+ root = Path(__file__).resolve().parents[1]/'supratex'/'inference'
23
+ package = ModuleType(name)
24
+ package.__path__ = [str(root)]
25
+ sys.modules[name] = package
26
+ spec = importlib.util.spec_from_file_location(name+'.pipeline', root/'pipeline.py')
27
+ module = importlib.util.module_from_spec(spec)
28
+ sys.modules[spec.name] = module
29
+ spec.loader.exec_module(module)
30
+ try:
31
+ yield module
32
+ finally:
33
+ for key in list(sys.modules):
34
+ if key == name or key.startswith(name+'.'):
35
+ sys.modules.pop(key, None)
36
+
37
+
38
+ def depth_pair():
39
+ _, x = np.mgrid[:128, :128]
40
+ image = (1 + .001*x + .3*(x >= 64)).astype(np.float32)
41
+ geometry = (2*image + .4).astype(np.float32)
42
+ return geometry, image
43
+
44
+
45
+ def test_affine_depth_alignment_keeps_observations_and_inputs_exact(core):
46
+ from _depth_prefilter_test.image_depth import image_depth_disagreement
47
+ geometry, image = depth_pair()
48
+ originals = geometry.tobytes(), image.tobytes()
49
+ stats = {}
50
+ rejected = image_depth_disagreement(geometry, image, stats=stats)
51
+ assert rejected.dtype == np.bool_ and not rejected.any()
52
+ assert stats['status'] == 'applied' and stats['scale'] == pytest.approx(2, abs=1e-5)
53
+ assert stats['offset'] == pytest.approx(.4, abs=1e-5)
54
+ assert originals == (geometry.tobytes(), image.tobytes())
55
+
56
+
57
+ def test_shifted_occlusion_boundary_rejects_only_nearby_depth_mismatches(core):
58
+ from _depth_prefilter_test.image_depth import image_depth_disagreement
59
+ geometry, image = depth_pair()
60
+ geometry[:, 64:72] -= .6
61
+ geometry[30:36, 10:16] += .5 # Depth disagreement far from an image edge.
62
+ image[10, 65] = np.nan
63
+ stats = {}
64
+ rejected = image_depth_disagreement(geometry, image, stats=stats)
65
+ assert rejected[:, 66:71].mean() > .95
66
+ assert not rejected[:, :40].any() and not rejected[:, 90:].any()
67
+ assert not rejected[10, 65]
68
+ assert stats['status'] == 'applied' and stats['rejected_pixels'] == int(rejected.sum())
69
+
70
+
71
+ @pytest.mark.parametrize('reason', ['insufficient_valid_depth', 'degenerate_image_depth_span'])
72
+ def test_unreliable_depth_fit_disables_only_the_new_gate(core, reason):
73
+ from _depth_prefilter_test.image_depth import image_depth_disagreement
74
+ geometry, image = np.ones((64, 64), dtype=np.float32), np.ones((64, 64), dtype=np.float32)
75
+ if reason == 'insufficient_valid_depth':
76
+ image[:60] = np.nan
77
+ stats = {}
78
+ assert not image_depth_disagreement(geometry, image, stats=stats).any()
79
+ assert stats['status'] == 'disabled' and stats['reason'] == reason
80
+
81
+
82
+ def test_bilinear_footprint_cannot_blend_a_rejected_neighbor(core):
83
+ keep = np.ones((4, 4), dtype=bool)
84
+ keep[1, 1] = False
85
+ # Pixel centers (1,1), (2,1), and the half-way point between them.
86
+ grid = torch.tensor([[[[-.25, -.25], [.25, -.25], [0., -.25]]]])
87
+ safe = core._source_footprint_safe(keep, grid, {'torch':torch, 'F':torch.nn.functional, 'np':np}, 'cpu')
88
+ assert safe.tolist() == [False, True, False]
89
+ assert not keep[1, 1] and keep.sum() == 15
90
+
91
+
92
+ @pytest.mark.parametrize('cv_camera', [False, True])
93
+ def test_full_grid_geometry_depth_uses_camera_z_for_both_camera_conventions(core, monkeypatch, cv_camera):
94
+ native = core.native_observation
95
+ monkeypatch.setattr(native, 'RESOLUTION', 4)
96
+ calls = []
97
+ def kernel(eye, directions, occupancy, low, high, resolution):
98
+ calls.append((eye.copy(), directions.copy()))
99
+ assert len(directions) == 16 and resolution == 4
100
+ return np.tile([1, 1, 1], (16, 1)), np.zeros(16, np.uint8), np.zeros(16, np.int64)
101
+ monkeypatch.setattr(native, '_dda_batch_kernel', lambda: kernel)
102
+ camera = {'camera_angle_x':.7, 'distance':3.}
103
+ if cv_camera:
104
+ camera = {'extrinsic':[[1,0,0,0],[0,-1,0,0],[0,0,-1,3],[0,0,0,1]],
105
+ 'intrinsic':[[1,0,.5],[0,1,.5],[0,0,1]]}
106
+ depth = native.first_hit_depth(camera, np.zeros(1, np.uint8))
107
+ np.testing.assert_array_equal(depth, np.full((4,4), 3.125, np.float32))
108
+ assert len(calls) == 1
109
+
110
+
111
+ def case_files(folder, size=8):
112
+ folder.mkdir()
113
+ vertices = torch.tensor([[0., -.0, .25], [.5, -.5, .5], [.125, .25, -.375]])
114
+ faces = torch.tensor([[2, 0, 1]], dtype=torch.int64)
115
+ torch.save({'verts':vertices, 'faces':faces}, folder/'geometry.pt')
116
+ (folder/'camera.json').write_text(json.dumps({'distance':2, 'camera_angle_x':.7}))
117
+ Image.new('RGBA', (size,size), (30,40,50,255)).save(folder/'condition_rgba.png')
118
+ for name in ('albedo.png','mra.png'):
119
+ Image.new('RGB', (8,8), (30,40,50)).save(folder/name)
120
+ return vertices, faces
121
+
122
+
123
+ @pytest.mark.parametrize('with_depth', [False, True])
124
+ def test_case_loader_keeps_geometry_bytes_and_legacy_inputs(core, monkeypatch, tmp_path, with_depth):
125
+ monkeypatch.setattr(core, 'RESOLUTION', 8)
126
+ folder = tmp_path/'case'
127
+ vertices, faces = case_files(folder, size=4)
128
+ if with_depth:
129
+ depth = np.ones((4,4), np.float32)
130
+ depth[0,0] = -1
131
+ np.save(folder/'image_depth.npy', depth)
132
+ before = {p.name:p.read_bytes() for p in folder.iterdir()}
133
+ case = core._load_case(folder, torch, Image)
134
+ assert case['vertices'].numpy().tobytes() == vertices.numpy().tobytes()
135
+ assert case['faces'].numpy().tobytes() == faces.numpy().tobytes()
136
+ if with_depth:
137
+ assert case['image_depth'].shape == (8,8) and case['image_depth'].dtype == np.float32
138
+ assert np.isnan(case['image_depth'][:2,:2]).all()
139
+ else:
140
+ assert case['image_depth'] is None
141
+ assert before == {p.name:p.read_bytes() for p in folder.iterdir()}
142
+
143
+
144
+ @pytest.mark.parametrize('with_depth', [False, True])
145
+ def test_partial_material_observations_apply_new_gate_only_when_depth_exists(core, monkeypatch, with_depth):
146
+ monkeypatch.setattr(core, 'RESOLUTION', 4)
147
+ coords = torch.tensor([[0,0,0,2], [0,1,0,2], [0,3,3,2]], dtype=torch.int32)
148
+ source_coords = coords.clone()
149
+ surface = SimpleNamespace(coords=coords)
150
+ shape = SimpleNamespace(coords=coords)
151
+ keep = np.ones((4,4), bool); keep[0,1] = False
152
+ depth = np.ones((4,4), np.float32)
153
+ def first_hit(camera, bits, trusted, **kwargs):
154
+ assert trusted.all()
155
+ if with_depth:
156
+ assert kwargs['image_depth'] is depth
157
+ kwargs['depth_gate_out']['keep_mask'] = keep
158
+ kwargs['stats']['image_depth'] = {'status':'applied','valid_pixels':16,'rejected_pixels':1}
159
+ else:
160
+ assert 'image_depth' not in kwargs and 'depth_gate_out' not in kwargs
161
+ return object()
162
+ native = core.native_observation
163
+ monkeypatch.setattr(native, 'build_occupancy_index', lambda c: (None,None,None))
164
+ monkeypatch.setattr(native, 'pixel_first_occupied', first_hit)
165
+ monkeypatch.setattr(native, 'owner_rows', lambda *a: torch.ones(3, dtype=torch.bool))
166
+ encoder = Mock(return_value=object())
167
+ monkeypatch.setattr(native, 'encode_partial', encoder)
168
+ monkeypatch.setattr(core, '_alignment_report', Mock())
169
+ external = torch.eye(4); external[2,3] = .875
170
+ projection = torch.tensor([[1.,0,.5],[0,1.,.5],[0,0,1.]])
171
+ case = {'camera':{},'rgba':Image.new('RGBA',(4,4),(255,255,255,255)),
172
+ 'albedo':Image.new('RGB',(4,4),'white'),'mra':Image.new('RGB',(4,4),'white'),
173
+ 'image_depth':depth if with_depth else None}
174
+ runtime = {'torch':torch,'F':torch.nn.functional,'np':np,'Image':Image,'ImageFilter':ImageFilter,'sparse':None}
175
+ observation, metadata = core._partial(surface, shape, case, external, projection, None, {}, runtime, 'cpu')
176
+ assert observation is encoder.return_value
177
+ assert encoder.call_args.args[2].tolist() == ([True,False,True] if with_depth else [True,True,True])
178
+ assert torch.equal(source_coords, coords)
179
+ if with_depth:
180
+ np.testing.assert_array_equal(metadata['observation_keep'], keep)
181
+ assert metadata['observation_admission']['rejected_image_depth_footprint_owners'] == 1
182
+ else:
183
+ assert metadata == {}
184
+
185
+
186
+ def test_metadata_preserves_all_true_disabled_gate_and_legacy_has_no_sidecars(core, tmp_path):
187
+ output = tmp_path/'asset.npz'
188
+ core._save_observation_metadata(output, {})
189
+ assert not list(tmp_path.iterdir())
190
+ keep = np.ones((1024,1024), bool)
191
+ metadata = {'observation_keep':keep, 'observation_admission':{'image_depth':{
192
+ 'status':'disabled','reason':'degenerate_image_depth_span','rejected_pixels':0}}}
193
+ core._save_observation_metadata(output, metadata)
194
+ np.testing.assert_array_equal(np.load(output.with_suffix('.observation_keep.npy'), allow_pickle=False), keep)
195
+ assert json.loads(output.with_suffix('.observation_admission.json').read_text()) == metadata['observation_admission']
196
+ assert keep.all()
197
+
198
+
199
+ def test_reprojection_masks_at_1024_without_changing_source_images(core, tmp_path):
200
+ from _depth_prefilter_test import reprojection
201
+ source = tmp_path/'source'; source.mkdir()
202
+ Image.new('RGB',(8,8),(20,30,40)).save(source/'condition_rgb.png')
203
+ Image.new('RGBA',(8,8),(20,30,40,255)).save(source/'condition_rgba.png')
204
+ for name in ('albedo.png','mra.png'):
205
+ Image.new('RGB',(1024,1024),(80,90,100)).save(source/name)
206
+ camera = {'distance':2,'camera_angle_x':.7}
207
+ (source/'camera.json').write_text(json.dumps(camera))
208
+ before = {p.name:p.read_bytes() for p in source.iterdir()}
209
+ keep = np.ones((1024,1024),bool); keep[:,501] = False
210
+ reprojection._prepare_inputs(source, tmp_path/'aligned', camera, keep=keep)
211
+ alpha = np.asarray(Image.open(tmp_path/'aligned/condition_rgba.png').getchannel('A'))
212
+ assert not alpha[:,501].any() and (alpha[:,500] == 255).all() and (alpha[:,502] == 255).all()
213
+ assert before == {p.name:p.read_bytes() for p in source.iterdir()}
214
+
215
+
216
+ def test_front_reprojection_forwards_gate_and_progress_without_changing_mesh(core, monkeypatch, tmp_path):
217
+ from _depth_prefilter_test import reprojection
218
+ source = tmp_path/'source'; case_files(source)
219
+ native = tmp_path/'asset.glb'; native.write_bytes(b'immutable-mesh')
220
+ keep = np.ones((1024,1024),bool); keep[100:102] = False
221
+ stage = Mock()
222
+ run_side = Mock(return_value=tmp_path/'output/front/mesh.glb')
223
+ monkeypatch.setattr(reprojection, '_run_side', run_side)
224
+ result = reprojection.run_front_view(native, source, tmp_path/'output', keep=keep, stage=stage)
225
+ assert result == run_side.return_value and native.read_bytes() == b'immutable-mesh'
226
+ assert run_side.call_args.kwargs['keep'] is keep and run_side.call_args.kwargs['stage'] is stage
227
+ with pytest.raises(ValueError, match='boolean 1024'):
228
+ reprojection.run_front_view(native, source, tmp_path/'invalid', keep=np.ones((8,8), bool))
229
+
230
+
231
+ def test_public_completion_returns_metadata_and_rejects_depth_dual_view(core, monkeypatch, tmp_path):
232
+ metadata = {'observation_keep':np.ones((1024,1024),bool),'observation_admission':{}}
233
+ run = Mock(return_value=metadata)
234
+ monkeypatch.setattr(core, '_run_completion', run)
235
+ options = dict(input_dir=tmp_path, output=tmp_path/'asset.npz', model_config=tmp_path/'config',
236
+ checkpoint=tmp_path/'weights', trellis_root=tmp_path, preview_only=True, resident=object())
237
+ assert core.run_completion(**options) is metadata
238
+ assert run.call_args.kwargs['preview_only'] and run.call_args.kwargs['resident'] is options['resident']
239
+ np.save(tmp_path/'image_depth.npy', np.ones((2,2),np.float32))
240
+ options.update(back_view_dir=tmp_path/'back', preview_only=False, resident=None)
241
+ with pytest.raises(ValueError, match='single-view'):
242
+ core.run_completion(**options)
243
+ assert run.call_count == 1
244
+
245
+
246
+ class TinySparse:
247
+ def __init__(self, feats, coords):
248
+ self.feats, self.coords = feats, coords
249
+ self._spatial_cache = {}
250
+
251
+ def replace(self, *, feats):
252
+ return TinySparse(feats, self.coords)
253
+
254
+ def __mul__(self, value):
255
+ return self.replace(feats=self.feats*value)
256
+
257
+ def __add__(self, value):
258
+ return self.replace(feats=self.feats+value)
259
+
260
+
261
+ @pytest.mark.parametrize('low_vram,resident_mode,preview_only,preview_failure', [
262
+ (False, False, False, False),
263
+ (False, False, True, False),
264
+ (True, False, False, False),
265
+ (True, True, False, False),
266
+ (True, True, True, False),
267
+ (True, True, True, True),
268
+ ])
269
+ def test_both_completion_paths_save_actual_gate_and_keep_mesh_before_preview_or_export(
270
+ core, monkeypatch, tmp_path, low_vram, resident_mode, preview_only, preview_failure):
271
+ """Use tiny CPU tensors and fake model outputs to exercise actual orchestration.
272
+
273
+ Numerical filter and interpolation tests above use their real implementations;
274
+ this test replaces only neural models/native voxelization and CUDA allocation.
275
+ """
276
+ monkeypatch.setenv('SUPRATEX_LOW_VRAM', '1' if low_vram else '0')
277
+ original_to = torch.Tensor.to
278
+ def cpu_to(tensor, *args, **kwargs):
279
+ if args and isinstance(args[0], str) and args[0].startswith('cuda'):
280
+ args = ('cpu', *args[1:])
281
+ if str(kwargs.get('device','')).startswith('cuda'):
282
+ kwargs['device'] = 'cpu'
283
+ return original_to(tensor, *args, **kwargs)
284
+ monkeypatch.setattr(torch.Tensor, 'to', cpu_to)
285
+ for name in ('as_tensor', 'randn'):
286
+ original = getattr(torch, name)
287
+ def allocate(*args, _original=original, **kwargs):
288
+ if str(kwargs.get('device','')).startswith('cuda'):
289
+ kwargs['device'] = 'cpu'
290
+ return _original(*args, **kwargs)
291
+ monkeypatch.setattr(torch, name, allocate)
292
+ generator = torch.Generator
293
+ monkeypatch.setattr(torch, 'Generator', lambda **kwargs: generator(device='cpu'))
294
+ monkeypatch.setattr(torch.cuda, 'is_available', lambda: True)
295
+ monkeypatch.setattr(torch.cuda, 'mem_get_info', lambda *args: (0,0))
296
+ monkeypatch.setattr(torch.cuda, 'empty_cache', lambda: None)
297
+
298
+ vertices = torch.tensor([[0., -.0, .25], [.5, -.5, .5], [.125, .25, -.375]])
299
+ faces = torch.tensor([[2,0,1]], dtype=torch.int64)
300
+ original_geometry = vertices.numpy().tobytes(), faces.numpy().tobytes()
301
+ coords = torch.tensor([[0,0,0,0],[0,1,0,0],[0,0,1,0]], dtype=torch.int32)
302
+ shape = TinySparse(torch.zeros((3,32)), coords)
303
+ case = {'vertices':vertices,'faces':faces,'camera':{},'image_depth':np.ones((1024,1024),np.float32)}
304
+ keep = np.ones((1024,1024),bool); keep[400:403,100:200] = False
305
+ metadata = {'observation_keep':keep, 'observation_admission':{'image_depth':{
306
+ 'status':'applied','rejected_pixels':300,'valid_pixels':10000}}}
307
+ partial = Mock(return_value=(SimpleNamespace(feats=torch.zeros((3,34))), metadata))
308
+ monkeypatch.setattr(core, '_partial', partial)
309
+ monkeypatch.setattr(core, '_load_case', lambda *args: case)
310
+ monkeypatch.setattr(core, '_shape', lambda *args: (shape, shape))
311
+ monkeypatch.setattr(core, '_camera', lambda *args: (torch.eye(4),torch.eye(3)))
312
+ monkeypatch.setattr(core, '_stage_sparse', lambda value, *args, **kwargs: value)
313
+ monkeypatch.setattr(core, '_stem', lambda path, *args: path)
314
+ monkeypatch.setattr(core, '_completion_vae_root', lambda *args: tmp_path)
315
+ normalization = {'mean':[0.]*32, 'std':[1.]*32}
316
+ dataset = {'shape_slat_normalization':normalization,'pbr_slat_normalization':normalization,
317
+ 'pretrained_shape_slat_dec':'shape_decoder', 'pretrained_pbr_slat_dec':'material_decoder'}
318
+ monkeypatch.setattr(core, 'validate_completion_artifacts', lambda *args: ({},dataset,tmp_path/'weights',{}))
319
+ monkeypatch.setattr(core, 'completion_decoder_stems', lambda *args: {
320
+ key:tmp_path/dataset[key] for key in ('pretrained_shape_slat_dec','pretrained_pbr_slat_dec')})
321
+ monkeypatch.setattr(core, 'load_completion_state_dict', lambda *args, **kwargs: {})
322
+
323
+ class Model:
324
+ low_vram = False
325
+ blocks, refiners, model_channels = [], [], 1
326
+ def __init__(self, name): self.name = name
327
+ def to(self, *args): return self
328
+ def eval(self): return self
329
+ def set_resolution(self, *args): pass
330
+ def __call__(self, value, **kwargs):
331
+ if self.name == 'shape_decoder':
332
+ return None, [value]
333
+ assert self.name == 'material_decoder'
334
+ return TinySparse(torch.zeros((3,6)), coords)
335
+ models = {name:Model(name) for name in (
336
+ 'shape_encoder','texture_encoder','transformer','shape_decoder','material_decoder')}
337
+ def vae(_module, stem):
338
+ name = Path(stem).name
339
+ if name.startswith('shape_enc'): name = 'shape_encoder'
340
+ if name.startswith('tex_enc'): name = 'texture_encoder'
341
+ return models[name]
342
+ monkeypatch.setattr(core, '_load_vae', vae)
343
+ neural = ModuleType('supratex.pipelines.observation_guided_completion')
344
+ neural.build_completion_model = lambda *args, **kwargs: models['transformer']
345
+ neural.sample_material_latents = lambda _model, noise, **kwargs: SimpleNamespace(samples=noise)
346
+ monkeypatch.setitem(sys.modules, neural.__name__, neural)
347
+ resident = SimpleNamespace(completion_model=Mock(side_effect=models.__getitem__)) if resident_mode else None
348
+ emitted = []
349
+ class Mesh:
350
+ def __init__(self, vertices, faces, *args, **kwargs):
351
+ self.vertices, self.faces = vertices, faces
352
+ def to(self, device): return self
353
+ monkeypatch.setattr(core, '_runtime', lambda: {
354
+ 'torch':torch,'models':object(),'Image':Image,'utils3d':None,'MeshWithVoxel':Mesh})
355
+ output = tmp_path/'asset.npz'
356
+ def emit(path, mesh, *args, **kwargs):
357
+ # Sidecars exist even when preview rendering later fails, for recovery.
358
+ np.testing.assert_array_equal(np.load(output.with_suffix('.observation_keep.npy'), allow_pickle=False), keep)
359
+ assert json.loads(output.with_suffix('.observation_admission.json').read_text()) == metadata['observation_admission']
360
+ emitted.append((mesh.vertices.numpy().tobytes(), mesh.faces.numpy().tobytes()))
361
+ if preview_failure:
362
+ raise RuntimeError('preview failed after saved geometry and gate')
363
+ preview = ModuleType('hf_space.ovoxel_preview'); preview.save_and_render = emit
364
+ monkeypatch.setitem(sys.modules, preview.__name__, preview)
365
+ monkeypatch.setattr(core, '_export', emit)
366
+ options = dict(input_dir=tmp_path, output=output, model_config=tmp_path/'config',
367
+ checkpoint=tmp_path/'weights', trellis_root=tmp_path,
368
+ preview_only=preview_only, resident=resident)
369
+ if preview_failure:
370
+ with pytest.raises(RuntimeError, match='preview failed'):
371
+ core.run_completion(**options)
372
+ else:
373
+ assert core.run_completion(**options) is metadata
374
+ assert emitted == [original_geometry]
375
+ assert (vertices.numpy().tobytes(), faces.numpy().tobytes()) == original_geometry
376
+ assert partial.call_count == 1
377
+ if resident is not None:
378
+ assert [call.args[0] for call in resident.completion_model.call_args_list] == [
379
+ 'shape_encoder','texture_encoder','transformer','shape_decoder','material_decoder']
tests/test_frontend_depth.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Camera depth comes from the same MoGe result without changing its image grid."""
2
+ from contextlib import nullcontext
3
+ import importlib.util
4
+ from pathlib import Path
5
+ import sys
6
+ from types import SimpleNamespace
7
+ from unittest.mock import Mock
8
+
9
+ import numpy as np
10
+ from PIL import Image
11
+ import pytest
12
+
13
+
14
+ def camera_module():
15
+ path = Path(__file__).resolve().parents[1] / 'supratex/frontend/camera.py'
16
+ spec = importlib.util.spec_from_file_location('_test_frontend_camera_depth', path)
17
+ module = importlib.util.module_from_spec(spec)
18
+ spec.loader.exec_module(module)
19
+ return module
20
+
21
+
22
+ class Tensor:
23
+ """Small CPU adapter for the Torch conversion boundary, not model inference."""
24
+ def __init__(self, value):
25
+ self.value = np.asarray(value)
26
+
27
+ def detach(self): return self
28
+ def cpu(self): return self
29
+ def to(self, _device): return self
30
+ def float(self): return Tensor(self.value.astype(np.float32))
31
+ def numpy(self): return self.value
32
+ def squeeze(self): return Tensor(self.value.squeeze())
33
+ def permute(self, *axes): return Tensor(self.value.transpose(axes))
34
+
35
+
36
+ def test_resident_camera_saves_same_inference_depth_and_valid_mask(monkeypatch, tmp_path):
37
+ camera = camera_module()
38
+ rgb = np.arange(18, dtype=np.uint8).reshape(2, 3, 3)
39
+ Image.fromarray(rgb).save(tmp_path/'input.png')
40
+ (tmp_path/'model.pt').write_bytes(b'checkpoint')
41
+ depth = np.array([[[1, 2, -1], [np.inf, 4, 5]]], dtype=np.float64)
42
+ result = {'intrinsics': Tensor(np.eye(3)), 'depth': Tensor(depth),
43
+ 'mask': Tensor(np.array([[[1, 0, 1], [1, 1, np.nan]]]))}
44
+ model = SimpleNamespace(infer=Mock(return_value=result), cpu=Mock())
45
+ model.to = Mock(return_value=model); model.eval = Mock(return_value=model)
46
+ pretrained = Mock(side_effect=AssertionError('The resident model must be reused'))
47
+ monkeypatch.setitem(sys.modules, '_fake_moge_depth', SimpleNamespace(MoGeModel=SimpleNamespace(from_pretrained=pretrained)))
48
+ empty_cache = Mock()
49
+ monkeypatch.setitem(sys.modules, 'torch', SimpleNamespace(from_numpy=Tensor, inference_mode=nullcontext,
50
+ cuda=SimpleNamespace(empty_cache=empty_cache)))
51
+ resident = SimpleNamespace(pool=SimpleNamespace(get=Mock(return_value=model)))
52
+ output = tmp_path/'handoff'/'image_depth.npy'
53
+ value = camera.estimate(tmp_path/'input.png', tmp_path, '_fake_moge_depth', resident=resident,
54
+ depth_output=output)
55
+ resident.pool.get.assert_called_once_with('camera')
56
+ model.infer.assert_called_once()
57
+ np.testing.assert_allclose(model.infer.call_args.args[0].numpy(), rgb.astype(np.float32).transpose(2, 0, 1)/255)
58
+ assert model.infer.call_args.kwargs == {'resolution_level': 9, 'force_projection': True, 'apply_mask': True}
59
+ saved = np.load(output, allow_pickle=False)
60
+ assert saved.shape == (2, 3) and saved.dtype == np.float32
61
+ np.testing.assert_equal(saved, [[1, np.nan, np.nan], [np.nan, 4, np.nan]])
62
+ assert value == camera.camera_from_intrinsics(np.eye(3), 3, 2, resolution_level=9)
63
+ model.cpu.assert_called_once(); empty_cache.assert_called_once(); pretrained.assert_not_called()
64
+ with pytest.raises(FileExistsError, match='must be new'):
65
+ camera.estimate(tmp_path/'input.png', tmp_path, '_fake_moge_depth', resident=resident, depth_output=output)
66
+ assert model.infer.call_count == 1
67
+
68
+
69
+ @pytest.mark.parametrize('result, message', [
70
+ ({}, 'did not return'),
71
+ ({'depth': Tensor(np.ones((4, 4)))}, 'pixel grid'),
72
+ ({'depth': Tensor(np.ones((2, 3))), 'mask': Tensor(np.ones((2, 2)))}, 'mask must match'),
73
+ ])
74
+ def test_camera_depth_rejects_missing_or_resampled_outputs(result, message):
75
+ with pytest.raises(ValueError, match=message):
76
+ camera_module()._image_depth(result, 3, 2)
77
+
78
+
79
+ def test_camera_without_depth_output_preserves_legacy_call(monkeypatch, tmp_path):
80
+ camera = camera_module()
81
+ Image.new('RGB', (3, 2), 'white').save(tmp_path/'input.png')
82
+ (tmp_path/'model.pt').write_bytes(b'checkpoint')
83
+ model = SimpleNamespace(infer=Mock(return_value={'intrinsics': Tensor(np.eye(3))}), cpu=Mock())
84
+ model.to = Mock(return_value=model); model.eval = Mock(return_value=model)
85
+ factory = Mock(return_value=model)
86
+ monkeypatch.setitem(sys.modules, '_fake_moge_legacy', SimpleNamespace(MoGeModel=SimpleNamespace(from_pretrained=factory)))
87
+ monkeypatch.setitem(sys.modules, 'torch', SimpleNamespace(from_numpy=Tensor, inference_mode=nullcontext))
88
+ result = camera.estimate(tmp_path/'input.png', tmp_path, '_fake_moge_legacy', device='cpu')
89
+ assert result['image_width'] == 3 and result['image_height'] == 2
90
+ factory.assert_called_once_with(str(tmp_path/'model.pt')); model.infer.assert_called_once()
91
+ model.cpu.assert_called_once()
92
+ assert not list(tmp_path.glob('*.npy'))
tests/test_geometry_results.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Geometry survives local cache loss and cannot resume from corrupt inputs."""
2
+ import json
3
+ from pathlib import Path
4
+ import shutil
5
+
6
+ import numpy as np
7
+ import pytest
8
+ from PIL import Image
9
+
10
+ from hf_space import geometry_results, ovoxel_results, split_pipeline
11
+
12
+
13
+ @pytest.fixture
14
+ def geometry(tmp_path, monkeypatch):
15
+ monkeypatch.setattr(ovoxel_results, 'ROOT', tmp_path / 'durable')
16
+ folder = tmp_path / ('a' * 32)
17
+ folder.mkdir()
18
+ for name in geometry_results.HANDOFF_FILES:
19
+ path = folder / name
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ path.write_bytes(b'fixture:' + name.encode())
22
+ (folder / 'geometry-checkpoint' / 'checkpoint.json').write_text(json.dumps({
23
+ 'format': split_pipeline.CHECKPOINT_FORMAT, 'seed': 42, 'remove_floaters_applied': True,
24
+ 'files': list(split_pipeline.CHECKPOINT_FILES[:-1])}))
25
+ Image.new('RGBA', (8, 8), 'white').save(folder / 'input.png')
26
+ metadata = {'views': 2, 'modes': ['clay'], 'default_mode': 'clay',
27
+ 'view_layout': 'input_front_y_up_v1', 'input_view': 0}
28
+ (folder / 'preview.json').write_text(json.dumps(metadata))
29
+ (folder / 'report.json').write_text('{"status":"complete"}')
30
+ (folder / 'timings.jsonl').write_text('{"name":"geometry.preview"}\n')
31
+ (folder / 'views').mkdir()
32
+ for index in range(2):
33
+ Image.new('RGB', (8, 8), 'gray').save(folder / 'views' / f'clay-{index:02d}.webp')
34
+ return folder
35
+
36
+
37
+ def test_geometry_is_restorable_after_original_worker_files_are_gone(geometry, tmp_path):
38
+ original = {name: (geometry / name).read_bytes() for name in geometry_results.HANDOFF_FILES}
39
+ identifier = geometry_results.persist(geometry / 'geometry.npz', {'project_revision': 'test'})
40
+ shutil.rmtree(geometry)
41
+ restored = geometry_results.restore(identifier, tmp_path / 'restored')
42
+ assert {name: (restored / name).read_bytes() for name in original} == original
43
+ assert not (restored / 'views').exists()
44
+ ready = json.loads((ovoxel_results.directory(identifier) / 'ready.json').read_text())
45
+ assert ready['output_kind'] == 'geometry'
46
+
47
+
48
+ def test_modified_geometry_is_rejected_before_texture_execution(geometry, tmp_path):
49
+ identifier = geometry_results.persist(geometry / 'geometry.npz', {'project_revision': 'test'})
50
+ (ovoxel_results.directory(identifier) / 'geometry-checkpoint' / 'geometry.pt').write_bytes(b'corrupt')
51
+ with pytest.raises(ValueError, match='integrity'):
52
+ geometry_results.restore(identifier, tmp_path / 'restored')
53
+
54
+
55
+ def test_partial_publish_is_not_resumable(geometry, tmp_path):
56
+ identifier = geometry_results.persist(geometry / 'geometry.npz', {'project_revision': 'test'})
57
+ (ovoxel_results.directory(identifier) / 'ready.json').unlink()
58
+ with pytest.raises(ValueError, match='incomplete'):
59
+ geometry_results.restore(identifier, tmp_path / 'restored')
60
+
61
+
62
+ def test_unexpected_checkpoint_paths_are_rejected(geometry, tmp_path):
63
+ identifier = geometry_results.persist(geometry / 'geometry.npz', {'project_revision': 'test'})
64
+ path = ovoxel_results.directory(identifier) / 'geometry-manifest.json'
65
+ manifest = json.loads(path.read_text())
66
+ manifest['files']['../../outside'] = {'size': 1, 'sha256': '0' * 64}
67
+ path.write_text(json.dumps(manifest))
68
+ with pytest.raises(ValueError, match='handoff'):
69
+ geometry_results.restore(identifier, tmp_path / 'restored')
70
+ assert not (tmp_path / 'restored').exists()
71
+
72
+
73
+ def test_incomplete_white_preview_is_not_published(geometry):
74
+ (geometry / 'views' / 'clay-01.webp').unlink()
75
+ with pytest.raises(ValueError, match='preview is incomplete'):
76
+ geometry_results.persist(geometry / 'geometry.npz', {'project_revision': 'test'})
77
+ assert not ovoxel_results.directory(geometry.name).exists()
78
+
79
+
80
+ def add_depth(folder):
81
+ checkpoint = folder/'geometry-checkpoint'
82
+ np.save(checkpoint/'image_depth.npy', np.array([[1, np.nan], [2, 3]], dtype=np.float32), allow_pickle=False)
83
+ metadata = json.loads((checkpoint/'checkpoint.json').read_text())
84
+ metadata['files'].append('image_depth.npy')
85
+ (checkpoint/'checkpoint.json').write_text(json.dumps(metadata))
86
+ return (checkpoint/'image_depth.npy').read_bytes()
87
+
88
+
89
+ def test_depth_is_bound_and_restored_without_changing_saved_geometry(geometry, tmp_path):
90
+ depth = add_depth(geometry)
91
+ mesh = (geometry/'geometry.npz').read_bytes()
92
+ checkpoint_mesh = (geometry/'geometry-checkpoint'/'geometry.pt').read_bytes()
93
+ identifier = geometry_results.persist(geometry/'geometry.npz', {'project_revision': 'test'})
94
+ durable = ovoxel_results.directory(identifier)
95
+ manifest = json.loads((durable/'geometry-manifest.json').read_text())
96
+ assert set(manifest['files']) == {*geometry_results.HANDOFF_FILES, 'geometry-checkpoint/image_depth.npy'}
97
+ assert manifest['files']['geometry-checkpoint/image_depth.npy']['sha256'] == geometry_results._digest(durable/'geometry-checkpoint'/'image_depth.npy')
98
+ shutil.rmtree(geometry)
99
+ restored = geometry_results.restore(identifier, tmp_path/'restored')
100
+ assert (restored/'geometry-checkpoint'/'image_depth.npy').read_bytes() == depth
101
+ assert (restored/'geometry.npz').read_bytes() == mesh
102
+ assert (restored/'geometry-checkpoint'/'geometry.pt').read_bytes() == checkpoint_mesh
103
+
104
+
105
+ @pytest.mark.parametrize('damage, message', [('missing', 'incomplete'), ('changed', 'integrity'), ('unbound', 'handoff')])
106
+ def test_new_depth_cannot_be_lost_or_changed_during_restore(geometry, tmp_path, damage, message):
107
+ add_depth(geometry)
108
+ identifier = geometry_results.persist(geometry/'geometry.npz', {'project_revision': 'test'})
109
+ durable = ovoxel_results.directory(identifier)
110
+ depth = durable/'geometry-checkpoint'/'image_depth.npy'
111
+ if damage == 'missing':
112
+ depth.unlink()
113
+ elif damage == 'changed':
114
+ depth.write_bytes(b'corrupt depth')
115
+ else:
116
+ path = durable/'geometry-manifest.json'
117
+ manifest = json.loads(path.read_text())
118
+ del manifest['files']['geometry-checkpoint/image_depth.npy']
119
+ path.write_text(json.dumps(manifest))
120
+ with pytest.raises(ValueError, match=message):
121
+ geometry_results.restore(identifier, tmp_path/'restored')
122
+
123
+
124
+ def test_declared_missing_depth_is_not_published(geometry):
125
+ add_depth(geometry)
126
+ (geometry/'geometry-checkpoint'/'image_depth.npy').unlink()
127
+ with pytest.raises(ValueError, match='incomplete'):
128
+ geometry_results.persist(geometry/'geometry.npz', {'project_revision': 'test'})
129
+ assert not ovoxel_results.directory(geometry.name).exists()
tests/test_history_geometry_texture.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The durable geometry checkpoint survives a separately requested texture stage."""
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from copy import deepcopy
4
+ from io import BytesIO
5
+ import json
6
+ import time
7
+ from types import SimpleNamespace
8
+ import uuid
9
+
10
+ from fastapi import FastAPI
11
+ from PIL import Image
12
+ import pytest
13
+ from starlette.testclient import TestClient
14
+
15
+ from hf_space.history_auth import Identity, csrf_token
16
+ from hf_space.history_bridge import AppSessions, AppSessionMiddleware, COOKIE_NAME
17
+ from hf_space.history_jobs import Runner
18
+ from hf_space.history_routes import install_routes
19
+ from hf_space.history_store import Conflict, LimitExceeded, NotFound, Store
20
+
21
+
22
+ @pytest.fixture
23
+ def store(tmp_path, monkeypatch):
24
+ monkeypatch.setenv('SPACES_ZERO_GPU', 'true')
25
+ for name in ('SPACE_ID', 'SPACE_HOST', 'OAUTH_CLIENT_ID', 'OAUTH_CLIENT_SECRET'):
26
+ monkeypatch.delenv(name, raising=False)
27
+ return Store(tmp_path/'history', asset_root=tmp_path/'assets').initialize()
28
+
29
+
30
+ def submit(store, owner='alice', key=None):
31
+ stream = BytesIO()
32
+ Image.new('RGB', (12, 12), 'red').save(stream, format='PNG')
33
+ upload = store.save_upload(owner, stream.getvalue())
34
+ return store.submit(owner, upload['id'], 42, key or uuid.uuid4().hex)
35
+
36
+
37
+ def asset(store, mode='clay'):
38
+ identifier = uuid.uuid4().hex
39
+ folder = store.asset_root/identifier
40
+ (folder/'views').mkdir(parents=True)
41
+ Image.new('RGB', (12, 12), 'gray').save(folder/'views'/f'{mode}-00.webp')
42
+ metadata = {'modes': [mode], 'views': 1, 'default_mode': mode, 'resolution': 12}
43
+ (folder/'preview.json').write_text(json.dumps(metadata))
44
+ (folder/'ready.json').write_text('{}')
45
+ return {'asset_id': identifier, 'preview_metadata': metadata,
46
+ 'file_manifest': {f'frames/{mode}-00.webp': {
47
+ 'size': (folder/'views'/f'{mode}-00.webp').stat().st_size,
48
+ 'version': uuid.uuid4().hex}}}
49
+
50
+
51
+ def geometry(store, owner='alice'):
52
+ job = submit(store, owner)
53
+ operation = job['operations'][0]['id']
54
+ store.claim_operation(operation)
55
+ return store.finish_operation(operation, asset(store))
56
+
57
+
58
+ def texture_op(store, job, key=None, owner='alice'):
59
+ requested = store.submit_texture(owner, job['id'], key or uuid.uuid4().hex)
60
+ return requested['operations'][-1]['id']
61
+
62
+
63
+ def test_geometry_is_complete_viewable_and_export_requires_explicit_texture(store):
64
+ initial = submit(store)
65
+ assert initial['workflow'] == 'geometry_texture'
66
+ assert initial['texture_status'] == 'not_requested'
67
+ with pytest.raises(Conflict):
68
+ texture_op(store, initial)
69
+ operation = initial['operations'][0]['id']
70
+ store.claim_operation(operation)
71
+ completed = store.finish_operation(operation, asset(store))
72
+ assert completed['geometry_asset_id'] == completed['asset_id']
73
+ assert completed['status'] == 'complete'
74
+ assert completed['texture_status'] == 'not_requested'
75
+ assert store.next_operation() is None
76
+ assert store.get_preview('alice', initial['id'])['modes'] == ['clay']
77
+ assert store.resolve_file('alice', initial['id'], 'frames/clay-00.webp').is_file()
78
+ with pytest.raises(Conflict, match='texture'):
79
+ store.submit_export('alice', initial['id'], 'blocked-export')
80
+
81
+
82
+ def test_texture_deduplicates_concurrent_clicks_and_scopes_keys_to_job_owner(store):
83
+ job, other = geometry(store), geometry(store)
84
+ keys = ['texture-click-one', 'texture-click-two']
85
+ with ThreadPoolExecutor(max_workers=2) as pool:
86
+ result = list(pool.map(lambda key: store.submit_texture('alice', job['id'], key), keys))
87
+ assert result[0]['operations'][-1]['id'] == result[1]['operations'][-1]['id']
88
+ current = store.get_job('alice', job['id'])
89
+ assert len([op for op in current['operations'] if op['kind'] == 'texture']) == 1
90
+ public = store.public_snapshot(current)
91
+ assert public['status'] == 'complete'
92
+ assert public['texture_status'] == 'queued'
93
+ assert public['gpu_operation_kind'] == 'texture'
94
+ assert public['gpu_operation_id'] == current['operations'][-1]['id']
95
+ assert 'owner' not in public and 'geometry_asset_id' not in public
96
+ with pytest.raises(Conflict):
97
+ store.submit_texture('alice', other['id'], keys[0])
98
+ with pytest.raises(NotFound):
99
+ store.submit_texture('bob', job['id'], keys[0])
100
+ restored = Store(store.root, asset_root=store.asset_root).initialize()
101
+ assert restored.submit_texture('alice', job['id'], keys[1])['operations'] == current['operations']
102
+
103
+
104
+ @pytest.mark.parametrize('terminal', ['failed', 'interrupted'])
105
+ def test_failed_or_interrupted_texture_keeps_geometry_and_can_retry_only_texture(store, terminal):
106
+ job = geometry(store)
107
+ original_files = deepcopy(job['files'])
108
+
109
+ def assert_single_case(current_store):
110
+ items = current_store.list_jobs('alice')['items']
111
+ assert len(items) == 1
112
+ assert items[0]['id'] == job['id']
113
+ assert items[0]['created_at'] == job['created_at']
114
+
115
+ assert_single_case(store)
116
+ operation = texture_op(store, job, 'texture-attempt-one')
117
+ store.claim_operation(operation)
118
+ store.update_progress(operation, {'label': 'Texturing', 'elapsed_seconds': 3})
119
+ assert_single_case(store)
120
+ assert store.get_job('alice', job['id'])['progress']['label'] == 'Complete'
121
+ if terminal == 'failed':
122
+ store.finish_operation(operation, error='Texture failed')
123
+ restored = Store(store.root, asset_root=store.asset_root).initialize()
124
+ current = restored.get_job('alice', job['id'])
125
+ assert_single_case(restored)
126
+ assert current['status'] == 'complete'
127
+ assert current['texture_status'] == terminal
128
+ assert current['asset_id'] == current['geometry_asset_id'] == job['asset_id']
129
+ assert current['files'] == original_files
130
+ assert restored.resolve_file('alice', job['id'], 'frames/clay-00.webp').is_file()
131
+ with pytest.raises(Conflict):
132
+ restored.submit_export('alice', job['id'], 'blocked-export')
133
+ # A replay is observational; a new explicit attempt gets a new operation.
134
+ assert restored.submit_texture('alice', job['id'], 'texture-attempt-one')['texture_status'] == terminal
135
+ retry = restored.submit_texture('alice', job['id'], 'texture-attempt-two')
136
+ assert retry['operations'][-1]['id'] != operation
137
+ assert retry['texture_status'] == 'queued' and retry['texture_error'] is None
138
+ assert len([op for op in retry['operations'] if op['kind'] == 'generate']) == 1
139
+ assert_single_case(restored)
140
+ retry_operation = retry['operations'][-1]['id']
141
+ restored.claim_operation(retry_operation)
142
+ textured = asset(restored, 'base_color')
143
+ completed = restored.finish_operation(retry_operation, textured)
144
+ assert completed['texture_status'] == 'complete'
145
+ assert completed['asset_id'] == textured['asset_id'] != job['asset_id']
146
+ assert completed['geometry_asset_id'] == job['asset_id']
147
+ assert completed['preview_metadata']['modes'] == ['base_color']
148
+ assert (restored.asset_root/job['asset_id']/'views'/'clay-00.webp').is_file()
149
+ assert_single_case(restored)
150
+ assert_single_case(Store(store.root, asset_root=store.asset_root).initialize())
151
+
152
+
153
+ def test_texture_completion_atomically_switches_assets_and_keeps_input(store):
154
+ job = geometry(store)
155
+ source = store.resolve_file('alice', job['id'], 'input.png').read_bytes()
156
+ original_input_metadata = deepcopy(job['files']['input.png'])
157
+ operation = texture_op(store, job)
158
+ store.claim_operation(operation)
159
+ textured = asset(store, 'basecolor')
160
+ current = store.finish_operation(operation, textured)
161
+ assert current['status'] == current['texture_status'] == 'complete'
162
+ assert current['asset_id'] == textured['asset_id'] != current['geometry_asset_id']
163
+ assert current['geometry_asset_id'] == job['asset_id']
164
+ assert current['preview_metadata']['modes'] == ['basecolor']
165
+ assert 'frames/clay-00.webp' not in current['files']
166
+ assert current['files']['input.png'] == original_input_metadata
167
+ assert store.resolve_file('alice', job['id'], 'input.png').read_bytes() == source
168
+ descriptor = store.get_file_descriptor('alice', job['id'], 'frames/basecolor-00.webp')
169
+ assert textured['asset_id'] in descriptor['path']
170
+ with pytest.raises(NotFound):
171
+ store.get_file_descriptor('alice', job['id'], 'frames/clay-00.webp')
172
+ assert store.submit_texture('alice', job['id'], 'another-complete-click')['operations'][-1]['id'] == operation
173
+ exported = store.submit_export('alice', job['id'], 'export-after-texture')
174
+ export_operation = exported['operations'][-1]['id']
175
+ store.claim_operation(export_operation)
176
+ (store.asset_root/textured['asset_id']/'asset.glb').write_bytes(b'glTFtest')
177
+ current = store.finish_operation(export_operation)
178
+ assert current['export_status'] == 'complete'
179
+ assert store.resolve_file('alice', job['id'], 'asset.glb').read_bytes() == b'glTFtest'
180
+
181
+
182
+ @pytest.mark.parametrize('bad_result', ['same_asset', 'replace_input', 'unready_asset', 'missing_frames'])
183
+ def test_invalid_texture_result_fails_without_replacing_geometry(store, bad_result):
184
+ job = geometry(store)
185
+ operation = texture_op(store, job)
186
+ result = asset(store, 'basecolor')
187
+ if bad_result == 'same_asset':
188
+ result['asset_id'] = job['geometry_asset_id']
189
+ elif bad_result == 'replace_input':
190
+ result['file_manifest']['input.png'] = job['files']['input.png']
191
+ elif bad_result == 'unready_asset':
192
+ (store.asset_root/result['asset_id']/'ready.json').unlink()
193
+ else:
194
+ result['file_manifest'] = {}
195
+ calls = []
196
+ def handle(current, update):
197
+ calls.append(current['geometry_asset_id'])
198
+ return result
199
+ runner = Runner(store, {'texture': handle})
200
+ assert runner.run_once(operation_id=operation, owner='alice')
201
+ assert not runner.run_once(operation_id=operation, owner='alice')
202
+ current = store.get_job('alice', job['id'])
203
+ assert calls == [job['geometry_asset_id']]
204
+ assert current['status'] == 'complete' and current['texture_status'] == 'failed'
205
+ assert current['asset_id'] == job['asset_id']
206
+ assert current['files'] == job['files']
207
+
208
+
209
+ def test_pending_texture_occupies_admission_slot_and_blocks_recovery_and_deletion(store):
210
+ job = geometry(store)
211
+ operation = texture_op(store, job)
212
+ store.max_pending_per_owner = 1
213
+ assert store.has_pending()
214
+ with pytest.raises(LimitExceeded):
215
+ submit(store)
216
+ with pytest.raises(Conflict):
217
+ store.submit_recover('alice', job['id'], 'recover-overlap')
218
+ with pytest.raises(Conflict):
219
+ store.delete_job('alice', job['id'])
220
+ store.claim_operation(operation)
221
+ store.finish_operation(operation, error='Cancelled')
222
+ assert not store.has_pending()
223
+ store.delete_job('alice', job['id'])
224
+ assert (store.asset_root/job['geometry_asset_id']/'ready.json').is_file()
225
+ with pytest.raises(NotFound):
226
+ store.get_job('alice', job['id'])
227
+
228
+
229
+ def test_texture_terminal_write_failure_retries_save_without_rerunning_gpu(store, monkeypatch):
230
+ job = geometry(store)
231
+ operation = texture_op(store, job)
232
+ result = asset(store, 'basecolor')
233
+ calls = []
234
+ write = store._write_json
235
+ failed = False
236
+
237
+ def flaky_write(path, value):
238
+ nonlocal failed
239
+ if value.get('texture_status') == 'complete' and not failed:
240
+ failed = True
241
+ raise OSError('temporary storage error')
242
+ return write(path, value)
243
+
244
+ def handle(current, update):
245
+ calls.append(current['geometry_asset_id'])
246
+ return result
247
+
248
+ monkeypatch.setattr(store, '_write_json', flaky_write)
249
+ runner = Runner(store, {'texture': handle})
250
+ with pytest.raises(OSError):
251
+ runner.run_once(operation_id=operation, owner='alice')
252
+ current = store.get_job('alice', job['id'])
253
+ assert current['status'] == 'complete' and current['texture_status'] == 'running'
254
+ assert current['asset_id'] == job['asset_id']
255
+ assert runner.run_once(operation_id=operation, owner='alice')
256
+ assert calls == [job['geometry_asset_id']]
257
+ current = store.get_job('alice', job['id'])
258
+ assert current['texture_status'] == 'complete' and current['asset_id'] == result['asset_id']
259
+ # The existing removal policy is reversible, so it retains both asset trees.
260
+ store.delete_job('alice', job['id'])
261
+ assert (store.asset_root/current['asset_id']/'ready.json').is_file()
262
+ assert (store.asset_root/current['geometry_asset_id']/'ready.json').is_file()
263
+
264
+
265
+ def test_texture_admission_respects_other_queued_generation(store):
266
+ job = geometry(store)
267
+ store.max_pending_per_owner = 1
268
+ pending = submit(store)
269
+ with pytest.raises(LimitExceeded):
270
+ texture_op(store, job)
271
+ assert store.get_job('alice', job['id'])['texture_status'] == 'not_requested'
272
+ op = pending['operations'][0]['id']
273
+ store.claim_operation(op)
274
+ store.finish_operation(op, error='Ended')
275
+ texture_op(store, job)
276
+ assert store.get_job('alice', job['id'])['texture_status'] == 'queued'
277
+
278
+
279
+ def test_legacy_record_stays_exportable_after_restart(store):
280
+ job = geometry(store)
281
+ path = store.root/'jobs'/job['id']/'job.json'
282
+ value = json.loads(path.read_text())
283
+ for name in ('workflow', 'geometry_asset_id', 'texture_status', 'texture_progress', 'texture_error'):
284
+ value.pop(name)
285
+ path.write_text(json.dumps(value))
286
+ restored = Store(store.root, asset_root=store.asset_root).initialize()
287
+ assert not restored.get_job('alice', job['id']).get('workflow')
288
+ with pytest.raises(Conflict):
289
+ restored.submit_texture('alice', job['id'], 'legacy-no-texture')
290
+ assert restored.submit_export('alice', job['id'], 'legacy-export')['export_status'] == 'queued'
291
+
292
+
293
+ @pytest.fixture
294
+ def web(store, tmp_path):
295
+ sessions = AppSessions('test-only-texture-session')
296
+ app = FastAPI()
297
+ install_routes(app, store, deployment_status=lambda: {'gpu_mode': 'zerogpu'},
298
+ private_cache_root=tmp_path/'cache')
299
+ app.add_middleware(AppSessionMiddleware, sessions=sessions)
300
+ with TestClient(app, base_url='https://testserver') as client:
301
+ yield SimpleNamespace(client=client, sessions=sessions, store=store)
302
+
303
+
304
+ def headers(web, owner='alice'):
305
+ claims = web.sessions.claims(Identity(owner, owner, 'test-oauth'), time.time()+600)
306
+ identity = web.sessions.validate(claims)
307
+ return {'Origin': 'https://testserver', 'X-CSRF-Token': csrf_token(identity),
308
+ 'Cookie': COOKIE_NAME+'='+web.sessions.serializer.dumps(claims)}
309
+
310
+
311
+ def test_texture_route_auth_csrf_schema_and_idempotency(web):
312
+ job = geometry(web.store)
313
+ path = f'/api/history/jobs/{job["id"]}/texture'
314
+ body = {'idempotency_key': 'route-texture-click'}
315
+ assert web.client.post(path, json=body).status_code == 401
316
+ assert web.client.post(path, headers=headers(web, 'bob'), json=body).status_code == 404
317
+ missing_csrf = headers(web)
318
+ missing_csrf.pop('X-CSRF-Token')
319
+ assert web.client.post(path, headers=missing_csrf, json=body).status_code == 403
320
+ assert web.client.post(path, headers=headers(web), json={**body, 'asset_id': job['asset_id']}).status_code == 400
321
+ request_headers = headers(web)
322
+ response = web.client.post(path, headers=request_headers, json=body)
323
+ assert response.status_code == 200, response.text
324
+ value = response.json()
325
+ assert value['workflow'] == 'geometry_texture' and value['texture_status'] == 'queued'
326
+ assert value['status'] == 'complete' and value['gpu_operation_kind'] == 'texture'
327
+ replay = web.client.post(path, headers=request_headers, json=body)
328
+ assert replay.json()['gpu_operation_id'] == value['gpu_operation_id']
329
+ frame = web.client.get(f'/api/history/jobs/{job["id"]}/files/frames/clay-00.webp', headers=request_headers)
330
+ assert frame.status_code == 200 and frame.headers['content-type'] == 'image/webp'
331
+ blocked = web.client.post(f'/api/history/jobs/{job["id"]}/export', headers=request_headers,
332
+ json={'idempotency_key': 'export-too-early'})
333
+ assert blocked.status_code == 409
334
+
335
+
336
+ def test_submit_route_marks_new_workflow(web):
337
+ stream = BytesIO()
338
+ Image.new('RGB', (12, 12), 'red').save(stream, format='PNG')
339
+ upload = web.store.save_upload('alice', stream.getvalue())
340
+ response = web.client.post('/api/history/submit', headers=headers(web), json={
341
+ 'upload_id': upload['id'], 'seed': 42, 'idempotency_key': 'route-first-geometry'})
342
+ assert response.status_code == 200, response.text
343
+ assert response.json()['workflow'] == 'geometry_texture'
344
+ assert response.json()['texture_status'] == 'not_requested'
tests/test_history_service_workflow.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Exercise public split jobs and operator full jobs through their real services."""
2
+ import hashlib
3
+ from io import BytesIO
4
+ import json
5
+ from pathlib import Path
6
+ from types import SimpleNamespace
7
+ import uuid
8
+
9
+ from PIL import Image
10
+ import pytest
11
+
12
+ from hf_space import generation_progress, geometry_results, history_service, ovoxel_results, split_pipeline
13
+ from hf_space.curated_refresh import Coordinator, PLAN
14
+ from hf_space.history_store import Store
15
+
16
+
17
+ @pytest.fixture
18
+ def service(tmp_path, monkeypatch):
19
+ monkeypatch.setenv('SPACES_ZERO_GPU', 'true')
20
+ for name in ('SPACE_ID', 'SPACE_HOST'):
21
+ monkeypatch.delenv(name, raising=False)
22
+ monkeypatch.setattr(ovoxel_results, 'ROOT', tmp_path/'durable')
23
+ monkeypatch.setattr(ovoxel_results, 'JOBS', tmp_path/'worker')
24
+ monkeypatch.setattr(generation_progress, 'DIRECTORY', tmp_path/'progress')
25
+ monkeypatch.setattr(generation_progress, 'JOBS', tmp_path/'worker')
26
+ monkeypatch.setattr(history_service.Service, 'ready', staticmethod(lambda kind: True))
27
+ store = Store(tmp_path/'history', asset_root=ovoxel_results.ROOT).initialize()
28
+ result = history_service.Service(store=store)
29
+ result.private_files = SimpleNamespace(seed=lambda *args, **kwargs: None)
30
+ return result
31
+
32
+
33
+ def generated_output(*, geometry):
34
+ folder = ovoxel_results.JOBS/uuid.uuid4().hex
35
+ folder.mkdir(parents=True)
36
+ mode = 'clay' if geometry else 'base_color'
37
+ filename = 'geometry.npz' if geometry else 'asset.npz'
38
+ Image.new('RGBA', (8, 8), 'red').save(folder/'input.png')
39
+ (folder/filename).write_bytes(b'saved-geometry' if geometry else b'saved-texture')
40
+ if geometry:
41
+ for name in geometry_results.HANDOFF_FILES:
42
+ path = folder/name
43
+ if not path.exists():
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ path.write_bytes(b'checkpoint:' + name.encode())
46
+ (folder/'geometry-checkpoint'/'checkpoint.json').write_text(json.dumps({
47
+ 'format': split_pipeline.CHECKPOINT_FORMAT, 'seed': 42, 'remove_floaters_applied': True,
48
+ 'files': list(split_pipeline.CHECKPOINT_FILES[:-1])}))
49
+ report = {'status': 'complete', 'project_revision': 'fixture',
50
+ 'output_kind': 'geometry' if geometry else 'final_ovoxel'}
51
+ (folder/'report.json').write_text(json.dumps(report))
52
+ (folder/'timings.jsonl').write_text('{"name":"fixture"}\n')
53
+ (folder/'preview.json').write_text(json.dumps({
54
+ 'modes': [mode], 'views': 1, 'default_mode': mode, 'resolution': 8,
55
+ 'view_layout': 'input_front_y_up_v1', 'input_view': 0}))
56
+ (folder/'views').mkdir()
57
+ Image.new('RGB', (8, 8), 'gray').save(folder/'views'/f'{mode}-00.webp')
58
+ return str(folder/filename), str(folder/filename), report
59
+
60
+
61
+ def new_job(service):
62
+ data = BytesIO()
63
+ Image.new('RGBA', (8, 8), 'red').save(data, format='PNG')
64
+ upload = service.store.save_upload('alice', data.getvalue())
65
+ return service.store.submit('alice', upload['id'], 42, uuid.uuid4().hex)
66
+
67
+
68
+ def test_service_runs_geometry_then_restores_exact_checkpoint_only_for_explicit_texture(service, monkeypatch):
69
+ calls, restored_folders = [], []
70
+
71
+ def generate_geometry(image, seed, **kwargs):
72
+ calls.append(('geometry', seed))
73
+ return generated_output(geometry=True)
74
+
75
+ def generate_texture(folder, seed, **kwargs):
76
+ restored_folders.append(Path(folder))
77
+ assert (folder/'geometry-checkpoint'/'geometry.pt').read_bytes() == b'checkpoint:geometry-checkpoint/geometry.pt'
78
+ assert (folder/'geometry.npz').read_bytes() == b'saved-geometry'
79
+ assert all((folder/name).is_file() for name in geometry_results.HANDOFF_FILES)
80
+ calls.append(('texture', seed))
81
+ return generated_output(geometry=False)
82
+
83
+ monkeypatch.setattr(history_service.release_backend, 'generate_geometry', generate_geometry)
84
+ monkeypatch.setattr(history_service.release_backend, 'generate_texture', generate_texture)
85
+ monkeypatch.setattr(history_service.release_backend, 'generate', lambda *a, **kw: pytest.fail('No full inference for split job'))
86
+ job = new_job(service)
87
+ assert service.runner.run_once(operation_id=job['operations'][0]['id'], owner='alice')
88
+ white = service.store.get_job('alice', job['id'])
89
+ assert white['status'] == 'complete' and white['texture_status'] == 'not_requested'
90
+ assert calls == [('geometry', 42)]
91
+ assert service.store.next_operation() is None
92
+ queued = service.store.submit_texture('alice', job['id'], 'explicit-texture-click')
93
+ assert service.runner.run_once(operation_id=queued['operations'][-1]['id'], owner='alice')
94
+ textured = service.store.get_job('alice', job['id'])
95
+ assert calls == [('geometry', 42), ('texture', 42)]
96
+ assert textured['status'] == textured['texture_status'] == 'complete'
97
+ assert textured['geometry_asset_id'] == white['asset_id'] != textured['asset_id']
98
+ assert textured['preview_metadata']['modes'] == ['base_color']
99
+ assert all(not path.exists() for path in restored_folders)
100
+ assert (ovoxel_results.directory(textured['asset_id'])/'asset.npz').read_bytes() == b'saved-texture'
101
+
102
+
103
+ def test_corrupt_saved_geometry_fails_before_texture_gpu_callback(service, monkeypatch):
104
+ monkeypatch.setattr(history_service.release_backend, 'generate_geometry',
105
+ lambda *a, **kw: generated_output(geometry=True))
106
+ monkeypatch.setattr(history_service.release_backend, 'generate_texture',
107
+ lambda *a, **kw: pytest.fail('Invalid checkpoint must not acquire GPU'))
108
+ job = new_job(service)
109
+ service.runner.run_once(operation_id=job['operations'][0]['id'], owner='alice')
110
+ white = service.store.get_job('alice', job['id'])
111
+ (ovoxel_results.directory(white['geometry_asset_id'])/'geometry-checkpoint'/'geometry.pt').write_bytes(b'corrupt')
112
+ queued = service.store.submit_texture('alice', job['id'], 'corrupt-texture-click')
113
+ service.runner.run_once(operation_id=queued['operations'][-1]['id'], owner='alice')
114
+ current = service.store.get_job('alice', job['id'])
115
+ assert current['status'] == 'complete' and current['texture_status'] == 'failed'
116
+ assert current['asset_id'] == white['asset_id']
117
+ assert service.store.resolve_file('alice', job['id'], 'frames/clay-00.webp').is_file()
118
+
119
+
120
+ def test_curated_refresh_keeps_full_pipeline_and_can_queue_export(service, tmp_path, monkeypatch):
121
+ monkeypatch.delenv('SPACES_ZERO_GPU')
122
+ image = tmp_path/'approved.png'
123
+ rgba = Image.new('RGBA', (8, 8), 'red')
124
+ rgba.save(image)
125
+ pixel_hash = hashlib.sha256(f'RGBA:{rgba.width}:{rgba.height}:'.encode() + rgba.tobytes()).hexdigest()
126
+ plan = json.loads(PLAN.read_text())
127
+ plan['enabled'] = True
128
+ for entry in plan['examples']:
129
+ entry.update(input_sha256=hashlib.sha256(image.read_bytes()).hexdigest(), input_rgba_sha256=pixel_hash)
130
+ monkeypatch.setattr(service, 'example_file', lambda name, kind: image)
131
+ calls = []
132
+
133
+ def full_generation(image, seed, **kwargs):
134
+ calls.append(seed)
135
+ return generated_output(geometry=False)
136
+
137
+ monkeypatch.setattr(history_service.release_backend, 'generate', full_generation)
138
+ monkeypatch.setattr(history_service.release_backend, 'generate_geometry',
139
+ lambda *a, **kw: pytest.fail('Curated refresh must use the full pipeline'))
140
+ coordinator = Coordinator(service, plan=plan, state_root=tmp_path/'refresh')
141
+ assert coordinator.run_once()
142
+ item = coordinator.state['items'][plan['examples'][0]['name']]
143
+ job = service.store.get_job(coordinator.owner, item['job_id'])
144
+ assert job.get('workflow') is None
145
+ assert service.runner.run_once()
146
+ assert calls == [42]
147
+ assert coordinator.run_once()
148
+ current = service.store.get_job(coordinator.owner, item['job_id'])
149
+ assert current['status'] == 'complete' and current['export_status'] == 'queued'
150
+ assert [op['kind'] for op in current['operations']] == ['generate', 'export']
151
+ assert item['stage'] == 'export_queued'
152
+ # Admission replay after a coordinator restart cannot create another job.
153
+ restored = Coordinator(service, plan=plan, state_root=tmp_path/'refresh')
154
+ assert not restored.run_once()
155
+ assert len(service.store.list_jobs(coordinator.owner)['items']) == 1
tests/test_history_ui_resume.cjs ADDED
@@ -0,0 +1,811 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const vm = require('node:vm');
6
+
7
+ const read = name => fs.readFileSync(path.join(__dirname, '../hf_space', name), 'utf8');
8
+ const uiSource = read('history_ui.js');
9
+ const markup = read('history_ui.py').match(/MARKUP = """([\s\S]*?)"""/)[1];
10
+ const tick = () => new Promise(resolve => setImmediate(resolve));
11
+ const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
12
+ const operationId = 'a'.repeat(32);
13
+ const savedJob = {id: 'saved-job', status: 'queued', gpu_mode: 'zerogpu',
14
+ gpu_operation_id: operationId, gpu_operation_kind: 'generate', progress: {}};
15
+ const deployment = (runtimeId, completed = 2, total = 5) => ({
16
+ gpu_mode: 'zerogpu', runtime_id: runtimeId, workspace: {stage: 'ready'},
17
+ native_build: {stage: 'compiled'}, foreground: {stage: 'pending'},
18
+ models: {stage: 'loading_models', startup_progress: {completed, total, unit: 'models', label: 'Internal model name'}}
19
+ });
20
+
21
+ function fakeDOM() {
22
+ const nodes = new Map();
23
+ class Element {
24
+ constructor(tagName = 'div') {
25
+ this.tagName = tagName; this.dataset = {}; this.style = {}; this.attributes = new Map();
26
+ this.children = []; this.handlers = new Map(); this.textHistory = []; this.textContent = ''; this.hidden = false;
27
+ this.disabled = false; this.inert = false; this.offsetHeight = 44;
28
+ this.classList = {toggle() {}, add() {}, remove() {}};
29
+ }
30
+ get textContent() { return this._textContent; }
31
+ set textContent(value) { this._textContent = value; this.textHistory.push(value); }
32
+ close() { this.hidden = true; }
33
+ addEventListener(type, handler) {
34
+ const handlers = this.handlers.get(type) || [];
35
+ handlers.push(handler); this.handlers.set(type, handlers);
36
+ }
37
+ click() { return Promise.all((this.handlers.get('click') || []).map(handler => handler({target: this}))); }
38
+ append(...children) {
39
+ children = children.flatMap(child => child.tagName === '#fragment' ? child.children : [child]);
40
+ this.children.push(...children);
41
+ for (const child of children) if (child.id) nodes.set(child.id, child);
42
+ }
43
+ replaceChildren(...children) { this.children = []; this.append(...children); }
44
+ querySelector(selector) {
45
+ if (selector.startsWith('#')) return nodes.get(selector.slice(1)) || null;
46
+ return this.querySelectorAll(selector)[0] || null;
47
+ }
48
+ querySelectorAll(selector) {
49
+ const match = node => selector.startsWith('.') ? (node.className || '').split(' ').includes(selector.slice(1)) :
50
+ selector.includes('[') ? node.tagName === selector.split('[')[0] &&
51
+ selector.match(/\[data-([\w-]+)/) && selector.match(/\[data-([\w-]+)/)[1].replace(/-([a-z])/g, (_, value) => value.toUpperCase()) in node.dataset :
52
+ node.tagName === selector;
53
+ return this.children.flatMap(child => [...(match(child) ? [child] : []), ...child.querySelectorAll(selector)]);
54
+ }
55
+ contains(node) { return node === this || this.children.some(child => child.contains(node)); }
56
+ setAttribute(name, value) { this.attributes.set(name, String(value)); }
57
+ getAttribute(name) { return this.attributes.get(name) || null; }
58
+ removeAttribute(name) { this.attributes.delete(name); if (name === 'value') delete this.value; }
59
+ }
60
+ // Derive available IDs and initial hidden states from the actual page markup.
61
+ // Missing/removed elements return null, so stale production DOM accesses fail.
62
+ for (const tag of markup.matchAll(/<([a-z][\w-]*)\b([^>]*)>/gi)) {
63
+ const id = tag[2].match(/\bid="([^"]+)"/);
64
+ if (!id) continue;
65
+ const node = new Element(tag[1]); node.id = id[1];
66
+ node.hidden = /\bhidden\b/.test(tag[2]); nodes.set(node.id, node);
67
+ }
68
+ assert.match(markup, /id="st-job-progress"[\s\S]*?<progress\b/);
69
+ nodes.get('st-job-progress').append(new Element('progress'));
70
+ nodes.get('st-texture-progress').append(new Element('progress'));
71
+ const documentHandlers = new Map();
72
+ const document = {hidden: false, getElementById: id => nodes.get(id) || null, handlers: documentHandlers,
73
+ createElement: tag => new Element(tag), createDocumentFragment: () => new Element('#fragment'),
74
+ addEventListener(type, handler) {
75
+ const handlers = documentHandlers.get(type) || [];
76
+ handlers.push(handler); documentHandlers.set(type, handlers);
77
+ }};
78
+ return {document, get: id => nodes.get(id) || null};
79
+ }
80
+
81
+ function setup(t) {
82
+ const dom = fakeDOM(), requests = [], submissions = [], finishGPU = deferred();
83
+ const network = {deployment: deployment('runtime-a'), statusFailure: false, nextStatus: null};
84
+ const images = {decoded: 0, created: [], revoked: []};
85
+ class UploadURL extends URL {
86
+ static createObjectURL(blob) { const url = 'blob:test-' + (images.created.length + 1); images.created.push({url, blob}); return url; }
87
+ static revokeObjectURL(url) { images.revoked.push(url); }
88
+ }
89
+ class UploadImage {
90
+ async decode() { images.decoded++; network.onDecode?.(); if (network.nextDecode) { const pending = network.nextDecode; network.nextDecode = null; await pending.promise; } }
91
+ }
92
+ let timerId = 0;
93
+ const timers = new Map();
94
+ const response = (data, status = 200) => new Response(JSON.stringify(data), {status});
95
+ const context = vm.createContext({
96
+ AbortController, AbortSignal, DOMException, Headers, URL: UploadURL, URLSearchParams, Image: UploadImage, crypto: require('node:crypto').webcrypto,
97
+ document: dom.document, window: {addEventListener() {}},
98
+ location: {origin: 'https://example.hf.space', href: 'https://example.hf.space/'},
99
+ matchMedia: () => ({matches: false}),
100
+ requestAnimationFrame: callback => callback(),
101
+ setTimeout(callback) { const id = ++timerId; timers.set(id, callback); return id; },
102
+ clearTimeout: id => timers.delete(id),
103
+ async fetch(url, options = {}) {
104
+ requests.push({url, method: options.method || 'GET', headers: options.headers, body: options.body});
105
+ if (url === '/api/history/status') {
106
+ if (network.nextStatus) {
107
+ const pending = network.nextStatus; network.nextStatus = null; return pending.promise;
108
+ }
109
+ return network.statusFailure ? response({detail: 'Temporarily unavailable'}, 503) : response(network.deployment);
110
+ }
111
+ if (/\/uploads\/[^/]+\/files\/foreground\.png$/.test(url)) {
112
+ if (network.nextImage) { const pending = network.nextImage; network.nextImage = null; return pending.promise; }
113
+ return network.imageFailure ? new Response('', {status: 503}) : new Response(new Blob(['cutout'], {type: 'image/png'}));
114
+ }
115
+ if (/^\/api\/history\/uploads\/[^/]+$/.test(url)) {
116
+ if (network.nextUploadStatus) { const pending = network.nextUploadStatus; network.nextUploadStatus = null; return pending.promise; }
117
+ return response(network.uploadResponse);
118
+ }
119
+ if (/^\/api\/history\/uploads\/[^/]+\/foreground$/.test(url)) return response(network.retryUploadResponse);
120
+ if (url === '/api/history/gpu/prepare') return response({});
121
+ if (url.startsWith('/api/history/jobs?') && network.jobsResponse) return response(network.jobsResponse);
122
+ if (url === '/api/history/submit' && network.nextSubmission) {
123
+ const pending = network.nextSubmission; network.nextSubmission = null; return pending.promise;
124
+ }
125
+ if (url === '/api/history/jobs/saved-job/texture') return response(network.textureResponse, network.textureFailure ? 503 : 200);
126
+ const previewJob = /^\/api\/history\/jobs\/([^/]+)\/preview\?/.exec(url);
127
+ if (previewJob) {
128
+ if (network.nextPreview) {
129
+ const pending = network.nextPreview; network.nextPreview = null; return pending.promise;
130
+ }
131
+ return response({view: 'render', html: network.previewByJob?.[previewJob[1]] || network.previewHTML || '<div>White preview</div>'});
132
+ }
133
+ throw new Error('Unexpected request in Resume integration test: ' + url);
134
+ }
135
+ });
136
+ vm.runInContext(read('zerogpu_client.js'), context);
137
+ const realClient = context.SupraZeroGPU;
138
+ context.window.SupraZeroGPU = {...realClient, createDispatcher: options => realClient.createDispatcher({
139
+ ...options,
140
+ quotaHeaders: async () => ({'x-zerogpu-token': 'test-only-token'}),
141
+ connect: async () => ({submit(endpoint, data) {
142
+ submissions.push({endpoint, data});
143
+ return (async function* () { await finishGPU.promise; })();
144
+ }})
145
+ })};
146
+ assert.equal((uiSource.match(/void boot\(\);/g) || []).length, 1);
147
+ vm.runInContext(uiSource.replace('void boot();',
148
+ 'globalThis.testUI = {S, renderSelected, refreshStatus, refreshJobs, updateGate, submit, gpuDispatcher, resumeGPU, selectJob, selectExample, previewKey, historyStatus, applyUploadResult, refreshUpload, clearPrivate, clearUploadPreview, retryUploadPreview};'), context);
149
+ const ui = context.testUI;
150
+ Object.assign(ui.S, {identity: {authenticated: true, csrf_token: 'csrf-test'}, authChecking: false,
151
+ selected: {kind: 'job', id: savedJob.id}, source: {upload_id: 'saved-upload'},
152
+ historyLoaded: true, examplesLoaded: true,
153
+ deployment: {...deployment('previous-runtime'), models: {stage: 'ready_released'}}});
154
+ ui.S.jobs.set(savedJob.id, {...savedJob});
155
+ ui.renderSelected();
156
+ t.after(async () => {
157
+ ui.S.stopped = true; ui.gpuDispatcher.reset(); finishGPU.resolve(); await tick();
158
+ });
159
+ return {...ui, ...dom, network, requests, submissions, response, images, timers,
160
+ prepareRequests: () => requests.filter(request => request.url === '/api/history/gpu/prepare')};
161
+ }
162
+
163
+ test('saved Resume refreshes readiness before preparation, ignores double clicks, and displays real right-panel progress', async t => {
164
+ const env = setup(t), status = deferred();
165
+ env.network.nextStatus = status;
166
+ assert.equal(env.resumeGPU.hidden, false);
167
+ assert.equal(env.get('st-job-time').hidden, true);
168
+ assert.equal(env.get('st-job-progress').dataset.resumable, 'true');
169
+ const firstClick = env.resumeGPU.click();
170
+ const secondClick = env.resumeGPU.click();
171
+ assert.equal(env.resumeGPU.disabled, true);
172
+ assert.equal(env.resumeGPU.textContent, 'Resuming…');
173
+ assert.deepEqual(env.requests.map(request => request.url), ['/api/history/status']);
174
+ assert.equal(env.prepareRequests().length, 0);
175
+ status.resolve(env.response(env.network.deployment));
176
+ await firstClick; await secondClick; await tick();
177
+ assert.equal(env.prepareRequests().length, 1);
178
+ assert.equal(env.prepareRequests()[0].headers['X-CSRF-Token'], 'csrf-test');
179
+ assert.equal(JSON.parse(env.prepareRequests()[0].body).operation_id, operationId);
180
+ assert.deepEqual(env.requests.slice(0, 2).map(request => request.url), ['/api/history/status', '/api/history/gpu/prepare']);
181
+ assert.equal(env.get('st-job-detail').textContent, 'Loading model weights · 40%');
182
+ const meter = env.get('st-job-progress').querySelector('progress');
183
+ assert.equal(meter.hidden, false); assert.equal(meter.value, 40);
184
+ assert.equal(env.resumeGPU.hidden, true);
185
+ assert.equal(env.get('st-job-progress').dataset.resumable, 'false');
186
+ assert.match(env.get('st-job-time').textContent, /first generation after each Space startup/);
187
+ assert.equal(env.get('st-job-time').hidden, false);
188
+ assert.equal(env.get('st-native-startup'), null);
189
+ assert.equal(env.get('st-gate-note').hidden, true);
190
+ assert.equal(env.submissions.length, 0);
191
+ });
192
+
193
+ test('status polling after a runtime change repeats CPU preparation once and advances that same requested task', async t => {
194
+ const env = setup(t);
195
+ await env.resumeGPU.click(); await tick();
196
+ assert.equal(env.prepareRequests().length, 1);
197
+ env.network.deployment = deployment('runtime-b', 0, 4);
198
+ await env.refreshStatus(); await tick();
199
+ assert.equal(env.prepareRequests().length, 2);
200
+ assert.equal(env.get('st-job-detail').textContent, 'Loading model weights · 0%');
201
+ assert.equal(env.resumeGPU.hidden, true);
202
+ env.network.deployment = deployment('runtime-b', 3, 4);
203
+ await env.refreshStatus(); await tick();
204
+ assert.equal(env.prepareRequests().length, 2);
205
+ assert.equal(env.get('st-job-detail').textContent, 'Loading model weights · 75%');
206
+ assert.equal(env.get('st-job-progress').querySelector('progress').value, 75);
207
+ env.network.deployment.models = {stage: 'ready_released'};
208
+ await env.refreshStatus(); await tick();
209
+ assert.equal(env.submissions.length, 1);
210
+ assert.equal(env.submissions[0].data.operation_id, operationId);
211
+ assert.equal(env.get('st-job-detail').textContent, 'Starting generation… Keep this page open.');
212
+ assert.equal(env.get('st-job-time').hidden, true);
213
+ assert.equal(env.get('st-job-progress').querySelector('progress').value, undefined);
214
+ await env.refreshStatus(); await tick();
215
+ assert.equal(env.prepareRequests().length, 2);
216
+ assert.equal(env.submissions.length, 1);
217
+ });
218
+
219
+ test('a failed status refresh keeps Resume available and does not prepare until a later successful click', async t => {
220
+ const env = setup(t);
221
+ env.network.statusFailure = true;
222
+ await env.resumeGPU.click(); await tick();
223
+ assert.equal(env.requests.filter(request => request.url === '/api/history/status').length, 2);
224
+ assert.equal(env.prepareRequests().length, 0);
225
+ assert.equal(env.resumeGPU.hidden, false);
226
+ assert.equal(env.resumeGPU.disabled, false);
227
+ assert.equal(env.resumeGPU.textContent, 'Resume task');
228
+ assert.equal(env.get('st-job-time').hidden, true);
229
+ assert.match(env.get('st-native-notice').textContent, /try Resume task again/);
230
+ env.network.statusFailure = false;
231
+ await env.resumeGPU.click(); await tick();
232
+ assert.equal(env.prepareRequests().length, 1);
233
+ assert.equal(env.resumeGPU.hidden, true);
234
+ assert.equal(env.get('st-job-detail').textContent, 'Loading model weights · 40%');
235
+ });
236
+
237
+ function geometryReady(env, fields = {}) {
238
+ const job = {...savedJob, workflow: 'geometry_texture', status: 'complete', asset_id: 'white-asset',
239
+ texture_status: 'not_requested', gpu_operation_id: null, gpu_operation_kind: null, ...fields};
240
+ env.S.jobs.set(job.id, job);
241
+ return job;
242
+ }
243
+
244
+ test('persisted startup wording is normalized in geometry and texture details without replacing actual stage labels', t => {
245
+ const env = setup(t), oldLabel = 'Running the latest single-view pipeline';
246
+ for (const workflow of [undefined, 'geometry_texture']) {
247
+ const job = {...savedJob, workflow, status: 'running', gpu_operation_id: null, progress: {label: oldLabel, elapsed_seconds: 12}};
248
+ env.S.jobs.set(job.id, job); env.renderSelected();
249
+ assert.equal(env.get('st-job-detail').textContent, 'Starting generation…');
250
+ assert.equal(env.get('st-job-time').textContent, '12s elapsed');
251
+ job.progress.label = 'Generating geometry'; env.renderSelected();
252
+ assert.equal(env.get('st-job-detail').textContent, 'Generating geometry');
253
+ }
254
+ const job = geometryReady(env, {texture_status: 'running', texture_progress: {label: oldLabel}});
255
+ env.S.previewLoaded = env.previewKey(job); env.renderSelected();
256
+ assert.equal(env.get('st-texture-detail').textContent, 'Starting generation…');
257
+ job.texture_progress.label = 'Generating materials'; env.renderSelected();
258
+ assert.equal(env.get('st-texture-detail').textContent, 'Generating materials');
259
+ assert.equal(env.requests.length, 0);
260
+ });
261
+
262
+ test('selecting white geometry never starts textures; an explicit click replaces the white display with central texture preparation', async t => {
263
+ const env = setup(t), job = geometryReady(env);
264
+ env.selectJob(job.id); await tick();
265
+ assert.equal(env.get('st-ovoxel').innerHTML, '<div>White preview</div>');
266
+ assert.equal(env.get('st-ovoxel').hidden, false);
267
+ assert.equal(env.get('st-texture').textContent, 'Generate textures');
268
+ assert.equal(env.get('st-texture-note').hidden, false);
269
+ assert.equal(env.get('st-extract').hidden, true);
270
+ assert.equal(env.get('st-extract').disabled, true);
271
+ assert.equal(env.prepareRequests().length, 0);
272
+ assert.equal(env.requests.filter(request => request.url.endsWith('/texture')).length, 0);
273
+ env.network.textureResponse = {...job, texture_status: 'queued', gpu_operation_kind: 'texture', gpu_operation_id: 'b'.repeat(32)};
274
+ const click = env.get('st-texture').click();
275
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
276
+ assert.equal(env.get('st-ovoxel').hidden, true);
277
+ assert.equal(env.get('st-native-empty').hidden, true);
278
+ assert.equal(env.get('st-texture-detail').textContent, 'Connecting to your task…');
279
+ assert.equal(env.get('st-texture-progress').querySelector('progress').hidden, false);
280
+ const duplicate = env.get('st-texture').click();
281
+ await click; await duplicate; await tick();
282
+ assert.equal(env.requests.filter(request => request.url.endsWith('/texture')).length, 1);
283
+ assert.equal(env.prepareRequests().length, 1);
284
+ assert.equal(env.get('st-texture-detail').textContent, 'Loading model weights · 40%');
285
+ assert.equal(env.get('st-texture-progress').querySelector('progress').value, 40);
286
+ assert.equal(env.get('st-ovoxel').innerHTML, '<div>White preview</div>');
287
+ assert.equal(env.get('st-ovoxel').hidden, true, 'white geometry is retained only for a failure fallback');
288
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
289
+ assert.equal(env.get('st-job-progress').hidden, true);
290
+ assert.equal(env.get('st-texture').hidden, true);
291
+ assert.equal(env.submissions.length, 0);
292
+ });
293
+
294
+ test('texture completion refreshes the asset preview and enables export without changing the selected job', async t => {
295
+ const env = setup(t), white = geometryReady(env);
296
+ env.selectJob(white.id); await tick();
297
+ const oldKey = env.previewKey(white);
298
+ const textured = {...white, asset_id: 'textured-asset', texture_status: 'complete'};
299
+ env.network.previewHTML = '<div>Textured preview</div>';
300
+ env.S.jobs.set(white.id, textured); env.renderSelected(); await tick();
301
+ assert.notEqual(env.previewKey(textured), oldKey);
302
+ assert.equal(env.S.selected.id, white.id);
303
+ assert.equal(env.get('st-ovoxel').innerHTML, '<div>Textured preview</div>');
304
+ assert.equal(env.requests.filter(request => request.url.includes('/preview?')).length, 2);
305
+ assert.equal(env.get('st-texture-actions').hidden, true);
306
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'false');
307
+ assert.equal(env.get('st-ovoxel').hidden, false);
308
+ assert.equal(env.get('st-extract').hidden, false);
309
+ assert.equal(env.get('st-extract').disabled, false);
310
+ assert.equal(env.historyStatus(textured).label, 'Complete');
311
+ assert.equal(env.prepareRequests().length, 0);
312
+ });
313
+
314
+ test('saved queued textures resume the existing operation only after a click and failed textures start a new retry', async t => {
315
+ const env = setup(t), queued = geometryReady(env, {texture_status: 'queued', gpu_operation_kind: 'texture', gpu_operation_id: 'b'.repeat(32)});
316
+ env.selectJob(queued.id); await tick();
317
+ assert.equal(env.get('st-texture').textContent, 'Resume textures');
318
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
319
+ assert.equal(env.get('st-ovoxel').hidden, true);
320
+ assert.equal(env.requests.filter(request => request.url.includes('/preview?')).length, 0);
321
+ assert.equal(env.prepareRequests().length, 0);
322
+ await env.get('st-texture').click(); await tick();
323
+ assert.equal(env.prepareRequests().length, 1);
324
+ assert.equal(env.requests.filter(request => request.url.endsWith('/texture')).length, 0);
325
+ const failed = {...queued, texture_status: 'failed', texture_error: 'Texture generation failed.', gpu_operation_id: null};
326
+ env.S.jobs.set(queued.id, failed); env.renderSelected(); await tick();
327
+ assert.equal(env.get('st-texture').textContent, 'Retry textures');
328
+ assert.equal(env.get('st-ovoxel').hidden, false);
329
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'false');
330
+ env.network.textureResponse = {...queued, gpu_operation_id: 'c'.repeat(32)};
331
+ await env.get('st-texture').click(); await tick();
332
+ assert.equal(env.requests.filter(request => request.url.endsWith('/texture')).length, 1);
333
+ assert.equal(env.prepareRequests().length, 2);
334
+ assert.equal(JSON.parse(env.prepareRequests()[1].body).operation_id, 'c'.repeat(32));
335
+ });
336
+
337
+ test('texture POST retries retain their idempotency key while running tasks and legacy jobs need no texture action', async t => {
338
+ const env = setup(t), job = geometryReady(env);
339
+ env.selectJob(job.id); await tick();
340
+ env.network.textureFailure = true; env.network.textureResponse = {detail: 'Temporarily unavailable'};
341
+ await env.get('st-texture').click(); await tick();
342
+ env.network.textureFailure = false;
343
+ env.network.textureResponse = {...job, texture_status: 'running', texture_progress: {label: 'Generating materials', elapsed_seconds: 10}};
344
+ await env.get('st-texture').click(); await tick();
345
+ const posts = env.requests.filter(request => request.url.endsWith('/texture'));
346
+ assert.equal(posts.length, 2);
347
+ assert.equal(JSON.parse(posts[0].body).idempotency_key, JSON.parse(posts[1].body).idempotency_key);
348
+ assert.equal(env.get('st-texture').hidden, true);
349
+ assert.equal(env.get('st-texture-title').textContent, 'Generating textures');
350
+ assert.equal(env.get('st-texture-progress').querySelector('progress').value, undefined);
351
+ assert.equal(env.historyStatus(env.S.jobs.get(job.id)).label, 'Generating textures');
352
+ const legacy = {...job, workflow: undefined};
353
+ env.S.jobs.set(job.id, legacy); env.renderSelected();
354
+ assert.equal(env.get('st-texture-actions').hidden, true);
355
+ assert.equal(env.get('st-extract').hidden, false);
356
+ assert.equal(env.historyStatus(legacy).label, 'Generation Complete');
357
+ env.S.examples.set('example', {image_url: '/example.png', preview_html: '<div>Example preview</div>'});
358
+ env.selectExample('example');
359
+ assert.equal(env.get('st-model').innerHTML, '<div>Example preview</div>');
360
+ assert.equal(env.get('st-texture-actions').hidden, true);
361
+ });
362
+
363
+ test('one saved geometry keeps one history card through texture failure, retry and success without submitting geometry again', async t => {
364
+ const env = setup(t), white = geometryReady(env, {upload_id: 'saved-upload', seed: 42});
365
+ env.selectJob(white.id); await tick();
366
+ const card = env.get('st-history-list').children[0];
367
+ const sameCase = () => {
368
+ assert.equal(env.S.jobs.size, 1);
369
+ assert.equal(env.S.selected.id, white.id);
370
+ assert.equal(env.get('st-history-list').children.length, 1);
371
+ assert.equal(env.get('st-history-list').children[0], card);
372
+ assert.equal(card.dataset.jobId, white.id);
373
+ assert.equal(env.requests.filter(request => request.url === '/api/history/submit').length, 0);
374
+ };
375
+ assert.equal(env.get('st-generate').disabled, true);
376
+ assert.equal(env.get('st-generate').textContent, 'Geometry ready');
377
+ assert.equal(env.get('st-input-panel').inert, false);
378
+ await env.get('st-generate').click();
379
+ env.get('st-generate').disabled = false; // The handler must guard programmatic bypasses too.
380
+ await env.submit(); env.updateGate(); sameCase();
381
+ env.network.textureResponse = {...white, texture_status: 'queued', gpu_operation_kind: 'texture', gpu_operation_id: 'b'.repeat(32)};
382
+ await env.get('st-texture').click(); await tick(); sameCase();
383
+ const failed = {...white, texture_status: 'failed', texture_error: 'Texture generation failed.'};
384
+ env.network.jobsResponse = {items: [failed], revision: 2};
385
+ await env.refreshJobs(); await tick(); sameCase();
386
+ assert.equal(env.get('st-ovoxel').innerHTML, '<div>White preview</div>');
387
+ assert.equal(env.get('st-ovoxel').hidden, false);
388
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'false');
389
+ assert.equal(env.get('st-texture').textContent, 'Retry textures');
390
+ assert.equal(env.get('st-generate').disabled, true);
391
+ env.network.textureResponse = {...white, texture_status: 'queued', gpu_operation_kind: 'texture', gpu_operation_id: 'c'.repeat(32)};
392
+ await env.get('st-texture').click(); await tick(); sameCase();
393
+ const textured = {...white, asset_id: 'textured-asset', texture_status: 'complete'};
394
+ env.network.previewHTML = '<div>Textured preview</div>';
395
+ env.network.jobsResponse = {items: [textured], revision: 3};
396
+ await env.refreshJobs(); await tick(); sameCase();
397
+ assert.equal(env.get('st-ovoxel').innerHTML, '<div>Textured preview</div>');
398
+ assert.equal(env.get('st-generate').textContent, 'Geometry generated');
399
+ assert.equal(env.get('st-generate').disabled, true);
400
+ assert.equal(env.get('st-extract').disabled, false);
401
+ assert.equal(env.requests.filter(request => request.url.endsWith('/texture')).length, 2);
402
+ });
403
+
404
+ test('changing the selected geometry seed or upload allows a legitimate new geometry submission', async t => {
405
+ for (const change of ['seed', 'upload']) {
406
+ const env = setup(t), white = geometryReady(env, {upload_id: 'saved-upload', seed: 42});
407
+ env.selectJob(white.id); await tick();
408
+ assert.equal(env.get('st-generate').disabled, true);
409
+ if (change === 'seed') {
410
+ env.get('st-seed').value = '43';
411
+ for (const handler of env.get('st-seed').handlers.get('input')) handler();
412
+ } else {
413
+ env.S.source = {upload_id: 'replacement-upload'}; env.updateGate();
414
+ }
415
+ assert.equal(env.get('st-generate').disabled, false);
416
+ assert.equal(env.get('st-generate').textContent, 'Generate geometry');
417
+ const submission = deferred(); env.network.nextSubmission = submission;
418
+ const pending = env.submit();
419
+ const posts = env.requests.filter(request => request.url === '/api/history/submit');
420
+ assert.equal(posts.length, 1);
421
+ const body = JSON.parse(posts[0].body);
422
+ assert.equal(body.seed, change === 'seed' ? 43 : 42);
423
+ assert.equal(body.upload_id, change === 'upload' ? 'replacement-upload' : 'saved-upload');
424
+ submission.resolve(env.response({...savedJob, id: 'new-job', seed: body.seed, upload_id: body.upload_id,
425
+ gpu_operation_id: 'd'.repeat(32)}));
426
+ await pending; await tick();
427
+ assert.equal(env.S.jobs.size, 2, 'independent geometry runs must remain separate cases');
428
+ assert.equal(env.S.selected.id, 'new-job');
429
+ }
430
+ });
431
+
432
+ test('a mismatched texture response cannot create another card or dispatch its operation', async t => {
433
+ const env = setup(t), white = geometryReady(env, {upload_id: 'saved-upload', seed: 42});
434
+ env.selectJob(white.id); await tick();
435
+ env.network.textureResponse = {...white, id: 'wrong-job', texture_status: 'queued',
436
+ gpu_operation_kind: 'texture', gpu_operation_id: 'e'.repeat(32)};
437
+ await env.get('st-texture').click(); await tick();
438
+ assert.equal(env.S.jobs.size, 1);
439
+ assert.equal(env.get('st-history-list').children.length, 1);
440
+ assert.equal(env.S.selected.id, white.id);
441
+ assert.equal(env.prepareRequests().length, 0);
442
+ assert.equal(env.submissions.length, 0);
443
+ assert.equal(env.get('st-ovoxel').innerHTML, '<div>White preview</div>');
444
+ assert.match(env.get('st-texture-detail').textContent, /did not match this saved model/);
445
+ assert.equal(env.get('st-texture').textContent, 'Retry textures');
446
+ });
447
+
448
+ test('an outdated white preview response is discarded before it changes the visible preview or input', async t => {
449
+ const env = setup(t), white = geometryReady(env, {upload_id: 'saved-upload', seed: 42}), preview = deferred();
450
+ const writes = [], panel = env.get('st-ovoxel');
451
+ let html = '';
452
+ Object.defineProperty(panel, 'innerHTML', {get: () => html, set(value) { html = value; writes.push(value); }});
453
+ env.network.nextPreview = preview;
454
+ env.selectJob(white.id); await tick();
455
+ const textured = {...white, asset_id: 'textured-asset', texture_status: 'complete'};
456
+ env.S.jobs.set(white.id, textured);
457
+ env.network.previewHTML = '<div>Textured preview</div>';
458
+ preview.resolve(env.response({view: 'render', html: '<div>Stale white preview</div>',
459
+ upload_id: 'stale-upload', display_url: '/api/history/jobs/saved-job/files/stale.png'}));
460
+ await tick(); await tick();
461
+ assert.deepEqual(writes, ['<div>Textured preview</div>']);
462
+ assert.equal(env.S.source.upload_id, 'saved-upload');
463
+ assert.equal(env.S.previewLoaded, env.previewKey(textured));
464
+ });
465
+
466
+ test('texture POST and status failures restore cached white geometry with retry, including a cold workspace', async t => {
467
+ for (const failure of ['post', 'status', 'cold']) {
468
+ const env = setup(t), white = geometryReady(env);
469
+ env.selectJob(white.id); await tick();
470
+ if (failure === 'post') {
471
+ env.network.textureFailure = true; env.network.textureResponse = {detail: 'Texture request could not connect.'};
472
+ } else if (failure === 'status') env.network.statusFailure = true;
473
+ else env.network.deployment = {...env.network.deployment, workspace: {stage: 'starting'}};
474
+ const click = env.get('st-texture').click();
475
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
476
+ assert.equal(env.get('st-ovoxel').hidden, true);
477
+ await click; await tick();
478
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'false');
479
+ assert.equal(env.get('st-ovoxel').hidden, false);
480
+ assert.equal(env.get('st-ovoxel').innerHTML, '<div>White preview</div>');
481
+ assert.equal(env.get('st-texture').hidden, false);
482
+ assert.equal(env.get('st-texture').textContent, 'Retry textures');
483
+ assert.equal(env.get('st-texture').disabled, failure === 'cold');
484
+ assert.equal(env.get('st-texture-title').textContent, 'Texture failed');
485
+ assert.equal(env.get('st-texture-progress').querySelector('progress').hidden, true);
486
+ assert.equal(env.requests.filter(request => request.url.includes('/preview?')).length, 1);
487
+ }
488
+ });
489
+
490
+ test('a dispatcher failure restores white and an actionable retry even while the saved texture is still queued', async t => {
491
+ const env = setup(t), white = geometryReady(env);
492
+ env.selectJob(white.id); await tick();
493
+ const queued = {...white, texture_status: 'queued', gpu_operation_kind: 'texture', gpu_operation_id: 'b'.repeat(32)};
494
+ env.network.textureResponse = queued; env.network.jobsResponse = {items: [queued], revision: 2};
495
+ await env.get('st-texture').click(); await tick();
496
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
497
+ env.gpuDispatcher.advance(() => 'Hugging Face could not verify your personal ZeroGPU quota. Sign in on Hugging Face, then refresh this Space.');
498
+ await tick(); await tick();
499
+ assert.equal(env.S.jobs.get(white.id).texture_status, 'queued');
500
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'false');
501
+ assert.equal(env.get('st-ovoxel').hidden, false);
502
+ assert.equal(env.get('st-texture').textContent, 'Retry textures');
503
+ assert.equal(env.get('st-texture').hidden, false);
504
+ assert.equal(env.get('st-texture').disabled, false);
505
+ assert.equal(env.get('st-texture-progress').querySelector('progress').hidden, true);
506
+ assert.match(env.get('st-texture-detail').textContent, /could not verify/);
507
+ const retry = env.get('st-texture').click();
508
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
509
+ assert.equal(env.get('st-texture-title').textContent, 'Preparing textures');
510
+ assert.equal(env.get('st-ovoxel').hidden, true);
511
+ await retry; await tick();
512
+ assert.equal(env.requests.filter(request => request.url.endsWith('/texture')).length, 1);
513
+ assert.equal(env.prepareRequests().length, 2);
514
+ });
515
+
516
+ test('texture activity belongs to its selected case and cannot hide another history preview after a delayed connection', async t => {
517
+ const env = setup(t), white = geometryReady(env), status = deferred();
518
+ env.selectJob(white.id); await tick();
519
+ const other = {...white, id: 'another-job', asset_id: 'another-white'};
520
+ env.S.jobs.set(other.id, other); env.network.previewByJob = {[other.id]: '<div>Other white preview</div>'};
521
+ env.network.nextStatus = status;
522
+ env.network.textureResponse = {...white, texture_status: 'queued', gpu_operation_kind: 'texture', gpu_operation_id: 'b'.repeat(32)};
523
+ const click = env.get('st-texture').click();
524
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
525
+ env.selectJob(other.id); await tick();
526
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'false');
527
+ assert.equal(env.get('st-ovoxel').hidden, false);
528
+ assert.equal(env.get('st-ovoxel').innerHTML, '<div>Other white preview</div>');
529
+ status.resolve(env.response(env.network.deployment)); await click; await tick();
530
+ assert.equal(env.S.selected.id, other.id);
531
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'false');
532
+ assert.equal(env.get('st-ovoxel').hidden, false);
533
+ const running = {...env.network.textureResponse, texture_status: 'running', texture_progress: {label: 'Generating materials'}};
534
+ env.S.jobs.set(white.id, running); env.selectJob(white.id); await tick();
535
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
536
+ assert.equal(env.get('st-ovoxel').hidden, true);
537
+ assert.equal(env.get('st-texture-title').textContent, 'Generating textures');
538
+ assert.equal(env.requests.filter(request => request.url.includes('/jobs/saved-job/preview?')).length, 1,
539
+ 'returning to active textures must not request white geometry again');
540
+ });
541
+
542
+ test('a pending white preview may be cached during textures but never becomes visible until a failure', async t => {
543
+ const env = setup(t), white = geometryReady(env), preview = deferred();
544
+ env.network.nextPreview = preview; env.selectJob(white.id); await tick();
545
+ env.network.textureResponse = {...white, texture_status: 'queued', gpu_operation_kind: 'texture', gpu_operation_id: 'b'.repeat(32)};
546
+ await env.get('st-texture').click(); await tick();
547
+ const panel = env.get('st-ovoxel'), visibility = [];
548
+ let hidden = panel.hidden;
549
+ Object.defineProperty(panel, 'hidden', {get: () => hidden, set(value) { hidden = value; visibility.push(value); }});
550
+ preview.resolve(env.response({view: 'render', html: '<div>Late white preview</div>'}));
551
+ await tick(); await tick();
552
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'true');
553
+ assert.equal(panel.hidden, true);
554
+ assert.equal(panel.innerHTML, '<div>Late white preview</div>');
555
+ assert.equal(visibility.includes(false), false, 'even an intermediate response must not flash white geometry');
556
+ assert.equal(env.get('st-native-empty').hidden, true);
557
+ assert.equal(env.S.previewLoaded, env.previewKey(white));
558
+ env.S.jobs.set(white.id, {...white, texture_status: 'failed', texture_error: 'Texture generation failed.'});
559
+ env.renderSelected();
560
+ assert.equal(env.get('st-output-panel').dataset.textureProgress, 'false');
561
+ assert.equal(panel.hidden, false);
562
+ assert.equal(env.get('st-texture').textContent, 'Retry textures');
563
+ assert.equal(env.requests.filter(request => request.url.includes('/preview?')).length, 1);
564
+ });
565
+
566
+ function assertInputLocked(env, locked) {
567
+ assert.equal(env.get('st-input-panel').inert, locked, 'the complete left panel follows generation state');
568
+ for (const id of ['st-file-input', 'st-pick-image', 'st-change-image', 'st-clear-image', 'st-seed', 'st-generate'])
569
+ assert.equal(env.get(id).disabled, locked, id);
570
+ assert.equal(env.get('st-history-list').inert, false, 'history remains usable');
571
+ assert.equal(env.get('st-output-panel').inert, false, 'right-side progress and resume remain usable');
572
+ }
573
+
574
+ test('running geometry prevents input changes, dropped files and repeat submissions until a terminal state', async t => {
575
+ const env = setup(t), job = {...savedJob, status: 'running', gpu_operation_id: null};
576
+ env.S.jobs.set(job.id, job); env.get('st-seed').value = '42'; env.renderSelected();
577
+ assertInputLocked(env, true);
578
+ const original = env.S.source, selected = env.S.selected, before = env.requests.length;
579
+ let pickerOpened = 0;
580
+ env.get('st-file-input').addEventListener('click', () => { pickerOpened++; });
581
+ await env.get('st-pick-image').click(); await env.get('st-change-image').click();
582
+ await env.get('st-clear-image').click(); await env.get('st-generate').click();
583
+ for (const handler of env.get('st-upload').handlers.get('drop'))
584
+ handler({preventDefault() {}, dataTransfer: {files: [{name: 'replacement.png', size: 10}]}});
585
+ env.S.examples.set('replacement', {image_url: '/replacement.png'});
586
+ env.selectExample('replacement'); await tick();
587
+ let fileNavigationPrevented = false;
588
+ for (const handler of env.document.handlers.get('drop') || [])
589
+ handler({preventDefault() { fileNavigationPrevented = true; }, dataTransfer: {types: ['Files']}});
590
+ assert.equal(fileNavigationPrevented, true, 'dropping over an inert panel cannot navigate the browser to the file');
591
+ assert.equal(pickerOpened, 0);
592
+ assert.equal(env.S.source, original);
593
+ assert.equal(env.S.selected, selected);
594
+ assert.equal(env.requests.length, before, 'no upload or generation may bypass the disabled panel');
595
+ for (const status of ['failed', 'interrupted']) {
596
+ job.status = status; env.renderSelected(); assertInputLocked(env, false);
597
+ job.status = 'running'; env.renderSelected(); assertInputLocked(env, true);
598
+ }
599
+ // A fresh page can still restore the current reference from history while
600
+ // keeping its editor locked; browsing must not leave a blank upload panel.
601
+ env.S.source = null; env.selectJob(job.id);
602
+ assert.match(env.S.source.input_url, /\/files\/input\.png$/);
603
+ assertInputLocked(env, true);
604
+ const white = geometryReady(env);
605
+ env.S.previewLoaded = env.previewKey(white); env.renderSelected();
606
+ assertInputLocked(env, false);
607
+ assert.equal(env.get('st-texture').disabled, false, 'geometry completion exposes the next explicit stage');
608
+ let idleDropPrevented = false;
609
+ for (const handler of env.document.handlers.get('drop') || [])
610
+ handler({preventDefault() { idleDropPrevented = true; }, dataTransfer: {types: ['Files']}});
611
+ assert.equal(idleDropPrevented, false, 'idle upload drop handling remains available');
612
+ });
613
+
614
+ test('Resume locks immediately through preparation and releases after a failed GPU connection', async t => {
615
+ const env = setup(t), status = deferred();
616
+ assertInputLocked(env, false); // A saved, unrequested task must not lock the workspace forever.
617
+ env.network.nextStatus = status;
618
+ const click = env.resumeGPU.click();
619
+ assertInputLocked(env, true);
620
+ status.resolve(env.response(env.network.deployment));
621
+ await click; await tick();
622
+ assert.equal(env.prepareRequests().length, 1);
623
+ assertInputLocked(env, true);
624
+ env.gpuDispatcher.advance(() => 'Model preparation failed');
625
+ await tick();
626
+ assertInputLocked(env, false);
627
+ assert.equal(env.resumeGPU.hidden, false);
628
+ assert.equal(env.resumeGPU.disabled, false);
629
+ });
630
+
631
+ test('texture generation locks the input across history selection and polling unlocks when it completes', async t => {
632
+ const env = setup(t), white = geometryReady(env), status = deferred();
633
+ env.S.previewLoaded = env.previewKey(white); env.renderSelected(); assertInputLocked(env, false);
634
+ env.network.nextStatus = status;
635
+ env.network.textureResponse = {...white, texture_status: 'queued', gpu_operation_kind: 'texture', gpu_operation_id: 'b'.repeat(32)};
636
+ const click = env.get('st-texture').click(); assertInputLocked(env, true);
637
+ status.resolve(env.response(env.network.deployment)); await click; await tick();
638
+ assertInputLocked(env, true);
639
+ const active = {...env.network.textureResponse, texture_status: 'running'};
640
+ env.S.jobs.set(active.id, active);
641
+ const older = {...white, id: 'older', asset_id: 'older-white'};
642
+ env.S.jobs.set(older.id, older);
643
+ const source = env.S.source;
644
+ env.selectJob(older.id); await tick();
645
+ assert.equal(env.S.selected.id, older.id);
646
+ assert.equal(env.S.source, source, 'history browsing must not replace the locked reference image');
647
+ assertInputLocked(env, true);
648
+ env.network.jobsResponse = {items: [{...active, texture_status: 'complete', gpu_operation_id: null}], revision: 2};
649
+ await env.refreshJobs(); await tick();
650
+ assertInputLocked(env, false);
651
+ });
652
+
653
+ test('the first submission locks inputs before its response and rejects a second click', async t => {
654
+ const env = setup(t), submission = deferred();
655
+ env.get('st-seed').value = '42'; env.network.nextSubmission = submission;
656
+ const first = env.submit();
657
+ assertInputLocked(env, true);
658
+ await env.submit();
659
+ assert.equal(env.requests.filter(request => request.url === '/api/history/submit').length, 1);
660
+ const queued = {...savedJob, id: 'new-job', gpu_operation_id: 'c'.repeat(32)};
661
+ submission.resolve(env.response(queued)); await first; await tick();
662
+ assertInputLocked(env, true);
663
+ env.network.jobsResponse = {items: [{...queued, status: 'failed', gpu_operation_id: null}], revision: 2};
664
+ await env.refreshJobs(); await tick();
665
+ assertInputLocked(env, false);
666
+ });
667
+
668
+
669
+ function uploadResult(id = 'upload-a', version) {
670
+ return {upload_id: id, foreground: {status: 'complete'},
671
+ foreground_url: '/api/history/uploads/' + id + '/files/foreground.png',
672
+ ...(version ? {files: {'foreground.png': {version}}} : {})};
673
+ }
674
+ function uploading(env, id = 'upload-a') {
675
+ env.clearUploadPreview();
676
+ const state = env.S.uploadState = {active: true, inputVersion: ++env.S.inputVersion,
677
+ identityVersion: env.S.identityVersion, controller: new AbortController(), uploadId: id,
678
+ timer: null, resultURL: null, failures: 0, phase: 'running'};
679
+ env.S.source = {upload_id: id, foreground_required: true, foreground_ready: false};
680
+ env.S.uploadBusy = true;
681
+ return state;
682
+ }
683
+ const cutoutRequests = env => env.requests.filter(request => request.url.endsWith('/files/foreground.png'));
684
+
685
+ test('generation status polling never reloads an already applied foreground or flashes its completed label', async t => {
686
+ const env = setup(t), state = uploading(env), result = uploadResult();
687
+ await env.applyUploadResult(result, state);
688
+ const source = env.S.source, src = env.get('st-input-image').src;
689
+ const labels = [...env.get('st-upload-label').textHistory];
690
+ env.S.submitKey = 'submission-in-flight';
691
+ env.S.jobs.set(savedJob.id, {...savedJob, status: 'running', gpu_operation_id: null});
692
+ for (let i = 0; i < 3; i++) { await env.refreshStatus(); await env.applyUploadResult({...result}, state); }
693
+ await tick();
694
+ assert.equal(cutoutRequests(env).length, 1);
695
+ assert.equal(env.images.decoded, 1);
696
+ assert.equal(env.S.source, source); assert.equal(env.get('st-input-image').src, src);
697
+ assert.equal(env.S.submitKey, 'submission-in-flight', 'repeated results must not reset submission identity');
698
+ assert.equal(env.S.source.foreground_ready, true); assert.equal(env.S.uploadBusy, false);
699
+ assert.deepEqual(env.get('st-upload-label').textHistory, labels);
700
+ assert.equal(env.get('st-upload-label').textContent, 'Background removed.');
701
+ assert.equal(env.requests.some(request => /\/uploads\/upload-a$/.test(request.url)), false);
702
+ assert.equal(env.requests.some(request => request.method === 'POST'), false);
703
+ assert.equal(env.submissions.length, 0);
704
+ });
705
+
706
+ test('overlapping upload polling and completion callbacks share one image fetch and decode', async t => {
707
+ const env = setup(t), state = uploading(env), result = uploadResult(), image = deferred();
708
+ env.network.uploadResponse = result; env.network.nextImage = image;
709
+ const first = env.applyUploadResult(result, state);
710
+ const refreshA = env.refreshUpload(state), refreshB = env.refreshUpload(state);
711
+ const callback = env.applyUploadResult({...result}, state);
712
+ await tick();
713
+ assert.equal(cutoutRequests(env).length, 1);
714
+ assert.equal(env.requests.filter(request => /\/uploads\/upload-a$/.test(request.url)).length, 1);
715
+ image.resolve(new Response(new Blob(['cutout'], {type: 'image/png'})));
716
+ await Promise.all([first, refreshA, refreshB, callback]);
717
+ assert.equal(env.images.decoded, 1); assert.equal(env.images.created.length, 1);
718
+ assert.equal(env.get('st-upload-label').textHistory.filter(text => text === 'Loading image…').length, 1);
719
+ assert.equal(env.get('st-upload-label').textHistory.filter(text => text === 'Background removed.').length, 1);
720
+ assert.equal(state.timer, null);
721
+ });
722
+
723
+ test('a disconnected cutout download can recover, then explicit foreground retry can apply its new result', async t => {
724
+ const env = setup(t), state = uploading(env), result = uploadResult();
725
+ env.network.uploadResponse = result; env.network.imageFailure = true;
726
+ await env.refreshUpload(state);
727
+ assert.equal(state.appliedResultKey, undefined);
728
+ assert.equal(env.get('st-upload-label').textContent, 'Preparing image…');
729
+ env.network.imageFailure = false;
730
+ await env.refreshUpload(state);
731
+ assert.equal(env.S.source.foreground_ready, true); assert.equal(env.images.decoded, 1);
732
+ const retried = uploading(env, 'upload-retry');
733
+ await env.applyUploadResult({upload_id: 'upload-retry', foreground: {status: 'failed', error: 'Please retry.'}}, retried);
734
+ assert.equal(env.get('st-upload-retry').hidden, false);
735
+ env.network.retryUploadResponse = uploadResult('upload-retry');
736
+ await env.retryUploadPreview();
737
+ assert.equal(env.images.decoded, 2);
738
+ assert.equal(env.S.source.upload_id, 'upload-retry'); assert.equal(env.S.source.foreground_ready, true);
739
+ assert.equal(env.requests.filter(request => request.method === 'POST' && request.url.endsWith('/foreground')).length, 1);
740
+ });
741
+
742
+ test('a different result version and a newly selected upload each apply once', async t => {
743
+ const env = setup(t), state = uploading(env);
744
+ await env.applyUploadResult(uploadResult('upload-a', 'version-1'), state);
745
+ const previous = env.get('st-input-image').src;
746
+ await env.applyUploadResult(uploadResult('upload-a', 'version-2'), state);
747
+ await env.applyUploadResult(uploadResult('upload-a', 'version-2'), state);
748
+ assert.notEqual(env.get('st-input-image').src, previous);
749
+ assert.equal(env.images.decoded, 2); assert.ok(env.images.revoked.includes(previous));
750
+ const next = uploading(env, 'upload-b');
751
+ await env.applyUploadResult(uploadResult('upload-b'), next);
752
+ await env.applyUploadResult(uploadResult('upload-b'), next);
753
+ assert.equal(env.images.decoded, 3); assert.equal(env.S.source.upload_id, 'upload-b');
754
+ });
755
+
756
+ test('a foreground decode from a previous account or input cannot replace the current image', {timeout: 2000}, async t => {
757
+ for (const change of ['account', 'input']) {
758
+ const env = setup(t), old = uploading(env), decode = deferred(), started = deferred();
759
+ env.network.nextDecode = decode; env.network.onDecode = started.resolve;
760
+ const pending = env.applyUploadResult(uploadResult(), old);
761
+ await started.promise;
762
+ const staleURL = old.resultURL;
763
+ if (change === 'account') env.clearPrivate();
764
+ const current = uploading(env, 'upload-new');
765
+ await env.applyUploadResult(uploadResult('upload-new'), current);
766
+ const source = env.S.source, src = env.get('st-input-image').src;
767
+ decode.resolve(); await pending;
768
+ assert.equal(env.S.source, source); assert.equal(env.get('st-input-image').src, src);
769
+ assert.equal(env.S.source.upload_id, 'upload-new');
770
+ assert.ok(env.images.revoked.includes(staleURL));
771
+ assert.equal(old.appliedResultKey, undefined);
772
+ assert.equal(env.get('st-upload-label').textContent, 'Background removed.');
773
+ }
774
+ });
775
+
776
+
777
+ test('late queued, failed or unavailable upload status cannot replace an already completed foreground', async t => {
778
+ for (const late of ['queued', 'failed', 'unavailable']) {
779
+ const env = setup(t), state = uploading(env), pending = deferred();
780
+ env.network.nextUploadStatus = pending;
781
+ const refresh = env.refreshUpload(state);
782
+ await env.applyUploadResult(uploadResult(), state);
783
+ const source = env.S.source, labels = [...env.get('st-upload-label').textHistory];
784
+ pending.resolve(env.response({upload_id: 'upload-a', foreground: {status: late}}, late === 'unavailable' ? 400 : 200));
785
+ await refresh;
786
+ assert.equal(env.S.source, source); assert.equal(env.S.source.foreground_ready, true);
787
+ assert.equal(env.S.uploadBusy, false); assert.equal(env.get('st-upload-retry').hidden, true);
788
+ assert.deepEqual(env.get('st-upload-label').textHistory, labels);
789
+ assert.equal(cutoutRequests(env).length, 1); assert.equal(state.timer, null);
790
+ }
791
+ });
792
+
793
+
794
+ test('a status read failure during cutout decode cannot revoke or replace the image being applied', {timeout: 2000}, async t => {
795
+ const env = setup(t), state = uploading(env), decode = deferred(), started = deferred(), status = deferred();
796
+ env.network.nextDecode = decode; env.network.onDecode = started.resolve;
797
+ const apply = env.applyUploadResult(uploadResult(), state);
798
+ await started.promise;
799
+ const resultURL = state.resultURL;
800
+ env.network.nextUploadStatus = status;
801
+ const refresh = env.refreshUpload(state);
802
+ status.resolve(env.response({detail: 'Status temporarily unavailable'}, 400));
803
+ await refresh;
804
+ assert.equal(state.resultURL, resultURL);
805
+ assert.equal(env.images.revoked.includes(resultURL), false);
806
+ assert.equal(env.get('st-upload-label').textContent, 'Loading image…');
807
+ decode.resolve(); await apply;
808
+ assert.equal(env.S.localURL, resultURL); assert.equal(env.get('st-input-image').src, resultURL);
809
+ assert.equal(env.S.source.foreground_ready, true); assert.equal(env.S.uploadBusy, false);
810
+ assert.equal(env.get('st-upload-label').textContent, 'Background removed.');
811
+ });
tests/test_native_eigen.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The pinned Eigen dependency is available without a GitLab submodule fetch."""
2
+ import hashlib
3
+ import io
4
+ import tarfile
5
+ from types import SimpleNamespace
6
+ from unittest.mock import Mock
7
+
8
+ import pytest
9
+
10
+ from hf_space import native_build
11
+
12
+
13
+ def test_vendored_archive_matches_pinned_checksum_and_extracts_headers(tmp_path):
14
+ archive = native_build.EIGEN_ARCHIVE
15
+ assert archive.stat().st_size == 3025643
16
+ assert hashlib.sha256(archive.read_bytes()).hexdigest() == native_build.EIGEN_SHA256
17
+ checkout = tmp_path / 'TRELLIS.2'
18
+ checkout.mkdir()
19
+ outside = tmp_path / 'keep.txt'
20
+ outside.write_text('unrelated workspace file')
21
+ target = native_build.prepare_eigen(checkout)
22
+ assert target == checkout / 'o-voxel' / 'third_party' / 'eigen'
23
+ assert b'Eigen' in (target / 'Eigen' / 'Core').read_bytes()
24
+ assert (target / 'Eigen' / 'src' / 'Core' / 'Matrix.h').is_file()
25
+ assert 'Mozilla Public License' in (target / 'COPYING.MPL2').read_text()
26
+ assert (target / 'COPYING.README').is_file()
27
+ assert outside.read_text() == 'unrelated workspace file'
28
+
29
+
30
+ def test_bad_checksum_never_creates_or_changes_eigen_tree(tmp_path):
31
+ archive = tmp_path / 'invalid.tar.gz'
32
+ archive.write_bytes(b'corrupt download')
33
+ checkout = tmp_path / 'checkout'
34
+ checkout.mkdir()
35
+ with pytest.raises(RuntimeError, match='checksum mismatch'):
36
+ native_build.prepare_eigen(checkout, archive)
37
+ assert list(checkout.iterdir()) == []
38
+
39
+
40
+ @pytest.mark.parametrize('kind', ['traversal', 'symlink', 'hardlink'])
41
+ def test_tar_traversal_and_links_are_rejected_before_any_extraction(tmp_path, monkeypatch, kind):
42
+ root = f'eigen-{native_build.EIGEN_REVISION}'
43
+ archive = tmp_path / 'malicious.tar.gz'
44
+ with tarfile.open(archive, 'w:gz') as output:
45
+ first = tarfile.TarInfo(root + '/normal.h')
46
+ first.size = 6
47
+ output.addfile(first, io.BytesIO(b'normal'))
48
+ member = tarfile.TarInfo(root + ('/../../escaped' if kind == 'traversal' else '/link'))
49
+ if kind != 'traversal':
50
+ member.type = tarfile.SYMTYPE if kind == 'symlink' else tarfile.LNKTYPE
51
+ member.linkname = '../../escaped'
52
+ output.addfile(member)
53
+ monkeypatch.setattr(native_build, 'EIGEN_SHA256', hashlib.sha256(archive.read_bytes()).hexdigest())
54
+ checkout = tmp_path / 'checkout'
55
+ checkout.mkdir()
56
+ with pytest.raises(RuntimeError, match='Unsafe member'):
57
+ native_build.prepare_eigen(checkout, archive)
58
+ assert list(checkout.iterdir()) == []
59
+ assert not (tmp_path / 'escaped').exists()
60
+
61
+
62
+ def test_only_pinned_trellis_uses_local_eigen_other_repos_keep_submodules(tmp_path, monkeypatch):
63
+ extract, run = Mock(), Mock()
64
+ monkeypatch.setattr(native_build, 'prepare_eigen', extract)
65
+ monkeypatch.setattr(native_build, 'run', run)
66
+ native_build.prepare_submodules('TRELLIS.2', native_build.EIGEN_TRELLIS_REVISION, tmp_path, {})
67
+ extract.assert_called_once_with(tmp_path)
68
+ run.assert_not_called()
69
+ native_build.prepare_submodules('CuMesh', 'other-revision', tmp_path, {'BUILD_TARGET': 'cuda'})
70
+ assert run.call_args.args[0] == ['git', '-C', tmp_path, 'submodule', 'update', '--init', '--recursive', '--depth=1']
71
+ with pytest.raises(RuntimeError, match='Review the vendored Eigen revision'):
72
+ native_build.prepare_submodules('TRELLIS.2', 'different-revision', tmp_path, {})
73
+
74
+
75
+ def test_capacity_diagnostics_report_disk_and_cgroup_limit_only(tmp_path, monkeypatch):
76
+ (tmp_path / 'memory.max').write_text('2147483648\n')
77
+ monkeypatch.setattr(native_build.shutil, 'disk_usage', lambda _: SimpleNamespace(total=1000, free=600))
78
+ assert native_build.startup_resources(tmp_path, tmp_path) == {
79
+ 'tmp_disk_total_bytes': 1000, 'tmp_disk_free_bytes': 600,
80
+ 'cgroup_memory_limit_bytes': 2147483648,
81
+ }
82
+ (tmp_path / 'memory.max').write_text('max\n')
83
+ assert native_build.startup_resources(tmp_path, tmp_path)['cgroup_memory_limit_bytes'] is None
tests/test_observation_filter_export.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Completion's depth admission survives storage and deferred texture refinement."""
2
+ import json
3
+ from pathlib import Path
4
+ import sys
5
+ from types import SimpleNamespace
6
+
7
+ import numpy as np
8
+ from PIL import Image
9
+ import pytest
10
+
11
+ from hf_space import ovoxel_results as results, zerogpu_runtime
12
+
13
+
14
+ def reference_inputs(folder):
15
+ folder.mkdir(parents=True)
16
+ (folder/'camera.json').write_text(json.dumps({'camera_angle_x': .5, 'distance': 2}))
17
+ Image.new('RGBA', (8, 8), 'white').save(folder/'condition_rgba.png')
18
+ for name in ('albedo.png', 'mra.png'):
19
+ Image.new('RGB', (1024, 1024), 'white').save(folder/name)
20
+ return folder
21
+
22
+
23
+ def filter_outputs(asset):
24
+ keep = np.ones((1024, 1024), dtype=np.bool_)
25
+ keep[100:200, 250:400] = False
26
+ np.save(asset.with_suffix('.observation_keep.npy'), keep, allow_pickle=False)
27
+ asset.with_suffix('.observation_admission.json').write_text(json.dumps({'enabled': True}))
28
+ return keep
29
+
30
+
31
+ def test_mask_survives_durable_reference_restore_without_geometry_changes(tmp_path, monkeypatch):
32
+ job = tmp_path/'job'
33
+ reference = reference_inputs(job/'asset_work'/'input')
34
+ asset = job/'asset.npz'
35
+ asset.write_bytes(b'immutable-final-geometry-and-material')
36
+ (reference/'geometry.pt').write_bytes(b'immutable-original-white-geometry')
37
+ expected = filter_outputs(asset)
38
+ results._stage_observation_filter(asset, reference)
39
+ durable = tmp_path/'durable'
40
+ durable.mkdir()
41
+ results._persist_reference_inputs(reference, durable)
42
+ manifest = json.loads((durable/results.REFERENCE_DIRECTORY/'manifest.json').read_text())
43
+ assert set(results.OBSERVATION_FILES) <= manifest['files'].keys()
44
+ monkeypatch.setattr(results, 'JOBS', tmp_path/'restarted-worker')
45
+ monkeypatch.setattr(results, '_copy_progress', lambda src, dst, token: dst.write_bytes(src.read_bytes()))
46
+ work = tmp_path/'export'
47
+ work.mkdir()
48
+ restored = results._reference_inputs('a'*32, durable, work, None)
49
+ np.testing.assert_array_equal(results._observation_keep(restored), expected)
50
+ assert asset.read_bytes() == b'immutable-final-geometry-and-material'
51
+ assert (reference/'geometry.pt').read_bytes() == b'immutable-original-white-geometry'
52
+
53
+
54
+ def test_legacy_references_do_not_require_a_filter(tmp_path):
55
+ reference = reference_inputs(tmp_path/'legacy')
56
+ results._stage_observation_filter(tmp_path/'asset.npz', reference)
57
+ assert results._observation_keep(reference) is None
58
+ assert set(results._validate_reference_inputs(reference)) == set(results.REFERENCE_FILES)
59
+
60
+
61
+ @pytest.mark.parametrize('missing', ['observation_keep.npy', 'observation_admission.json'])
62
+ def test_partial_filter_cannot_silently_reproject_unfiltered_colors(tmp_path, missing):
63
+ reference = reference_inputs(tmp_path/'reference')
64
+ asset = tmp_path/'asset.npz'
65
+ filter_outputs(asset)
66
+ asset.with_suffix('.'+missing).unlink()
67
+ with pytest.raises(ValueError, match='did not save'):
68
+ results._stage_observation_filter(asset, reference)
69
+
70
+
71
+ def test_new_depth_completion_requires_saved_filter(tmp_path):
72
+ reference = reference_inputs(tmp_path/'reference')
73
+ np.save(reference/'image_depth.npy', np.ones((8, 8), dtype=np.float32))
74
+ with pytest.raises(ValueError, match='did not save'):
75
+ results._stage_observation_filter(tmp_path/'asset.npz', reference)
76
+
77
+
78
+ def test_manifest_bound_mask_rejects_changes_and_disappearance(tmp_path):
79
+ reference = reference_inputs(tmp_path/'reference')
80
+ asset = tmp_path/'asset.npz'
81
+ filter_outputs(asset)
82
+ results._stage_observation_filter(asset, reference)
83
+ bindings = results._validate_reference_inputs(reference)
84
+ np.save(reference/'observation_keep.npy', np.ones((1024, 1024), dtype=bool))
85
+ with pytest.raises(ValueError, match='integrity'):
86
+ results._validate_reference_inputs(reference, bindings)
87
+ (reference/'observation_keep.npy').unlink()
88
+ with pytest.raises(ValueError, match='incomplete'):
89
+ results._validate_reference_inputs(reference, bindings)
90
+
91
+
92
+ @pytest.mark.parametrize('mask', [np.ones((8, 8), dtype=bool), np.ones((1024, 1024), dtype=np.uint8)])
93
+ def test_bad_mask_dimensions_and_types_are_rejected(tmp_path, mask):
94
+ np.save(tmp_path/'observation_keep.npy', mask)
95
+ (tmp_path/'observation_admission.json').write_text('{}')
96
+ with pytest.raises(ValueError, match='mask is invalid'):
97
+ results._observation_keep(tmp_path)
98
+
99
+
100
+ def test_zerogpu_deferred_export_passes_original_admission_mask(tmp_path, monkeypatch):
101
+ reference = reference_inputs(tmp_path/'reference')
102
+ asset = tmp_path/'asset.npz'
103
+ expected = filter_outputs(asset)
104
+ results._stage_observation_filter(asset, reference)
105
+ calls = []
106
+ monkeypatch.setattr(results, '_export_local', lambda *args, **kwargs: None)
107
+ def reproject(output, source, work, *, stage, keep):
108
+ assert source == reference
109
+ np.testing.assert_array_equal(keep, expected)
110
+ calls.append(output)
111
+ return output
112
+ monkeypatch.setitem(sys.modules, 'supratex.inference.reprojection', SimpleNamespace(run_front_view=reproject))
113
+ output = tmp_path/'asset.glb'
114
+ value, _ = zerogpu_runtime.export(str(asset), str(output), str(reference), str(tmp_path/'work'), None)
115
+ assert value == str(output) and calls == [output]
tests/test_zerogpu_client.cjs ADDED
@@ -0,0 +1,631 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const vm = require('node:vm');
6
+ const source = fs.readFileSync(path.join(__dirname, '../hf_space/zerogpu_client.js'), 'utf8');
7
+ const id = 'a'.repeat(32);
8
+ const snapshot = {gpu_mode: 'zerogpu', gpu_operation_id: id};
9
+ const deferred = () => { let resolve, reject; const promise = new Promise((r, j) => { resolve = r; reject = j; }); return {promise, resolve, reject}; };
10
+ const tick = () => new Promise(resolve => setImmediate(resolve));
11
+ function setup(connect, overrides = {}, environment = {}) {
12
+ const context = vm.createContext({AbortController, Headers, URL, setTimeout, clearTimeout,
13
+ location: {origin: 'https://example.hf.space'}, ...environment});
14
+ vm.runInContext(source, context);
15
+ const state = {identity: {authenticated: true, csrf_token: 'csrf-1'}, version: 0, stopped: false, ready: true};
16
+ const errors = [], completed = [];
17
+ const dispatcher = context.SupraZeroGPU.createDispatcher({
18
+ identity: () => state.identity, version: () => state.version, stopped: () => state.stopped, ready: () => state.ready,
19
+ onError: (record, message) => errors.push({record, message}),
20
+ onComplete: (record, result) => completed.push({record, result}), connect,
21
+ quotaHeaders: async () => ({'x-zerogpu-token': 'personal-quota-token', 'x-zerogpu-uuid': 'session-id'}), ...overrides
22
+ });
23
+ return {dispatcher, state, errors, completed, runtime: context.SupraZeroGPU};
24
+ }
25
+ function client(events, calls) {
26
+ return {submit(endpoint, data, eventData, trigger, allEvents, headers) {
27
+ calls.push({endpoint, data, headers, eventData, trigger, allEvents});
28
+ return (async function* () { for (const event of events) yield event; })();
29
+ }};
30
+ }
31
+
32
+ test('only authenticated ZeroGPU snapshots can dispatch, never anonymous or dedicated GPU work', () => {
33
+ let connections = 0;
34
+ const {dispatcher, state} = setup(() => { connections++; });
35
+ dispatcher.observe({gpu_operation_id: id});
36
+ dispatcher.observe({...snapshot, gpu_operation_id: '../../someone-else'});
37
+ state.identity.authenticated = false;
38
+ dispatcher.observe(snapshot);
39
+ assert.equal(connections, 0);
40
+ });
41
+
42
+ test('polling submits each operation once and forwards CSRF without owner credentials', async () => {
43
+ const calls = [], connection = deferred(), options = [];
44
+ const {dispatcher, completed} = setup(config => { options.push(config); return connection.promise; });
45
+ dispatcher.observe(snapshot, {requested: true});
46
+ dispatcher.observe({items: [snapshot, snapshot]});
47
+ const finished = dispatcher.dispatch(snapshot);
48
+ connection.resolve(client([{type: 'data', data: [{status: 'complete'}]}], calls));
49
+ await finished;
50
+ dispatcher.observe(snapshot);
51
+ assert.equal(options.length, 1);
52
+ assert.deepEqual(JSON.parse(JSON.stringify(options[0])), {
53
+ events: ['data', 'status'], headers: {'X-CSRF-Token': 'csrf-1'}
54
+ });
55
+ assert.equal(calls.length, 1);
56
+ assert.equal(calls[0].endpoint, '/run_gpu_operation');
57
+ assert.equal(calls[0].data.operation_id, id);
58
+ assert.equal(calls[0].headers['x-zerogpu-token'], 'personal-quota-token');
59
+ assert.equal(calls[0].headers['x-zerogpu-uuid'], 'session-id');
60
+ assert.equal(calls[0].headers['x-gradio-user'], 'app');
61
+ assert.equal(calls[0].allEvents, undefined);
62
+ assert.equal(completed.length, 1);
63
+ });
64
+
65
+ test('explicit task prepares once, waits for models, then polling advances its first GPU attempt', async () => {
66
+ const calls = [], prepared = [];
67
+ const {dispatcher, state} = setup(() => client([], calls), {prepare: async record => prepared.push(record.gpu_operation_id)});
68
+ state.ready = false;
69
+ await dispatcher.dispatch(snapshot);
70
+ dispatcher.observe(snapshot);
71
+ assert.deepEqual(prepared, [id]);
72
+ assert.equal(calls.length, 0);
73
+ assert.equal(dispatcher.pending(), true);
74
+ state.ready = true;
75
+ dispatcher.advance();
76
+ await dispatcher.dispatch(snapshot);
77
+ assert.equal(calls.length, 1);
78
+ assert.equal(dispatcher.pending(), false);
79
+ });
80
+
81
+ test('opening and polling saved history never prepares models or dispatches old queued work', async () => {
82
+ const calls = [], prepared = [];
83
+ const {dispatcher} = setup(() => client([], calls), {prepare: async record => prepared.push(record.gpu_operation_id)});
84
+ dispatcher.observe(snapshot); dispatcher.observe({items: [snapshot]}); dispatcher.advance();
85
+ await tick();
86
+ assert.equal(dispatcher.requested(id), false);
87
+ assert.equal(prepared.length, 0); assert.equal(calls.length, 0);
88
+ await dispatcher.retry(snapshot);
89
+ assert.deepEqual(prepared, [id]); assert.equal(calls.length, 1);
90
+ });
91
+
92
+ test('the first runtime initializes without repeating preparation and unchanged runtime polls are inert', async () => {
93
+ const calls = [], prepared = [];
94
+ const {dispatcher, state} = setup(() => client([], calls), {prepare: async record => prepared.push(record.gpu_operation_id)});
95
+ state.ready = false;
96
+ await dispatcher.dispatch(snapshot);
97
+ dispatcher.syncRuntime('runtime-a');
98
+ dispatcher.syncRuntime('runtime-a');
99
+ dispatcher.observe(snapshot);
100
+ await tick();
101
+ assert.deepEqual(prepared, [id]);
102
+ assert.equal(calls.length, 0);
103
+ assert.equal(dispatcher.pending(), true);
104
+ });
105
+
106
+ test('a new runtime repeats CPU preparation only for explicitly requested waiting work', async () => {
107
+ const calls = [], prepared = [];
108
+ const saved = {...snapshot, gpu_operation_id: 'b'.repeat(32)};
109
+ const {dispatcher, state} = setup(() => client([], calls), {prepare: async record => prepared.push(record.gpu_operation_id)});
110
+ state.ready = false;
111
+ dispatcher.syncRuntime('runtime-a');
112
+ dispatcher.observe(saved);
113
+ await dispatcher.dispatch(snapshot);
114
+ dispatcher.syncRuntime('runtime-b');
115
+ await tick();
116
+ dispatcher.syncRuntime('runtime-b');
117
+ dispatcher.observe({items: [snapshot, saved]});
118
+ await tick();
119
+ assert.deepEqual(prepared, [id, id]);
120
+ assert.equal(dispatcher.requested(saved.gpu_operation_id), false);
121
+ assert.equal(calls.length, 0);
122
+ state.ready = true;
123
+ dispatcher.advance();
124
+ await dispatcher.dispatch(snapshot);
125
+ assert.deepEqual(calls.map(call => call.data.operation_id), [id]);
126
+ });
127
+
128
+ test('an earlier runtime preparation success cannot bypass the new runtime preparation', async () => {
129
+ const calls = [], attempts = [], oldPrepare = deferred(), newPrepare = deferred();
130
+ const {dispatcher, state, errors, completed} = setup(() => client([], calls), {
131
+ prepare(record, signal) {
132
+ attempts.push({record, signal});
133
+ return attempts.length === 1 ? oldPrepare.promise : newPrepare.promise;
134
+ }
135
+ });
136
+ state.ready = false;
137
+ dispatcher.syncRuntime('runtime-a');
138
+ const stale = dispatcher.dispatch(snapshot);
139
+ dispatcher.syncRuntime('runtime-b');
140
+ await tick();
141
+ assert.equal(attempts.length, 2);
142
+ state.ready = true;
143
+ oldPrepare.resolve();
144
+ await stale; await tick();
145
+ assert.equal(calls.length, 0);
146
+ assert.equal(errors.length, 0);
147
+ assert.equal(completed.length, 0);
148
+ assert.equal(dispatcher.pending(), true);
149
+ newPrepare.resolve();
150
+ await dispatcher.dispatch(snapshot);
151
+ assert.equal(calls.length, 1);
152
+ assert.equal(completed.length, 1);
153
+ });
154
+
155
+ test('an earlier runtime preparation failure cannot fail or cancel the current attempt', async () => {
156
+ const calls = [], oldPrepare = deferred(), newPrepare = deferred();
157
+ let attempts = 0;
158
+ const {dispatcher, state, errors, completed} = setup(() => client([], calls), {
159
+ prepare: () => ++attempts === 1 ? oldPrepare.promise : newPrepare.promise
160
+ });
161
+ state.ready = false;
162
+ dispatcher.syncRuntime('runtime-a');
163
+ const stale = dispatcher.dispatch(snapshot);
164
+ dispatcher.syncRuntime('runtime-b');
165
+ await tick();
166
+ assert.equal(attempts, 2);
167
+ oldPrepare.reject(new Error('Model preparation failed in the previous runtime'));
168
+ await stale; await tick();
169
+ assert.equal(errors.length, 0);
170
+ assert.equal(dispatcher.failure(id), '');
171
+ assert.equal(dispatcher.pending(), true);
172
+ state.ready = true;
173
+ newPrepare.resolve();
174
+ await dispatcher.dispatch(snapshot);
175
+ assert.equal(calls.length, 1);
176
+ assert.equal(completed.length, 1);
177
+ });
178
+
179
+ test('runtime changes never replay running, completed, or failed GPU submissions', async () => {
180
+ const calls = [], prepared = [], finishRunning = deferred();
181
+ const running = {...snapshot, gpu_operation_id: 'b'.repeat(32)};
182
+ const failed = {...snapshot, gpu_operation_id: 'c'.repeat(32)};
183
+ let connections = 0;
184
+ const {dispatcher, state, errors, completed} = setup(() => {
185
+ connections++;
186
+ if (connections === 2) return {submit(endpoint, data) {
187
+ calls.push({endpoint, data});
188
+ return (async function* () {
189
+ await finishRunning.promise;
190
+ yield {type: 'data', data: [{status: 'complete'}]};
191
+ })();
192
+ }};
193
+ return client(connections === 3 ? [{type: 'status', stage: 'error', message: 'GPU quota exceeded'}] : [], calls);
194
+ }, {prepare: async record => prepared.push(record.gpu_operation_id)});
195
+ dispatcher.syncRuntime('runtime-a');
196
+ await dispatcher.dispatch(snapshot);
197
+ const runningTask = dispatcher.dispatch(running);
198
+ await tick();
199
+ await dispatcher.dispatch(failed);
200
+ state.ready = false;
201
+ dispatcher.syncRuntime('runtime-b');
202
+ dispatcher.observe({items: [snapshot, running, failed]});
203
+ await tick();
204
+ assert.deepEqual(prepared, [id, running.gpu_operation_id, failed.gpu_operation_id]);
205
+ assert.equal(calls.length, 3);
206
+ assert.equal(errors.length, 1);
207
+ finishRunning.resolve();
208
+ await runningTask;
209
+ assert.equal(completed.length, 2);
210
+ dispatcher.syncRuntime('runtime-c');
211
+ await tick();
212
+ assert.equal(calls.length, 3);
213
+ assert.equal(prepared.length, 3);
214
+ });
215
+
216
+ test('a runtime change does not restart a GPU connection already in progress', async () => {
217
+ const calls = [], prepared = [], connection = deferred();
218
+ let connections = 0;
219
+ const {dispatcher, state, completed} = setup(() => { connections++; return connection.promise; }, {
220
+ prepare: async record => prepared.push(record.gpu_operation_id)
221
+ });
222
+ dispatcher.syncRuntime('runtime-a');
223
+ const connecting = dispatcher.dispatch(snapshot);
224
+ await tick();
225
+ assert.equal(connections, 1);
226
+ state.ready = false;
227
+ dispatcher.syncRuntime('runtime-b');
228
+ await tick();
229
+ connection.resolve(client([], calls));
230
+ await connecting;
231
+ assert.deepEqual(prepared, [id]);
232
+ assert.equal(connections, 1);
233
+ assert.equal(calls.length, 1);
234
+ assert.equal(completed.length, 1);
235
+ });
236
+
237
+ test('account reset prevents runtime changes from replaying waiting or unresolved preparation', async () => {
238
+ const calls = [], prepared = [], oldPrepare = deferred();
239
+ const waiting = {...snapshot, gpu_operation_id: 'b'.repeat(32)};
240
+ const {dispatcher, state, errors, completed} = setup(() => client([], calls), {
241
+ prepare(record) {
242
+ prepared.push(record.gpu_operation_id);
243
+ return record.gpu_operation_id === id ? oldPrepare.promise : Promise.resolve();
244
+ }
245
+ });
246
+ state.ready = false;
247
+ dispatcher.syncRuntime('runtime-a');
248
+ const stale = dispatcher.dispatch(snapshot);
249
+ await dispatcher.dispatch(waiting);
250
+ state.version++;
251
+ state.identity = {authenticated: true, csrf_token: 'new-account'};
252
+ dispatcher.reset();
253
+ dispatcher.syncRuntime('runtime-b');
254
+ dispatcher.observe({items: [snapshot, waiting]});
255
+ state.ready = true;
256
+ oldPrepare.resolve();
257
+ await stale;
258
+ dispatcher.syncRuntime('runtime-c');
259
+ dispatcher.advance();
260
+ await tick();
261
+ assert.deepEqual(prepared, [id, waiting.gpu_operation_id]);
262
+ assert.equal(dispatcher.requested(id), false);
263
+ assert.equal(dispatcher.requested(waiting.gpu_operation_id), false);
264
+ assert.equal(calls.length, 0);
265
+ assert.equal(errors.length, 0);
266
+ assert.equal(completed.length, 0);
267
+ });
268
+
269
+ test('background model failure releases the waiting task for an explicit retry', async () => {
270
+ const calls = [];
271
+ const {dispatcher, state, errors} = setup(() => client([], calls));
272
+ state.ready = false;
273
+ await dispatcher.dispatch(snapshot);
274
+ dispatcher.advance('Model preparation failed');
275
+ dispatcher.observe(snapshot);
276
+ assert.equal(calls.length, 0); assert.equal(dispatcher.pending(), false);
277
+ assert.match(errors[0].message, /Models could not be prepared/);
278
+ state.ready = true;
279
+ await dispatcher.retry(snapshot);
280
+ assert.equal(calls.length, 1);
281
+ });
282
+
283
+ test('foreground dispatch waits only for its model and ignores failures in full generation preparation', async () => {
284
+ const calls = [], foreground = {...snapshot, gpu_operation_kind: 'foreground'};
285
+ const generation = {...snapshot, gpu_operation_id: 'b'.repeat(32), gpu_operation_kind: 'generation'};
286
+ const deployment = {gpu_mode: 'zerogpu', workspace: {stage: 'ready'}, foreground: {stage: 'loading_models'},
287
+ native_build: {stage: 'compiled'}, models: {stage: 'failed'}};
288
+ const env = setup(() => client([], calls), {ready: record => env.runtime.operationReady(deployment, record)});
289
+ await env.dispatcher.dispatch(foreground);
290
+ await env.dispatcher.dispatch(generation);
291
+ env.dispatcher.advance(record => env.runtime.preparationError(deployment, record));
292
+ assert.equal(env.errors.length, 1);
293
+ assert.equal(env.errors[0].record.gpu_operation_id, generation.gpu_operation_id);
294
+ assert.equal(env.dispatcher.failure(id), '');
295
+ assert.equal(env.dispatcher.pending(record => !env.runtime.operationReady(deployment, record)), true);
296
+ deployment.foreground.stage = 'ready_released';
297
+ env.dispatcher.advance(record => env.runtime.preparationError(deployment, record));
298
+ await env.dispatcher.dispatch(foreground);
299
+ assert.deepEqual(calls.map(call => call.data.operation_id), [id]);
300
+ assert.equal(env.dispatcher.pending(), false);
301
+ });
302
+
303
+ test('quota errors are sanitized and polling does not repeatedly spend or retry quota', async () => {
304
+ const calls = [];
305
+ let attempts = 0;
306
+ const {dispatcher, errors, completed} = setup(() => {
307
+ attempts++;
308
+ return client([{type: 'status', stage: 'error', message: 'GPU quota exceeded hf_secret /data/private-user'}], calls);
309
+ });
310
+ await dispatcher.dispatch(snapshot);
311
+ dispatcher.observe(snapshot);
312
+ assert.equal(attempts, 1);
313
+ assert.equal(calls.length, 1);
314
+ assert.equal(completed.length, 0);
315
+ assert.match(errors[0].message, /your personal quota/i);
316
+ assert.doesNotMatch(errors[0].message, /hf_secret|\/data\//);
317
+ await dispatcher.retry(snapshot);
318
+ assert.equal(attempts, 2);
319
+ });
320
+
321
+ test('quota verification error is an authentication failure, not exhausted allowance', async () => {
322
+ const calls = [];
323
+ const {dispatcher, errors} = setup(() => client([{type: 'status', stage: 'error',
324
+ message: 'Hugging Face could not verify your personal ZeroGPU quota. Sign in on Hugging Face, then refresh this Space.'}], calls));
325
+ await dispatcher.dispatch(snapshot);
326
+ assert.match(errors[0].message, /could not verify your account/i);
327
+ assert.match(errors[0].message, /does not mean your quota is exhausted/i);
328
+ assert.doesNotMatch(errors[0].message, /wait|resets|quota is insufficient/i);
329
+ });
330
+
331
+ test('an unspecified quota error never claims the allowance has been exhausted', async () => {
332
+ const calls = [];
333
+ const {dispatcher, errors} = setup(() => client([{type: 'status', stage: 'error', message: 'Quota service unavailable'}], calls));
334
+ await dispatcher.dispatch(snapshot);
335
+ assert.match(errors[0].message, /quota may still be available/i);
336
+ assert.doesNotMatch(errors[0].message, /exhausted|insufficient|reset/i);
337
+ });
338
+
339
+ test('a task time limit is not mistaken for insufficient quota just because it mentions quota', async () => {
340
+ const calls = [];
341
+ const {dispatcher, errors} = setup(() => client([{type: 'data', data: [{status: 'failed',
342
+ error: 'The ZeroGPU time limit was reached. Please try again with your available GPU quota.'}]}], calls));
343
+ await dispatcher.dispatch(snapshot);
344
+ assert.match(errors[0].message, /task exceeded its GPU time limit/i);
345
+ assert.doesNotMatch(errors[0].message, /insufficient|exhausted|reset/i);
346
+ });
347
+
348
+ test('account switch while connection loads cannot submit the previous user operation', async () => {
349
+ const calls = [], connection = deferred();
350
+ let closed = 0;
351
+ const {dispatcher, state, errors, completed} = setup(() => connection.promise);
352
+ const pending = dispatcher.dispatch(snapshot);
353
+ await tick();
354
+ state.version++; state.identity = {authenticated: true, csrf_token: 'other-user'};
355
+ dispatcher.reset();
356
+ connection.resolve({...client([], calls), close() { closed++; }});
357
+ await pending; await tick();
358
+ assert.equal(calls.length, 0);
359
+ assert.equal(errors.length, 0);
360
+ assert.equal(completed.length, 0);
361
+ assert.equal(closed, 1);
362
+ });
363
+
364
+ test('queued callback result is not reported as completed and requires explicit retry', async () => {
365
+ const calls = [];
366
+ const {dispatcher, completed, errors} = setup(() => client([{type: 'data', data: [{status: 'queued'}]}], calls));
367
+ await dispatcher.dispatch(snapshot);
368
+ assert.equal(completed.length, 0);
369
+ assert.match(errors[0].message, /busy/i);
370
+ dispatcher.observe(snapshot);
371
+ assert.equal(calls.length, 1);
372
+ });
373
+
374
+ test('terminal server failures surface as failures, not completed Gradio transport', async () => {
375
+ const calls = [];
376
+ const {dispatcher, completed, errors} = setup(() => client([
377
+ {type: 'data', data: [{status: 'failed', error: 'Your GPU quota is exhausted.'}]},
378
+ {type: 'status', stage: 'complete'}
379
+ ], calls));
380
+ await dispatcher.dispatch(snapshot);
381
+ assert.equal(completed.length, 0);
382
+ assert.match(errors[0].message, /quota/i);
383
+ });
384
+
385
+ test('renewed CSRF session creates a new connection for later work', async () => {
386
+ const configs = [], calls = [];
387
+ const {dispatcher, state} = setup(config => { configs.push(config); return client([], calls); });
388
+ await dispatcher.dispatch(snapshot);
389
+ state.identity.csrf_token = 'csrf-2';
390
+ await dispatcher.dispatch({...snapshot, gpu_operation_id: 'b'.repeat(32)});
391
+ assert.deepEqual(configs.map(config => config.headers['X-CSRF-Token']), ['csrf-1', 'csrf-2']);
392
+ });
393
+
394
+ test('missing personal quota headers fail before connecting and never claim depleted quota', async () => {
395
+ let connections = 0;
396
+ const {dispatcher, errors} = setup(() => { connections++; }, {quotaHeaders: async () => ({})});
397
+ await dispatcher.dispatch(snapshot);
398
+ assert.equal(connections, 0);
399
+ assert.match(errors[0].message, /could not verify your account/);
400
+ assert.doesNotMatch(errors[0].message, /quota is insufficient|reset/);
401
+ });
402
+
403
+ test('each simultaneous operation protects its own fresh headers from the SDK second handshake', async () => {
404
+ const requests = [], optionsSeen = [];
405
+ let tokens = 0;
406
+ const {dispatcher} = setup(options => {
407
+ optionsSeen.push(options);
408
+ const instance = {
409
+ fetch(url, options) { requests.push({url, headers: new Headers(options.headers)}); },
410
+ submit(endpoint, data, eventData, trigger, allEvents, headers) {
411
+ assert.equal(headers['x-gradio-user'], 'app');
412
+ return (async function* () {
413
+ await tick();
414
+ await instance.fetch('https://example.hf.space/gradio_api/queue/join', {
415
+ headers: {...headers, 'x-zerogpu-token': 'sdk-fallback', 'x-gradio-user': 'api', 'Content-Type': 'application/json'}
416
+ });
417
+ await instance.fetch('https://example.hf.space/gradio_api/info', {headers: {}});
418
+ yield {type: 'data', data: [{status: 'complete'}]};
419
+ })();
420
+ }
421
+ };
422
+ return instance;
423
+ }, {quotaHeaders: async () => ({'x-zerogpu-token': 'token-' + ++tokens})});
424
+ await Promise.all([dispatcher.dispatch(snapshot), dispatcher.dispatch({...snapshot, gpu_operation_id: 'b'.repeat(32)})]);
425
+ const gpu = requests.filter(item => item.url.endsWith('/queue/join'));
426
+ assert.deepEqual(gpu.map(item => item.headers.get('x-zerogpu-token')).sort(), ['token-3', 'token-4']);
427
+ assert.ok(gpu.every(item => item.headers.get('x-gradio-user') === 'app'));
428
+ assert.ok(requests.filter(item => item.url.endsWith('/info')).every(item => !item.headers.has('x-zerogpu-token')));
429
+ assert.ok(optionsSeen.every(options => Object.keys(options.headers).join() === 'X-CSRF-Token'));
430
+ });
431
+
432
+ function browserEnvironment({hidden = false, token = 'visible-token'} = {}) {
433
+ const win = new EventTarget(), doc = new EventTarget(), posts = [];
434
+ doc.hidden = hidden;
435
+ const parent = {postMessage(message, origin, ports) {
436
+ posts.push({message, origin});
437
+ if (message === 'supports-zerogpu-headers') {
438
+ const event = new Event('message');
439
+ Object.assign(event, {source: parent, origin: 'https://huggingface.co', data: message});
440
+ queueMicrotask(() => win.dispatchEvent(event));
441
+ } else queueMicrotask(() => ports[0].postMessage(token ? {'X-ZeroGPU-Token': token, 'X-ZeroGPU-UUID': 'test-session'} : {}));
442
+ }};
443
+ win.parent = parent;
444
+ const context = vm.createContext({window: win, document: doc, MessageChannel, Headers, setTimeout, clearTimeout});
445
+ vm.runInContext(source, context);
446
+ return {win, doc, parent, posts, request: context.SupraZeroGPU.requestQuotaHeaders};
447
+ }
448
+
449
+ test('quota handshake waits for page visibility and asks HF for support before private headers', async () => {
450
+ const env = browserEnvironment({hidden: true}), controller = new AbortController();
451
+ const request = env.request(controller.signal);
452
+ await tick(); assert.equal(env.posts.length, 0);
453
+ env.doc.hidden = false; env.doc.dispatchEvent(new Event('visibilitychange'));
454
+ const headers = await request;
455
+ assert.deepEqual(env.posts.map(post => post.message), ['supports-zerogpu-headers', 'zerogpu-headers']);
456
+ assert.ok(env.posts.every(post => post.origin === 'https://huggingface.co'));
457
+ assert.equal(headers['x-zerogpu-token'], 'visible-token');
458
+ assert.equal(headers['x-zerogpu-uuid'], 'test-session');
459
+ assert.equal(headers['x-gradio-user'], 'app');
460
+ });
461
+
462
+ test('quota handshake rejects absent tokens and cancels hidden-page waits on account reset', async () => {
463
+ const missing = browserEnvironment({token: ''});
464
+ await assert.rejects(missing.request(new AbortController().signal), /could not verify/);
465
+ const hidden = browserEnvironment({hidden: true}), controller = new AbortController();
466
+ const waiting = hidden.request(controller.signal);
467
+ controller.abort();
468
+ await assert.rejects(waiting, {name: 'AbortError'});
469
+ assert.equal(hidden.posts.length, 0);
470
+ });
471
+
472
+ test('a token omitted after the page becomes hidden retries when visibility returns', async () => {
473
+ const env = browserEnvironment(), send = env.parent.postMessage;
474
+ let firstHeaders = true, settled = false;
475
+ env.parent.postMessage = (message, origin, ports) => {
476
+ if (message === 'zerogpu-headers' && firstHeaders) {
477
+ firstHeaders = false;
478
+ env.posts.push({message, origin});
479
+ env.doc.hidden = true;
480
+ ports[0].postMessage({'X-ZeroGPU-UUID': 'test-session'});
481
+ } else send(message, origin, ports);
482
+ };
483
+ const waiting = env.request(new AbortController().signal).then(value => { settled = true; return value; });
484
+ while (firstHeaders) await tick();
485
+ await tick();
486
+ assert.equal(settled, false);
487
+ assert.equal(env.posts.length, 2);
488
+ env.doc.hidden = false; env.doc.dispatchEvent(new Event('visibilitychange'));
489
+ const headers = await waiting;
490
+ assert.equal(headers['x-zerogpu-token'], 'visible-token');
491
+ assert.deepEqual(env.posts.map(post => post.message), [
492
+ 'supports-zerogpu-headers', 'zerogpu-headers', 'supports-zerogpu-headers', 'zerogpu-headers'
493
+ ]);
494
+ });
495
+
496
+ test('final queue send waits for visibility again instead of reusing earlier headers in a hidden page', async () => {
497
+ const env = browserEnvironment(), sent = [];
498
+ const {dispatcher} = setup(() => {
499
+ const instance = {
500
+ fetch(url, options) { sent.push(new Headers(options.headers)); },
501
+ submit() { return (async function* () {
502
+ env.doc.hidden = true;
503
+ await instance.fetch('https://example.hf.space/gradio_api/queue/join', {headers: {'x-zerogpu-token': 'old'}});
504
+ yield {type: 'data', data: [{status: 'complete'}]};
505
+ })(); }
506
+ };
507
+ return instance;
508
+ }, {quotaHeaders: env.request}, {document: env.doc});
509
+ const pending = dispatcher.dispatch(snapshot);
510
+ while (!env.doc.hidden) await tick();
511
+ await tick(); assert.equal(sent.length, 0);
512
+ env.doc.hidden = false; env.doc.dispatchEvent(new Event('visibilitychange'));
513
+ await pending;
514
+ assert.equal(sent.length, 1);
515
+ assert.equal(sent[0].get('x-zerogpu-token'), 'visible-token');
516
+ assert.equal(env.posts.filter(post => post.message === 'zerogpu-headers').length, 2);
517
+ });
518
+
519
+ test('only the trusted HF parent can acknowledge header support', async () => {
520
+ const env = browserEnvironment(), controller = new AbortController();
521
+ env.parent.postMessage = (message, origin) => env.posts.push({message, origin});
522
+ const waiting = env.request(controller.signal);
523
+ await tick();
524
+ for (const properties of [
525
+ {source: {}, origin: 'https://huggingface.co'},
526
+ {source: env.parent, origin: 'https://untrusted.example'}
527
+ ]) {
528
+ const event = new Event('message'); Object.assign(event, {...properties, data: 'supports-zerogpu-headers'});
529
+ env.win.dispatchEvent(event);
530
+ }
531
+ await tick(); assert.equal(env.posts.length, 1);
532
+ controller.abort(); await assert.rejects(waiting, {name: 'AbortError'});
533
+ });
534
+
535
+ test('ZeroGPU workspace is usable before model preparation while GPU execution remains gated', () => {
536
+ const context = vm.createContext({}); vm.runInContext(source, context);
537
+ const {workspaceReady, modelsReady} = context.SupraZeroGPU;
538
+ const pending = {gpu_mode: 'zerogpu', workspace: {stage: 'ready'}, native_build: {stage: 'pending'}, models: {stage: 'pending'}};
539
+ assert.equal(workspaceReady(pending), true); assert.equal(modelsReady(pending), false);
540
+ assert.equal(workspaceReady({...pending, gpu_mode: 'dedicated'}), false);
541
+ assert.equal(workspaceReady({...pending, workspace: {stage: 'loading'}}), false);
542
+ const loaded = {...pending, native_build: {stage: 'compiled'}, models: {stage: 'ready_released'}};
543
+ assert.equal(workspaceReady(loaded), true); assert.equal(modelsReady(loaded), true);
544
+ assert.equal(workspaceReady({...loaded, gpu_mode: 'dedicated'}), true);
545
+ });
546
+
547
+ test('foreground and generation readiness, failure, and progress use independent model status', () => {
548
+ const context = vm.createContext({}); vm.runInContext(source, context);
549
+ const {operationReady, preparationError, preparationState} = context.SupraZeroGPU;
550
+ const foreground = {gpu_operation_kind: 'foreground'}, generation = {gpu_operation_kind: 'generation'};
551
+ const state = {gpu_mode: 'zerogpu', workspace: {stage: 'ready'}, native_build: {stage: 'building',
552
+ startup_progress: {completed: 2, total: 4, unit: 'steps'}},
553
+ foreground: {stage: 'ready_released', startup_progress: {completed: 885e6, total: 885e6, unit: 'bytes'}},
554
+ models: {stage: 'pending', startup_progress: {completed: 0, total: 65.9e9, unit: 'bytes'}}};
555
+ assert.equal(operationReady(state, foreground), true);
556
+ assert.equal(operationReady(state, {foreground: {status: 'queued'}}), true);
557
+ assert.equal(operationReady(state, generation), false);
558
+ assert.equal(preparationState(state, foreground), state.foreground);
559
+ assert.equal(preparationState(state, generation), state.native_build);
560
+ state.native_build.stage = 'compiled';
561
+ assert.equal(preparationState(state, generation), state.models);
562
+ state.models.stage = 'ready_released'; state.foreground.stage = 'failed';
563
+ assert.equal(operationReady(state, foreground), false);
564
+ assert.equal(operationReady(state, generation), true);
565
+ assert.match(preparationError(state, foreground), /Background-removal/);
566
+ assert.equal(preparationError(state, generation), null);
567
+ state.workspace.stage = 'starting';
568
+ assert.equal(operationReady(state, generation), false);
569
+ });
570
+
571
+ test('preparation percentages use the current phase total with explicit bytes, models, or steps', () => {
572
+ const context = vm.createContext({}); vm.runInContext(source, context);
573
+ const {formatProgress} = context.SupraZeroGPU;
574
+ assert.equal(formatProgress({completed: 442.5e6, total: 885e6, unit: 'bytes'}).text, '50% · 442.5 / 885.0 MB');
575
+ assert.equal(formatProgress({completed: 6.59e9, total: 65.9e9, unit: 'bytes'}).text, '10% · 6.6 / 65.9 GB');
576
+ assert.equal(formatProgress({completed: 2, total: 5, unit: 'models'}).text, '40% · 2 / 5 models');
577
+ assert.equal(formatProgress({completed: 1, total: 4, unit: 'steps'}).text, '25% · 1 / 4 steps');
578
+ assert.equal(formatProgress({label: 'Checking metadata', completed: 0, total: 0, unit: 'steps'}).text, '0%');
579
+ assert.equal(formatProgress({completed: -5, total: 10, unit: 'steps'}).percent, 0);
580
+ assert.equal(formatProgress({completed: 15, total: 10, unit: 'steps'}).percent, 100);
581
+ assert.equal(formatProgress({completed: Infinity, total: NaN}).percent, 0);
582
+ });
583
+
584
+ test('the unmodified official client sends one app marker and preserves fresh HF headers after its own handshake', async () => {
585
+ const vendor = fs.readFileSync(path.join(__dirname, '../hf_space/vendor/gradio-client-2.1.0.js'), 'utf8');
586
+ const {Client} = await import('data:text/javascript;base64,' + Buffer.from(vendor).toString('base64'));
587
+ const previous = {window: global.window, document: global.document, fetch: global.fetch};
588
+ const requests = [];
589
+ let handshakes = 0;
590
+ global.window = {location: {hostname: 'example.hf.space', search: ''}, supports_zerogpu_headers: true,
591
+ parent: {postMessage(message, origin, ports) {
592
+ handshakes++;
593
+ assert.equal(message, 'zerogpu-headers'); assert.equal(origin, 'https://huggingface.co');
594
+ ports[0].postMessage({'X-ZeroGPU-Token': 'sdk-second-token', 'X-ZeroGPU-UUID': 'sdk-second-uuid'});
595
+ ports[0].close();
596
+ }}};
597
+ global.document = {};
598
+ global.fetch = async (url, options) => {
599
+ requests.push({url, options, headers: new Headers(options.headers)});
600
+ // Finish at input validation: this checks the real queue/join wire request
601
+ // without opening a stream, contacting a server, or executing GPU work.
602
+ return new Response(JSON.stringify({detail: 'Diagnostic task is not available'}), {status: 422});
603
+ };
604
+ try {
605
+ const {dispatcher} = setup(options => {
606
+ const instance = new Client('https://example.hf.space', options);
607
+ instance.config = {root: 'https://example.hf.space', protocol: 'sse_v3', enable_queue: true,
608
+ dependencies: [{id: 0, queue: true, inputs: [1], outputs: [2]}],
609
+ components: [{id: 1, type: 'textbox'}, {id: 2, type: 'json'}]};
610
+ instance.api_prefix = '/gradio_api';
611
+ instance.api_info = {named_endpoints: {'/run_gpu_operation': {parameters: [{parameter_name: 'operation_id'}]}}};
612
+ instance.api_map = {run_gpu_operation: 0};
613
+ instance.handle_blob = async (root, data) => data;
614
+ return instance;
615
+ });
616
+ await dispatcher.dispatch(snapshot);
617
+ assert.equal(handshakes, 1); assert.equal(requests.length, 1);
618
+ const request = requests[0];
619
+ assert.match(request.url, /\/gradio_api\/queue\/join/);
620
+ assert.equal(request.headers.get('x-zerogpu-token'), 'personal-quota-token');
621
+ assert.equal(request.headers.get('x-zerogpu-uuid'), 'session-id');
622
+ assert.equal(request.headers.get('x-gradio-user'), 'app');
623
+ assert.equal(request.headers.get('x-csrf-token'), 'csrf-1');
624
+ assert.equal(request.options.credentials, 'include');
625
+ assert.deepEqual(JSON.parse(request.options.body).data, [id]);
626
+ } finally {
627
+ if (previous.window === undefined) delete global.window; else global.window = previous.window;
628
+ if (previous.document === undefined) delete global.document; else global.document = previous.document;
629
+ global.fetch = previous.fetch;
630
+ }
631
+ });
tests/test_zerogpu_concurrency.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Concurrent direct operations use separate state and never spend GPU twice."""
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ from io import BytesIO
4
+ import threading
5
+
6
+ from PIL import Image
7
+ import pytest
8
+
9
+ from hf_space.history_jobs import Runner
10
+ from hf_space.history_store import NotFound, Store
11
+
12
+
13
+ @pytest.fixture
14
+ def store(tmp_path, monkeypatch):
15
+ monkeypatch.setenv('SPACES_ZERO_GPU', 'true')
16
+ monkeypatch.delenv('SPACE_ID', raising=False)
17
+ monkeypatch.delenv('SPACE_HOST', raising=False)
18
+ return Store(tmp_path / 'history', asset_root=tmp_path / 'assets').initialize()
19
+
20
+
21
+ def pending(store, owner):
22
+ stream = BytesIO()
23
+ Image.new('RGB', (16, 16), 'red').save(stream, format='PNG')
24
+ upload = store.save_upload(owner, stream.getvalue())
25
+ return store.request_foreground(owner, upload['id'], 'request-' + upload['id'])['gpu_operation_id']
26
+
27
+
28
+ def output(store, job):
29
+ color = (1, 2, 3, 255) if job['owner'] == 'alice' else (4, 5, 6, 255)
30
+ return store.write_foreground_output(
31
+ job, Image.new('RGBA', (16, 16), color),
32
+ {'source': 'rmbg2', 'model': 'briaai/RMBG-2.0', 'uncropped': True})
33
+
34
+
35
+ @pytest.mark.parametrize('owners', [('alice', 'bob'), ('alice', 'alice')],
36
+ ids=['different-visitors', 'same-visitor-separate-operations'])
37
+ def test_operations_enter_handlers_together_without_global_lock(store, owners):
38
+ operations = [(owner, pending(store, owner)) for owner in owners]
39
+ together = threading.Barrier(2)
40
+ entered = set()
41
+ guard = threading.Lock()
42
+
43
+ def execute(job, update):
44
+ with guard:
45
+ entered.add(job['operation_id'])
46
+ # A serial application queue fails here; both handlers must be active
47
+ # before either can finish or return its GPU lease.
48
+ together.wait(timeout=5)
49
+ update(label='Running ' + job['owner'])
50
+ return output(store, job)
51
+
52
+ runner = Runner(store, {'foreground': execute})
53
+ with store.execution_lock, ThreadPoolExecutor(max_workers=2) as executor:
54
+ tasks = [executor.submit(runner.run_once, operation_id=operation, owner=owner)
55
+ for owner, operation in operations]
56
+ assert all(task.result(timeout=10) for task in tasks)
57
+ assert entered == {operation for _, operation in operations}
58
+ for owner, operation in operations:
59
+ current = store.get_operation(owner, operation)
60
+ assert current['job']['owner'] == owner
61
+ assert current['operation']['status'] == 'complete'
62
+
63
+
64
+ def test_simultaneous_replays_execute_once_and_still_check_owner(store):
65
+ operation = pending(store, 'alice')
66
+ entered, release = threading.Event(), threading.Event()
67
+ calls = []
68
+
69
+ def execute(job, update):
70
+ calls.append(job['operation_id'])
71
+ entered.set()
72
+ assert release.wait(timeout=5)
73
+ return output(store, job)
74
+
75
+ runner = Runner(store, {'foreground': execute})
76
+ with ThreadPoolExecutor(max_workers=2) as executor:
77
+ first = executor.submit(runner.run_once, operation_id=operation, owner='alice')
78
+ try:
79
+ assert entered.wait(timeout=5)
80
+ duplicate = executor.submit(runner.run_once, operation_id=operation, owner='alice')
81
+ assert duplicate.result(timeout=2) is False
82
+ with pytest.raises(NotFound):
83
+ runner.run_once(operation_id=operation, owner='bob')
84
+ assert store.get_operation('alice', operation)['operation']['status'] == 'running'
85
+ finally:
86
+ release.set()
87
+ assert first.result(timeout=5) is True
88
+ assert runner.run_once(operation_id=operation, owner='alice') is False
89
+ assert calls == [operation]
90
+ assert store.get_operation('alice', operation)['operation']['status'] == 'complete'
91
+
92
+
93
+ def test_concurrent_terminal_failures_retry_only_the_selected_result(store, monkeypatch):
94
+ operations = {owner: pending(store, owner) for owner in ('alice', 'bob')}
95
+ together = threading.Barrier(2)
96
+ calls, writes = {}, {}
97
+ guard = threading.Lock()
98
+ finish = store.finish_operation
99
+
100
+ def execute(job, update):
101
+ with guard:
102
+ calls[job['owner']] = calls.get(job['owner'], 0) + 1
103
+ together.wait(timeout=5)
104
+ return output(store, job)
105
+
106
+ def flaky_finish(operation_id, *args, **kwargs):
107
+ with guard:
108
+ writes[operation_id] = writes.get(operation_id, 0) + 1
109
+ first_write = writes[operation_id] == 1
110
+ if first_write:
111
+ raise OSError('temporary storage interruption')
112
+ return finish(operation_id, *args, **kwargs)
113
+
114
+ monkeypatch.setattr(store, 'finish_operation', flaky_finish)
115
+ runner = Runner(store, {'foreground': execute})
116
+ with ThreadPoolExecutor(max_workers=2) as executor:
117
+ tasks = [executor.submit(runner.run_once, operation_id=operation, owner=owner)
118
+ for owner, operation in operations.items()]
119
+ for task in tasks:
120
+ with pytest.raises(OSError, match='storage interruption'):
121
+ task.result(timeout=10)
122
+ with pytest.raises(NotFound):
123
+ runner.run_once(operation_id=operations['alice'], owner='bob')
124
+ assert runner.run_once() is False # pending writes never revive a background FIFO
125
+ assert runner.run_once(operation_id=operations['alice'], owner='alice') is True
126
+ assert store.get_operation('alice', operations['alice'])['operation']['status'] == 'complete'
127
+ assert store.get_operation('bob', operations['bob'])['operation']['status'] == 'running'
128
+ assert runner.run_once(operation_id=operations['bob'], owner='bob') is True
129
+ assert store.get_operation('bob', operations['bob'])['operation']['status'] == 'complete'
130
+ assert calls == {'alice': 1, 'bob': 1}
131
+
132
+
133
+ def test_zero_mode_cannot_start_an_anonymous_background_worker(store):
134
+ operation = pending(store, 'alice')
135
+ runner = Runner(store, {'foreground': lambda *_: pytest.fail('anonymous GPU work')})
136
+ assert runner.start() is None
137
+ assert runner.run_once() is False
138
+ assert store.get_operation('alice', operation)['operation']['status'] == 'queued'
139
+
140
+
141
+ def test_paid_mode_keeps_serial_execution(store, monkeypatch):
142
+ alice, bob = pending(store, 'alice'), pending(store, 'bob')
143
+ monkeypatch.delenv('SPACES_ZERO_GPU')
144
+ entered, release = threading.Event(), threading.Event()
145
+ calls = []
146
+
147
+ def execute(job, update):
148
+ calls.append(job['owner'])
149
+ if job['owner'] == 'alice':
150
+ entered.set()
151
+ assert release.wait(timeout=5)
152
+ return output(store, job)
153
+
154
+ runner = Runner(store, {'foreground': execute})
155
+ with ThreadPoolExecutor(max_workers=1) as executor:
156
+ first = executor.submit(runner.run_once, operation_id=alice, owner='alice')
157
+ try:
158
+ assert entered.wait(timeout=5)
159
+ assert runner.run_once(operation_id=bob, owner='bob') is False
160
+ assert store.get_operation('bob', bob)['operation']['status'] == 'queued'
161
+ finally:
162
+ release.set()
163
+ assert first.result(timeout=5) is True
164
+ assert runner.run_once(operation_id=bob, owner='bob') is True
165
+ assert calls == ['alice', 'bob']