Reverb commited on
Commit
f673e25
·
verified ·
1 Parent(s): 42c0ab8

Upload 8 files

Browse files
Files changed (8) hide show
  1. .gitignore +59 -0
  2. README.md +38 -0
  3. __init__.py +0 -0
  4. app.py +447 -0
  5. quota.py +148 -0
  6. requirements.txt +44 -0
  7. ui_helpers.py +119 -0
  8. workspace.py +219 -0
.gitignore ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .venv/
10
+ venv/
11
+
12
+ # Workspace runtime data (kept local, not pushed)
13
+ workspace/current/*
14
+ workspace/history/*
15
+ !workspace/current/.gitkeep
16
+ !workspace/history/.gitkeep
17
+ !workspace/exports/.gitkeep
18
+ !workspace/presets/.gitkeep
19
+
20
+ # Model weights (downloaded at runtime, not committed)
21
+ weights/
22
+ models/
23
+ *.safetensors
24
+ *.bin
25
+ *.pt
26
+ *.pth
27
+ *.ckpt
28
+
29
+ # Large generated files
30
+ *.glb
31
+ *.fbx
32
+ *.usdz
33
+ !presets/*.glb # if we ship example assets
34
+
35
+ # Generated textures
36
+ workspace/current/textures/*.png
37
+ workspace/current/textures/*.jpg
38
+
39
+ # OS junk
40
+ .DS_Store
41
+ Thumbs.db
42
+ desktop.ini
43
+
44
+ # Editor
45
+ .vscode/
46
+ .idea/
47
+ *.swp
48
+ *.swo
49
+
50
+ # HF Spaces
51
+ .huggingface/
52
+
53
+ # Logs
54
+ *.log
55
+ logs/
56
+
57
+ # Temp
58
+ tmp/
59
+ *.tmp
README.md ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Open3DForge
3
+ emoji: 🛠️
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ hardware: zero-a10g
12
+ short_description: Personal image-to-game-ready 3D asset pipeline for UE5
13
+ ---
14
+
15
+ # Open3DForge
16
+
17
+ > **Personal AI-powered image-to-game-ready 3D asset pipeline**
18
+ > Built for solo game development. UE5-first. Runs on HF ZeroGPU.
19
+
20
+ A solo-developer tool for generating special/custom 3D assets (characters, unique props) that can't be sourced from Fab or Megascans. Generation → cleanup → UV unwrap → PBR baking → LODs → collision → optional rigging → engine-ready export.
21
+
22
+ ## Pipeline
23
+
24
+ 1. **Generate** — TRELLIS.2 (hard surface) or Hunyuan3D-2 (organic) from 1-4 input images
25
+ 2. **Post-Process** — repair, decimate, UV unwrap (xatlas), bake normals/albedo/materials/AO (nvdiffrast), inpaint hidden UVs (SDXL), LODs, collision (CoACD), pivot, scale
26
+ 3. **Auto-Rig** — UniRig for character skeletons
27
+ 4. **Export** — UE5 FBX bundle with DirectX normals + ORM packing
28
+
29
+ ## Hardware
30
+
31
+ - **HF ZeroGPU H200** (70GB VRAM, 25 min/day on Pro)
32
+ - **Local fallback** on RTX 4070 when quota's exhausted
33
+
34
+ ## Status
35
+
36
+ 🚧 Active development — Milestone 1 (foundation) in progress.
37
+
38
+ See `PLAN.md` for the full architecture document.
__init__.py ADDED
File without changes
app.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Open3DForge — Image-to-game-ready 3D asset pipeline
3
+ ====================================================
4
+
5
+ Milestone 1: Foundation
6
+ -----------------------
7
+ This is the scaffold. It validates that:
8
+ - The HF Space builds with the right Gradio + ZeroGPU configuration
9
+ - `@spaces.GPU` allocates and releases an H200 successfully
10
+ - `gr.Model3D` renders GLB files inline
11
+ - The 5-tab UI structure is sound
12
+ - Workspace folder management works
13
+ - Quota tracking persists across page reloads
14
+
15
+ Subsequent milestones fill in the actual pipeline stages.
16
+
17
+ Run locally: python app.py
18
+ Deploy: git push to HF Space
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ from pathlib import Path
25
+
26
+ import gradio as gr
27
+ import spaces # HF ZeroGPU
28
+
29
+ from src import quota, ui_helpers, workspace
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # ZeroGPU test function — proves that GPU allocation works.
33
+ # Replaced in Milestone 2 with the actual TRELLIS.2 generator.
34
+ # ---------------------------------------------------------------------------
35
+
36
+ @spaces.GPU(duration=15)
37
+ def zerogpu_smoke_test() -> str:
38
+ """Allocate a GPU, run a trivial torch op, release. Reports timing."""
39
+ start = time.time()
40
+ try:
41
+ import torch
42
+ if not torch.cuda.is_available():
43
+ return "❌ GPU not available inside @spaces.GPU. Something is wrong."
44
+
45
+ device = torch.device("cuda")
46
+ # Trivial GPU work
47
+ a = torch.randn(1024, 1024, device=device)
48
+ b = torch.randn(1024, 1024, device=device)
49
+ c = a @ b
50
+ torch.cuda.synchronize()
51
+ result_sum = float(c.sum().item())
52
+
53
+ gpu_name = torch.cuda.get_device_name(0)
54
+ vram_total = torch.cuda.get_device_properties(0).total_memory / 1e9
55
+ elapsed = time.time() - start
56
+
57
+ quota.record_usage("smoke_test", elapsed)
58
+
59
+ return (
60
+ f"✅ **GPU allocation successful**\n\n"
61
+ f"- Device: `{gpu_name}`\n"
62
+ f"- VRAM: `{vram_total:.1f} GB`\n"
63
+ f"- Test op (1024×1024 matmul): `{elapsed:.2f}s`\n"
64
+ f"- Result sum: `{result_sum:.2f}` (sanity check, non-zero = ✓)\n\n"
65
+ f"ZeroGPU integration is working. Ready for Milestone 2."
66
+ )
67
+ except Exception as e:
68
+ elapsed = time.time() - start
69
+ return f"❌ GPU test failed after {elapsed:.2f}s:\n```\n{type(e).__name__}: {e}\n```"
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Stage stubs — return placeholder messages until milestones implement them.
74
+ # ---------------------------------------------------------------------------
75
+
76
+ def _stub(stage: str) -> str:
77
+ return (
78
+ f"🚧 **{stage}** — not implemented yet.\n\n"
79
+ f"This stub will be replaced in a future milestone. "
80
+ f"See `PLAN.md` for the full pipeline spec."
81
+ )
82
+
83
+
84
+ def stub_generate(*_args, **_kwargs):
85
+ return _stub("Stage 1: Generation")
86
+
87
+
88
+ def stub_post_process(*_args, **_kwargs):
89
+ return _stub("Stage 2: Post-Processing")
90
+
91
+
92
+ def stub_auto_rig(*_args, **_kwargs):
93
+ return _stub("Stage 3: Auto-Rigging")
94
+
95
+
96
+ def stub_export(*_args, **_kwargs):
97
+ return _stub("Stage 4: Export"), None # message, file
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Presets tab logic (this one is real even in Milestone 1)
102
+ # ---------------------------------------------------------------------------
103
+
104
+ def ui_refresh_presets() -> gr.Dropdown:
105
+ return gr.Dropdown(choices=workspace.list_presets(), label="Saved presets")
106
+
107
+
108
+ def ui_save_preset(name: str) -> tuple[str, gr.Dropdown]:
109
+ if not name or not name.strip():
110
+ return "❌ Preset name cannot be empty.", ui_refresh_presets()
111
+ # Placeholder config — Milestone 9 wires in real settings from each tab
112
+ config = {
113
+ "name": name,
114
+ "version": 1,
115
+ "created_at": time.time(),
116
+ "note": "Placeholder — real preset data wired in Milestone 9",
117
+ }
118
+ workspace.save_preset(name, config)
119
+ return f"✅ Saved preset: `{name}`", ui_refresh_presets()
120
+
121
+
122
+ def ui_delete_preset(name: str) -> tuple[str, gr.Dropdown]:
123
+ if not name:
124
+ return "❌ Select a preset to delete.", ui_refresh_presets()
125
+ if workspace.delete_preset(name):
126
+ return f"🗑️ Deleted preset: `{name}`", ui_refresh_presets()
127
+ return f"❌ Preset not found: `{name}`", ui_refresh_presets()
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Layout
132
+ # ---------------------------------------------------------------------------
133
+
134
+ CUSTOM_CSS = """
135
+ .status-bar {
136
+ font-size: 0.85em;
137
+ color: #888;
138
+ padding: 8px 12px;
139
+ border-top: 1px solid #333;
140
+ margin-top: 12px;
141
+ }
142
+ .asset-summary {
143
+ font-size: 0.9em;
144
+ background: rgba(255,255,255,0.03);
145
+ padding: 12px;
146
+ border-radius: 6px;
147
+ border: 1px solid rgba(255,255,255,0.08);
148
+ }
149
+ """
150
+
151
+
152
+ def build_ui() -> gr.Blocks:
153
+ with gr.Blocks(
154
+ title="Open3DForge",
155
+ theme=gr.themes.Soft(primary_hue="indigo", neutral_hue="slate"),
156
+ css=CUSTOM_CSS,
157
+ ) as demo:
158
+
159
+ # --- Header --------------------------------------------------------
160
+ gr.Markdown(
161
+ "# 🛠️ Open3DForge\n"
162
+ "*Personal image-to-game-ready 3D asset pipeline · UE5-first · "
163
+ "Built on HF ZeroGPU*"
164
+ )
165
+
166
+ # --- Tabs ----------------------------------------------------------
167
+ with gr.Tabs() as tabs:
168
+
169
+ # ============ Tab 1: Generate =================================
170
+ with gr.Tab("1. Generate", id=1):
171
+ gr.Markdown(
172
+ "### Stage 1 — Image to 3D\n"
173
+ "Upload 1–4 reference images. Multi-view dramatically "
174
+ "improves quality for characters (front / 3-quarter / side / back)."
175
+ )
176
+ with gr.Row():
177
+ with gr.Column(scale=1):
178
+ gen_images = gr.File(
179
+ label="Reference images (1–4)",
180
+ file_count="multiple",
181
+ file_types=["image"],
182
+ )
183
+ gen_model = gr.Radio(
184
+ choices=[
185
+ "TRELLIS.2 (Hard Surface)",
186
+ "Hunyuan3D-2 (Organic / Characters)",
187
+ ],
188
+ value="TRELLIS.2 (Hard Surface)",
189
+ label="Generation model",
190
+ )
191
+ gen_quality = gr.Radio(
192
+ choices=["Fast (~30s)", "Balanced (~60s)", "Hero (~90s)"],
193
+ value="Balanced (~60s)",
194
+ label="Quality preset",
195
+ )
196
+ with gr.Accordion("Advanced", open=False):
197
+ gen_seed = gr.Number(value=42, label="Seed", precision=0)
198
+ gen_steps = gr.Slider(
199
+ 20, 50, value=35, step=5,
200
+ label="Inference steps",
201
+ )
202
+ gen_octree = gr.Dropdown(
203
+ choices=[128, 192, 256], value=192,
204
+ label="Octree resolution",
205
+ )
206
+ gen_tex_size = gr.Dropdown(
207
+ choices=[1024, 2048, 4096], value=2048,
208
+ label="Texture size",
209
+ )
210
+ gen_symmetry = gr.Radio(
211
+ choices=["off", "bilateral", "radial"],
212
+ value="off",
213
+ label="Symmetry hint",
214
+ )
215
+ gen_btn = gr.Button("Generate", variant="primary")
216
+ with gr.Column(scale=1):
217
+ gen_status = gr.Markdown("*Awaiting input.*")
218
+ gen_btn.click(
219
+ fn=stub_generate,
220
+ inputs=[gen_images, gen_model, gen_quality, gen_seed,
221
+ gen_steps, gen_octree, gen_tex_size, gen_symmetry],
222
+ outputs=gen_status,
223
+ )
224
+
225
+ # ============ Tab 2: Post-Process =============================
226
+ with gr.Tab("2. Post-Process", id=2):
227
+ gr.Markdown(
228
+ "### Stage 2 — Mesh cleanup, UV unwrap, texture baking\n"
229
+ "Toggle steps on/off. Decimation has a live preview, the "
230
+ "rest run on confirm."
231
+ )
232
+ with gr.Row():
233
+ with gr.Column(scale=1):
234
+ pp_repair = gr.Checkbox(value=True, label="Mesh repair (pymeshfix)")
235
+ pp_cleanup = gr.Checkbox(value=True, label="Geometry cleanup (PyMeshLab)")
236
+ pp_decimate = gr.Checkbox(value=True, label="Decimation")
237
+ pp_target_faces = gr.Slider(
238
+ 1000, 200000, value=25000, step=1000,
239
+ label="Target faces",
240
+ )
241
+ pp_symmetry = gr.Checkbox(value=False, label="Enforce bilateral symmetry")
242
+ pp_unwrap = gr.Checkbox(value=True, label="UV unwrap (xatlas)")
243
+ pp_normal_bake = gr.Checkbox(value=True, label="Normal bake (nvdiffrast)")
244
+ pp_normal_format = gr.Radio(
245
+ choices=["DirectX (UE5)", "OpenGL (Unity/Godot)"],
246
+ value="DirectX (UE5)",
247
+ label="Normal format",
248
+ )
249
+ pp_albedo_bake = gr.Checkbox(value=True, label="Albedo bake")
250
+ pp_material_bake = gr.Checkbox(value=True, label="Material bake (TRELLIS.2 attrs)")
251
+ pp_ao = gr.Checkbox(value=True, label="AO bake")
252
+ pp_ao_quality = gr.Radio(
253
+ choices=["Fast", "Standard", "High"],
254
+ value="Standard",
255
+ label="AO quality",
256
+ )
257
+ pp_inpaint = gr.Checkbox(value=False, label="SDXL inpaint hidden UVs (~30s GPU)")
258
+ pp_lods = gr.Checkbox(value=True, label="Generate LODs (LOD0/1/2)")
259
+ pp_collision = gr.Checkbox(value=True, label="Collision mesh (CoACD)")
260
+ pp_pivot = gr.Radio(
261
+ choices=["bottom_center", "geometric_center", "custom"],
262
+ value="bottom_center",
263
+ label="Pivot point",
264
+ )
265
+ pp_scale_m = gr.Number(value=1.8, label="Real-world height (meters)")
266
+ pp_btn = gr.Button("Run Post-Processing", variant="primary")
267
+ with gr.Column(scale=1):
268
+ pp_status = gr.Markdown("*No asset to process. Generate one first.*")
269
+ pp_btn.click(
270
+ fn=stub_post_process,
271
+ inputs=[pp_repair, pp_cleanup, pp_decimate, pp_target_faces,
272
+ pp_symmetry, pp_unwrap, pp_normal_bake, pp_normal_format,
273
+ pp_albedo_bake, pp_material_bake, pp_ao, pp_ao_quality,
274
+ pp_inpaint, pp_lods, pp_collision, pp_pivot, pp_scale_m],
275
+ outputs=pp_status,
276
+ )
277
+
278
+ # ============ Tab 3: Auto-Rig =================================
279
+ with gr.Tab("3. Auto-Rig", id=3):
280
+ gr.Markdown(
281
+ "### Stage 3 — Auto-rigging (optional)\n"
282
+ "Uses UniRig (VAST-AI). For characters and creatures. "
283
+ "After rigging, drop the FBX into [Mixamo](https://mixamo.com) "
284
+ "for free animation presets."
285
+ )
286
+ with gr.Row():
287
+ with gr.Column(scale=1):
288
+ rig_type = gr.Dropdown(
289
+ choices=["Humanoid", "Quadruped", "Bird", "Insect", "Custom"],
290
+ value="Humanoid",
291
+ label="Character type",
292
+ )
293
+ rig_seed = gr.Number(value=0, label="Skeleton seed", precision=0)
294
+ rig_spring = gr.Checkbox(value=False, label="Spring bones (hair/cloth/tail)")
295
+ rig_format = gr.Radio(
296
+ choices=["FBX (UE5 recommended)", "GLB"],
297
+ value="FBX (UE5 recommended)",
298
+ label="Export format",
299
+ )
300
+ rig_btn = gr.Button("Auto-Rig", variant="primary")
301
+ with gr.Column(scale=1):
302
+ rig_status = gr.Markdown("*Process an asset in Stage 2 first.*")
303
+ rig_btn.click(
304
+ fn=stub_auto_rig,
305
+ inputs=[rig_type, rig_seed, rig_spring, rig_format],
306
+ outputs=rig_status,
307
+ )
308
+
309
+ # ============ Tab 4: Export ===================================
310
+ with gr.Tab("4. Export", id=4):
311
+ gr.Markdown(
312
+ "### Stage 4 — Engine-ready export\n"
313
+ "UE5 default: FBX with DirectX normals + ORM-packed textures."
314
+ )
315
+ with gr.Row():
316
+ with gr.Column(scale=1):
317
+ ex_engine = gr.Dropdown(
318
+ choices=["UE5", "Unity (HDRP)", "Godot 4", "Blender", "Web (Three.js)"],
319
+ value="UE5",
320
+ label="Target engine",
321
+ )
322
+ ex_name = gr.Textbox(value="Asset_01", label="Asset name")
323
+ ex_type = gr.Radio(
324
+ choices=["Character (SK_)", "Prop (SM_)", "Environment (SM_)"],
325
+ value="Prop (SM_)",
326
+ label="Asset type",
327
+ )
328
+ ex_include_lods = gr.Checkbox(value=True, label="Include LODs")
329
+ ex_include_collision = gr.Checkbox(value=True, label="Include collision mesh")
330
+ ex_btn = gr.Button("Export", variant="primary")
331
+ with gr.Column(scale=1):
332
+ ex_status = gr.Markdown("*Nothing to export yet.*")
333
+ ex_file = gr.File(label="Download", visible=True)
334
+ ex_btn.click(
335
+ fn=stub_export,
336
+ inputs=[ex_engine, ex_name, ex_type, ex_include_lods, ex_include_collision],
337
+ outputs=[ex_status, ex_file],
338
+ )
339
+
340
+ # ============ Tab 5: Presets ==================================
341
+ with gr.Tab("5. Presets", id=5):
342
+ gr.Markdown(
343
+ "### Saved configurations\n"
344
+ "Save parameter sets for asset types you create repeatedly "
345
+ "(e.g., \"character_UE5_hero\", \"prop_UE5_standard\"). "
346
+ "Wired up properly in Milestone 9."
347
+ )
348
+ with gr.Row():
349
+ with gr.Column():
350
+ pr_list = gr.Dropdown(
351
+ choices=workspace.list_presets(),
352
+ label="Saved presets",
353
+ )
354
+ pr_refresh = gr.Button("Refresh list", size="sm")
355
+ with gr.Row():
356
+ pr_name = gr.Textbox(label="New preset name", scale=2)
357
+ pr_save = gr.Button("Save current settings", variant="primary", scale=1)
358
+ pr_delete = gr.Button("Delete selected", variant="stop")
359
+ pr_status = gr.Markdown()
360
+ pr_refresh.click(fn=ui_refresh_presets, outputs=pr_list)
361
+ pr_save.click(fn=ui_save_preset, inputs=pr_name, outputs=[pr_status, pr_list])
362
+ pr_delete.click(fn=ui_delete_preset, inputs=pr_list, outputs=[pr_status, pr_list])
363
+
364
+ # ============ Tab 6: Diagnostics (hidden in prod, useful now) =
365
+ with gr.Tab("Diagnostics", id=99):
366
+ gr.Markdown(
367
+ "### Milestone 1 — Foundation check\n"
368
+ "Verify the Space environment is working correctly before "
369
+ "building out the pipeline."
370
+ )
371
+ with gr.Row():
372
+ with gr.Column():
373
+ diag_btn = gr.Button("🧪 Run GPU smoke test", variant="primary")
374
+ diag_out = gr.Markdown()
375
+ with gr.Column():
376
+ gr.Markdown("**Workspace state:**")
377
+ diag_state = gr.JSON(value=workspace.get_state().to_dict())
378
+ diag_refresh = gr.Button("Refresh state", size="sm")
379
+ diag_btn.click(fn=zerogpu_smoke_test, outputs=diag_out)
380
+ diag_refresh.click(
381
+ fn=lambda: workspace.get_state().to_dict(),
382
+ outputs=diag_state,
383
+ )
384
+
385
+ # --- Persistent right-side viewer + asset summary ------------------
386
+ gr.Markdown("---")
387
+ with gr.Row():
388
+ with gr.Column(scale=2):
389
+ viewer = gr.Model3D(
390
+ label="3D viewer",
391
+ value=ui_helpers.get_viewer_model_path(),
392
+ clear_color=[0.1, 0.1, 0.12, 1.0],
393
+ height=500,
394
+ )
395
+ with gr.Column(scale=1):
396
+ summary = gr.Markdown(
397
+ value=ui_helpers.get_asset_summary(),
398
+ elem_classes=["asset-summary"],
399
+ )
400
+ refresh_summary = gr.Button("🔄 Refresh viewer", size="sm")
401
+ refresh_summary.click(
402
+ fn=lambda: (
403
+ ui_helpers.get_viewer_model_path(),
404
+ ui_helpers.get_asset_summary(),
405
+ ),
406
+ outputs=[viewer, summary],
407
+ )
408
+
409
+ # --- Global status bar --------------------------------------------
410
+ status_bar = gr.Markdown(
411
+ value=ui_helpers.get_status_bar(),
412
+ elem_classes=["status-bar"],
413
+ )
414
+
415
+ # --- Global refresh: every pipeline action updates the viewer, summary,
416
+ # and status bar. We chain .then() onto each button so the refresh runs
417
+ # only after the action completes (and even if it fails — Gradio still
418
+ # fires .then() after exceptions inside the main handler).
419
+ def _global_refresh():
420
+ return (
421
+ ui_helpers.get_viewer_model_path(),
422
+ ui_helpers.get_asset_summary(),
423
+ ui_helpers.get_status_bar(),
424
+ )
425
+
426
+ for trigger_btn in (gen_btn, pp_btn, rig_btn, ex_btn,
427
+ diag_btn, diag_refresh, refresh_summary):
428
+ trigger_btn.click(
429
+ fn=_global_refresh,
430
+ outputs=[viewer, summary, status_bar],
431
+ )
432
+
433
+ return demo
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # Entrypoint
438
+ # ---------------------------------------------------------------------------
439
+
440
+ if __name__ == "__main__":
441
+ workspace.ensure_dirs()
442
+ demo = build_ui()
443
+ demo.queue(default_concurrency_limit=1).launch(
444
+ server_name="0.0.0.0",
445
+ server_port=7860,
446
+ show_error=True,
447
+ )
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"
requirements.txt ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Open3DForge — Dependencies
2
+ # Versions pinned for HF Spaces stability (ZeroGPU torch 2.4.x compatible)
3
+
4
+ # ===== Milestone 1: Foundation =====
5
+ gradio==4.44.0
6
+ spaces # HF ZeroGPU decorator
7
+ huggingface-hub>=0.24,<0.26 # Gradio 4.44 requires HfFolder (removed in 0.26+)
8
+ numpy>=1.26,<2.0
9
+ pillow>=10.0.0
10
+
11
+ # ===== Milestone 2: Stage 1 Generation (uncomment as needed) =====
12
+ # torch==2.4.0
13
+ # torchvision==0.19.0
14
+ # rembg[gpu]==2.0.59
15
+ # transformers>=4.44.0
16
+ # diffusers>=0.30.0
17
+ # huggingface-hub>=0.24.0
18
+ # # TRELLIS.2 — install from source in app.py or via setup script
19
+ # # Hunyuan3D-2 — install from source in app.py or via setup script
20
+ # einops>=0.8.0
21
+ # scipy>=1.13.0
22
+
23
+ # ===== Milestone 3: Stage 2 CPU pipeline =====
24
+ # trimesh[easy]>=4.4.0
25
+ # pymeshfix>=0.17.0
26
+ # pymeshlab>=2025.7
27
+ # xatlas>=0.0.9
28
+ # fast-simplification>=0.1.7
29
+ # coacd==1.0.4 # pinned: newer versions break on some HF environments
30
+
31
+ # ===== Milestone 4: Stage 2 GPU baking =====
32
+ # nvdiffrast @ git+https://github.com/NVlabs/nvdiffrast.git
33
+ # imageio>=2.34.0
34
+
35
+ # ===== Milestone 5: Stage 2 SDXL inpainting =====
36
+ # # Uses diffusers (already included)
37
+ # accelerate>=0.33.0
38
+ # safetensors>=0.4.3
39
+
40
+ # ===== Milestone 7: Stage 3 Auto-rigging =====
41
+ # # UniRig — install from source in app.py or via setup script
42
+
43
+ # ===== Milestone 8: Export =====
44
+ # pygltflib>=1.16.0
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 ""
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()