Reverb commited on
Commit
299ba9b
·
1 Parent(s): 1fe7d3d

Fix state persistence across ZeroGPU subprocess boundary

Browse files

ZeroGPU 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.

Files changed (4) hide show
  1. app.py +2 -2
  2. src/stages/stage1_generate.py +6 -1
  3. src/ui_helpers.py +18 -14
  4. src/workspace.py +83 -3
app.py CHANGED
@@ -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. Generate one in Tab 1 first."
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
 
src/stages/stage1_generate.py CHANGED
@@ -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
src/ui_helpers.py CHANGED
@@ -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 right now.
76
 
77
- Prefers the most-processed version available.
 
 
78
  """
79
- state = workspace.get_state()
80
  # Order: most processed → least processed
81
- candidates = (
82
- state.rigged_glb,
83
- state.final_glb,
84
- state.unwrapped_glb,
85
- state.low_poly_glb,
86
- state.cleaned_glb,
87
- state.repaired_glb,
88
- state.raw_gen_glb,
89
- state.high_poly_glb,
90
- )
 
 
91
  for path in candidates:
92
- if path and path.exists():
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
 
src/workspace.py CHANGED
@@ -120,13 +120,93 @@ class AssetState:
120
  return out
121
 
122
 
123
- # Module-level singleton: in a single-user app, this is fine.
124
- # In Gradio, requests are serialized so there's no race condition at the
125
- # typical interaction level. Real concurrent generation would need locking.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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