providers.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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 _short_json(data, limit=600):
  71. try:
  72. return json.dumps(data, ensure_ascii=False)[:limit]
  73. except Exception:
  74. return str(data)[:limit]
  75. def _read_http_error(error):
  76. body = error.read().decode("utf-8", "ignore")[:1200]
  77. return body
  78. def _should_retry_without_url_fields(status, body):
  79. if status not in (400, 422):
  80. return False
  81. text = body.lower()
  82. retry_markers = [
  83. "response_format",
  84. "store",
  85. "background",
  86. "output_format",
  87. "unknown parameter",
  88. "unsupported parameter",
  89. "unrecognized",
  90. "extra inputs",
  91. "not permitted",
  92. ]
  93. return any(marker in text for marker in retry_markers)
  94. def _extract_image_item(data):
  95. if isinstance(data, dict):
  96. if data.get("data"):
  97. items = data["data"]
  98. elif data.get("images"):
  99. items = data["images"]
  100. else:
  101. items = [data]
  102. elif isinstance(data, list):
  103. items = data
  104. else:
  105. items = []
  106. if not items:
  107. return {}
  108. item = items[0]
  109. if isinstance(item, str):
  110. if item.startswith("http://") or item.startswith("https://"):
  111. return {"url": item}
  112. return {"b64_json": item}
  113. if not isinstance(item, dict):
  114. return {}
  115. image_url = item.get("image_url")
  116. if isinstance(image_url, dict) and image_url.get("url"):
  117. return {"url": image_url["url"]}
  118. return item
  119. def gen_image_openai(prompt, api_key, base_url="https://api.openai.com/v1",
  120. model="gpt-image-2", size="1024x1024", timeout=180):
  121. """调用 OpenAI 兼容图像接口(含第三方中转),返回 PIL.Image (RGBA)。
  122. 兼容性处理:
  123. - 仅对 gpt-image* 模型发送 background/output_format 等专属参数;其他模型
  124. (dall-e-3 / flux / sora_image / gpt-4o-image 等中转常见模型)不发,避免被拒。
  125. - 返回兼容 b64_json 或 url 两种格式。
  126. """
  127. url = base_url.rstrip("/") + "/images/generations"
  128. headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
  129. model_name = model.lower()
  130. payload = {"model": model, "prompt": prompt, "size": size, "n": 1}
  131. wants_transparent = "transparent background" in prompt.lower() or "no background" in prompt.lower()
  132. if model_name == "gpt-image-2":
  133. # x.long.bid exposes gpt-image-2, but its upstream only accepts the
  134. # minimal image payload. Adding background/output_format causes 502.
  135. pass
  136. elif "gpt-image" in model_name:
  137. # gpt-image-1 official-compatible endpoints can use these fields.
  138. # If a gateway rejects them with 400/422, the retry path strips them.
  139. if wants_transparent:
  140. payload["background"] = "transparent"
  141. payload["output_format"] = "png"
  142. else:
  143. # Same OpenAI-compatible image flow used by the content platform:
  144. # ask compatible gateways for a downloadable URL, then still accept b64.
  145. payload["response_format"] = "url"
  146. payload["store"] = False
  147. if wants_transparent:
  148. payload["background"] = "transparent"
  149. payload["output_format"] = "png"
  150. try:
  151. data = _http_json(url, payload, headers, timeout)
  152. except urllib.error.HTTPError as e:
  153. body = _read_http_error(e)
  154. if _should_retry_without_url_fields(e.code, body):
  155. fallback = dict(payload)
  156. fallback.pop("response_format", None)
  157. fallback.pop("store", None)
  158. fallback.pop("background", None)
  159. fallback.pop("output_format", None)
  160. try:
  161. data = _http_json(url, fallback, headers, timeout)
  162. except urllib.error.HTTPError as e2:
  163. body2 = _read_http_error(e2)
  164. raise RuntimeError(f"图像接口 HTTP {e2.code}:{body2[:600]}\n"
  165. f"(model={model}, url={url};已尝试移除 response_format/store 重试)")
  166. else:
  167. raise RuntimeError(f"图像接口 HTTP {e.code}:{body[:600]}\n"
  168. f"(model={model}, url={url})")
  169. except urllib.error.URLError as e:
  170. raise RuntimeError(f"连不上图像接口:{e}\n(检查 Base URL / 网络 / 证书;url={url})")
  171. item = _extract_image_item(data)
  172. source_url = None
  173. if item.get("b64_json"):
  174. raw = base64.b64decode(item["b64_json"])
  175. elif item.get("base64"):
  176. raw = base64.b64decode(item["base64"])
  177. elif item.get("image"):
  178. raw = base64.b64decode(item["image"])
  179. elif item.get("url"):
  180. source_url = item["url"]
  181. raw = _http_bytes(source_url, timeout)
  182. else:
  183. raise RuntimeError(f"返回里既无 b64_json/base64/image 也无 url:{_short_json(data)}")
  184. img = Image.open(io.BytesIO(raw)).convert("RGBA")
  185. img = _auto_declutter(img)
  186. img.info["source_url"] = source_url or ""
  187. return img
  188. def _auto_declutter(img):
  189. """统一出口:若模型把"透明背景"画成了灰白棋盘格(真像素、alpha 全 255),
  190. 自动抠成真透明。对本就透明 / 无棋盘格的图(角色、整张背景)是无操作,
  191. 所以可以无差别地套在每张生成图上;失败时绝不影响主流程。"""
  192. try:
  193. import dechecker
  194. cleaned, changed = dechecker.declutter_img(img)
  195. return cleaned if changed else img
  196. except Exception:
  197. return img
  198. PROVIDERS = {"OpenAI 兼容接口": gen_image_openai}
  199. def generate(provider, prompt, api_key, base_url, model, size):
  200. fn = PROVIDERS.get(provider)
  201. if fn is None:
  202. raise ValueError(f"未知 provider: {provider}")
  203. return fn(prompt, api_key=api_key, base_url=base_url, model=model, size=size)
  204. def chat_json_openai(messages, api_key, base_url="https://api.openai.com/v1",
  205. model="gpt-4o-mini", timeout=120):
  206. url = base_url.rstrip("/") + "/chat/completions"
  207. headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
  208. payload = {
  209. "model": model,
  210. "messages": messages,
  211. "temperature": 0.75,
  212. "response_format": {"type": "json_object"},
  213. }
  214. try:
  215. data = _http_json(url, payload, headers, timeout)
  216. except urllib.error.HTTPError as e:
  217. body = _read_http_error(e)
  218. fallback = dict(payload)
  219. fallback.pop("response_format", None)
  220. try:
  221. data = _http_json(url, fallback, headers, timeout)
  222. except urllib.error.HTTPError as e2:
  223. body2 = _read_http_error(e2)
  224. raise RuntimeError(f"文字模型 HTTP {e2.code}:{body2[:600]}\n"
  225. f"(model={model}, url={url};已移除 response_format 重试)")
  226. content = (((data.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip()
  227. if content.startswith("```"):
  228. content = content.strip("`")
  229. if content.lower().startswith("json"):
  230. content = content[4:].strip()
  231. try:
  232. return json.loads(content)
  233. except Exception as e:
  234. raise RuntimeError(f"文字模型没有返回合法 JSON:{e};内容={content[:800]}")
  235. def analyze_reference_images(reference_urls=None, image_data_urls=None, api_key="",
  236. base_url="https://api.openai.com/v1", model="gpt-4o-mini",
  237. timeout=120):
  238. """Use a vision-capable OpenAI-compatible chat model to summarize style refs.
  239. `reference_urls` can include direct image URLs. `image_data_urls` are browser
  240. FileReader data URLs from uploaded screenshots/images.
  241. """
  242. reference_urls = [x for x in (reference_urls or []) if x][:4]
  243. image_data_urls = [x for x in (image_data_urls or []) if x][:4]
  244. if not reference_urls and not image_data_urls:
  245. return {}
  246. content = [{
  247. "type": "text",
  248. "text": (
  249. "分析这些参考图/截图的视觉方向,用于原创移动老虎机游戏美术生成。"
  250. "只提炼风格,不要复刻具体 IP、logo、角色或版式。"
  251. "返回 JSON:art_style, palette, materials, ui_shape_language, character_direction, "
  252. "background_direction, avoid, image_prompt_style。"
  253. ),
  254. }]
  255. for url in reference_urls:
  256. content.append({"type": "image_url", "image_url": {"url": url}})
  257. for data_url in image_data_urls:
  258. content.append({"type": "image_url", "image_url": {"url": data_url}})
  259. return chat_json_openai([
  260. {"role": "system", "content": "你是游戏美术总监,擅长把参考图转成原创美术风格规范。只输出 JSON。"},
  261. {"role": "user", "content": content},
  262. ], api_key=api_key, base_url=base_url, model=model, timeout=timeout)