Spaces:
Sleeping
Fix state persistence across ZeroGPU subprocess boundary
Browse filesZeroGPU runs @spaces.GPU functions in a forked subprocess. Any writes
to module-level variables (_state) are invisible to the parent Gradio
process, causing Tab 2 to report "no asset" and the viewer to clear on
refresh even after a successful generation.
Fix:
- workspace.py: get_state() now calls _build_state_from_disk() which
scans CURRENT/ for known filenames and reads .meta.json for metadata.
This makes all state reads cross-process-safe.
- workspace.py: flush_meta(state) writes face_count, vertex_count,
model_used, asset_name to CURRENT/.meta.json from the subprocess.
- stage1_generate.py: both generate_hunyuan and generate_trellis call
workspace.flush_meta(ws) before yielding the final result.
- ui_helpers.py: get_viewer_model_path() now scans CURRENT/ directly
(no longer reads _state) — correct from any process.
- The viewer glb path returned directly from handle_generate bypasses
all state lookups for the initial display.
- app.py +2 -2
- src/stages/stage1_generate.py +6 -1
- src/ui_helpers.py +18 -14
- src/workspace.py +83 -3
|
@@ -103,10 +103,10 @@ def run_post_process(
|
|
| 103 |
do_albedo, do_material, do_ao, ao_quality,
|
| 104 |
do_inpaint, do_lods, do_collision, pivot, scale_m,
|
| 105 |
):
|
| 106 |
-
state = workspace.get_state()
|
| 107 |
current_glb = state.raw_gen_glb or state.high_poly_glb
|
| 108 |
if not current_glb or not current_glb.exists():
|
| 109 |
-
return "❌ No generated asset found.
|
| 110 |
|
| 111 |
log = []
|
| 112 |
|
|
|
|
| 103 |
do_albedo, do_material, do_ao, ao_quality,
|
| 104 |
do_inpaint, do_lods, do_collision, pivot, scale_m,
|
| 105 |
):
|
| 106 |
+
state = workspace.get_state() # rebuilt from disk by get_state()
|
| 107 |
current_glb = state.raw_gen_glb or state.high_poly_glb
|
| 108 |
if not current_glb or not current_glb.exists():
|
| 109 |
+
return "❌ No generated asset found. Run Stage 1 (Generate) first."
|
| 110 |
|
| 111 |
log = []
|
| 112 |
|
|
@@ -172,10 +172,10 @@ def generate_trellis(
|
|
| 172 |
ws.model_used = "TRELLIS.2"
|
| 173 |
ws.face_count = face_count
|
| 174 |
ws.vertex_count = vertex_count
|
|
|
|
| 175 |
|
| 176 |
elapsed = time.time() - t0
|
| 177 |
|
| 178 |
-
# Record approximate GPU time consumed on the remote Space for quota display
|
| 179 |
from src import quota as _quota
|
| 180 |
_quota.record_usage(f"generate_trellis_{quality.split()[0].lower()}", elapsed)
|
| 181 |
|
|
@@ -369,6 +369,11 @@ def generate_hunyuan(
|
|
| 369 |
ws.face_count = face_count
|
| 370 |
ws.vertex_count = vertex_count
|
| 371 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 372 |
elapsed = time.time() - t0
|
| 373 |
|
| 374 |
from src import quota as _quota
|
|
|
|
| 172 |
ws.model_used = "TRELLIS.2"
|
| 173 |
ws.face_count = face_count
|
| 174 |
ws.vertex_count = vertex_count
|
| 175 |
+
workspace.flush_meta(ws)
|
| 176 |
|
| 177 |
elapsed = time.time() - t0
|
| 178 |
|
|
|
|
| 179 |
from src import quota as _quota
|
| 180 |
_quota.record_usage(f"generate_trellis_{quality.split()[0].lower()}", elapsed)
|
| 181 |
|
|
|
|
| 369 |
ws.face_count = face_count
|
| 370 |
ws.vertex_count = vertex_count
|
| 371 |
|
| 372 |
+
# Persist metadata to disk so the parent Gradio process can read it
|
| 373 |
+
# (ZeroGPU runs this function in a subprocess; in-memory _state is not
|
| 374 |
+
# shared with the parent process that handles UI refresh calls).
|
| 375 |
+
workspace.flush_meta(ws)
|
| 376 |
+
|
| 377 |
elapsed = time.time() - t0
|
| 378 |
|
| 379 |
from src import quota as _quota
|
|
@@ -72,24 +72,28 @@ def get_asset_summary() -> str:
|
|
| 72 |
|
| 73 |
|
| 74 |
def get_viewer_model_path() -> str | None:
|
| 75 |
-
"""Pick the best GLB to show in the 3D viewer
|
| 76 |
|
| 77 |
-
|
|
|
|
|
|
|
| 78 |
"""
|
| 79 |
-
|
| 80 |
# Order: most processed → least processed
|
| 81 |
-
candidates =
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
for path in candidates:
|
| 92 |
-
if path
|
| 93 |
return str(path)
|
| 94 |
return None
|
| 95 |
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def get_viewer_model_path() -> str | None:
|
| 75 |
+
"""Pick the best GLB to show in the 3D viewer.
|
| 76 |
|
| 77 |
+
Reads the filesystem directly so it works across the ZeroGPU subprocess
|
| 78 |
+
boundary (the in-memory state written inside @spaces.GPU is invisible to
|
| 79 |
+
the parent Gradio process).
|
| 80 |
"""
|
| 81 |
+
from .workspace import CURRENT
|
| 82 |
# Order: most processed → least processed
|
| 83 |
+
candidates = [
|
| 84 |
+
CURRENT / "rigged.glb",
|
| 85 |
+
CURRENT / "scaled.glb",
|
| 86 |
+
CURRENT / "pivoted.glb",
|
| 87 |
+
CURRENT / "lods" / "LOD0.glb",
|
| 88 |
+
CURRENT / "unwrapped.glb",
|
| 89 |
+
CURRENT / "low_poly.glb",
|
| 90 |
+
CURRENT / "cleaned.glb",
|
| 91 |
+
CURRENT / "repaired.glb",
|
| 92 |
+
CURRENT / "raw_gen.glb",
|
| 93 |
+
CURRENT / "high_poly.glb",
|
| 94 |
+
]
|
| 95 |
for path in candidates:
|
| 96 |
+
if path.exists():
|
| 97 |
return str(path)
|
| 98 |
return None
|
| 99 |
|
|
@@ -120,13 +120,93 @@ class AssetState:
|
|
| 120 |
return out
|
| 121 |
|
| 122 |
|
| 123 |
-
#
|
| 124 |
-
#
|
| 125 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
_state: AssetState = AssetState()
|
| 127 |
|
| 128 |
|
| 129 |
def get_state() -> AssetState:
|
|
|
|
|
|
|
|
|
|
| 130 |
return _state
|
| 131 |
|
| 132 |
|
|
|
|
| 120 |
return out
|
| 121 |
|
| 122 |
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
# Filesystem-based state persistence
|
| 125 |
+
#
|
| 126 |
+
# ZeroGPU runs @spaces.GPU functions in a forked subprocess. Any writes to
|
| 127 |
+
# module-level variables (like _state) happen in the subprocess and are
|
| 128 |
+
# invisible to the parent Gradio process. To survive the process boundary we
|
| 129 |
+
# write state to a JSON file inside CURRENT/ and always read back from there.
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
_META_FILE = CURRENT / ".meta.json"
|
| 133 |
+
|
| 134 |
+
# File names that map to AssetState path attributes (order = preference)
|
| 135 |
+
_PATH_ATTRS: list[tuple[str, Path]] = [
|
| 136 |
+
("rigged_fbx", CURRENT / "rigged.fbx"),
|
| 137 |
+
("rigged_glb", CURRENT / "rigged.glb"),
|
| 138 |
+
("final_glb", CURRENT / "scaled.glb"),
|
| 139 |
+
("final_glb", CURRENT / "pivoted.glb"),
|
| 140 |
+
("unwrapped_glb", CURRENT / "unwrapped.glb"),
|
| 141 |
+
("low_poly_glb", CURRENT / "low_poly.glb"),
|
| 142 |
+
("cleaned_glb", CURRENT / "cleaned.glb"),
|
| 143 |
+
("repaired_glb", CURRENT / "repaired.glb"),
|
| 144 |
+
("raw_gen_glb", CURRENT / "raw_gen.glb"),
|
| 145 |
+
("high_poly_glb", CURRENT / "high_poly.glb"),
|
| 146 |
+
("normal_dx_png", CURRENT / "textures" / "normal_dx.png"),
|
| 147 |
+
("normal_gl_png", CURRENT / "textures" / "normal_gl.png"),
|
| 148 |
+
("albedo_png", CURRENT / "textures" / "albedo.png"),
|
| 149 |
+
("roughness_png", CURRENT / "textures" / "roughness.png"),
|
| 150 |
+
("metallic_png", CURRENT / "textures" / "metallic.png"),
|
| 151 |
+
("ao_png", CURRENT / "textures" / "ao.png"),
|
| 152 |
+
("orm_png", CURRENT / "textures" / "orm.png"),
|
| 153 |
+
("collision_glb", CURRENT / "collision.glb"),
|
| 154 |
+
]
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _build_state_from_disk() -> AssetState:
|
| 158 |
+
"""Reconstruct AssetState by scanning CURRENT/ and reading .meta.json."""
|
| 159 |
+
state = AssetState()
|
| 160 |
+
|
| 161 |
+
# Read persisted metadata (face count, model name, etc.)
|
| 162 |
+
if _META_FILE.exists():
|
| 163 |
+
try:
|
| 164 |
+
meta = json.loads(_META_FILE.read_text())
|
| 165 |
+
state.asset_name = meta.get("asset_name", "untitled")
|
| 166 |
+
state.model_used = meta.get("model_used", "")
|
| 167 |
+
state.face_count = meta.get("face_count", 0)
|
| 168 |
+
state.vertex_count = meta.get("vertex_count", 0)
|
| 169 |
+
except Exception:
|
| 170 |
+
pass
|
| 171 |
+
|
| 172 |
+
# Populate path attributes from filesystem
|
| 173 |
+
seen_attrs: set[str] = set()
|
| 174 |
+
for attr, path in _PATH_ATTRS:
|
| 175 |
+
if path.exists() and attr not in seen_attrs:
|
| 176 |
+
setattr(state, attr, path)
|
| 177 |
+
seen_attrs.add(attr)
|
| 178 |
+
|
| 179 |
+
# LODs
|
| 180 |
+
lod_dir = CURRENT / "lods"
|
| 181 |
+
if lod_dir.exists():
|
| 182 |
+
state.lod_glbs = sorted(lod_dir.glob("LOD*.glb"))
|
| 183 |
+
|
| 184 |
+
return state
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def flush_meta(state: AssetState) -> None:
|
| 188 |
+
"""Write lightweight metadata to disk so the parent process can read it."""
|
| 189 |
+
try:
|
| 190 |
+
_META_FILE.write_text(json.dumps({
|
| 191 |
+
"asset_name": state.asset_name,
|
| 192 |
+
"model_used": state.model_used,
|
| 193 |
+
"face_count": state.face_count,
|
| 194 |
+
"vertex_count": state.vertex_count,
|
| 195 |
+
}))
|
| 196 |
+
except Exception:
|
| 197 |
+
pass
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# Module-level singleton — kept for in-process use (e.g. stage2 steps that
|
| 201 |
+
# run in the same process as Gradio). Always prefer get_state() which syncs
|
| 202 |
+
# from disk first, making it safe across the ZeroGPU process boundary.
|
| 203 |
_state: AssetState = AssetState()
|
| 204 |
|
| 205 |
|
| 206 |
def get_state() -> AssetState:
|
| 207 |
+
"""Return current state, rebuilding from disk to handle ZeroGPU isolation."""
|
| 208 |
+
global _state
|
| 209 |
+
_state = _build_state_from_disk()
|
| 210 |
return _state
|
| 211 |
|
| 212 |
|