-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
540 lines (445 loc) · 17.9 KB
/
app.py
File metadata and controls
540 lines (445 loc) · 17.9 KB
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
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
#!/usr/bin/env python3
"""
SlotForge Catálogo — servidor Flask local.
Rotas:
GET / -> index.html (catálogo)
GET /static/heroes/<f> -> imagens PNG
GET /api/catalog -> catalog.json
POST /api/regenerate -> regera imagem do hero via nano-banana-pro
body: {"hero_id": "...", "prompt": "..."}
retorna: {"image_url": "..."}
"""
import os
import json
import subprocess
import tempfile
import time
import uuid
import shutil
import re
import urllib.request
import urllib.error
from pathlib import Path
from flask import Flask, jsonify, request, send_from_directory, render_template
BASE = Path(os.environ.get("SLOTFORGE_BASE", Path(__file__).parent.resolve()))
CATALOG_PATH = BASE / "data" / "catalog.json"
EVERYTHING_PATH = BASE / "data" / "everything.json"
STATIC = BASE / "static"
REGEN_DIR = STATIC / "regenerated"
NEW_DIR = STATIC / "new_heroes"
EVERYTHING_DIR = STATIC / "everything"
REGEN_DIR.mkdir(parents=True, exist_ok=True)
NEW_DIR.mkdir(parents=True, exist_ok=True)
EVERYTHING_DIR.mkdir(parents=True, exist_ok=True)
GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "AIzaSyCyOly1Yqp1gshq0z66DQI51tMboW3h2Js")
GEMINI_MODEL = "gemini-2.5-flash"
app = Flask(__name__, static_folder=str(STATIC), template_folder=str(BASE / "templates"))
def load_catalog():
with open(CATALOG_PATH) as f:
return json.load(f)
@app.route("/")
def index():
return render_template("index.html")
@app.route("/criar")
def criar():
return render_template("criar.html")
@app.route("/tudo")
def tudo():
return render_template("tudo.html")
@app.route("/api/everything")
def api_everything():
if not EVERYTHING_PATH.exists():
return jsonify({"total": 0, "items": []})
with open(EVERYTHING_PATH) as f:
return jsonify(json.load(f))
@app.route("/api/delete_image", methods=["POST"])
def api_delete_image():
"""Deleta imagem do workspace (arquivo original + cópia static + remove do JSON)."""
data = request.get_json() or {}
item_id = data.get("id", "").strip()
if not item_id.startswith("all_"):
return jsonify({"ok": False, "error": "id inválido"}), 400
if not EVERYTHING_PATH.exists():
return jsonify({"ok": False, "error": "everything.json não existe"}), 500
with open(EVERYTHING_PATH) as f:
doc = json.load(f)
target = None
target_idx = None
for i, it in enumerate(doc["items"]):
if it["id"] == item_id:
target = it
target_idx = i
break
if not target:
return jsonify({"ok": False, "error": "item não encontrado"}), 404
# Deletar arquivo static + (opcionalmente) o original se existir no workspace
ws_root = Path(os.environ.get("SLOTFORGE_WORKSPACE", "/home/user/workspace"))
original = ws_root / target["rel_path"]
static_copy = BASE / target["image_url"].lstrip("/")
errors = []
# No VPS o original não existe — OK se só conseguir deletar static
if original.exists():
try:
original.unlink()
except Exception as e:
errors.append(f"original: {e}")
if static_copy.exists():
try:
static_copy.unlink()
except Exception as e:
errors.append(f"static: {e}")
else:
errors.append(f"static não encontrado: {static_copy}")
# Remover do JSON e recalcular stats
doc["items"].pop(target_idx)
doc["total"] = len(doc["items"])
cat_counts, fmt_counts = {}, {}
for it in doc["items"]:
cat_counts[it["category"]] = cat_counts.get(it["category"], 0) + 1
fmt_counts[it["format_class"]] = fmt_counts.get(it["format_class"], 0) + 1
doc["by_category"] = cat_counts
doc["by_format"] = fmt_counts
with open(EVERYTHING_PATH, "w") as f:
json.dump(doc, f, indent=2, ensure_ascii=False)
return jsonify({
"ok": True,
"deleted": target["rel_path"],
"total_remaining": doc["total"],
"errors": errors,
})
# ============================================================
# REFERENCIAS — coletar imagens do catalog pra pasta dedicada
# ============================================================
REFERENCES_DIR = STATIC / "referencias"
REFERENCES_JSON = BASE / "data" / "referencias.json"
def _load_references():
if REFERENCES_JSON.exists():
with open(REFERENCES_JSON) as f:
return json.load(f)
return {"total": 0, "items": []}
def _save_references(doc):
REFERENCES_JSON.parent.mkdir(parents=True, exist_ok=True)
with open(REFERENCES_JSON, "w") as f:
json.dump(doc, f, indent=2, ensure_ascii=False)
@app.route("/referencias")
def referencias():
return render_template("referencias.html")
@app.route("/api/references")
def api_references_list():
return jsonify(_load_references())
@app.route("/api/add_to_references", methods=["POST"])
def api_add_to_references():
"""Copia imagem de everything/ pra referencias/ e registra em referencias.json."""
data = request.get_json() or {}
item_id = data.get("id", "").strip()
if not item_id.startswith("all_"):
return jsonify({"ok": False, "error": "id inválido"}), 400
if not EVERYTHING_PATH.exists():
return jsonify({"ok": False, "error": "everything.json não existe"}), 500
with open(EVERYTHING_PATH) as f:
doc_all = json.load(f)
target = next((it for it in doc_all["items"] if it["id"] == item_id), None)
if not target:
return jsonify({"ok": False, "error": "item não encontrado"}), 404
src = BASE / target["image_url"].lstrip("/")
if not src.exists():
return jsonify({"ok": False, "error": f"arquivo fonte não existe: {src.name}"}), 404
REFERENCES_DIR.mkdir(parents=True, exist_ok=True)
refs = _load_references()
# Já existe?
if any(r["source_id"] == item_id for r in refs["items"]):
return jsonify({"ok": True, "already": True, "total": refs["total"], "message": "já está em referências"})
# Copia com nome preservado
dest = REFERENCES_DIR / src.name
# Evita colisão
if dest.exists():
dest = REFERENCES_DIR / f"{src.stem}_{item_id[-8:]}{src.suffix}"
shutil.copy2(src, dest)
note = (data.get("note") or "").strip()[:500]
ref_item = {
"ref_id": f"ref_{item_id[4:]}",
"source_id": item_id,
"filename": dest.name,
"image_url": f"/static/referencias/{dest.name}",
"category": target.get("category"),
"format_class": target.get("format_class"),
"size_kb": target.get("size_kb"),
"original_rel_path": target.get("rel_path"),
"added_at": int(time.time()),
"note": note,
}
refs["items"].append(ref_item)
refs["total"] = len(refs["items"])
_save_references(refs)
return jsonify({"ok": True, "total": refs["total"], "item": ref_item})
@app.route("/api/remove_from_references", methods=["POST"])
def api_remove_from_references():
data = request.get_json() or {}
ref_id = data.get("ref_id", "").strip()
if not ref_id:
return jsonify({"ok": False, "error": "ref_id obrigatório"}), 400
refs = _load_references()
idx = next((i for i, r in enumerate(refs["items"]) if r["ref_id"] == ref_id), None)
if idx is None:
return jsonify({"ok": False, "error": "não encontrado"}), 404
target = refs["items"][idx]
fpath = REFERENCES_DIR / target["filename"]
errors = []
if fpath.exists():
try:
fpath.unlink()
except Exception as e:
errors.append(str(e))
refs["items"].pop(idx)
refs["total"] = len(refs["items"])
_save_references(refs)
return jsonify({"ok": True, "total": refs["total"], "errors": errors})
@app.route("/api/catalog")
def api_catalog():
return jsonify(load_catalog())
@app.route("/api/regenerate", methods=["POST"])
def api_regenerate():
data = request.get_json() or {}
hero_id = data.get("hero_id", "unknown")
prompt = (data.get("prompt") or "").strip()
if not prompt:
return jsonify({"error": "prompt vazio"}), 400
# Parâmetros de aspecto — vêm do request ou inferir por hero_id
aspect = (data.get("aspect_ratio") or "").strip()
model = (data.get("model") or "nano_banana_pro").strip()
if not aspect:
# Inferir por id: heroes iPad 1x5 → 4:3; forgingslots runs → 9:16; default 4:3
if "1x5" in hero_id or hero_id.startswith(("top10_", "tgs_", "diamond_")):
aspect = "4:3"
elif "portrait" in hero_id or "cabinet" in hero_id or "pulltab" in hero_id:
aspect = "9:16"
elif "landscape" in hero_id:
aspect = "16:9"
else:
aspect = "4:3"
# Safe filename — asi-generate-image salva em /home/user/workspace/{filename}.png
safe_id = "".join(c for c in hero_id if c.isalnum() or c in "_-")[:60] or "hero"
ts = int(time.time())
filename = f"regen_{safe_id}_{ts}_{uuid.uuid4().hex[:4]}"
payload = {
"prompt": prompt,
"filename": filename,
"aspect_ratio": aspect,
"model": model,
}
env = os.environ.copy()
cmd = ["asi-generate-image", json.dumps(payload)]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=240, env=env)
except FileNotFoundError:
return jsonify({"ok": False, "error": "asi-generate-image não encontrado"}), 500
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "timeout (240s)"}), 500
if proc.returncode != 0:
return jsonify({
"ok": False,
"error": f"exit={proc.returncode}",
"stdout": proc.stdout[-1000:],
"stderr": proc.stderr[-1000:],
}), 500
# asi-generate-image salva em /home/user/workspace/{filename}.png — mover pra static/regenerated
workspace_out = Path("/home/user/workspace") / f"{filename}.png"
if not workspace_out.exists():
# Pode retornar JSON com caminho — tentar parse
try:
result = json.loads(proc.stdout)
path_key = result.get("path") or result.get("file_path") or result.get("output")
if path_key and Path(path_key).exists():
workspace_out = Path(path_key)
except Exception:
pass
if not workspace_out.exists():
return jsonify({
"ok": False,
"error": "arquivo não encontrado após geração",
"stdout": proc.stdout[-800:],
"expected": str(workspace_out),
}), 500
out_name = f"{filename}.png"
dst = REGEN_DIR / out_name
shutil.move(str(workspace_out), str(dst))
return jsonify({
"ok": True,
"hero_id": hero_id,
"image_url": f"/static/regenerated/{out_name}",
"aspect_ratio": aspect,
"model": model,
})
# =========================
# CRIAR NOVOS HEROES
# =========================
IDEATE_SYSTEM_PROMPT = """Você é um art director de slot machines. Dado um TEMA de jogo,
gere um spec JSON completo pra um hero iPad landscape 1x5 no mesmo padrão do
SlotForge. Retorne APENAS JSON válido, sem markdown/cercas. Use inglês nos campos.
Schema OBRIGATÓRIO:
{
"title": "Nome comercial curto",
"id_suffix": "snake_case_based_on_title",
"logo": "texto grande do logo (1-3 palavras MAIÚSCULAS)",
"logo_ornament": "descrição do ornamento ao redor do logo",
"reel_frame": "descrição rica material/cor/ornamentação do frame (2-3 frases)",
"button_bar": "material/temática do bar inferior, combinando com reel_frame (1-2 frases)",
"sym1": "descrição do símbolo WILD premium (sempre o mais forte)",
"sym2": "símbolo alto premium",
"sym3": "símbolo médio premium",
"sym4": "símbolo baixo temático",
"sym5": "símbolo royal (letra K/Q/J/A/10) com cor temática",
"background": "cenário cinematográfico 1 frase, atmosférico",
"palette": "4-5 cores dominantes em texto (ex. 'deep purples, warm oranges, rich gold')"
}
REGRAS:
- NUNCA usar nomes de jogos reais (Buffalo, Dragon Link, Lightning Link, Dancing Drums, Cleopatra, Wolf Run, Huff N Puff, Wheel of Fortune, etc.)
- Símbolos devem ser ricos e visualmente distintos um do outro
- Palette deve bater com o tema
- Title e logo devem combinar
"""
def call_gemini_json(user_prompt: str, max_tokens: int = 2000) -> dict:
url = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}"
body = {
"contents": [{"role": "user", "parts": [{"text": user_prompt}]}],
"systemInstruction": {"parts": [{"text": IDEATE_SYSTEM_PROMPT}]},
"generationConfig": {
"temperature": 0.9,
"maxOutputTokens": max_tokens,
"responseMimeType": "application/json",
},
}
data = json.dumps(body).encode()
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as resp:
raw = json.loads(resp.read())
text = raw["candidates"][0]["content"]["parts"][0]["text"]
return json.loads(text)
@app.route("/api/ideate", methods=["POST"])
def api_ideate():
data = request.get_json() or {}
theme = (data.get("theme") or "").strip()
if not theme:
return jsonify({"error": "theme vazio"}), 400
try:
spec = call_gemini_json(f"TEMA: {theme}\n\nGere o spec completo em JSON.")
return jsonify({"ok": True, "theme": theme, "spec": spec})
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:600]
return jsonify({"ok": False, "error": f"HTTP {e.code}: {body}"}), 500
except Exception as e:
return jsonify({"ok": False, "error": f"{type(e).__name__}: {e}"}), 500
def load_hero_clean_template():
"""Lê o TEMPLATE do catalog.json (é o template hero_clean_gen)."""
with open(CATALOG_PATH) as f:
cat = json.load(f)
return cat.get("template_fullscreen", "")
@app.route("/api/generate_hero", methods=["POST"])
def api_generate_hero():
data = request.get_json() or {}
spec = data.get("spec") or {}
theme = (data.get("theme") or "").strip()
required = ["title", "logo", "logo_ornament", "reel_frame", "button_bar",
"sym1", "sym2", "sym3", "sym4", "sym5", "background", "palette"]
for k in required:
if not spec.get(k):
return jsonify({"ok": False, "error": f"spec faltando campo: {k}"}), 400
template = load_hero_clean_template()
prompt = template.format(
LOGO=spec["logo"],
LOGO_ORNAMENT=spec["logo_ornament"],
REEL_FRAME=spec["reel_frame"],
BUTTON_BAR=spec["button_bar"],
SYM1=spec["sym1"],
SYM2=spec["sym2"],
SYM3=spec["sym3"],
SYM4=spec["sym4"],
SYM5=spec["sym5"],
BACKGROUND=spec["background"],
PALETTE=spec["palette"],
)
# Gerar idúnico
suffix = spec.get("id_suffix") or re.sub(r"[^a-z0-9]+", "_", spec["title"].lower()).strip("_")
ts = int(time.time())
hero_id = f"custom_{ts}_{suffix}"[:90]
filename = f"new_{hero_id}"
payload = {
"prompt": prompt,
"filename": filename,
"aspect_ratio": "4:3",
"model": "nano_banana_pro",
}
try:
proc = subprocess.run(
["asi-generate-image", json.dumps(payload)],
capture_output=True, text=True, timeout=240,
)
except Exception as e:
return jsonify({"ok": False, "error": f"subprocess: {e}"}), 500
if proc.returncode != 0:
return jsonify({
"ok": False, "error": f"exit={proc.returncode}",
"stderr": proc.stderr[-800:],
}), 500
src = Path("/home/user/workspace") / f"{filename}.png"
if not src.exists():
return jsonify({
"ok": False,
"error": "imagem não encontrada",
"stdout": proc.stdout[-600:],
}), 500
out_name = f"{hero_id}.png"
dst = NEW_DIR / out_name
shutil.move(str(src), str(dst))
# Adicionar entrada ao catálogo em memória (e persistir)
try:
with open(CATALOG_PATH) as f:
catalog = json.load(f)
except Exception:
return jsonify({"ok": False, "error": "catálogo não carregou"}), 500
new_entry = {
"id": hero_id,
"title": spec["title"],
"series": "Custom Creations",
"form_factor": "iPad landscape 1x5 (MadLab)",
"canonical_size": "2048x1536",
"image_url": f"/static/new_heroes/{out_name}",
"has_image": True,
"fullscreen_prompt": prompt,
"spec": {
"logo": spec["logo"],
"logo_ornament": spec["logo_ornament"],
"reel_frame": spec["reel_frame"],
"button_bar": spec["button_bar"],
"symbols": [spec["sym1"], spec["sym2"], spec["sym3"], spec["sym4"], spec["sym5"]],
"background": spec["background"],
"palette": spec["palette"],
"theme_input": theme,
},
"model": "nano_banana_pro",
"source_script": "app.py /api/generate_hero",
"layers": None,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
catalog["heroes"].append(new_entry)
catalog["stats"]["total_heroes"] = len(catalog["heroes"])
catalog["stats"]["with_image"] = sum(1 for h in catalog["heroes"] if h["has_image"])
catalog["stats"]["by_series"]["Custom Creations"] = \
catalog["stats"]["by_series"].get("Custom Creations", 0) + 1
with open(CATALOG_PATH, "w") as f:
json.dump(catalog, f, indent=2, ensure_ascii=False)
return jsonify({"ok": True, "hero": new_entry})
@app.route("/api/reindex", methods=["POST"])
def api_reindex():
"""Re-roda build_catalog.py."""
proc = subprocess.run(
["python", str(BASE / "build_catalog.py")],
capture_output=True, text=True, timeout=120,
)
return jsonify({
"ok": proc.returncode == 0,
"stdout": proc.stdout,
"stderr": proc.stderr,
})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)