Synchronize production exactly with verified ZeroGPU Test Space
Browse filesCopy 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
- README.md +43 -13
- app.py +33 -9
- deployment/vendor/eigen-21e4582d1739107337a03460c81412981130373e.tar.gz +3 -0
- deployment/vendor/eigen-source.json +18 -0
- hf_space/backend.py +1 -1
- hf_space/brand/huggingface-logo.svg +37 -0
- hf_space/curated_refresh.py +1 -1
- hf_space/generation_progress.py +9 -1
- hf_space/geometry_preview.py +52 -0
- hf_space/geometry_results.py +105 -0
- hf_space/gpu_mode.py +14 -0
- hf_space/history_jobs.py +122 -63
- hf_space/history_routes.py +37 -5
- hf_space/history_service.py +64 -11
- hf_space/history_store.py +89 -10
- hf_space/history_ui.css +34 -10
- hf_space/history_ui.js +379 -123
- hf_space/history_ui.py +24 -7
- hf_space/native_build.py +113 -7
- hf_space/ovoxel_results.py +53 -5
- hf_space/preview_recovery.py +13 -2
- hf_space/release_backend.py +215 -17
- hf_space/resident_pipeline.py +42 -13
- hf_space/runtime_worker.py +29 -4
- hf_space/split_pipeline.py +146 -0
- hf_space/vendor/gradio-client-2.1.0.js +1940 -0
- hf_space/vendor/gradio-client-LICENSE +201 -0
- hf_space/vendor/gradio-client-source.json +9 -0
- hf_space/viewport.js +2 -2
- hf_space/zerogpu_client.js +295 -0
- hf_space/zerogpu_jobs.py +78 -0
- hf_space/zerogpu_runtime.py +149 -0
- infer.py +10 -1
- requirements.txt +1 -1
- supratex/frontend/camera.py +39 -4
- supratex/frontend/pipeline.py +30 -5
- supratex/inference/image_depth.py +115 -0
- supratex/inference/native_observation.py +73 -4
- supratex/inference/pipeline.py +107 -19
- supratex/inference/reprojection/__init__.py +41 -4
- tests/test_depth_prefilter.py +379 -0
- tests/test_frontend_depth.py +92 -0
- tests/test_geometry_results.py +129 -0
- tests/test_history_geometry_texture.py +344 -0
- tests/test_history_service_workflow.py +155 -0
- tests/test_history_ui_resume.cjs +811 -0
- tests/test_native_eigen.py +83 -0
- tests/test_observation_filter_export.py +115 -0
- tests/test_zerogpu_client.cjs +631 -0
- 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
|
| 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
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
| 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='
|
| 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 '
|
| 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 |
-
"""
|
| 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
|
| 67 |
-
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 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 =
|
| 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('
|
| 279 |
-
|
|
|
|
| 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
|
|
|
|
| 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 =
|
|
|
|
| 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 {'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 104 |
-
#
|
| 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 |
-
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
**({'foreground_prepared': True} if prepared else {}))
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
| 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.
|
| 11 |
-
#st-resource-footer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 11 |
-
const
|
|
|
|
|
|
|
| 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)
|
| 445 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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…' :
|
| 473 |
-
|
|
|
|
| 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 &&
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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',
|
| 697 |
const position = progress.queue_position;
|
| 698 |
-
const
|
| 699 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
job.status === 'queued' && position !== undefined && position !== null ?
|
| 701 |
-
`Waiting in queue · Position ${position}` :
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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;
|
|
|
|
|
|
|
| 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.' :
|
|
|
|
| 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
|
|
|
|
| 763 |
const position = progress.queue_position;
|
| 764 |
-
text('st-export-label',
|
|
|
|
| 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 (
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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', '
|
| 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 |
-
|
| 903 |
-
|
| 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 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
}
|
| 935 |
async function refreshUpload(state) {
|
| 936 |
-
if (!currentUpload(state) || !state.uploadId) return;
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', '
|
| 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', '
|
| 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 ? '
|
| 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',
|
| 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
|
| 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', () => {
|
|
|
|
|
|
|
| 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 (
|
| 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 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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><
|
| 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
|
| 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 / "
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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=
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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':
|
| 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':
|
| 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={
|
|
|
|
| 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={
|
|
|
|
| 208 |
config=_stage_attempt(attempt)
|
| 209 |
update(stage='loading_models',startup_progress={
|
| 210 |
-
'label':'Initializing model runtime','completed':0,'total':
|
| 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':
|
| 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':
|
| 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 |
-
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,'
|
| 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
|
| 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='
|
| 419 |
-
if progress:progress(.05,'
|
| 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':'
|
|
|
|
| 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'],
|
| 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
|
| 38 |
-
#
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 40 |
-
//
|
| 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 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 173 |
-
|
|
|
|
| 174 |
finally:
|
| 175 |
if estimator is not None:
|
| 176 |
estimator.close()
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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")
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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,
|
| 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
|
| 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 |
-
|
| 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,
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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']
|