Spaces:
Sleeping
Sleeping
File size: 10,209 Bytes
a54a5ca 299ba9b a54a5ca 299ba9b a54a5ca | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | """
Workspace management for Open3DForge.
Single-user pattern: one persistent workspace folder.
No session IDs, no multi-tenancy.
Layout:
workspace/
current/ -- active work, overwritten per generation
exports/ -- finished asset zips, kept indefinitely
presets/ -- saved parameter configurations (JSON)
history/ -- thumbnails + metadata of past assets
"""
from __future__ import annotations
import json
import os
import shutil
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parent.parent
WORKSPACE = ROOT / "workspace"
CURRENT = WORKSPACE / "current"
EXPORTS = WORKSPACE / "exports"
PRESETS = WORKSPACE / "presets"
HISTORY = WORKSPACE / "history"
# Subdirectories of `current/` created on each generation
CURRENT_TEXTURES = CURRENT / "textures"
CURRENT_LODS = CURRENT / "lods"
def ensure_dirs() -> None:
"""Create all workspace directories if missing. Safe to call repeatedly."""
for p in (WORKSPACE, CURRENT, EXPORTS, PRESETS, HISTORY,
CURRENT_TEXTURES, CURRENT_LODS):
p.mkdir(parents=True, exist_ok=True)
def reset_current() -> None:
"""Clear `current/` for a fresh asset. Called at the start of generation."""
if CURRENT.exists():
shutil.rmtree(CURRENT)
CURRENT.mkdir(parents=True, exist_ok=True)
CURRENT_TEXTURES.mkdir(parents=True, exist_ok=True)
CURRENT_LODS.mkdir(parents=True, exist_ok=True)
# ---------------------------------------------------------------------------
# Asset state (in-memory representation of what's in `current/`)
# ---------------------------------------------------------------------------
@dataclass
class AssetState:
"""Tracks what files exist in `current/` and the pipeline progress.
Updated by each stage as it completes. Used by the UI to enable/disable
buttons and show status indicators.
"""
# Input
input_images: list[Path] = field(default_factory=list)
# Stage 1 outputs
high_poly_glb: Path | None = None # raw TRELLIS.2 output, kept for baking
raw_gen_glb: Path | None = None # decimated base from generation
# Stage 2 outputs
repaired_glb: Path | None = None
cleaned_glb: Path | None = None
low_poly_glb: Path | None = None # post-decimation working mesh
unwrapped_glb: Path | None = None # has UV coordinates
final_glb: Path | None = None # all post-processing done
# Textures (Stage 2 baking)
albedo_png: Path | None = None
normal_gl_png: Path | None = None
normal_dx_png: Path | None = None
roughness_png: Path | None = None
metallic_png: Path | None = None
ao_png: Path | None = None
orm_png: Path | None = None # UE5-packed AO/Rough/Metal
metallic_smoothness_png: Path | None = None # Unity-packed
# LODs
lod_glbs: list[Path] = field(default_factory=list)
# Collision
collision_glb: Path | None = None
# Rigging (Stage 3)
rigged_glb: Path | None = None
rigged_fbx: Path | None = None
# Metadata
asset_name: str = "untitled"
generated_at: float = field(default_factory=time.time)
model_used: str = "" # "TRELLIS.2" or "Hunyuan3D-2"
face_count: int = 0
vertex_count: int = 0
def to_dict(self) -> dict[str, Any]:
"""Serialise for status display / debug."""
out: dict[str, Any] = {}
for k, v in self.__dict__.items():
if isinstance(v, Path):
out[k] = str(v) if v else None
elif isinstance(v, list):
out[k] = [str(p) for p in v]
else:
out[k] = v
return out
# ---------------------------------------------------------------------------
# Filesystem-based state persistence
#
# ZeroGPU runs @spaces.GPU functions in a forked subprocess. Any writes to
# module-level variables (like _state) happen in the subprocess and are
# invisible to the parent Gradio process. To survive the process boundary we
# write state to a JSON file inside CURRENT/ and always read back from there.
# ---------------------------------------------------------------------------
_META_FILE = CURRENT / ".meta.json"
# File names that map to AssetState path attributes (order = preference)
_PATH_ATTRS: list[tuple[str, Path]] = [
("rigged_fbx", CURRENT / "rigged.fbx"),
("rigged_glb", CURRENT / "rigged.glb"),
("final_glb", CURRENT / "scaled.glb"),
("final_glb", CURRENT / "pivoted.glb"),
("unwrapped_glb", CURRENT / "unwrapped.glb"),
("low_poly_glb", CURRENT / "low_poly.glb"),
("cleaned_glb", CURRENT / "cleaned.glb"),
("repaired_glb", CURRENT / "repaired.glb"),
("raw_gen_glb", CURRENT / "raw_gen.glb"),
("high_poly_glb", CURRENT / "high_poly.glb"),
("normal_dx_png", CURRENT / "textures" / "normal_dx.png"),
("normal_gl_png", CURRENT / "textures" / "normal_gl.png"),
("albedo_png", CURRENT / "textures" / "albedo.png"),
("roughness_png", CURRENT / "textures" / "roughness.png"),
("metallic_png", CURRENT / "textures" / "metallic.png"),
("ao_png", CURRENT / "textures" / "ao.png"),
("orm_png", CURRENT / "textures" / "orm.png"),
("collision_glb", CURRENT / "collision.glb"),
]
def _build_state_from_disk() -> AssetState:
"""Reconstruct AssetState by scanning CURRENT/ and reading .meta.json."""
state = AssetState()
# Read persisted metadata (face count, model name, etc.)
if _META_FILE.exists():
try:
meta = json.loads(_META_FILE.read_text())
state.asset_name = meta.get("asset_name", "untitled")
state.model_used = meta.get("model_used", "")
state.face_count = meta.get("face_count", 0)
state.vertex_count = meta.get("vertex_count", 0)
except Exception:
pass
# Populate path attributes from filesystem
seen_attrs: set[str] = set()
for attr, path in _PATH_ATTRS:
if path.exists() and attr not in seen_attrs:
setattr(state, attr, path)
seen_attrs.add(attr)
# LODs
lod_dir = CURRENT / "lods"
if lod_dir.exists():
state.lod_glbs = sorted(lod_dir.glob("LOD*.glb"))
return state
def flush_meta(state: AssetState) -> None:
"""Write lightweight metadata to disk so the parent process can read it."""
try:
_META_FILE.write_text(json.dumps({
"asset_name": state.asset_name,
"model_used": state.model_used,
"face_count": state.face_count,
"vertex_count": state.vertex_count,
}))
except Exception:
pass
# Module-level singleton — kept for in-process use (e.g. stage2 steps that
# run in the same process as Gradio). Always prefer get_state() which syncs
# from disk first, making it safe across the ZeroGPU process boundary.
_state: AssetState = AssetState()
def get_state() -> AssetState:
"""Return current state, rebuilding from disk to handle ZeroGPU isolation."""
global _state
_state = _build_state_from_disk()
return _state
def reset_state() -> AssetState:
"""Replace the global state with a fresh one. Returns the new state."""
global _state
_state = AssetState()
return _state
# ---------------------------------------------------------------------------
# Presets (JSON-on-disk, loaded as dicts)
# ---------------------------------------------------------------------------
def list_presets() -> list[str]:
"""Return preset names (filenames without .json), sorted."""
if not PRESETS.exists():
return []
return sorted(p.stem for p in PRESETS.glob("*.json"))
def load_preset(name: str) -> dict[str, Any]:
"""Load a preset by name. Raises FileNotFoundError if missing."""
path = PRESETS / f"{name}.json"
if not path.exists():
raise FileNotFoundError(f"Preset not found: {name}")
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def save_preset(name: str, config: dict[str, Any]) -> Path:
"""Save a preset as JSON. Overwrites if exists."""
safe_name = _sanitize_filename(name)
path = PRESETS / f"{safe_name}.json"
with path.open("w", encoding="utf-8") as f:
json.dump(config, f, indent=2, sort_keys=True)
return path
def delete_preset(name: str) -> bool:
"""Delete a preset. Returns True if deleted, False if it didn't exist."""
path = PRESETS / f"{name}.json"
if path.exists():
path.unlink()
return True
return False
def _sanitize_filename(name: str) -> str:
"""Strip path separators and unsafe chars from a filename stem."""
return "".join(c for c in name if c.isalnum() or c in "_-").strip("_-") or "preset"
# ---------------------------------------------------------------------------
# Workspace stats (for UI status bar)
# ---------------------------------------------------------------------------
def workspace_size_mb() -> float:
"""Total size of the workspace in MB."""
total = 0
if WORKSPACE.exists():
for path in WORKSPACE.rglob("*"):
if path.is_file():
total += path.stat().st_size
return total / (1024 * 1024)
def current_size_mb() -> float:
"""Size of `current/` only (active work)."""
total = 0
if CURRENT.exists():
for path in CURRENT.rglob("*"):
if path.is_file():
total += path.stat().st_size
return total / (1024 * 1024)
def export_count() -> int:
"""How many exported zips exist."""
if not EXPORTS.exists():
return 0
return len(list(EXPORTS.glob("*.zip")))
# ---------------------------------------------------------------------------
# Module init
# ---------------------------------------------------------------------------
# Auto-create folders on import so the app never crashes on a fresh checkout
ensure_dirs()
|