providers.py 12 KB

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