providers.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. """图像大模型调用层(仅用标准库 urllib,无第三方依赖)。
  2. 内置 OpenAI 图像接口(gpt-image-1,支持透明背景)。
  3. 接其他厂商:实现同签名函数加进 PROVIDERS 即可(兼容 OpenAI 协议的中转 Base URL 可直接用)。
  4. """
  5. import base64
  6. import http.client
  7. import io
  8. import json
  9. import os
  10. import ssl
  11. import urllib.request
  12. from urllib.parse import urlparse
  13. from PIL import Image
  14. def _ssl_context():
  15. """构造可信的 SSL 上下文。
  16. 优先用 certifi 的 CA 证书(解决 macOS python.org 找不到根证书的
  17. CERTIFICATE_VERIFY_FAILED 问题);都没有时退回系统默认。
  18. 设环境变量 ANIM_STUDIO_INSECURE=1 可临时关闭校验(不推荐,仅排错用)。
  19. """
  20. if os.environ.get("ANIM_STUDIO_INSECURE") == "1":
  21. ctx = ssl.create_default_context()
  22. ctx.check_hostname = False
  23. ctx.verify_mode = ssl.CERT_NONE
  24. return ctx
  25. try:
  26. import certifi
  27. return ssl.create_default_context(cafile=certifi.where())
  28. except Exception:
  29. return ssl.create_default_context()
  30. _SSL = _ssl_context()
  31. def _http_json(url, payload, headers, timeout):
  32. data = json.dumps(payload).encode("utf-8")
  33. parsed = urlparse(url)
  34. path = parsed.path or "/"
  35. if parsed.query:
  36. path += "?" + parsed.query
  37. conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
  38. kwargs = {"timeout": timeout}
  39. if parsed.scheme == "https":
  40. kwargs["context"] = _SSL
  41. conn = conn_cls(parsed.hostname, parsed.port, **kwargs)
  42. try:
  43. conn.request("POST", path, body=data, headers=headers)
  44. res = conn.getresponse()
  45. body = res.read()
  46. finally:
  47. conn.close()
  48. if res.status < 200 or res.status >= 300:
  49. raise urllib.error.HTTPError(url, res.status, res.reason, res.headers, io.BytesIO(body))
  50. return json.loads(body.decode("utf-8"))
  51. def _http_bytes(url, timeout):
  52. parsed = urlparse(url)
  53. path = parsed.path or "/"
  54. if parsed.query:
  55. path += "?" + parsed.query
  56. conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
  57. kwargs = {"timeout": timeout}
  58. if parsed.scheme == "https":
  59. kwargs["context"] = _SSL
  60. conn = conn_cls(parsed.hostname, parsed.port, **kwargs)
  61. try:
  62. conn.request("GET", path)
  63. res = conn.getresponse()
  64. body = res.read()
  65. finally:
  66. conn.close()
  67. if res.status < 200 or res.status >= 300:
  68. raise RuntimeError(f"下载生成图失败:{url} 返回 {res.status}")
  69. return body
  70. def _url_image_to_data_url(url, timeout, max_bytes=8 * 1024 * 1024):
  71. req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
  72. with urllib.request.urlopen(req, timeout=timeout, context=_SSL) as res:
  73. ctype = (res.headers.get("content-type") or "image/png").split(";")[0].strip()
  74. raw = res.read(max_bytes + 1)
  75. if len(raw) > max_bytes:
  76. raise RuntimeError("image is larger than 8MB")
  77. if not ctype.startswith("image/"):
  78. ctype = "image/png"
  79. return f"data:{ctype};base64," + base64.b64encode(raw).decode("ascii")
  80. def _short_json(data, limit=600):
  81. try:
  82. return json.dumps(data, ensure_ascii=False)[:limit]
  83. except Exception:
  84. return str(data)[:limit]
  85. def _read_http_error(error):
  86. body = error.read().decode("utf-8", "ignore")[:1200]
  87. return body
  88. def _should_retry_without_url_fields(status, body):
  89. if status not in (400, 422):
  90. return False
  91. text = body.lower()
  92. retry_markers = [
  93. "response_format",
  94. "store",
  95. "background",
  96. "output_format",
  97. "unknown parameter",
  98. "unsupported parameter",
  99. "unrecognized",
  100. "extra inputs",
  101. "not permitted",
  102. ]
  103. return any(marker in text for marker in retry_markers)
  104. def _extract_image_item(data):
  105. if isinstance(data, dict):
  106. if data.get("data"):
  107. items = data["data"]
  108. elif data.get("images"):
  109. items = data["images"]
  110. else:
  111. items = [data]
  112. elif isinstance(data, list):
  113. items = data
  114. else:
  115. items = []
  116. if not items:
  117. return {}
  118. item = items[0]
  119. if isinstance(item, str):
  120. if item.startswith("http://") or item.startswith("https://"):
  121. return {"url": item}
  122. return {"b64_json": item}
  123. if not isinstance(item, dict):
  124. return {}
  125. image_url = item.get("image_url")
  126. if isinstance(image_url, dict) and image_url.get("url"):
  127. return {"url": image_url["url"]}
  128. return item
  129. def gen_image_openai(prompt, api_key, base_url="https://api.openai.com/v1",
  130. model="gpt-image-2", size="1024x1024", timeout=180):
  131. """调用 OpenAI 兼容图像接口(含第三方中转),返回 PIL.Image (RGBA)。
  132. 兼容性处理:
  133. - 仅对 gpt-image* 模型发送 background/output_format 等专属参数;其他模型
  134. (dall-e-3 / flux / sora_image / gpt-4o-image 等中转常见模型)不发,避免被拒。
  135. - 返回兼容 b64_json 或 url 两种格式。
  136. """
  137. url = base_url.rstrip("/") + "/images/generations"
  138. headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
  139. model_name = model.lower()
  140. payload = {"model": model, "prompt": prompt, "size": size, "n": 1}
  141. wants_transparent = "transparent background" in prompt.lower() or "no background" in prompt.lower()
  142. if model_name == "gpt-image-2":
  143. # x.long.bid exposes gpt-image-2, but its upstream only accepts the
  144. # minimal image payload. Adding background/output_format causes 502.
  145. pass
  146. elif "gpt-image" in model_name:
  147. # gpt-image-1 official-compatible endpoints can use these fields.
  148. # If a gateway rejects them with 400/422, the retry path strips them.
  149. if wants_transparent:
  150. payload["background"] = "transparent"
  151. payload["output_format"] = "png"
  152. else:
  153. # Same OpenAI-compatible image flow used by the content platform:
  154. # ask compatible gateways for a downloadable URL, then still accept b64.
  155. payload["response_format"] = "url"
  156. payload["store"] = False
  157. if wants_transparent:
  158. payload["background"] = "transparent"
  159. payload["output_format"] = "png"
  160. try:
  161. data = _http_json(url, payload, headers, timeout)
  162. except urllib.error.HTTPError as e:
  163. body = _read_http_error(e)
  164. if _should_retry_without_url_fields(e.code, body):
  165. fallback = dict(payload)
  166. fallback.pop("response_format", None)
  167. fallback.pop("store", None)
  168. fallback.pop("background", None)
  169. fallback.pop("output_format", None)
  170. try:
  171. data = _http_json(url, fallback, headers, timeout)
  172. except urllib.error.HTTPError as e2:
  173. body2 = _read_http_error(e2)
  174. raise RuntimeError(f"图像接口 HTTP {e2.code}:{body2[:600]}\n"
  175. f"(model={model}, url={url};已尝试移除 response_format/store 重试)")
  176. else:
  177. raise RuntimeError(f"图像接口 HTTP {e.code}:{body[:600]}\n"
  178. f"(model={model}, url={url})")
  179. except urllib.error.URLError as e:
  180. raise RuntimeError(f"连不上图像接口:{e}\n(检查 Base URL / 网络 / 证书;url={url})")
  181. item = _extract_image_item(data)
  182. source_url = None
  183. if item.get("b64_json"):
  184. raw = base64.b64decode(item["b64_json"])
  185. elif item.get("base64"):
  186. raw = base64.b64decode(item["base64"])
  187. elif item.get("image"):
  188. raw = base64.b64decode(item["image"])
  189. elif item.get("url"):
  190. source_url = item["url"]
  191. raw = _http_bytes(source_url, timeout)
  192. else:
  193. raise RuntimeError(f"返回里既无 b64_json/base64/image 也无 url:{_short_json(data)}")
  194. img = Image.open(io.BytesIO(raw)).convert("RGBA")
  195. img = _auto_declutter(img)
  196. img.info["source_url"] = source_url or ""
  197. return img
  198. def _auto_declutter(img):
  199. """统一出口:若模型把"透明背景"画成了灰白棋盘格(真像素、alpha 全 255),
  200. 自动抠成真透明。对本就透明 / 无棋盘格的图(角色、整张背景)是无操作,
  201. 所以可以无差别地套在每张生成图上;失败时绝不影响主流程。"""
  202. try:
  203. import dechecker
  204. cleaned, changed = dechecker.declutter_img(img)
  205. return cleaned if changed else img
  206. except Exception:
  207. return img
  208. PROVIDERS = {"OpenAI 兼容接口": gen_image_openai}
  209. def generate(provider, prompt, api_key, base_url, model, size):
  210. fn = PROVIDERS.get(provider)
  211. if fn is None:
  212. raise ValueError(f"未知 provider: {provider}")
  213. return fn(prompt, api_key=api_key, base_url=base_url, model=model, size=size)
  214. TEXT_MODEL_FALLBACKS = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.5"]
  215. def _chat_json_once(url, headers, messages, model, timeout):
  216. payload = {
  217. "model": model,
  218. "messages": messages,
  219. "temperature": 0.75,
  220. "response_format": {"type": "json_object"},
  221. }
  222. try:
  223. data = _http_json(url, payload, headers, timeout)
  224. except urllib.error.HTTPError as e:
  225. fallback = dict(payload)
  226. fallback.pop("response_format", None)
  227. try:
  228. data = _http_json(url, fallback, headers, timeout)
  229. except urllib.error.HTTPError as e2:
  230. raise e2
  231. content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip()
  232. if content.startswith("```"):
  233. content = content.strip("`")
  234. if content.lower().startswith("json"):
  235. content = content[4:].strip()
  236. try:
  237. obj = json.loads(content)
  238. except Exception as e:
  239. raise RuntimeError(f"文字模型没有返回合法 JSON:{e};内容={content[:800]}")
  240. if isinstance(obj, dict):
  241. obj.setdefault("_model_used", model)
  242. return obj
  243. def chat_json_openai(messages, api_key, base_url="https://api.openai.com/v1",
  244. model="gpt-5.4-mini", timeout=120):
  245. url = base_url.rstrip("/") + "/chat/completions"
  246. headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
  247. candidates = [model] + [x for x in TEXT_MODEL_FALLBACKS if x != model]
  248. errors = []
  249. for candidate in candidates:
  250. try:
  251. return _chat_json_once(url, headers, messages, candidate, timeout)
  252. except urllib.error.HTTPError as e:
  253. body = _read_http_error(e)
  254. errors.append(f"{candidate}: HTTP {e.code} {body[:180]}")
  255. if e.code not in (429, 500, 502, 503, 504):
  256. break
  257. raise RuntimeError(f"文字模型不可用,已尝试 {', '.join(candidates)}:"
  258. f"{' | '.join(errors)}\n(url={url};已移除 response_format 重试)")
  259. def analyze_reference_images(reference_urls=None, image_data_urls=None, api_key="",
  260. base_url="https://api.openai.com/v1", model="gpt-5.4-mini",
  261. timeout=120):
  262. """Use a vision-capable OpenAI-compatible chat model to summarize style refs.
  263. `reference_urls` can include direct image URLs. `image_data_urls` are browser
  264. FileReader data URLs from uploaded screenshots/images.
  265. """
  266. reference_urls = [x for x in (reference_urls or []) if x][:4]
  267. image_data_urls = [x for x in (image_data_urls or []) if x][:4]
  268. if not reference_urls and not image_data_urls:
  269. return {}
  270. content = [{
  271. "type": "text",
  272. "text": (
  273. "分析这些参考图/截图的视觉方向,用于原创移动老虎机游戏美术生成。"
  274. "只提炼风格,不要复刻具体 IP、logo、角色或版式。"
  275. "返回 JSON:art_style, palette, materials, ui_shape_language, character_direction, "
  276. "background_direction, avoid, image_prompt_style。"
  277. ),
  278. }]
  279. for url in reference_urls:
  280. try:
  281. content.append({"type": "image_url", "image_url": {"url": _url_image_to_data_url(url, timeout=30)}})
  282. except Exception as e:
  283. content.append({
  284. "type": "text",
  285. "text": f"参考图 URL 本地下载失败,不能作为视觉输入,只能作为文字线索:{url};错误:{str(e)[:160]}",
  286. })
  287. for data_url in image_data_urls:
  288. content.append({"type": "image_url", "image_url": {"url": data_url}})
  289. return chat_json_openai([
  290. {"role": "system", "content": "你是游戏美术总监,擅长把参考图转成原创美术风格规范。只输出 JSON。"},
  291. {"role": "user", "content": content},
  292. ], api_key=api_key, base_url=base_url, model=model, timeout=timeout)