server.py 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  1. """Anim Studio 网站后端(仅用 Python 标准库,无 web 框架依赖)。
  2. 启动:
  3. pip install Pillow # 唯一必需第三方库
  4. python server.py
  5. 浏览器打开 http://127.0.0.1:7861
  6. 路由:
  7. GET / 可视化网站
  8. GET /api/manifest 默认 manifest
  9. GET /api/games 已生成的 game 列表
  10. GET /api/library?game=.. 某 game 的资源/动画库
  11. GET /assets/<game>/<path> 资源文件
  12. POST /api/generate 运行生成管线
  13. POST /api/export 把某 game 打包成 Cocos 整合包
  14. POST /api/open-folder 打开某 game 的本地素材目录
  15. POST /api/delete 删除某 game 的资源库
  16. """
  17. import json
  18. import mimetypes
  19. import os
  20. import posixpath
  21. import shutil
  22. import subprocess
  23. import sys
  24. import threading
  25. import time
  26. import traceback
  27. import uuid
  28. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  29. from urllib.parse import urlparse, parse_qs, unquote
  30. import pipeline
  31. import exporter
  32. import slot_workflow
  33. import spine_builder
  34. import providers
  35. import config
  36. HERE = os.path.dirname(os.path.abspath(__file__))
  37. OUT_ROOT = os.path.join(HERE, "out")
  38. WEB_DIR = os.path.join(HERE, "web")
  39. DEFAULT_MANIFEST = os.path.join(HERE, "animation_manifest.json")
  40. PORT = 7861
  41. DEFAULT_BASE_URL = config.get("ANIM_STUDIO_BASE_URL", "https://x.long.bid/v1")
  42. DEFAULT_API_KEY = config.get("ANIM_STUDIO_API_KEY", "")
  43. DEFAULT_IMAGE_MODEL = config.get("ANIM_STUDIO_IMAGE_MODEL", "gpt-image-2")
  44. DEFAULT_TEXT_MODEL = config.get("ANIM_STUDIO_TEXT_MODEL", "gpt-5.4-mini")
  45. JOBS = {}
  46. JOBS_LOCK = threading.Lock()
  47. def list_games():
  48. if not os.path.isdir(OUT_ROOT):
  49. return []
  50. return [n for n in sorted(os.listdir(OUT_ROOT))
  51. if os.path.isfile(os.path.join(OUT_ROOT, n, "library.json"))]
  52. def safe_join(root, *parts):
  53. p = posixpath.normpath("/".join(parts)).lstrip("/")
  54. full = os.path.join(root, *p.split("/"))
  55. if not os.path.abspath(full).startswith(os.path.abspath(root)):
  56. raise ValueError("path traversal")
  57. return full
  58. def _job_snapshot(job_id):
  59. with JOBS_LOCK:
  60. job = JOBS.get(job_id)
  61. return dict(job) if job else None
  62. def _set_job(job_id, **fields):
  63. with JOBS_LOCK:
  64. job = JOBS.setdefault(job_id, {})
  65. job.update(fields)
  66. def _append_job_log(job_id, msg):
  67. with JOBS_LOCK:
  68. job = JOBS.setdefault(job_id, {})
  69. job.setdefault("logs", []).append(msg)
  70. job["updatedAt"] = time.time()
  71. def _run_generate_job(job_id, manifest, creds):
  72. _set_job(job_id, status="running", logs=[], game=manifest.get("game"), startedAt=time.time(), updatedAt=time.time())
  73. try:
  74. lib, _ = pipeline.run(manifest, OUT_ROOT, creds=creds, log=lambda m: _append_job_log(job_id, m))
  75. _set_job(job_id, status="done", ok=True, game=lib["game"], updatedAt=time.time())
  76. except Exception as e:
  77. traceback.print_exc()
  78. _append_job_log(job_id, f"❌ 生成任务失败: {e}")
  79. _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
  80. def _load_library(game):
  81. library_path = os.path.join(OUT_ROOT, game, "library.json")
  82. if not os.path.isfile(library_path):
  83. return {"game": game, "characters": [], "vfx": [], "ui": [], "ui_art": []}
  84. with open(library_path, encoding="utf-8") as f:
  85. lib = json.load(f)
  86. if _repair_library_index(game, lib):
  87. with open(library_path, "w", encoding="utf-8") as f:
  88. json.dump(lib, f, ensure_ascii=False, indent=2)
  89. return lib
  90. def _spine_size_from_json(path):
  91. try:
  92. data = json.load(open(path, encoding="utf-8"))
  93. sk = data.get("skeleton", {})
  94. return int(round(sk.get("width") or 1000)), int(round(sk.get("height") or 1000))
  95. except Exception:
  96. return 1000, 1000
  97. def _image_size(path):
  98. try:
  99. from PIL import Image
  100. with Image.open(path) as img:
  101. return img.size
  102. except Exception:
  103. return 0, 0
  104. def _repair_library_index(game, lib):
  105. """Index files that exist on disk but were not written to library.json.
  106. This lets partial retry jobs become visible immediately even if a later
  107. required task failed before the old library index was updated.
  108. """
  109. changed = False
  110. base = os.path.join(OUT_ROOT, game)
  111. manifest = _manifest_from_library(lib)
  112. by_section = {
  113. "characters": {x.get("id"): x for x in lib.get("characters", [])},
  114. "ui_art": {x.get("id"): x for x in lib.get("ui_art", [])},
  115. "vfx": {x.get("id"): x for x in lib.get("vfx", [])},
  116. "ui": {x.get("id"): x for x in lib.get("ui", [])},
  117. }
  118. for c in manifest.get("characters", []):
  119. cid = c.get("id")
  120. if not cid or cid in by_section["characters"]:
  121. continue
  122. paths = [os.path.join(base, "characters", f"{cid}.{ext}") for ext in ("json", "atlas", "png")]
  123. if all(os.path.isfile(p) for p in paths):
  124. w, h = _spine_size_from_json(paths[0])
  125. item = {
  126. "id": cid,
  127. "png": f"characters/{cid}.png",
  128. "w": w, "h": h,
  129. "type": c.get("type", "spine"),
  130. "role": c.get("role", ""),
  131. "animations": spine_builder.anim_data(c.get("animations", ["idle"])),
  132. "files": [f"characters/{cid}.json", f"characters/{cid}.atlas", f"characters/{cid}.png"],
  133. }
  134. lib.setdefault("characters", []).append(item)
  135. by_section["characters"][cid] = item
  136. changed = True
  137. for a in manifest.get("ui_art", []):
  138. aid = a.get("id")
  139. if not aid or aid in by_section["ui_art"]:
  140. continue
  141. path = os.path.join(base, "ui_art", f"{aid}.png")
  142. if os.path.isfile(path):
  143. w, h = _image_size(path)
  144. item = {"id": aid, "file": f"ui_art/{aid}.png", "w": w, "h": h,
  145. "transparent": a.get("transparent", True)}
  146. lib.setdefault("ui_art", []).append(item)
  147. by_section["ui_art"][aid] = item
  148. changed = True
  149. for v in manifest.get("vfx", []):
  150. vid = v.get("id")
  151. if not vid or vid in by_section["vfx"]:
  152. continue
  153. path = os.path.join(base, "vfx", f"{vid}.particle.json")
  154. if os.path.isfile(path):
  155. cfg = json.load(open(path, encoding="utf-8"))
  156. item = {"id": vid, "template": v.get("template"),
  157. "file": f"vfx/{vid}.particle.json", "config": cfg}
  158. lib.setdefault("vfx", []).append(item)
  159. by_section["vfx"][vid] = item
  160. changed = True
  161. ui_path = os.path.join(base, "ui", "TweenPresets.ts")
  162. if os.path.isfile(ui_path):
  163. for u in manifest.get("ui", []):
  164. uid = u.get("id")
  165. if uid and uid not in by_section["ui"]:
  166. item = {"id": uid, "preset": u.get("preset"), "params": u.get("params", {})}
  167. lib.setdefault("ui", []).append(item)
  168. by_section["ui"][uid] = item
  169. changed = True
  170. return changed
  171. def _manifest_from_library(lib):
  172. if lib.get("slot_config"):
  173. return slot_workflow.complete_manifest({"slot_config": lib["slot_config"]})
  174. return {
  175. "game": lib.get("game") or "game",
  176. "style": "",
  177. "characters": [],
  178. "ui_art": [],
  179. "vfx": [],
  180. "ui": [],
  181. }
  182. TASK_KIND_MAP = {
  183. "characters": "characters",
  184. "character": "characters",
  185. "ui_art": "ui_art",
  186. "art": "ui_art",
  187. "vfx": "vfx",
  188. "ui": "ui",
  189. }
  190. def _zh_name(kind, item, slot_config=None):
  191. item_id = item.get("id", "")
  192. role = item.get("role", "")
  193. known = {
  194. "wild": "百搭符号",
  195. "scatter": "免费旋转触发符号",
  196. "coin_cash": "现金金币符号",
  197. "collect": "收集符号",
  198. "bg_main": "主背景",
  199. "cover": "封面图",
  200. "logo": "游戏 Logo",
  201. "reel_frame": "卷轴框",
  202. "btn_spin": "旋转按钮",
  203. "btn_round": "通用圆按钮",
  204. "hud_pill": "HUD 信息条",
  205. "boss_explosion": "大魔王裂开爆炸特效",
  206. }
  207. if role == "boss" or item_id == (slot_config or {}).get("boss", {}).get("id"):
  208. return (slot_config or {}).get("boss", {}).get("title") or "大魔王关主"
  209. if item_id in known:
  210. return known[item_id]
  211. if item_id.startswith("jelly_"):
  212. return "果冻角色 " + item_id.replace("jelly_", "")
  213. if kind == "vfx":
  214. return "粒子特效 " + item_id
  215. if kind == "ui":
  216. return "界面动效 " + item_id
  217. return "素材 " + item_id
  218. def _task_use(kind, item):
  219. item_id = item.get("id", "")
  220. role = item.get("role", "")
  221. if role == "boss":
  222. return "关主角色。待机时 watch/charge,玩家赢时撒币并裂开爆炸,玩家输时举剑踩踏耀武扬威。"
  223. if kind == "characters":
  224. return "卷轴符号或可动画角色,用于 slot 盘面和中奖反馈。"
  225. if kind == "ui_art":
  226. if item_id == "bg_main":
  227. return "游戏主场景背景。"
  228. if item_id == "cover":
  229. return "封面、分享图或入口展示。"
  230. return "界面美术元素,用于 Cocos 原型的背景、按钮、框体或 HUD。"
  231. if kind == "vfx":
  232. return "粒子特效配置,用于中奖、金币雨、爆炸或强调反馈。"
  233. return "UI tween 动效预设,用于按钮、弹窗、数字滚动等界面反馈。"
  234. def _task_prompt(kind, item):
  235. if kind == "characters" and item.get("type") == "spine_parts":
  236. parts = item.get("parts") or []
  237. part_text = "\n".join([f"- {p.get('id')}: {p.get('prompt', '')}" for p in parts])
  238. sheet = item.get("spriteSheet") or {}
  239. mode = ""
  240. if item.get("partGeneration") == "sprite_sheet" or sheet.get("enabled"):
  241. mode = f"\n生成方式:先生成 {sheet.get('cols', 4)}×{sheet.get('rows', 4)} 透明拆件表,再按格子自动切图。"
  242. return (item.get("prompt", "") + mode + ("\n拆件:\n" + part_text if part_text else "")).strip()
  243. return item.get("prompt") or item.get("template") or item.get("preset") or ""
  244. def _build_tasks(lib):
  245. manifest = _manifest_from_library(lib)
  246. slot_config = manifest.get("slot_config", {})
  247. existing = {
  248. "characters": {x.get("id"): x for x in lib.get("characters", [])},
  249. "ui_art": {x.get("id"): x for x in lib.get("ui_art", [])},
  250. "vfx": {x.get("id"): x for x in lib.get("vfx", [])},
  251. "ui": {x.get("id"): x for x in lib.get("ui", [])},
  252. }
  253. tasks = {k: [] for k in ("characters", "ui_art", "vfx", "ui")}
  254. for kind in tasks:
  255. for item in manifest.get(kind, []):
  256. item_id = item.get("id")
  257. asset = existing[kind].get(item_id)
  258. tasks[kind].append({
  259. "kind": kind,
  260. "id": item_id,
  261. "englishName": item_id,
  262. "chineseName": _zh_name(kind, item, slot_config),
  263. "use": _task_use(kind, item),
  264. "prompt": _task_prompt(kind, item),
  265. "status": "done" if asset else "missing",
  266. "asset": asset,
  267. "assetType": item.get("type") or kind,
  268. "animations": item.get("animations", []),
  269. "transparent": item.get("transparent"),
  270. "size": item.get("size"),
  271. })
  272. return tasks
  273. def _filter_manifest_tasks(manifest, kind, task_ids):
  274. wanted = set(task_ids)
  275. filtered = json.loads(json.dumps(manifest, ensure_ascii=False))
  276. for key in ("characters", "ui_art", "vfx", "ui"):
  277. filtered[key] = [x for x in manifest.get(key, []) if key == kind and x.get("id") in wanted]
  278. return filtered
  279. def _filter_manifest_groups(manifest, groups):
  280. filtered = json.loads(json.dumps(manifest, ensure_ascii=False))
  281. for key in ("characters", "ui_art", "vfx", "ui"):
  282. wanted = set(groups.get(key, []))
  283. filtered[key] = [x for x in manifest.get(key, []) if x.get("id") in wanted]
  284. return filtered
  285. def _run_retry_job(job_id, game, kind, task_ids, creds):
  286. _set_job(job_id, status="running", logs=[], game=game, startedAt=time.time(), updatedAt=time.time())
  287. try:
  288. lib = _load_library(game)
  289. manifest = _manifest_from_library(lib)
  290. partial = _filter_manifest_tasks(manifest, kind, task_ids)
  291. labels = ", ".join(task_ids)
  292. _append_job_log(job_id, f"补生成任务:{kind} / {labels}")
  293. new_lib, _ = pipeline.run(partial, OUT_ROOT, creds=creds,
  294. log=lambda m: _append_job_log(job_id, m),
  295. merge_existing=True)
  296. _set_job(job_id, status="done", ok=True, game=new_lib["game"], updatedAt=time.time())
  297. except Exception as e:
  298. traceback.print_exc()
  299. _append_job_log(job_id, f"❌ 补生成失败: {e}")
  300. _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
  301. def _run_retry_missing_job(job_id, game, groups, creds):
  302. _set_job(job_id, status="running", logs=[], game=game, startedAt=time.time(), updatedAt=time.time())
  303. try:
  304. lib = _load_library(game)
  305. manifest = _manifest_from_library(lib)
  306. partial = _filter_manifest_groups(manifest, groups)
  307. labels = []
  308. for kind, ids in groups.items():
  309. if ids:
  310. labels.append(f"{kind}: {', '.join(ids)}")
  311. _append_job_log(job_id, "批量补生成缺失项:" + ";".join(labels))
  312. new_lib, _ = pipeline.run(partial, OUT_ROOT, creds=creds,
  313. log=lambda m: _append_job_log(job_id, m),
  314. merge_existing=True)
  315. _set_job(job_id, status="done", ok=True, game=new_lib["game"], updatedAt=time.time())
  316. except Exception as e:
  317. traceback.print_exc()
  318. _append_job_log(job_id, f"❌ 批量补生成失败: {e}")
  319. _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
  320. def _split_lines(value):
  321. if isinstance(value, list):
  322. return [str(x).strip() for x in value if str(x).strip()]
  323. return [x.strip() for x in str(value or "").splitlines() if x.strip()]
  324. def _creative_fallback(data):
  325. brief = (data.get("brief") or "").lower()
  326. text = " ".join([brief, data.get("styleNotes", "").lower(), data.get("avoidNotes", "").lower()])
  327. allowed_themes = {"jelly", "fruit", "egypt", "pirate", "pirate_jelly", "cyber"}
  328. requested_theme = data.get("theme") if data.get("theme") in allowed_themes else ""
  329. theme = requested_theme or "jelly"
  330. pirate_hint = any(k in text for k in ("pirate", "海盗", "treasure", "宝藏", "金币", "船长"))
  331. jelly_hint = any(k in text for k in ("jelly", "果冻", "candy", "糖果", "gummy", "软糖"))
  332. if not requested_theme or requested_theme == "jelly":
  333. if pirate_hint and jelly_hint:
  334. theme = "pirate_jelly"
  335. elif any(k in text for k in ("egypt", "埃及", "金字塔", "法老")):
  336. theme = "egypt"
  337. elif pirate_hint:
  338. theme = "pirate"
  339. elif any(k in text for k in ("cyber", "赛博", "neon", "霓虹")):
  340. theme = "cyber"
  341. elif any(k in text for k in ("fruit", "水果", "cherry", "樱桃")):
  342. theme = "fruit"
  343. reel_mode = data.get("reelMode") or ("cluster" if any(k in text for k in ("消除", "cluster", "连通", "match")) else "ways")
  344. volatility = data.get("volatility") or ("high" if any(k in text for k in ("刺激", "大奖", "高波动", "big win")) else "medium")
  345. features = list(data.get("features") or ["cascades", "free_spins", "wilds"])
  346. if any(k in text for k in ("金币", "jackpot", "hold", "respin", "大奖池")) and "hold_win" not in features:
  347. features.append("hold_win")
  348. if any(k in text for k in ("倍率", "multiplier", "连锁")) and "multipliers" not in features:
  349. features.append("multipliers")
  350. if theme == "pirate_jelly":
  351. for feature in ("hold_win", "multipliers"):
  352. if feature not in features:
  353. features.append(feature)
  354. title = data.get("title") or "AI Custom Slot"
  355. style = data.get("styleNotes") or data.get("brief") or ""
  356. return {
  357. "gameId": data.get("gameId") or title,
  358. "title": title,
  359. "theme": theme,
  360. "style": style,
  361. "reelMode": reel_mode,
  362. "volatility": volatility,
  363. "targetRtp": data.get("targetRtp", 96),
  364. "characterCount": data.get("characterCount", 10),
  365. "uiCompleteness": data.get("uiCompleteness", "full"),
  366. "feedbackIntensity": data.get("feedbackIntensity", "standard"),
  367. "enableBoss": bool(data.get("enableBoss", True)),
  368. "bossPresence": data.get("bossPresence", "full"),
  369. "enableMathModel": bool(data.get("enableMathModel", True)),
  370. "features": features,
  371. }
  372. def build_game_plan(slot_request, creative_payload, source):
  373. features = slot_request.get("features") or []
  374. hook_parts = []
  375. if "hold_win" in features:
  376. hook_parts.append("金币锁格 respin 大奖目标")
  377. if "cascades" in features:
  378. hook_parts.append("连锁下落爽感")
  379. if "free_spins" in features:
  380. hook_parts.append("Scatter 免费旋转期待")
  381. if "multipliers" in features:
  382. hook_parts.append("递增倍率爆点")
  383. core_hook = " + ".join(hook_parts[:2]) or "轻量现代老虎机循环"
  384. vision = creative_payload.get("visionStyleAnalysis") or {}
  385. art_direction = {
  386. "theme": slot_request.get("theme"),
  387. "style": slot_request.get("style", ""),
  388. "reference_style_analysis": vision,
  389. "avoid": creative_payload.get("avoidNotes", ""),
  390. }
  391. return {
  392. "source": source,
  393. "creative": {
  394. "brief": creative_payload.get("brief", ""),
  395. "references": creative_payload.get("references", []),
  396. "uploadedReferenceImages": creative_payload.get("uploadedReferenceImages", 0),
  397. "visionStyleAnalysis": vision,
  398. "visionStyleAnalysisError": creative_payload.get("visionStyleAnalysisError", ""),
  399. },
  400. "gameDesign": {
  401. "title": slot_request.get("title"),
  402. "coreHook": core_hook,
  403. "artDirection": art_direction,
  404. "reelExperience": slot_request.get("reelMode"),
  405. "volatility": slot_request.get("volatility"),
  406. "differentiators": hook_parts,
  407. "bossDesign": {
  408. "enabled": bool(slot_request.get("enableBoss", True)),
  409. "presence": slot_request.get("bossPresence", "full"),
  410. "idle": "静静等待时会呼吸、环顾、蓄力",
  411. "playerWin": "玩家赢钱时撒币,然后受击裂开爆炸",
  412. "playerLose": "玩家输钱时举剑嘲讽、踩踏并攻击",
  413. },
  414. "assetStrategy": {
  415. "symbolCount": slot_request.get("characterCount"),
  416. "uiCompleteness": slot_request.get("uiCompleteness"),
  417. "feedbackIntensity": slot_request.get("feedbackIntensity"),
  418. },
  419. },
  420. }
  421. def creative_to_slot_request(data):
  422. api_key = (data.get("api_key") or DEFAULT_API_KEY).strip()
  423. base_url = (data.get("base_url") or DEFAULT_BASE_URL).strip()
  424. text_model = (data.get("text_model") or DEFAULT_TEXT_MODEL).strip()
  425. references = _split_lines(data.get("references"))
  426. reference_images = data.get("reference_images") or []
  427. if not isinstance(reference_images, list):
  428. reference_images = []
  429. payload = {
  430. "title": data.get("title") or "AI Custom Slot",
  431. "gameId": data.get("gameId") or "",
  432. "brief": data.get("brief") or "",
  433. "references": references,
  434. "uploadedReferenceImages": len(reference_images),
  435. "styleNotes": data.get("styleNotes") or "",
  436. "avoidNotes": data.get("avoidNotes") or "",
  437. "theme": data.get("theme") or "",
  438. "reelMode": data.get("reelMode") or "",
  439. "volatility": data.get("volatility") or "",
  440. "targetRtp": data.get("targetRtp", 96),
  441. "characterCount": data.get("characterCount", 10),
  442. "uiCompleteness": data.get("uiCompleteness", "full"),
  443. "feedbackIntensity": data.get("feedbackIntensity", "standard"),
  444. "enableBoss": bool(data.get("enableBoss", True)),
  445. "bossPresence": data.get("bossPresence", "full"),
  446. "features": data.get("features") or [],
  447. "enableMathModel": bool(data.get("enableMathModel", True)),
  448. }
  449. if not api_key:
  450. fallback = _creative_fallback(payload)
  451. return fallback, "fallback_no_api_key", build_game_plan(fallback, payload, "fallback_no_api_key")
  452. vision_notes = {}
  453. direct_image_urls = [
  454. x for x in references
  455. if x.lower().startswith(("http://", "https://")) and x.lower().split("?")[0].endswith((".png", ".jpg", ".jpeg", ".webp"))
  456. ]
  457. if direct_image_urls or reference_images:
  458. try:
  459. vision_notes = providers.analyze_reference_images(
  460. reference_urls=direct_image_urls,
  461. image_data_urls=reference_images,
  462. api_key=api_key,
  463. base_url=base_url,
  464. model=text_model,
  465. )
  466. payload["visionStyleAnalysis"] = vision_notes
  467. if vision_notes.get("image_prompt_style"):
  468. payload["styleNotes"] = ";".join([
  469. str(payload.get("styleNotes") or ""),
  470. "视觉参考分析:" + str(vision_notes.get("image_prompt_style")),
  471. ]).strip(";")
  472. elif vision_notes:
  473. payload["styleNotes"] = ";".join([
  474. str(payload.get("styleNotes") or ""),
  475. "视觉参考分析:" + json.dumps(vision_notes, ensure_ascii=False),
  476. ]).strip(";")
  477. except Exception as e:
  478. payload["visionStyleAnalysisError"] = str(e)[:500]
  479. messages = [
  480. {
  481. "role": "system",
  482. "content": (
  483. "你是资深移动老虎机游戏策划和美术总监。根据用户的基础描述、参考图或网址、风格要求,"
  484. "生成一份可执行的 slot 工作流输入 JSON。必须原创,不能复刻参考图里的具体 IP、logo、角色。"
  485. "只输出 JSON,不要解释。"
  486. ),
  487. },
  488. {
  489. "role": "user",
  490. "content": json.dumps({
  491. "task": "把创意简报转换为 slot_workflow.build_workflow 可接受的输入",
  492. "allowedFields": {
  493. "gameId": "string slug or title",
  494. "title": "string",
  495. "theme": "jelly|fruit|egypt|pirate|pirate_jelly|cyber",
  496. "style": "English image-generation style prompt, include reference-derived art direction",
  497. "reelMode": "ways|paylines|megaways|cluster",
  498. "volatility": "low|medium|high",
  499. "targetRtp": "number percent, e.g. 96",
  500. "characterCount": "6|8|10|12",
  501. "uiCompleteness": "basic|full",
  502. "feedbackIntensity": "quiet|standard|loud",
  503. "enableBoss": "boolean",
  504. "bossPresence": "light|standard|full",
  505. "enableMathModel": "boolean",
  506. "features": "array of cascades, free_spins, wilds, hold_win, multipliers"
  507. },
  508. "creativeBrief": payload,
  509. "outputRules": [
  510. "Return JSON object with exactly these workflow fields.",
  511. "Pick one clear core hook, not every feature.",
  512. "Use references as style inspiration only; do not copy protected characters, logos, or exact layout.",
  513. "Style must be specific enough for image generation."
  514. ],
  515. }, ensure_ascii=False),
  516. },
  517. ]
  518. try:
  519. obj = providers.chat_json_openai(messages, api_key=api_key, base_url=base_url, model=text_model)
  520. except Exception:
  521. fallback = _creative_fallback(payload)
  522. return fallback, "fallback_text_model_failed", build_game_plan(fallback, payload, "fallback_text_model_failed")
  523. fallback = _creative_fallback(payload)
  524. out = dict(fallback)
  525. for key in ("gameId", "title", "theme", "style", "reelMode", "volatility", "targetRtp",
  526. "characterCount", "uiCompleteness", "feedbackIntensity", "enableBoss",
  527. "bossPresence", "enableMathModel"):
  528. if key in obj and obj[key] not in (None, ""):
  529. out[key] = obj[key]
  530. if isinstance(obj.get("features"), list) and obj["features"]:
  531. allowed = {"cascades", "free_spins", "wilds", "hold_win", "multipliers"}
  532. out["features"] = [x for x in obj["features"] if x in allowed]
  533. source = "vision_text_model" if vision_notes else "text_model"
  534. return out, source, build_game_plan(out, payload, source)
  535. class Handler(BaseHTTPRequestHandler):
  536. def _send(self, code, body, ctype="application/json; charset=utf-8"):
  537. if isinstance(body, (dict, list)):
  538. body = json.dumps(body, ensure_ascii=False).encode("utf-8")
  539. elif isinstance(body, str):
  540. body = body.encode("utf-8")
  541. self.send_response(code)
  542. self.send_header("Content-Type", ctype)
  543. self.send_header("Content-Length", str(len(body)))
  544. self.end_headers()
  545. self.wfile.write(body)
  546. def _file(self, path):
  547. if not os.path.isfile(path):
  548. return self._send(404, {"error": "not found"})
  549. ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
  550. with open(path, "rb") as f:
  551. data = f.read()
  552. self.send_response(200)
  553. self.send_header("Content-Type", ctype)
  554. self.send_header("Content-Length", str(len(data)))
  555. self.end_headers()
  556. self.wfile.write(data)
  557. def log_message(self, *a):
  558. pass # 静音
  559. def do_GET(self):
  560. u = urlparse(self.path)
  561. path, qs = u.path, parse_qs(u.query)
  562. if path == "/" or path == "/index.html":
  563. return self._file(os.path.join(WEB_DIR, "index.html"))
  564. if path == "/api/manifest":
  565. with open(DEFAULT_MANIFEST, encoding="utf-8") as f:
  566. return self._send(200, f.read())
  567. if path == "/api/games":
  568. return self._send(200, {"games": list_games()})
  569. if path == "/api/job":
  570. job_id = qs.get("id", [""])[0]
  571. job = _job_snapshot(job_id)
  572. if not job:
  573. return self._send(404, {"ok": False, "error": "job not found"})
  574. job["id"] = job_id
  575. return self._send(200, job)
  576. if path == "/api/library":
  577. games = list_games()
  578. game = (qs.get("game", [None])[0]) or (games[-1] if games else None)
  579. if not game:
  580. return self._send(200, {"game": None, "games": games,
  581. "characters": [], "vfx": [], "ui": [], "tasks": {}})
  582. library_path = os.path.join(OUT_ROOT, game, "library.json")
  583. if not os.path.isfile(library_path):
  584. return self._send(200, {"game": game, "games": games, "assetBase": f"/assets/{game}/",
  585. "characters": [], "vfx": [], "ui": [], "tasks": {}})
  586. with open(library_path, encoding="utf-8") as f:
  587. lib = json.load(f)
  588. lib["games"] = games
  589. lib["assetBase"] = f"/assets/{game}/"
  590. lib["tasks"] = _build_tasks(lib)
  591. all_tasks = [t for rows in lib["tasks"].values() for t in rows]
  592. lib["taskSummary"] = {
  593. "total": len(all_tasks),
  594. "done": sum(1 for t in all_tasks if t.get("status") == "done"),
  595. "missing": sum(1 for t in all_tasks if t.get("status") != "done"),
  596. }
  597. return self._send(200, lib)
  598. if path.startswith("/assets/"):
  599. rel = unquote(path[len("/assets/"):])
  600. try:
  601. return self._file(safe_join(OUT_ROOT, rel))
  602. except ValueError:
  603. return self._send(400, {"error": "bad path"})
  604. return self._send(404, {"error": "not found"})
  605. def do_POST(self):
  606. try:
  607. return self._do_POST()
  608. except Exception as e:
  609. traceback.print_exc()
  610. return self._send(500, {"ok": False, "error": str(e)})
  611. def _read_json_body(self):
  612. length = int(self.headers.get("Content-Length", 0))
  613. return json.loads(self.rfile.read(length) or b"{}")
  614. def _do_POST(self):
  615. route = urlparse(self.path).path
  616. if route == "/api/export":
  617. return self._post_export()
  618. if route == "/api/open-folder":
  619. return self._post_open_folder()
  620. if route == "/api/retry-task":
  621. return self._post_retry_task()
  622. if route == "/api/retry-missing":
  623. return self._post_retry_missing()
  624. if route == "/api/delete":
  625. return self._post_delete()
  626. if route == "/api/slot-workflow":
  627. return self._post_slot_workflow()
  628. if route == "/api/creative-manifest":
  629. return self._post_creative_manifest()
  630. if route != "/api/generate":
  631. return self._send(404, {"error": "not found"})
  632. try:
  633. data = self._read_json_body()
  634. except Exception as e:
  635. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  636. try:
  637. manifest = json.loads(data["manifest"]) if isinstance(data.get("manifest"), str) \
  638. else data.get("manifest")
  639. manifest = slot_workflow.complete_manifest(manifest)
  640. except Exception as e:
  641. return self._send(400, {"ok": False, "error": f"manifest 非法 JSON: {e}"})
  642. creds = {
  643. "provider": data.get("provider", "OpenAI 兼容接口"),
  644. "api_key": (data.get("api_key") or DEFAULT_API_KEY).strip(),
  645. "base_url": (data.get("base_url") or DEFAULT_BASE_URL).strip(),
  646. "model": (data.get("model") or DEFAULT_IMAGE_MODEL).strip(),
  647. "size": data.get("size", "1024x1024"),
  648. }
  649. logs = []
  650. if data.get("async", True):
  651. job_id = uuid.uuid4().hex
  652. with JOBS_LOCK:
  653. JOBS[job_id] = {"status": "queued", "ok": None, "logs": ["任务已创建,等待开始…"],
  654. "game": manifest.get("game"), "createdAt": time.time(), "updatedAt": time.time()}
  655. threading.Thread(target=_run_generate_job, args=(job_id, manifest, creds), daemon=True).start()
  656. return self._send(200, {"ok": True, "jobId": job_id, "game": manifest.get("game")})
  657. try:
  658. lib, _ = pipeline.run(manifest, OUT_ROOT, creds=creds, log=lambda m: logs.append(m))
  659. except Exception as e:
  660. return self._send(500, {"ok": False, "error": str(e), "logs": logs})
  661. return self._send(200, {"ok": True, "logs": logs, "game": lib["game"]})
  662. def _post_slot_workflow(self):
  663. try:
  664. data = self._read_json_body()
  665. except Exception as e:
  666. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  667. try:
  668. result = slot_workflow.build_workflow(data)
  669. except Exception as e:
  670. traceback.print_exc()
  671. return self._send(500, {"ok": False, "error": str(e)})
  672. return self._send(200, {"ok": True, **result})
  673. def _post_creative_manifest(self):
  674. try:
  675. data = self._read_json_body()
  676. except Exception as e:
  677. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  678. try:
  679. normalized, source, game_plan = creative_to_slot_request(data)
  680. normalized["creative"] = game_plan.get("creative", {})
  681. normalized["gameDesign"] = game_plan.get("gameDesign", {})
  682. result = slot_workflow.build_workflow(normalized)
  683. return self._send(200, {"ok": True, "source": source, "game_plan": game_plan,
  684. "creative_request": normalized, **result})
  685. except Exception as e:
  686. traceback.print_exc()
  687. return self._send(500, {"ok": False, "error": str(e)})
  688. def _post_export(self):
  689. try:
  690. data = self._read_json_body()
  691. except Exception as e:
  692. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  693. game = (data.get("game") or "").strip()
  694. if not game or game not in list_games():
  695. return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
  696. logs = []
  697. try:
  698. pack = exporter.export(game, OUT_ROOT, log=lambda m: logs.append(m))
  699. except Exception as e:
  700. traceback.print_exc()
  701. return self._send(500, {"ok": False, "error": str(e), "logs": logs})
  702. return self._send(200, {"ok": True, "logs": logs, "game": game,
  703. "pack": os.path.abspath(pack)})
  704. def _post_open_folder(self):
  705. try:
  706. data = self._read_json_body()
  707. except Exception as e:
  708. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  709. game = (data.get("game") or "").strip()
  710. if not game or game not in list_games():
  711. return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
  712. target = os.path.abspath(os.path.join(OUT_ROOT, game))
  713. if not target.startswith(os.path.abspath(OUT_ROOT) + os.sep) or not os.path.isdir(target):
  714. return self._send(400, {"ok": False, "error": "素材目录不存在或路径非法"})
  715. try:
  716. if sys.platform == "darwin":
  717. subprocess.Popen(["open", target])
  718. elif os.name == "nt":
  719. os.startfile(target) # type: ignore[attr-defined]
  720. else:
  721. subprocess.Popen(["xdg-open", target])
  722. except Exception as e:
  723. return self._send(500, {"ok": False, "error": f"打开素材目录失败: {e}"})
  724. return self._send(200, {"ok": True, "game": game, "path": target})
  725. def _post_retry_task(self):
  726. try:
  727. data = self._read_json_body()
  728. except Exception as e:
  729. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  730. game = (data.get("game") or "").strip()
  731. if not game or game not in list_games():
  732. return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
  733. kind = TASK_KIND_MAP.get((data.get("kind") or "").strip())
  734. if not kind:
  735. return self._send(400, {"ok": False, "error": "无效的任务类型"})
  736. task_ids = data.get("ids") or data.get("taskIds") or [data.get("id")]
  737. if isinstance(task_ids, str):
  738. task_ids = [task_ids]
  739. task_ids = [str(x).strip() for x in task_ids if str(x or "").strip()]
  740. if not task_ids:
  741. return self._send(400, {"ok": False, "error": "没有选择要重试的任务"})
  742. lib = _load_library(game)
  743. tasks = _build_tasks(lib).get(kind, [])
  744. valid_ids = {t["id"] for t in tasks}
  745. bad = [x for x in task_ids if x not in valid_ids]
  746. if bad:
  747. return self._send(400, {"ok": False, "error": f"任务不存在: {', '.join(bad)}"})
  748. creds = {
  749. "provider": data.get("provider", "OpenAI 兼容接口"),
  750. "api_key": (data.get("api_key") or DEFAULT_API_KEY).strip(),
  751. "base_url": (data.get("base_url") or DEFAULT_BASE_URL).strip(),
  752. "model": (data.get("model") or DEFAULT_IMAGE_MODEL).strip(),
  753. "size": data.get("size", "1024x1024"),
  754. }
  755. job_id = uuid.uuid4().hex
  756. with JOBS_LOCK:
  757. JOBS[job_id] = {"status": "queued", "ok": None, "logs": ["补生成任务已创建,等待开始…"],
  758. "game": game, "createdAt": time.time(), "updatedAt": time.time()}
  759. threading.Thread(target=_run_retry_job, args=(job_id, game, kind, task_ids, creds), daemon=True).start()
  760. return self._send(200, {"ok": True, "jobId": job_id, "game": game})
  761. def _post_retry_missing(self):
  762. try:
  763. data = self._read_json_body()
  764. except Exception as e:
  765. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  766. game = (data.get("game") or "").strip()
  767. if not game or game not in list_games():
  768. return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
  769. lib = _load_library(game)
  770. tasks = _build_tasks(lib)
  771. groups = {k: [t["id"] for t in rows if t.get("status") != "done"]
  772. for k, rows in tasks.items()}
  773. groups = {k: v for k, v in groups.items() if v}
  774. if not groups:
  775. return self._send(400, {"ok": False, "error": "当前资源库没有缺失任务"})
  776. creds = {
  777. "provider": data.get("provider", "OpenAI 兼容接口"),
  778. "api_key": (data.get("api_key") or DEFAULT_API_KEY).strip(),
  779. "base_url": (data.get("base_url") or DEFAULT_BASE_URL).strip(),
  780. "model": (data.get("model") or DEFAULT_IMAGE_MODEL).strip(),
  781. "size": data.get("size", "1024x1024"),
  782. }
  783. job_id = uuid.uuid4().hex
  784. with JOBS_LOCK:
  785. JOBS[job_id] = {"status": "queued", "ok": None, "logs": ["批量补生成任务已创建,等待开始…"],
  786. "game": game, "createdAt": time.time(), "updatedAt": time.time()}
  787. threading.Thread(target=_run_retry_missing_job, args=(job_id, game, groups, creds), daemon=True).start()
  788. return self._send(200, {"ok": True, "jobId": job_id, "game": game, "groups": groups})
  789. def _post_delete(self):
  790. try:
  791. data = self._read_json_body()
  792. except Exception as e:
  793. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  794. game = (data.get("game") or "").strip()
  795. if not game or game not in list_games():
  796. return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
  797. target = os.path.join(OUT_ROOT, game)
  798. # 安全校验:必须落在 OUT_ROOT 内
  799. if not os.path.abspath(target).startswith(os.path.abspath(OUT_ROOT) + os.sep):
  800. return self._send(400, {"ok": False, "error": "非法路径"})
  801. shutil.rmtree(target)
  802. return self._send(200, {"ok": True, "deleted": game, "games": list_games()})
  803. if __name__ == "__main__":
  804. os.makedirs(OUT_ROOT, exist_ok=True)
  805. print(f"Anim Studio 网站: http://127.0.0.1:{PORT}")
  806. ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()