server.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  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/delete 删除某 game 的资源库
  15. """
  16. import json
  17. import mimetypes
  18. import os
  19. import posixpath
  20. import shutil
  21. import threading
  22. import time
  23. import traceback
  24. import uuid
  25. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  26. from urllib.parse import urlparse, parse_qs, unquote
  27. import pipeline
  28. import exporter
  29. import slot_workflow
  30. import providers
  31. import config
  32. HERE = os.path.dirname(os.path.abspath(__file__))
  33. OUT_ROOT = os.path.join(HERE, "out")
  34. WEB_DIR = os.path.join(HERE, "web")
  35. DEFAULT_MANIFEST = os.path.join(HERE, "animation_manifest.json")
  36. PORT = 7861
  37. DEFAULT_BASE_URL = config.get("ANIM_STUDIO_BASE_URL", "https://x.long.bid/v1")
  38. DEFAULT_API_KEY = config.get("ANIM_STUDIO_API_KEY", "")
  39. DEFAULT_IMAGE_MODEL = config.get("ANIM_STUDIO_IMAGE_MODEL", "gpt-image-2")
  40. DEFAULT_TEXT_MODEL = config.get("ANIM_STUDIO_TEXT_MODEL", "gpt-5.4-mini")
  41. JOBS = {}
  42. JOBS_LOCK = threading.Lock()
  43. def list_games():
  44. if not os.path.isdir(OUT_ROOT):
  45. return []
  46. return [n for n in sorted(os.listdir(OUT_ROOT))
  47. if os.path.isfile(os.path.join(OUT_ROOT, n, "library.json"))]
  48. def safe_join(root, *parts):
  49. p = posixpath.normpath("/".join(parts)).lstrip("/")
  50. full = os.path.join(root, *p.split("/"))
  51. if not os.path.abspath(full).startswith(os.path.abspath(root)):
  52. raise ValueError("path traversal")
  53. return full
  54. def _job_snapshot(job_id):
  55. with JOBS_LOCK:
  56. job = JOBS.get(job_id)
  57. return dict(job) if job else None
  58. def _set_job(job_id, **fields):
  59. with JOBS_LOCK:
  60. job = JOBS.setdefault(job_id, {})
  61. job.update(fields)
  62. def _append_job_log(job_id, msg):
  63. with JOBS_LOCK:
  64. job = JOBS.setdefault(job_id, {})
  65. job.setdefault("logs", []).append(msg)
  66. job["updatedAt"] = time.time()
  67. def _run_generate_job(job_id, manifest, creds):
  68. _set_job(job_id, status="running", logs=[], game=manifest.get("game"), startedAt=time.time(), updatedAt=time.time())
  69. try:
  70. lib, _ = pipeline.run(manifest, OUT_ROOT, creds=creds, log=lambda m: _append_job_log(job_id, m))
  71. _set_job(job_id, status="done", ok=True, game=lib["game"], updatedAt=time.time())
  72. except Exception as e:
  73. traceback.print_exc()
  74. _append_job_log(job_id, f"❌ 生成任务失败: {e}")
  75. _set_job(job_id, status="error", ok=False, error=str(e), updatedAt=time.time())
  76. def _split_lines(value):
  77. if isinstance(value, list):
  78. return [str(x).strip() for x in value if str(x).strip()]
  79. return [x.strip() for x in str(value or "").splitlines() if x.strip()]
  80. def _creative_fallback(data):
  81. brief = (data.get("brief") or "").lower()
  82. text = " ".join([brief, data.get("styleNotes", "").lower(), data.get("avoidNotes", "").lower()])
  83. allowed_themes = {"jelly", "fruit", "egypt", "pirate", "pirate_jelly", "cyber"}
  84. requested_theme = data.get("theme") if data.get("theme") in allowed_themes else ""
  85. theme = requested_theme or "jelly"
  86. pirate_hint = any(k in text for k in ("pirate", "海盗", "treasure", "宝藏", "金币", "船长"))
  87. jelly_hint = any(k in text for k in ("jelly", "果冻", "candy", "糖果", "gummy", "软糖"))
  88. if not requested_theme or requested_theme == "jelly":
  89. if pirate_hint and jelly_hint:
  90. theme = "pirate_jelly"
  91. elif any(k in text for k in ("egypt", "埃及", "金字塔", "法老")):
  92. theme = "egypt"
  93. elif pirate_hint:
  94. theme = "pirate"
  95. elif any(k in text for k in ("cyber", "赛博", "neon", "霓虹")):
  96. theme = "cyber"
  97. elif any(k in text for k in ("fruit", "水果", "cherry", "樱桃")):
  98. theme = "fruit"
  99. reel_mode = data.get("reelMode") or ("cluster" if any(k in text for k in ("消除", "cluster", "连通", "match")) else "ways")
  100. volatility = data.get("volatility") or ("high" if any(k in text for k in ("刺激", "大奖", "高波动", "big win")) else "medium")
  101. features = list(data.get("features") or ["cascades", "free_spins", "wilds"])
  102. if any(k in text for k in ("金币", "jackpot", "hold", "respin", "大奖池")) and "hold_win" not in features:
  103. features.append("hold_win")
  104. if any(k in text for k in ("倍率", "multiplier", "连锁")) and "multipliers" not in features:
  105. features.append("multipliers")
  106. if theme == "pirate_jelly":
  107. for feature in ("hold_win", "multipliers"):
  108. if feature not in features:
  109. features.append(feature)
  110. title = data.get("title") or "AI Custom Slot"
  111. style = data.get("styleNotes") or data.get("brief") or ""
  112. return {
  113. "gameId": data.get("gameId") or title,
  114. "title": title,
  115. "theme": theme,
  116. "style": style,
  117. "reelMode": reel_mode,
  118. "volatility": volatility,
  119. "targetRtp": data.get("targetRtp", 96),
  120. "characterCount": data.get("characterCount", 10),
  121. "uiCompleteness": data.get("uiCompleteness", "full"),
  122. "feedbackIntensity": data.get("feedbackIntensity", "standard"),
  123. "enableMathModel": bool(data.get("enableMathModel", True)),
  124. "features": features,
  125. }
  126. def build_game_plan(slot_request, creative_payload, source):
  127. features = slot_request.get("features") or []
  128. hook_parts = []
  129. if "hold_win" in features:
  130. hook_parts.append("金币锁格 respin 大奖目标")
  131. if "cascades" in features:
  132. hook_parts.append("连锁下落爽感")
  133. if "free_spins" in features:
  134. hook_parts.append("Scatter 免费旋转期待")
  135. if "multipliers" in features:
  136. hook_parts.append("递增倍率爆点")
  137. core_hook = " + ".join(hook_parts[:2]) or "轻量现代老虎机循环"
  138. vision = creative_payload.get("visionStyleAnalysis") or {}
  139. art_direction = {
  140. "theme": slot_request.get("theme"),
  141. "style": slot_request.get("style", ""),
  142. "reference_style_analysis": vision,
  143. "avoid": creative_payload.get("avoidNotes", ""),
  144. }
  145. return {
  146. "source": source,
  147. "creative": {
  148. "brief": creative_payload.get("brief", ""),
  149. "references": creative_payload.get("references", []),
  150. "uploadedReferenceImages": creative_payload.get("uploadedReferenceImages", 0),
  151. "visionStyleAnalysis": vision,
  152. "visionStyleAnalysisError": creative_payload.get("visionStyleAnalysisError", ""),
  153. },
  154. "gameDesign": {
  155. "title": slot_request.get("title"),
  156. "coreHook": core_hook,
  157. "artDirection": art_direction,
  158. "reelExperience": slot_request.get("reelMode"),
  159. "volatility": slot_request.get("volatility"),
  160. "differentiators": hook_parts,
  161. "assetStrategy": {
  162. "symbolCount": slot_request.get("characterCount"),
  163. "uiCompleteness": slot_request.get("uiCompleteness"),
  164. "feedbackIntensity": slot_request.get("feedbackIntensity"),
  165. },
  166. },
  167. }
  168. def creative_to_slot_request(data):
  169. api_key = (data.get("api_key") or DEFAULT_API_KEY).strip()
  170. base_url = (data.get("base_url") or DEFAULT_BASE_URL).strip()
  171. text_model = (data.get("text_model") or DEFAULT_TEXT_MODEL).strip()
  172. references = _split_lines(data.get("references"))
  173. reference_images = data.get("reference_images") or []
  174. if not isinstance(reference_images, list):
  175. reference_images = []
  176. payload = {
  177. "title": data.get("title") or "AI Custom Slot",
  178. "gameId": data.get("gameId") or "",
  179. "brief": data.get("brief") or "",
  180. "references": references,
  181. "uploadedReferenceImages": len(reference_images),
  182. "styleNotes": data.get("styleNotes") or "",
  183. "avoidNotes": data.get("avoidNotes") or "",
  184. "theme": data.get("theme") or "",
  185. "reelMode": data.get("reelMode") or "",
  186. "volatility": data.get("volatility") or "",
  187. "targetRtp": data.get("targetRtp", 96),
  188. "characterCount": data.get("characterCount", 10),
  189. "uiCompleteness": data.get("uiCompleteness", "full"),
  190. "feedbackIntensity": data.get("feedbackIntensity", "standard"),
  191. "features": data.get("features") or [],
  192. "enableMathModel": bool(data.get("enableMathModel", True)),
  193. }
  194. if not api_key:
  195. fallback = _creative_fallback(payload)
  196. return fallback, "fallback_no_api_key", build_game_plan(fallback, payload, "fallback_no_api_key")
  197. vision_notes = {}
  198. direct_image_urls = [
  199. x for x in references
  200. if x.lower().startswith(("http://", "https://")) and x.lower().split("?")[0].endswith((".png", ".jpg", ".jpeg", ".webp"))
  201. ]
  202. if direct_image_urls or reference_images:
  203. try:
  204. vision_notes = providers.analyze_reference_images(
  205. reference_urls=direct_image_urls,
  206. image_data_urls=reference_images,
  207. api_key=api_key,
  208. base_url=base_url,
  209. model=text_model,
  210. )
  211. payload["visionStyleAnalysis"] = vision_notes
  212. if vision_notes.get("image_prompt_style"):
  213. payload["styleNotes"] = ";".join([
  214. str(payload.get("styleNotes") or ""),
  215. "视觉参考分析:" + str(vision_notes.get("image_prompt_style")),
  216. ]).strip(";")
  217. elif vision_notes:
  218. payload["styleNotes"] = ";".join([
  219. str(payload.get("styleNotes") or ""),
  220. "视觉参考分析:" + json.dumps(vision_notes, ensure_ascii=False),
  221. ]).strip(";")
  222. except Exception as e:
  223. payload["visionStyleAnalysisError"] = str(e)[:500]
  224. messages = [
  225. {
  226. "role": "system",
  227. "content": (
  228. "你是资深移动老虎机游戏策划和美术总监。根据用户的基础描述、参考图或网址、风格要求,"
  229. "生成一份可执行的 slot 工作流输入 JSON。必须原创,不能复刻参考图里的具体 IP、logo、角色。"
  230. "只输出 JSON,不要解释。"
  231. ),
  232. },
  233. {
  234. "role": "user",
  235. "content": json.dumps({
  236. "task": "把创意简报转换为 slot_workflow.build_workflow 可接受的输入",
  237. "allowedFields": {
  238. "gameId": "string slug or title",
  239. "title": "string",
  240. "theme": "jelly|fruit|egypt|pirate|pirate_jelly|cyber",
  241. "style": "English image-generation style prompt, include reference-derived art direction",
  242. "reelMode": "ways|paylines|megaways|cluster",
  243. "volatility": "low|medium|high",
  244. "targetRtp": "number percent, e.g. 96",
  245. "characterCount": "6|8|10|12",
  246. "uiCompleteness": "basic|full",
  247. "feedbackIntensity": "quiet|standard|loud",
  248. "enableMathModel": "boolean",
  249. "features": "array of cascades, free_spins, wilds, hold_win, multipliers"
  250. },
  251. "creativeBrief": payload,
  252. "outputRules": [
  253. "Return JSON object with exactly these workflow fields.",
  254. "Pick one clear core hook, not every feature.",
  255. "Use references as style inspiration only; do not copy protected characters, logos, or exact layout.",
  256. "Style must be specific enough for image generation."
  257. ],
  258. }, ensure_ascii=False),
  259. },
  260. ]
  261. try:
  262. obj = providers.chat_json_openai(messages, api_key=api_key, base_url=base_url, model=text_model)
  263. except Exception:
  264. fallback = _creative_fallback(payload)
  265. return fallback, "fallback_text_model_failed", build_game_plan(fallback, payload, "fallback_text_model_failed")
  266. fallback = _creative_fallback(payload)
  267. out = dict(fallback)
  268. for key in ("gameId", "title", "theme", "style", "reelMode", "volatility", "targetRtp",
  269. "characterCount", "uiCompleteness", "feedbackIntensity", "enableMathModel"):
  270. if key in obj and obj[key] not in (None, ""):
  271. out[key] = obj[key]
  272. if isinstance(obj.get("features"), list) and obj["features"]:
  273. allowed = {"cascades", "free_spins", "wilds", "hold_win", "multipliers"}
  274. out["features"] = [x for x in obj["features"] if x in allowed]
  275. source = "vision_text_model" if vision_notes else "text_model"
  276. return out, source, build_game_plan(out, payload, source)
  277. class Handler(BaseHTTPRequestHandler):
  278. def _send(self, code, body, ctype="application/json; charset=utf-8"):
  279. if isinstance(body, (dict, list)):
  280. body = json.dumps(body, ensure_ascii=False).encode("utf-8")
  281. elif isinstance(body, str):
  282. body = body.encode("utf-8")
  283. self.send_response(code)
  284. self.send_header("Content-Type", ctype)
  285. self.send_header("Content-Length", str(len(body)))
  286. self.end_headers()
  287. self.wfile.write(body)
  288. def _file(self, path):
  289. if not os.path.isfile(path):
  290. return self._send(404, {"error": "not found"})
  291. ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
  292. with open(path, "rb") as f:
  293. data = f.read()
  294. self.send_response(200)
  295. self.send_header("Content-Type", ctype)
  296. self.send_header("Content-Length", str(len(data)))
  297. self.end_headers()
  298. self.wfile.write(data)
  299. def log_message(self, *a):
  300. pass # 静音
  301. def do_GET(self):
  302. u = urlparse(self.path)
  303. path, qs = u.path, parse_qs(u.query)
  304. if path == "/" or path == "/index.html":
  305. return self._file(os.path.join(WEB_DIR, "index.html"))
  306. if path == "/api/manifest":
  307. with open(DEFAULT_MANIFEST, encoding="utf-8") as f:
  308. return self._send(200, f.read())
  309. if path == "/api/games":
  310. return self._send(200, {"games": list_games()})
  311. if path == "/api/job":
  312. job_id = qs.get("id", [""])[0]
  313. job = _job_snapshot(job_id)
  314. if not job:
  315. return self._send(404, {"ok": False, "error": "job not found"})
  316. job["id"] = job_id
  317. return self._send(200, job)
  318. if path == "/api/library":
  319. games = list_games()
  320. game = (qs.get("game", [None])[0]) or (games[-1] if games else None)
  321. if not game:
  322. return self._send(200, {"game": None, "games": games,
  323. "characters": [], "vfx": [], "ui": []})
  324. library_path = os.path.join(OUT_ROOT, game, "library.json")
  325. if not os.path.isfile(library_path):
  326. return self._send(200, {"game": game, "games": games, "assetBase": f"/assets/{game}/",
  327. "characters": [], "vfx": [], "ui": []})
  328. with open(library_path, encoding="utf-8") as f:
  329. lib = json.load(f)
  330. lib["games"] = games
  331. lib["assetBase"] = f"/assets/{game}/"
  332. return self._send(200, lib)
  333. if path.startswith("/assets/"):
  334. rel = unquote(path[len("/assets/"):])
  335. try:
  336. return self._file(safe_join(OUT_ROOT, rel))
  337. except ValueError:
  338. return self._send(400, {"error": "bad path"})
  339. return self._send(404, {"error": "not found"})
  340. def do_POST(self):
  341. try:
  342. return self._do_POST()
  343. except Exception as e:
  344. traceback.print_exc()
  345. return self._send(500, {"ok": False, "error": str(e)})
  346. def _read_json_body(self):
  347. length = int(self.headers.get("Content-Length", 0))
  348. return json.loads(self.rfile.read(length) or b"{}")
  349. def _do_POST(self):
  350. route = urlparse(self.path).path
  351. if route == "/api/export":
  352. return self._post_export()
  353. if route == "/api/delete":
  354. return self._post_delete()
  355. if route == "/api/slot-workflow":
  356. return self._post_slot_workflow()
  357. if route == "/api/creative-manifest":
  358. return self._post_creative_manifest()
  359. if route != "/api/generate":
  360. return self._send(404, {"error": "not found"})
  361. try:
  362. data = self._read_json_body()
  363. except Exception as e:
  364. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  365. try:
  366. manifest = json.loads(data["manifest"]) if isinstance(data.get("manifest"), str) \
  367. else data.get("manifest")
  368. except Exception as e:
  369. return self._send(400, {"ok": False, "error": f"manifest 非法 JSON: {e}"})
  370. creds = {
  371. "provider": data.get("provider", "OpenAI 兼容接口"),
  372. "api_key": (data.get("api_key") or DEFAULT_API_KEY).strip(),
  373. "base_url": (data.get("base_url") or DEFAULT_BASE_URL).strip(),
  374. "model": (data.get("model") or DEFAULT_IMAGE_MODEL).strip(),
  375. "size": data.get("size", "1024x1024"),
  376. "remove_bg": bool(data.get("remove_bg", False)),
  377. }
  378. logs = []
  379. if data.get("async", True):
  380. job_id = uuid.uuid4().hex
  381. with JOBS_LOCK:
  382. JOBS[job_id] = {"status": "queued", "ok": None, "logs": ["任务已创建,等待开始…"],
  383. "game": manifest.get("game"), "createdAt": time.time(), "updatedAt": time.time()}
  384. threading.Thread(target=_run_generate_job, args=(job_id, manifest, creds), daemon=True).start()
  385. return self._send(200, {"ok": True, "jobId": job_id, "game": manifest.get("game")})
  386. try:
  387. lib, _ = pipeline.run(manifest, OUT_ROOT, creds=creds, log=lambda m: logs.append(m))
  388. except Exception as e:
  389. return self._send(500, {"ok": False, "error": str(e), "logs": logs})
  390. return self._send(200, {"ok": True, "logs": logs, "game": lib["game"]})
  391. def _post_slot_workflow(self):
  392. try:
  393. data = self._read_json_body()
  394. except Exception as e:
  395. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  396. try:
  397. result = slot_workflow.build_workflow(data)
  398. except Exception as e:
  399. traceback.print_exc()
  400. return self._send(500, {"ok": False, "error": str(e)})
  401. return self._send(200, {"ok": True, **result})
  402. def _post_creative_manifest(self):
  403. try:
  404. data = self._read_json_body()
  405. except Exception as e:
  406. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  407. try:
  408. normalized, source, game_plan = creative_to_slot_request(data)
  409. normalized["creative"] = game_plan.get("creative", {})
  410. normalized["gameDesign"] = game_plan.get("gameDesign", {})
  411. result = slot_workflow.build_workflow(normalized)
  412. return self._send(200, {"ok": True, "source": source, "game_plan": game_plan,
  413. "creative_request": normalized, **result})
  414. except Exception as e:
  415. traceback.print_exc()
  416. return self._send(500, {"ok": False, "error": str(e)})
  417. def _post_export(self):
  418. try:
  419. data = self._read_json_body()
  420. except Exception as e:
  421. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  422. game = (data.get("game") or "").strip()
  423. if not game or game not in list_games():
  424. return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
  425. logs = []
  426. try:
  427. pack = exporter.export(game, OUT_ROOT, log=lambda m: logs.append(m))
  428. except Exception as e:
  429. traceback.print_exc()
  430. return self._send(500, {"ok": False, "error": str(e), "logs": logs})
  431. return self._send(200, {"ok": True, "logs": logs, "game": game,
  432. "pack": os.path.abspath(pack)})
  433. def _post_delete(self):
  434. try:
  435. data = self._read_json_body()
  436. except Exception as e:
  437. return self._send(400, {"ok": False, "error": f"请求体不是合法 JSON: {e}"})
  438. game = (data.get("game") or "").strip()
  439. if not game or game not in list_games():
  440. return self._send(400, {"ok": False, "error": f"无效的 game: {game!r}"})
  441. target = os.path.join(OUT_ROOT, game)
  442. # 安全校验:必须落在 OUT_ROOT 内
  443. if not os.path.abspath(target).startswith(os.path.abspath(OUT_ROOT) + os.sep):
  444. return self._send(400, {"ok": False, "error": "非法路径"})
  445. shutil.rmtree(target)
  446. return self._send(200, {"ok": True, "deleted": game, "games": list_games()})
  447. if __name__ == "__main__":
  448. os.makedirs(OUT_ROOT, exist_ok=True)
  449. print(f"Anim Studio 网站: http://127.0.0.1:{PORT}")
  450. ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()