victor HF Staff commited on
Commit
dfb0c26
·
1 Parent(s): e647247

Isometric City: infinite grid city builder with FLUX Klein 9B

Browse files

- gr.Server with custom HTML5 Canvas isometric grid
- Click empty tiles to generate buildings via FLUX Klein 9B API
- Persistent storage via HF Bucket at /data
- Pan to explore, infinite grid, depth-sorted rendering

Files changed (4) hide show
  1. README.md +5 -6
  2. app.py +140 -0
  3. index.html +415 -0
  4. requirements.txt +2 -0
README.md CHANGED
@@ -1,12 +1,11 @@
1
  ---
2
  title: Isometric City
3
- emoji: 🦀
4
- colorFrom: blue
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.11.0
8
  app_file: app.py
9
  pinned: false
 
10
  ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: Isometric City
3
+ emoji: 🏙️
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: 6.10.0
8
  app_file: app.py
9
  pinned: false
10
+ short_description: Infinite isometric city builder with FLUX
11
  ---
 
 
app.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import shutil
4
+ import time
5
+ import threading
6
+
7
+ from gradio import Server
8
+ from gradio_client import Client
9
+ from fastapi.responses import HTMLResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Persistent storage
14
+ # ---------------------------------------------------------------------------
15
+ DATA_DIR = os.environ.get("DATA_DIR", "/data")
16
+ GRID_FILE = os.path.join(DATA_DIR, "grid.json")
17
+ IMAGES_DIR = os.path.join(DATA_DIR, "images")
18
+
19
+ os.makedirs(IMAGES_DIR, exist_ok=True)
20
+
21
+ _grid_lock = threading.Lock()
22
+
23
+
24
+ def load_grid():
25
+ with _grid_lock:
26
+ if os.path.exists(GRID_FILE):
27
+ with open(GRID_FILE) as f:
28
+ return json.load(f)
29
+ return {}
30
+
31
+
32
+ def save_grid(grid):
33
+ with _grid_lock:
34
+ with open(GRID_FILE, "w") as f:
35
+ json.dump(grid, f)
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # FLUX Klein client (lazy init — Space may be sleeping)
40
+ # ---------------------------------------------------------------------------
41
+ _flux = None
42
+
43
+
44
+ def get_flux():
45
+ global _flux
46
+ if _flux is None:
47
+ _flux = Client("black-forest-labs/flux-klein-9b-kv")
48
+ return _flux
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Server
53
+ # ---------------------------------------------------------------------------
54
+ app = Server()
55
+
56
+ # Serve generated images as static files
57
+ app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images")
58
+
59
+
60
+ @app.get("/", response_class=HTMLResponse)
61
+ async def homepage():
62
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
63
+ with open(html_path, "r", encoding="utf-8") as f:
64
+ return f.read()
65
+
66
+
67
+ @app.api(name="get_grid")
68
+ def get_grid() -> str:
69
+ """Return all placed buildings as JSON."""
70
+ return json.dumps(load_grid())
71
+
72
+
73
+ @app.api(name="place_building")
74
+ def place_building(x: int, y: int, prompt: str) -> str:
75
+ """Generate an isometric building and place it on the grid."""
76
+ key = f"{x},{y}"
77
+ grid = load_grid()
78
+
79
+ if key in grid:
80
+ return json.dumps({"error": "Tile already occupied"})
81
+
82
+ full_prompt = (
83
+ f"isometric building, white background, game asset, "
84
+ f"single building centered, no ground, no shadow, clean edges, "
85
+ f"{prompt}"
86
+ )
87
+
88
+ try:
89
+ result, seed = get_flux().predict(
90
+ prompt=full_prompt,
91
+ input_images=[],
92
+ seed=0,
93
+ randomize_seed=True,
94
+ width=512,
95
+ height=512,
96
+ num_inference_steps=4,
97
+ prompt_upsampling=False,
98
+ api_name="/generate",
99
+ )
100
+ except Exception as e:
101
+ return json.dumps({"error": str(e)})
102
+
103
+ # Copy generated image to persistent storage
104
+ filename = f"{x}_{y}.webp"
105
+ dest = os.path.join(IMAGES_DIR, filename)
106
+ shutil.copy2(result, dest)
107
+
108
+ grid[key] = {
109
+ "prompt": prompt,
110
+ "seed": seed,
111
+ "ts": int(time.time()),
112
+ }
113
+ save_grid(grid)
114
+
115
+ return json.dumps({"ok": True, "image": f"/images/{filename}", "seed": seed})
116
+
117
+
118
+ @app.api(name="remove_building")
119
+ def remove_building(x: int, y: int) -> str:
120
+ """Remove a building from the grid."""
121
+ key = f"{x},{y}"
122
+ grid = load_grid()
123
+
124
+ if key not in grid:
125
+ return json.dumps({"error": "No building at this tile"})
126
+
127
+ del grid[key]
128
+ save_grid(grid)
129
+
130
+ img_path = os.path.join(IMAGES_DIR, f"{x}_{y}.webp")
131
+ if os.path.exists(img_path):
132
+ os.remove(img_path)
133
+
134
+ return json.dumps({"ok": True})
135
+
136
+
137
+ demo = app
138
+
139
+ if __name__ == "__main__":
140
+ demo.launch(show_error=True, ssr_mode=False)
index.html ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Isometric City</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
9
+ <style>
10
+ :root {
11
+ --font: 'Inter', -apple-system, sans-serif;
12
+ }
13
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
14
+ html, body { height: 100%; overflow: hidden; background: #f0efe8; font-family: var(--font); }
15
+ canvas { display: block; cursor: grab; }
16
+ canvas.grabbing { cursor: grabbing; }
17
+
18
+ /* Modal */
19
+ #modal-overlay {
20
+ display: none;
21
+ position: fixed; inset: 0;
22
+ background: rgba(0,0,0,0.6);
23
+ z-index: 100;
24
+ align-items: center; justify-content: center;
25
+ }
26
+ #modal-overlay.visible { display: flex; }
27
+
28
+ #modal {
29
+ background: #1a1a1a;
30
+ color: #fff;
31
+ border-radius: 12px;
32
+ padding: 1.5rem;
33
+ width: 380px;
34
+ max-width: 90vw;
35
+ font-family: var(--font);
36
+ }
37
+ #modal h2 {
38
+ font-size: 1rem;
39
+ font-weight: 600;
40
+ margin-bottom: 0.25rem;
41
+ }
42
+ #modal .modal-sub {
43
+ font-size: 0.75rem;
44
+ color: #888;
45
+ margin-bottom: 1rem;
46
+ }
47
+ #modal input {
48
+ width: 100%;
49
+ padding: 0.6rem 0.75rem;
50
+ border: 1px solid #333;
51
+ border-radius: 8px;
52
+ background: #111;
53
+ color: #fff;
54
+ font-family: var(--font);
55
+ font-size: 0.85rem;
56
+ outline: none;
57
+ margin-bottom: 0.75rem;
58
+ }
59
+ #modal input:focus { border-color: #555; }
60
+ #modal .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
61
+ #modal button {
62
+ padding: 0.5rem 1rem;
63
+ border: none;
64
+ border-radius: 8px;
65
+ font-family: var(--font);
66
+ font-size: 0.8rem;
67
+ font-weight: 500;
68
+ cursor: pointer;
69
+ }
70
+ #modal-cancel { background: #333; color: #aaa; }
71
+ #modal-build { background: #fff; color: #000; font-weight: 600; }
72
+ #modal-build:disabled { opacity: 0.4; cursor: not-allowed; }
73
+
74
+ /* HUD */
75
+ #hud {
76
+ position: fixed;
77
+ top: 12px; left: 12px;
78
+ background: rgba(0,0,0,0.75);
79
+ color: #fff;
80
+ padding: 0.5rem 0.75rem;
81
+ border-radius: 8px;
82
+ font-size: 0.75rem;
83
+ pointer-events: none;
84
+ z-index: 50;
85
+ line-height: 1.5;
86
+ }
87
+
88
+ /* Toast */
89
+ #toast {
90
+ display: none;
91
+ position: fixed;
92
+ bottom: 16px; left: 50%; transform: translateX(-50%);
93
+ background: #1a1a1a;
94
+ color: #fff;
95
+ padding: 0.5rem 1rem;
96
+ border-radius: 8px;
97
+ font-size: 0.8rem;
98
+ z-index: 200;
99
+ }
100
+ #toast.visible { display: block; }
101
+ #toast.error { border: 1px solid #661111; color: #ff6b6b; }
102
+ </style>
103
+ </head>
104
+ <body>
105
+
106
+ <canvas id="canvas"></canvas>
107
+
108
+ <div id="hud">
109
+ <div>Isometric City</div>
110
+ <div id="hud-info">Click a tile to build</div>
111
+ </div>
112
+
113
+ <div id="modal-overlay">
114
+ <div id="modal">
115
+ <h2>Build on tile</h2>
116
+ <p class="modal-sub" id="modal-coords"></p>
117
+ <input id="modal-prompt" placeholder="Describe your building... e.g. medieval castle" />
118
+ <div class="modal-actions">
119
+ <button id="modal-cancel">Cancel</button>
120
+ <button id="modal-build">Build</button>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <div id="toast"></div>
126
+
127
+ <script type="module">
128
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
129
+
130
+ // ZeroGPU handshake (not needed for CPU Space, but harmless)
131
+ const ZGH = "supports-zerogpu-headers";
132
+ window.addEventListener("message", e => { if (e.data === ZGH) window.supports_zerogpu_headers = true; });
133
+ const hn = location.hostname;
134
+ if (hn.endsWith(".hf.space") || hn.includes(".dev.")) {
135
+ const o = hn.includes(".dev.") ? `https://moon-${hn.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co";
136
+ parent.postMessage(ZGH, o);
137
+ }
138
+
139
+ // ---- Constants ----
140
+ const TILE_W = 128;
141
+ const TILE_H = 64;
142
+ const IMG_SIZE = 140; // rendered building size on canvas
143
+
144
+ // ---- State ----
145
+ const grid = {}; // "x,y" -> { prompt, seed, ts, img (Image object or null), loading }
146
+ let offsetX = 0, offsetY = 0;
147
+ let dragging = false, dragStartX = 0, dragStartY = 0, dragStartOX = 0, dragStartOY = 0;
148
+ let dragMoved = false;
149
+ let client = null;
150
+ let pendingTile = null; // {x, y} for the modal
151
+
152
+ // ---- Canvas setup ----
153
+ const canvas = document.getElementById("canvas");
154
+ const ctx = canvas.getContext("2d");
155
+
156
+ function resize() {
157
+ canvas.width = window.innerWidth;
158
+ canvas.height = window.innerHeight;
159
+ offsetX = canvas.width / 2;
160
+ offsetY = canvas.height / 3;
161
+ draw();
162
+ }
163
+ window.addEventListener("resize", resize);
164
+
165
+ // ---- Isometric math ----
166
+ function gridToScreen(gx, gy) {
167
+ return [
168
+ (gx - gy) * TILE_W / 2 + offsetX,
169
+ (gx + gy) * TILE_H / 2 + offsetY,
170
+ ];
171
+ }
172
+
173
+ function screenToGrid(sx, sy) {
174
+ const ax = sx - offsetX;
175
+ const ay = sy - offsetY;
176
+ const gx = (ax / (TILE_W / 2) + ay / (TILE_H / 2)) / 2;
177
+ const gy = (ay / (TILE_H / 2) - ax / (TILE_W / 2)) / 2;
178
+ return [Math.floor(gx), Math.floor(gy)];
179
+ }
180
+
181
+ // ---- Drawing ----
182
+ function draw() {
183
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
184
+ ctx.fillStyle = "#f0efe8";
185
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
186
+
187
+ // Determine visible tile range
188
+ const [minGx, minGy] = screenToGrid(0, 0);
189
+ const [maxGx1, maxGy1] = screenToGrid(canvas.width, canvas.height);
190
+ const [maxGx2, minGy2] = screenToGrid(canvas.width, 0);
191
+ const [minGx2, maxGy2] = screenToGrid(0, canvas.height);
192
+
193
+ const pad = 3;
194
+ const startX = Math.min(minGx, minGx2) - pad;
195
+ const endX = Math.max(maxGx1, maxGx2) + pad;
196
+ const startY = Math.min(minGy, minGy2) - pad;
197
+ const endY = Math.max(maxGy1, maxGy2) + pad;
198
+
199
+ // Draw grid lines
200
+ ctx.strokeStyle = "rgba(0,0,0,0.12)";
201
+ ctx.lineWidth = 1;
202
+
203
+ for (let gx = startX; gx <= endX; gx++) {
204
+ for (let gy = startY; gy <= endY; gy++) {
205
+ const [cx, cy] = gridToScreen(gx, gy);
206
+ ctx.beginPath();
207
+ ctx.moveTo(cx, cy - TILE_H / 2);
208
+ ctx.lineTo(cx + TILE_W / 2, cy);
209
+ ctx.lineTo(cx, cy + TILE_H / 2);
210
+ ctx.lineTo(cx - TILE_W / 2, cy);
211
+ ctx.closePath();
212
+ ctx.stroke();
213
+ }
214
+ }
215
+
216
+ // Draw buildings (sorted by gy+gx for depth ordering)
217
+ const entries = Object.entries(grid)
218
+ .filter(([, v]) => v.img || v.loading)
219
+ .sort(([a], [b]) => {
220
+ const [ax, ay] = a.split(",").map(Number);
221
+ const [bx, by] = b.split(",").map(Number);
222
+ return (ax + ay) - (bx + by);
223
+ });
224
+
225
+ for (const [key, val] of entries) {
226
+ const [gx, gy] = key.split(",").map(Number);
227
+ const [cx, cy] = gridToScreen(gx, gy);
228
+
229
+ if (val.loading && !val.img) {
230
+ // Pulsing placeholder
231
+ const pulse = 0.4 + 0.3 * Math.sin(Date.now() / 300);
232
+ ctx.fillStyle = `rgba(100, 140, 255, ${pulse})`;
233
+ ctx.beginPath();
234
+ ctx.moveTo(cx, cy - TILE_H / 2);
235
+ ctx.lineTo(cx + TILE_W / 2, cy);
236
+ ctx.lineTo(cx, cy + TILE_H / 2);
237
+ ctx.lineTo(cx - TILE_W / 2, cy);
238
+ ctx.closePath();
239
+ ctx.fill();
240
+
241
+ ctx.fillStyle = "rgba(255,255,255,0.8)";
242
+ ctx.font = "10px Inter, sans-serif";
243
+ ctx.textAlign = "center";
244
+ ctx.fillText("generating...", cx, cy + 4);
245
+ }
246
+
247
+ if (val.img) {
248
+ // Draw building image — bottom-center aligned to tile center
249
+ const iw = IMG_SIZE;
250
+ const ih = IMG_SIZE;
251
+ ctx.drawImage(val.img, cx - iw / 2, cy - ih + TILE_H / 2, iw, ih);
252
+ }
253
+ }
254
+
255
+ // Animate if there are loading tiles
256
+ if (entries.some(([, v]) => v.loading && !v.img)) {
257
+ requestAnimationFrame(draw);
258
+ }
259
+ }
260
+
261
+ // ---- Pan ----
262
+ canvas.addEventListener("mousedown", (e) => {
263
+ dragging = true;
264
+ dragMoved = false;
265
+ dragStartX = e.clientX;
266
+ dragStartY = e.clientY;
267
+ dragStartOX = offsetX;
268
+ dragStartOY = offsetY;
269
+ canvas.classList.add("grabbing");
270
+ });
271
+
272
+ window.addEventListener("mousemove", (e) => {
273
+ if (!dragging) return;
274
+ const dx = e.clientX - dragStartX;
275
+ const dy = e.clientY - dragStartY;
276
+ if (Math.abs(dx) + Math.abs(dy) > 4) dragMoved = true;
277
+ offsetX = dragStartOX + dx;
278
+ offsetY = dragStartOY + dy;
279
+ draw();
280
+ });
281
+
282
+ window.addEventListener("mouseup", () => {
283
+ dragging = false;
284
+ canvas.classList.remove("grabbing");
285
+ });
286
+
287
+ // ---- Click to build ----
288
+ canvas.addEventListener("click", (e) => {
289
+ if (dragMoved) return;
290
+ const [gx, gy] = screenToGrid(e.clientX, e.clientY);
291
+ const key = `${gx},${gy}`;
292
+
293
+ if (grid[key]) {
294
+ // Tile occupied — maybe show info later
295
+ return;
296
+ }
297
+
298
+ pendingTile = { x: gx, y: gy };
299
+ document.getElementById("modal-coords").textContent = `Tile (${gx}, ${gy})`;
300
+ document.getElementById("modal-prompt").value = "";
301
+ document.getElementById("modal-overlay").classList.add("visible");
302
+ document.getElementById("modal-prompt").focus();
303
+ });
304
+
305
+ // ---- Modal ----
306
+ const modalOverlay = document.getElementById("modal-overlay");
307
+ const modalPrompt = document.getElementById("modal-prompt");
308
+ const modalBuild = document.getElementById("modal-build");
309
+ const modalCancel = document.getElementById("modal-cancel");
310
+
311
+ function closeModal() {
312
+ modalOverlay.classList.remove("visible");
313
+ pendingTile = null;
314
+ }
315
+
316
+ modalCancel.addEventListener("click", closeModal);
317
+ modalOverlay.addEventListener("click", (e) => { if (e.target === modalOverlay) closeModal(); });
318
+ document.addEventListener("keydown", (e) => {
319
+ if (e.key === "Escape") closeModal();
320
+ });
321
+
322
+ modalPrompt.addEventListener("keydown", (e) => {
323
+ if (e.key === "Enter" && modalPrompt.value.trim()) {
324
+ e.preventDefault();
325
+ startBuild();
326
+ }
327
+ });
328
+
329
+ modalBuild.addEventListener("click", () => {
330
+ if (modalPrompt.value.trim()) startBuild();
331
+ });
332
+
333
+ async function startBuild() {
334
+ const { x, y } = pendingTile;
335
+ const prompt = modalPrompt.value.trim();
336
+ closeModal();
337
+
338
+ const key = `${x},${y}`;
339
+ grid[key] = { prompt, loading: true, img: null };
340
+ draw(); // start animation loop
341
+
342
+ try {
343
+ const result = await client.predict("/place_building", { x, y, prompt });
344
+ const data = JSON.parse(result.data[0]);
345
+
346
+ if (data.error) {
347
+ delete grid[key];
348
+ showToast(data.error, true);
349
+ draw();
350
+ return;
351
+ }
352
+
353
+ // Load the image
354
+ grid[key].loading = false;
355
+ grid[key].seed = data.seed;
356
+ const img = new Image();
357
+ img.crossOrigin = "anonymous";
358
+ img.onload = () => { grid[key].img = img; draw(); };
359
+ img.onerror = () => { showToast("Failed to load image", true); draw(); };
360
+ img.src = data.image;
361
+ } catch (e) {
362
+ delete grid[key];
363
+ showToast(e.message || "Build failed", true);
364
+ draw();
365
+ }
366
+ }
367
+
368
+ // ---- Load existing grid ----
369
+ async function loadGrid() {
370
+ try {
371
+ const result = await client.predict("/get_grid", {});
372
+ const data = JSON.parse(result.data[0]);
373
+
374
+ for (const [key, val] of Object.entries(data)) {
375
+ const [x, y] = key.split(",").map(Number);
376
+ grid[key] = { ...val, loading: false, img: null };
377
+
378
+ const img = new Image();
379
+ img.crossOrigin = "anonymous";
380
+ img.onload = () => { grid[key].img = img; draw(); };
381
+ img.src = `/images/${x}_${y}.webp`;
382
+ }
383
+ draw();
384
+ document.getElementById("hud-info").textContent =
385
+ `${Object.keys(data).length} buildings · Click a tile to build`;
386
+ } catch (e) {
387
+ showToast("Failed to load grid: " + e.message, true);
388
+ }
389
+ }
390
+
391
+ // ---- Toast ----
392
+ let toastTimer = null;
393
+ function showToast(msg, isError) {
394
+ const t = document.getElementById("toast");
395
+ t.textContent = msg;
396
+ t.className = isError ? "visible error" : "visible";
397
+ clearTimeout(toastTimer);
398
+ toastTimer = setTimeout(() => { t.className = ""; }, 4000);
399
+ }
400
+
401
+ // ---- Init ----
402
+ async function init() {
403
+ resize();
404
+ try {
405
+ client = await Client.connect(window.location.origin);
406
+ await loadGrid();
407
+ } catch (e) {
408
+ showToast("Failed to connect: " + e.message, true);
409
+ }
410
+ }
411
+
412
+ init();
413
+ </script>
414
+ </body>
415
+ </html>
requirements.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ gradio
2
+ gradio_client