Reverb commited on
Commit
a54a5ca
·
verified ·
1 Parent(s): c8ed469

Upload 4 files

Browse files
Files changed (3) hide show
  1. src/quota.py +148 -0
  2. src/ui_helpers.py +119 -0
  3. src/workspace.py +219 -0
src/quota.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ZeroGPU quota tracking.
3
+
4
+ HF Pro gives 1500s (25 min) of H200 time per day. There's no official Python
5
+ API to query remaining quota directly — we track it locally per-call.
6
+
7
+ This module:
8
+ - Records GPU time consumed via the `@spaces.GPU` decorated functions
9
+ - Provides estimates for upcoming operations
10
+ - Resets daily (per-day file on disk)
11
+
12
+ Note: this is approximate. The authoritative source is HF's quota error if
13
+ you go over. Our tracking is for UX (showing "~18/25 min used today").
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import time
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+
23
+ from .workspace import WORKSPACE
24
+
25
+ QUOTA_FILE = WORKSPACE / "quota_log.json"
26
+ DAILY_QUOTA_SECONDS = 1500 # 25 minutes for HF Pro
27
+ OVERAGE_RATE_PER_SECOND = 1.0 / 600.0 # $1 per 600s (10 min)
28
+
29
+
30
+ @dataclass
31
+ class QuotaState:
32
+ date: str # YYYY-MM-DD (UTC)
33
+ used_seconds: float
34
+ operations: list[dict] # log of recent operations
35
+
36
+ def remaining_seconds(self) -> float:
37
+ return max(0.0, DAILY_QUOTA_SECONDS - self.used_seconds)
38
+
39
+ def usage_fraction(self) -> float:
40
+ return min(1.0, self.used_seconds / DAILY_QUOTA_SECONDS)
41
+
42
+ def overage_cost_usd(self) -> float:
43
+ overage = max(0.0, self.used_seconds - DAILY_QUOTA_SECONDS)
44
+ return overage * OVERAGE_RATE_PER_SECOND
45
+
46
+
47
+ def _today_utc() -> str:
48
+ return time.strftime("%Y-%m-%d", time.gmtime())
49
+
50
+
51
+ def _load_or_new() -> QuotaState:
52
+ today = _today_utc()
53
+ if QUOTA_FILE.exists():
54
+ try:
55
+ with QUOTA_FILE.open("r") as f:
56
+ data = json.load(f)
57
+ if data.get("date") == today:
58
+ return QuotaState(
59
+ date=data["date"],
60
+ used_seconds=float(data["used_seconds"]),
61
+ operations=data.get("operations", []),
62
+ )
63
+ except (json.JSONDecodeError, KeyError, ValueError):
64
+ pass
65
+ # Fresh day or corrupted file
66
+ return QuotaState(date=today, used_seconds=0.0, operations=[])
67
+
68
+
69
+ def _save(state: QuotaState) -> None:
70
+ with QUOTA_FILE.open("w") as f:
71
+ json.dump(
72
+ {
73
+ "date": state.date,
74
+ "used_seconds": state.used_seconds,
75
+ "operations": state.operations[-50:], # keep last 50
76
+ },
77
+ f,
78
+ indent=2,
79
+ )
80
+
81
+
82
+ def get_state() -> QuotaState:
83
+ """Get current quota state, refreshed for today."""
84
+ return _load_or_new()
85
+
86
+
87
+ def record_usage(operation: str, seconds: float) -> QuotaState:
88
+ """Record a completed GPU operation. Returns updated state."""
89
+ state = _load_or_new()
90
+ state.used_seconds += seconds
91
+ state.operations.append({
92
+ "op": operation,
93
+ "seconds": round(seconds, 2),
94
+ "timestamp": time.time(),
95
+ })
96
+ _save(state)
97
+ return state
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Per-operation estimates (used by UI to warn before expensive operations)
102
+ # ---------------------------------------------------------------------------
103
+
104
+ ESTIMATES = {
105
+ # Stage 1
106
+ "generate_trellis2_fast": 30,
107
+ "generate_trellis2_balanced": 60,
108
+ "generate_trellis2_hero": 90,
109
+ "generate_hunyuan3d": 60,
110
+ # Stage 2 — baking (nvdiffrast is fast)
111
+ "bake_normal_2k": 5,
112
+ "bake_normal_4k": 12,
113
+ "bake_albedo": 2,
114
+ "bake_materials": 3,
115
+ "bake_ao_fast": 3,
116
+ "bake_ao_standard": 10,
117
+ "bake_ao_high": 30,
118
+ # Stage 2 — optional
119
+ "inpaint_sdxl": 30,
120
+ # Stage 3
121
+ "auto_rig": 40,
122
+ }
123
+
124
+
125
+ def estimate(operation: str) -> int:
126
+ """Get the typical GPU duration for an operation, in seconds.
127
+
128
+ Used to:
129
+ - set `@spaces.GPU(duration=N)` correctly
130
+ - show cost warnings in the UI before triggering an operation
131
+ """
132
+ return ESTIMATES.get(operation, 60)
133
+
134
+
135
+ def format_status() -> str:
136
+ """One-line quota summary for the UI status bar."""
137
+ s = get_state()
138
+ used_min = s.used_seconds / 60
139
+ total_min = DAILY_QUOTA_SECONDS / 60
140
+ remaining_min = s.remaining_seconds() / 60
141
+
142
+ if s.used_seconds >= DAILY_QUOTA_SECONDS:
143
+ cost = s.overage_cost_usd()
144
+ return f"⚠️ Quota: {used_min:.1f}/{total_min:.0f} min (overage: ${cost:.2f})"
145
+ elif s.usage_fraction() > 0.8:
146
+ return f"⚠️ Quota: {used_min:.1f}/{total_min:.0f} min ({remaining_min:.1f} min left)"
147
+ else:
148
+ return f"Quota: {used_min:.1f}/{total_min:.0f} min H200 today"
src/ui_helpers.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UI helpers — shared functions across Gradio tabs.
3
+
4
+ Keeps `app.py` focused on layout, not formatting logic.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from . import quota, workspace
10
+
11
+
12
+ def get_status_bar() -> str:
13
+ """Build the global status bar shown at the bottom of every tab.
14
+
15
+ Markdown-formatted single line with quota + workspace info.
16
+ """
17
+ state = workspace.get_state()
18
+ parts = [
19
+ quota.format_status(),
20
+ f"📁 Workspace: {workspace.current_size_mb():.1f} MB",
21
+ f"📦 Exports: {workspace.export_count()}",
22
+ ]
23
+ if state.model_used:
24
+ parts.append(f"🔧 Last: {state.model_used}")
25
+ return " · ".join(parts)
26
+
27
+
28
+ def get_asset_summary() -> str:
29
+ """Multi-line summary of what's currently in `workspace/current/`.
30
+
31
+ Used in the viewer panel to show pipeline progress.
32
+ """
33
+ state = workspace.get_state()
34
+ lines = [f"**Asset:** `{state.asset_name}`"]
35
+
36
+ if state.face_count:
37
+ lines.append(f"- Faces: {state.face_count:,}")
38
+ lines.append(f"- Vertices: {state.vertex_count:,}")
39
+
40
+ stages = []
41
+ if state.high_poly_glb:
42
+ stages.append("✓ Generated")
43
+ if state.cleaned_glb:
44
+ stages.append("✓ Cleaned")
45
+ if state.low_poly_glb:
46
+ stages.append("✓ Decimated")
47
+ if state.unwrapped_glb:
48
+ stages.append("✓ UV unwrapped")
49
+ if state.normal_dx_png or state.normal_gl_png:
50
+ stages.append("✓ Normal baked")
51
+ if state.albedo_png:
52
+ stages.append("✓ Albedo baked")
53
+ if state.orm_png or (state.roughness_png and state.metallic_png):
54
+ stages.append("✓ PBR maps")
55
+ if state.lod_glbs:
56
+ stages.append(f"✓ LODs ({len(state.lod_glbs)})")
57
+ if state.collision_glb:
58
+ stages.append("✓ Collision")
59
+ if state.rigged_glb or state.rigged_fbx:
60
+ stages.append("✓ Rigged")
61
+
62
+ if stages:
63
+ lines.append("")
64
+ lines.append("**Progress:**")
65
+ for s in stages:
66
+ lines.append(f"- {s}")
67
+ else:
68
+ lines.append("")
69
+ lines.append("*No asset loaded yet. Start at the Generate tab.*")
70
+
71
+ return "\n".join(lines)
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
+
96
+
97
+ def quota_warning(operation: str) -> str:
98
+ """Generate a warning if an operation would exceed the daily quota.
99
+
100
+ Returns an empty string if the operation fits comfortably.
101
+ """
102
+ estimated = quota.estimate(operation)
103
+ state = quota.get_state()
104
+ remaining = state.remaining_seconds()
105
+
106
+ if estimated > remaining:
107
+ overage = estimated - remaining
108
+ cost = overage * quota.OVERAGE_RATE_PER_SECOND
109
+ return (
110
+ f"⚠️ **Quota warning:** This will use ~{estimated}s but you only "
111
+ f"have {remaining:.0f}s left today. "
112
+ f"Overage cost: ~${cost:.2f}"
113
+ )
114
+ elif estimated > remaining * 0.5:
115
+ return (
116
+ f"ℹ️ Estimated GPU time: ~{estimated}s "
117
+ f"({remaining:.0f}s remaining today)"
118
+ )
119
+ return ""
src/workspace.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Workspace management for Open3DForge.
3
+
4
+ Single-user pattern: one persistent workspace folder.
5
+ No session IDs, no multi-tenancy.
6
+
7
+ Layout:
8
+ workspace/
9
+ current/ -- active work, overwritten per generation
10
+ exports/ -- finished asset zips, kept indefinitely
11
+ presets/ -- saved parameter configurations (JSON)
12
+ history/ -- thumbnails + metadata of past assets
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import time
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Paths
27
+ # ---------------------------------------------------------------------------
28
+
29
+ ROOT = Path(__file__).resolve().parent.parent
30
+ WORKSPACE = ROOT / "workspace"
31
+ CURRENT = WORKSPACE / "current"
32
+ EXPORTS = WORKSPACE / "exports"
33
+ PRESETS = WORKSPACE / "presets"
34
+ HISTORY = WORKSPACE / "history"
35
+
36
+ # Subdirectories of `current/` created on each generation
37
+ CURRENT_TEXTURES = CURRENT / "textures"
38
+ CURRENT_LODS = CURRENT / "lods"
39
+
40
+
41
+ def ensure_dirs() -> None:
42
+ """Create all workspace directories if missing. Safe to call repeatedly."""
43
+ for p in (WORKSPACE, CURRENT, EXPORTS, PRESETS, HISTORY,
44
+ CURRENT_TEXTURES, CURRENT_LODS):
45
+ p.mkdir(parents=True, exist_ok=True)
46
+
47
+
48
+ def reset_current() -> None:
49
+ """Clear `current/` for a fresh asset. Called at the start of generation."""
50
+ if CURRENT.exists():
51
+ shutil.rmtree(CURRENT)
52
+ CURRENT.mkdir(parents=True, exist_ok=True)
53
+ CURRENT_TEXTURES.mkdir(parents=True, exist_ok=True)
54
+ CURRENT_LODS.mkdir(parents=True, exist_ok=True)
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Asset state (in-memory representation of what's in `current/`)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ @dataclass
62
+ class AssetState:
63
+ """Tracks what files exist in `current/` and the pipeline progress.
64
+
65
+ Updated by each stage as it completes. Used by the UI to enable/disable
66
+ buttons and show status indicators.
67
+ """
68
+
69
+ # Input
70
+ input_images: list[Path] = field(default_factory=list)
71
+
72
+ # Stage 1 outputs
73
+ high_poly_glb: Path | None = None # raw TRELLIS.2 output, kept for baking
74
+ raw_gen_glb: Path | None = None # decimated base from generation
75
+
76
+ # Stage 2 outputs
77
+ repaired_glb: Path | None = None
78
+ cleaned_glb: Path | None = None
79
+ low_poly_glb: Path | None = None # post-decimation working mesh
80
+ unwrapped_glb: Path | None = None # has UV coordinates
81
+ final_glb: Path | None = None # all post-processing done
82
+
83
+ # Textures (Stage 2 baking)
84
+ albedo_png: Path | None = None
85
+ normal_gl_png: Path | None = None
86
+ normal_dx_png: Path | None = None
87
+ roughness_png: Path | None = None
88
+ metallic_png: Path | None = None
89
+ ao_png: Path | None = None
90
+ orm_png: Path | None = None # UE5-packed AO/Rough/Metal
91
+ metallic_smoothness_png: Path | None = None # Unity-packed
92
+
93
+ # LODs
94
+ lod_glbs: list[Path] = field(default_factory=list)
95
+
96
+ # Collision
97
+ collision_glb: Path | None = None
98
+
99
+ # Rigging (Stage 3)
100
+ rigged_glb: Path | None = None
101
+ rigged_fbx: Path | None = None
102
+
103
+ # Metadata
104
+ asset_name: str = "untitled"
105
+ generated_at: float = field(default_factory=time.time)
106
+ model_used: str = "" # "TRELLIS.2" or "Hunyuan3D-2"
107
+ face_count: int = 0
108
+ vertex_count: int = 0
109
+
110
+ def to_dict(self) -> dict[str, Any]:
111
+ """Serialise for status display / debug."""
112
+ out: dict[str, Any] = {}
113
+ for k, v in self.__dict__.items():
114
+ if isinstance(v, Path):
115
+ out[k] = str(v) if v else None
116
+ elif isinstance(v, list):
117
+ out[k] = [str(p) for p in v]
118
+ else:
119
+ out[k] = v
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
+
133
+ def reset_state() -> AssetState:
134
+ """Replace the global state with a fresh one. Returns the new state."""
135
+ global _state
136
+ _state = AssetState()
137
+ return _state
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Presets (JSON-on-disk, loaded as dicts)
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def list_presets() -> list[str]:
145
+ """Return preset names (filenames without .json), sorted."""
146
+ if not PRESETS.exists():
147
+ return []
148
+ return sorted(p.stem for p in PRESETS.glob("*.json"))
149
+
150
+
151
+ def load_preset(name: str) -> dict[str, Any]:
152
+ """Load a preset by name. Raises FileNotFoundError if missing."""
153
+ path = PRESETS / f"{name}.json"
154
+ if not path.exists():
155
+ raise FileNotFoundError(f"Preset not found: {name}")
156
+ with path.open("r", encoding="utf-8") as f:
157
+ return json.load(f)
158
+
159
+
160
+ def save_preset(name: str, config: dict[str, Any]) -> Path:
161
+ """Save a preset as JSON. Overwrites if exists."""
162
+ safe_name = _sanitize_filename(name)
163
+ path = PRESETS / f"{safe_name}.json"
164
+ with path.open("w", encoding="utf-8") as f:
165
+ json.dump(config, f, indent=2, sort_keys=True)
166
+ return path
167
+
168
+
169
+ def delete_preset(name: str) -> bool:
170
+ """Delete a preset. Returns True if deleted, False if it didn't exist."""
171
+ path = PRESETS / f"{name}.json"
172
+ if path.exists():
173
+ path.unlink()
174
+ return True
175
+ return False
176
+
177
+
178
+ def _sanitize_filename(name: str) -> str:
179
+ """Strip path separators and unsafe chars from a filename stem."""
180
+ return "".join(c for c in name if c.isalnum() or c in "_-").strip("_-") or "preset"
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Workspace stats (for UI status bar)
185
+ # ---------------------------------------------------------------------------
186
+
187
+ def workspace_size_mb() -> float:
188
+ """Total size of the workspace in MB."""
189
+ total = 0
190
+ if WORKSPACE.exists():
191
+ for path in WORKSPACE.rglob("*"):
192
+ if path.is_file():
193
+ total += path.stat().st_size
194
+ return total / (1024 * 1024)
195
+
196
+
197
+ def current_size_mb() -> float:
198
+ """Size of `current/` only (active work)."""
199
+ total = 0
200
+ if CURRENT.exists():
201
+ for path in CURRENT.rglob("*"):
202
+ if path.is_file():
203
+ total += path.stat().st_size
204
+ return total / (1024 * 1024)
205
+
206
+
207
+ def export_count() -> int:
208
+ """How many exported zips exist."""
209
+ if not EXPORTS.exists():
210
+ return 0
211
+ return len(list(EXPORTS.glob("*.zip")))
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Module init
216
+ # ---------------------------------------------------------------------------
217
+
218
+ # Auto-create folders on import so the app never crashes on a fresh checkout
219
+ ensure_dirs()