613 lines
19 KiB
Python
613 lines
19 KiB
Python
#!/usr/bin/env python3
|
||
"""gpt-image-2.5 文生图 / 图生图工具。
|
||
|
||
支持:
|
||
- AI / Agent CLI 调用(参数或 JSON)
|
||
- 流程回调(HTTP webhook / 状态文件 / NDJSON 事件流)
|
||
- 指定本地生成保存路径
|
||
|
||
接口文档: Doc/gpt-image_doc.md
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from pathlib import Path
|
||
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
|
||
|
||
DEFAULT_MODEL = "gpt-image-2.5"
|
||
VIP_MODEL = "gpt-image-2-vip"
|
||
DEFAULT_API_KEY = "sk-b69877bd82c6412ca4872351f428d580"
|
||
DEFAULT_BASE_URL = "https://grsai.dakka.com.cn" # 国内节点;全球: https://grsaiapi.com
|
||
DEFAULT_ASPECT_RATIO = "1024x1024"
|
||
DEFAULT_QUALITY = "auto"
|
||
DEFAULT_REPLY_TYPE = "json"
|
||
POLL_INTERVAL_SEC = 2.0
|
||
POLL_TIMEOUT_SEC = 300.0
|
||
# 标准模型仅支持约 1K;任一边超过该阈值需改用 VIP 像素尺寸
|
||
MAX_STANDARD_EDGE = 1024
|
||
VIP_MODELS = frozenset(
|
||
{
|
||
"gpt-image-2-vip",
|
||
"gpt-image-2.5-flare",
|
||
"gpt-image-2.5-sunburst",
|
||
}
|
||
)
|
||
|
||
|
||
class GenerateError(RuntimeError):
|
||
"""生成失败。"""
|
||
|
||
|
||
def _now_iso() -> str:
|
||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||
|
||
|
||
def _resolve_api_key(explicit: Optional[str] = None) -> str:
|
||
return (
|
||
explicit
|
||
or os.environ.get("GRSAI_API_KEY")
|
||
or os.environ.get("GPT_IMAGE_API_KEY")
|
||
or DEFAULT_API_KEY
|
||
)
|
||
|
||
|
||
def _normalize_base_url(base_url: str) -> str:
|
||
return base_url.rstrip("/")
|
||
|
||
|
||
def _guess_mime(path: Path) -> str:
|
||
mime, _ = mimetypes.guess_type(str(path))
|
||
return mime or "application/octet-stream"
|
||
|
||
|
||
def _file_to_data_url(path: Path) -> str:
|
||
data = path.read_bytes()
|
||
b64 = base64.b64encode(data).decode("ascii")
|
||
return f"data:{_guess_mime(path)};base64,{b64}"
|
||
|
||
|
||
def parse_pixel_size(aspect_ratio: str) -> Optional[tuple[int, int]]:
|
||
"""解析 '2048x2048' / '2048X1152';比例字符串(如 16:9)返回 None。"""
|
||
text = (aspect_ratio or "").strip().lower().replace(" ", "")
|
||
if "x" not in text:
|
||
return None
|
||
left, _, right = text.partition("x")
|
||
if not left.isdigit() or not right.isdigit():
|
||
return None
|
||
return int(left), int(right)
|
||
|
||
|
||
def needs_vip_for_size(aspect_ratio: str) -> bool:
|
||
"""任一边 >1024 的像素尺寸需要 VIP 模型。"""
|
||
size = parse_pixel_size(aspect_ratio)
|
||
if size is None:
|
||
return False
|
||
w, h = size
|
||
return w > MAX_STANDARD_EDGE or h > MAX_STANDARD_EDGE
|
||
|
||
|
||
def resolve_model_for_size(model: str, aspect_ratio: str) -> str:
|
||
"""大于 1K 时:已是 VIP 系列则保留;否则自动升级到 gpt-image-2-vip。"""
|
||
if not needs_vip_for_size(aspect_ratio):
|
||
return model
|
||
if model in VIP_MODELS:
|
||
return model
|
||
return VIP_MODEL
|
||
|
||
|
||
def normalize_image_ref(ref: str) -> str:
|
||
"""将本地路径转为 data URL;URL / base64 / data URL 原样返回。"""
|
||
text = ref.strip()
|
||
if not text:
|
||
raise GenerateError("空的参考图路径")
|
||
if text.startswith(("http://", "https://", "data:")):
|
||
return text
|
||
# 纯 base64(无 data: 前缀)
|
||
if len(text) > 256 and all(
|
||
c.isalnum() or c in "+/=\n\r" for c in text[:200]
|
||
) and not Path(text).exists():
|
||
return text
|
||
|
||
path = Path(text).expanduser().resolve()
|
||
if not path.is_file():
|
||
raise GenerateError(f"参考图不存在: {path}")
|
||
return _file_to_data_url(path)
|
||
|
||
|
||
def http_json(
|
||
method: str,
|
||
url: str,
|
||
*,
|
||
headers: Optional[Dict[str, str]] = None,
|
||
body: Optional[Dict[str, Any]] = None,
|
||
timeout: float = 300.0,
|
||
) -> Dict[str, Any]:
|
||
data = None
|
||
req_headers = {"Accept": "application/json", **(headers or {})}
|
||
if body is not None:
|
||
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||
req_headers["Content-Type"] = "application/json"
|
||
|
||
request = urllib.request.Request(url, data=data, headers=req_headers, method=method)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
||
raw = resp.read().decode("utf-8")
|
||
if not raw.strip():
|
||
return {}
|
||
return json.loads(raw)
|
||
except urllib.error.HTTPError as exc:
|
||
err_body = exc.read().decode("utf-8", errors="replace")
|
||
try:
|
||
payload = json.loads(err_body) if err_body else {}
|
||
except json.JSONDecodeError:
|
||
payload = {"error": err_body}
|
||
message = payload.get("error") or err_body or str(exc)
|
||
raise GenerateError(f"HTTP {exc.code}: {message}") from exc
|
||
except urllib.error.URLError as exc:
|
||
raise GenerateError(f"网络错误: {exc}") from exc
|
||
|
||
|
||
def download_file(url: str, dest: Path, timeout: float = 120.0) -> Path:
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
request = urllib.request.Request(url, headers={"User-Agent": "AIGame-gpt-image/1.0"})
|
||
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
||
dest.write_bytes(resp.read())
|
||
return dest
|
||
|
||
|
||
class CallbackHub:
|
||
"""向 stdout / 文件 / HTTP webhook 广播流程事件。"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
callback_url: Optional[str] = None,
|
||
callback_file: Optional[Union[str, Path]] = None,
|
||
quiet: bool = False,
|
||
) -> None:
|
||
self.callback_url = callback_url
|
||
self.callback_file = Path(callback_file) if callback_file else None
|
||
self.quiet = quiet
|
||
self.events: List[Dict[str, Any]] = []
|
||
|
||
def emit(self, event: str, **payload: Any) -> Dict[str, Any]:
|
||
record = {
|
||
"event": event,
|
||
"ts": _now_iso(),
|
||
**payload,
|
||
}
|
||
self.events.append(record)
|
||
|
||
if not self.quiet:
|
||
# NDJSON,便于 AI / 流程引擎按行解析
|
||
print(json.dumps(record, ensure_ascii=False), flush=True)
|
||
|
||
if self.callback_file:
|
||
self.callback_file.parent.mkdir(parents=True, exist_ok=True)
|
||
self.callback_file.write_text(
|
||
json.dumps(
|
||
{"latest": record, "history": self.events},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
if self.callback_url:
|
||
try:
|
||
http_json("POST", self.callback_url, body=record, timeout=30.0)
|
||
except Exception as exc: # noqa: BLE001 — 回调失败不阻断主流程
|
||
if not self.quiet:
|
||
warn = {
|
||
"event": "callback_error",
|
||
"ts": _now_iso(),
|
||
"error": str(exc),
|
||
"callback_url": self.callback_url,
|
||
}
|
||
print(json.dumps(warn, ensure_ascii=False), flush=True)
|
||
|
||
return record
|
||
|
||
|
||
def query_result(
|
||
base_url: str,
|
||
task_id: str,
|
||
api_key: str,
|
||
timeout: float = 60.0,
|
||
) -> Dict[str, Any]:
|
||
url = f"{_normalize_base_url(base_url)}/v1/api/result?{urllib.parse.urlencode({'id': task_id})}"
|
||
return http_json(
|
||
"GET",
|
||
url,
|
||
headers={"Authorization": f"Bearer {api_key}"},
|
||
timeout=timeout,
|
||
)
|
||
|
||
|
||
def submit_generate(
|
||
*,
|
||
prompt: str,
|
||
api_key: str,
|
||
base_url: str = DEFAULT_BASE_URL,
|
||
model: str = DEFAULT_MODEL,
|
||
images: Optional[Sequence[str]] = None,
|
||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||
quality: str = DEFAULT_QUALITY,
|
||
reply_type: str = DEFAULT_REPLY_TYPE,
|
||
background: Optional[str] = None,
|
||
mask: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
image_refs = [normalize_image_ref(x) for x in (images or [])]
|
||
body: Dict[str, Any] = {
|
||
"model": model,
|
||
"prompt": prompt,
|
||
"images": image_refs,
|
||
"aspectRatio": aspect_ratio,
|
||
"quality": quality,
|
||
"replyType": reply_type,
|
||
}
|
||
if background:
|
||
body["background"] = background
|
||
if mask:
|
||
body["mask"] = normalize_image_ref(mask)
|
||
|
||
url = f"{_normalize_base_url(base_url)}/v1/api/generate"
|
||
return http_json(
|
||
"POST",
|
||
url,
|
||
headers={"Authorization": f"Bearer {api_key}"},
|
||
body=body,
|
||
timeout=300.0,
|
||
)
|
||
|
||
|
||
def wait_until_done(
|
||
*,
|
||
base_url: str,
|
||
api_key: str,
|
||
initial: Dict[str, Any],
|
||
reply_type: str,
|
||
callbacks: CallbackHub,
|
||
poll_interval: float = POLL_INTERVAL_SEC,
|
||
poll_timeout: float = POLL_TIMEOUT_SEC,
|
||
) -> Dict[str, Any]:
|
||
status = (initial.get("status") or "").lower()
|
||
if status in {"succeeded", "failed", "violation"} or (
|
||
reply_type == "json" and initial.get("results")
|
||
):
|
||
return initial
|
||
|
||
task_id = initial.get("id")
|
||
if not task_id:
|
||
raise GenerateError(f"响应缺少任务 id: {initial}")
|
||
|
||
deadline = time.time() + poll_timeout
|
||
last_progress = initial.get("progress")
|
||
current = initial
|
||
|
||
while time.time() < deadline:
|
||
current = query_result(base_url, task_id, api_key)
|
||
status = (current.get("status") or "").lower()
|
||
progress = current.get("progress")
|
||
if progress != last_progress:
|
||
callbacks.emit(
|
||
"progress",
|
||
id=task_id,
|
||
status=status,
|
||
progress=progress,
|
||
)
|
||
last_progress = progress
|
||
|
||
if status == "succeeded" or current.get("results"):
|
||
return current
|
||
if status in {"failed", "violation"}:
|
||
raise GenerateError(current.get("error") or f"任务失败: {current}")
|
||
|
||
time.sleep(poll_interval)
|
||
|
||
raise GenerateError(f"等待超时({poll_timeout}s),最后状态: {current}")
|
||
|
||
|
||
def resolve_output_paths(
|
||
output: Optional[str],
|
||
count: int,
|
||
) -> List[Path]:
|
||
if not output:
|
||
stamp = time.strftime("%Y%m%d_%H%M%S")
|
||
output = str(Path("output") / f"gpt_image_{stamp}.png")
|
||
|
||
path = Path(output).expanduser()
|
||
if count <= 1:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
return [path]
|
||
|
||
# 多图:若给的是文件路径,则追加 _1/_2;若是目录则编号保存
|
||
if path.suffix:
|
||
stem, suffix = path.stem, path.suffix
|
||
parent = path.parent
|
||
parent.mkdir(parents=True, exist_ok=True)
|
||
return [parent / f"{stem}_{i + 1}{suffix}" for i in range(count)]
|
||
|
||
path.mkdir(parents=True, exist_ok=True)
|
||
return [path / f"image_{i + 1}.png" for i in range(count)]
|
||
|
||
|
||
def save_results(
|
||
result: Dict[str, Any],
|
||
output: Optional[str],
|
||
) -> List[Dict[str, Any]]:
|
||
results = result.get("results") or []
|
||
if not results:
|
||
raise GenerateError(f"成功响应但无 results: {result}")
|
||
|
||
paths = resolve_output_paths(output, len(results))
|
||
saved: List[Dict[str, Any]] = []
|
||
for item, dest in zip(results, paths):
|
||
url = item.get("url")
|
||
if not url:
|
||
raise GenerateError(f"结果缺少 url: {item}")
|
||
download_file(url, dest)
|
||
saved.append({"url": url, "path": str(dest.resolve())})
|
||
return saved
|
||
|
||
|
||
def generate(
|
||
*,
|
||
prompt: str,
|
||
output: Optional[str] = None,
|
||
images: Optional[Sequence[str]] = None,
|
||
model: str = DEFAULT_MODEL,
|
||
api_key: Optional[str] = None,
|
||
base_url: str = DEFAULT_BASE_URL,
|
||
aspect_ratio: str = DEFAULT_ASPECT_RATIO,
|
||
quality: str = DEFAULT_QUALITY,
|
||
reply_type: str = DEFAULT_REPLY_TYPE,
|
||
background: Optional[str] = None,
|
||
mask: Optional[str] = None,
|
||
callback_url: Optional[str] = None,
|
||
callback_file: Optional[str] = None,
|
||
poll_interval: float = POLL_INTERVAL_SEC,
|
||
poll_timeout: float = POLL_TIMEOUT_SEC,
|
||
quiet: bool = False,
|
||
on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""执行一次文/图生图,返回结构化结果(可供流程编排直接使用)。"""
|
||
key = _resolve_api_key(api_key)
|
||
model = resolve_model_for_size(model, aspect_ratio)
|
||
callbacks = CallbackHub(
|
||
callback_url=callback_url,
|
||
callback_file=callback_file,
|
||
quiet=quiet,
|
||
)
|
||
if on_event:
|
||
original_emit = callbacks.emit
|
||
|
||
def emit_with_hook(event: str, **payload: Any) -> Dict[str, Any]:
|
||
record = original_emit(event, **payload)
|
||
on_event(record)
|
||
return record
|
||
|
||
callbacks.emit = emit_with_hook # type: ignore[method-assign]
|
||
|
||
callbacks.emit(
|
||
"started",
|
||
model=model,
|
||
prompt=prompt,
|
||
output=output,
|
||
aspect_ratio=aspect_ratio,
|
||
image_count=len(images or []),
|
||
reply_type=reply_type,
|
||
)
|
||
|
||
try:
|
||
submitted = submit_generate(
|
||
prompt=prompt,
|
||
api_key=key,
|
||
base_url=base_url,
|
||
model=model,
|
||
images=images,
|
||
aspect_ratio=aspect_ratio,
|
||
quality=quality,
|
||
reply_type=reply_type,
|
||
background=background,
|
||
mask=mask,
|
||
)
|
||
callbacks.emit(
|
||
"submitted",
|
||
id=submitted.get("id"),
|
||
status=submitted.get("status"),
|
||
progress=submitted.get("progress"),
|
||
)
|
||
|
||
# async / 未完成时轮询
|
||
effective_reply = reply_type
|
||
if (submitted.get("status") or "").lower() == "running" and not submitted.get(
|
||
"results"
|
||
):
|
||
effective_reply = "async"
|
||
|
||
final = wait_until_done(
|
||
base_url=base_url,
|
||
api_key=key,
|
||
initial=submitted,
|
||
reply_type=effective_reply,
|
||
callbacks=callbacks,
|
||
poll_interval=poll_interval,
|
||
poll_timeout=poll_timeout,
|
||
)
|
||
|
||
status = (final.get("status") or "").lower()
|
||
if status in {"failed", "violation"}:
|
||
raise GenerateError(final.get("error") or f"任务失败: {final}")
|
||
|
||
saved = save_results(final, output)
|
||
result = {
|
||
"ok": True,
|
||
"id": final.get("id"),
|
||
"status": final.get("status") or "succeeded",
|
||
"progress": final.get("progress", 100),
|
||
"model": model,
|
||
"prompt": prompt,
|
||
"files": saved,
|
||
"raw": final,
|
||
}
|
||
callbacks.emit("succeeded", **{k: v for k, v in result.items() if k != "raw"})
|
||
return result
|
||
except Exception as exc:
|
||
err = {"ok": False, "error": str(exc)}
|
||
callbacks.emit("failed", **err)
|
||
raise
|
||
|
||
|
||
def _load_json_arg(raw: Optional[str]) -> Dict[str, Any]:
|
||
if not raw:
|
||
return {}
|
||
if raw == "-":
|
||
return json.load(sys.stdin)
|
||
path = Path(raw)
|
||
if path.is_file():
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
return json.loads(raw)
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
p = argparse.ArgumentParser(
|
||
prog="gpt_image_generate",
|
||
description="gpt-image-2.5 文生图/图生图(支持 AI 调用与流程回调)",
|
||
)
|
||
p.add_argument("--prompt", "-p", help="提示词")
|
||
p.add_argument(
|
||
"--image",
|
||
"-i",
|
||
action="append",
|
||
dest="images",
|
||
default=[],
|
||
help="参考图(可多次;本地路径 / URL / base64)",
|
||
)
|
||
p.add_argument(
|
||
"--output",
|
||
"-o",
|
||
help="生成图保存路径(文件或目录)。多图时自动编号",
|
||
)
|
||
p.add_argument(
|
||
"--model",
|
||
default=DEFAULT_MODEL,
|
||
help=f"模型,默认 {DEFAULT_MODEL};像素任一边>1024 时自动升为 {VIP_MODEL}",
|
||
)
|
||
p.add_argument("--api-key", default=None, help="API Key,默认读环境变量或内置配置")
|
||
p.add_argument(
|
||
"--base-url",
|
||
default=DEFAULT_BASE_URL,
|
||
help="API 节点,默认国内节点",
|
||
)
|
||
p.add_argument(
|
||
"--aspect-ratio",
|
||
default=DEFAULT_ASPECT_RATIO,
|
||
help='比例或像素,如 "16:9" / "1024x1024";>1K 请用像素如 "2048x2048"(自动 VIP)',
|
||
)
|
||
p.add_argument("--quality", default=DEFAULT_QUALITY, help="质量: auto/low/medium/high/...")
|
||
p.add_argument(
|
||
"--reply-type",
|
||
default=DEFAULT_REPLY_TYPE,
|
||
choices=["json", "stream", "async"],
|
||
help="回复类型",
|
||
)
|
||
p.add_argument("--background", default=None, help="背景参数,如 transparent(VIP 模型)")
|
||
p.add_argument("--mask", default=None, help="mask 图路径或 URL")
|
||
p.add_argument(
|
||
"--callback-url",
|
||
default=None,
|
||
help="流程回调 URL:每个事件 POST JSON",
|
||
)
|
||
p.add_argument(
|
||
"--callback-file",
|
||
default=None,
|
||
help="流程回调状态文件(持续覆盖写入 latest/history)",
|
||
)
|
||
p.add_argument("--poll-interval", type=float, default=POLL_INTERVAL_SEC)
|
||
p.add_argument("--poll-timeout", type=float, default=POLL_TIMEOUT_SEC)
|
||
p.add_argument(
|
||
"--json",
|
||
dest="json_input",
|
||
default=None,
|
||
help='JSON 参数(对象字符串 / 文件路径 / "-" 读 stdin)',
|
||
)
|
||
p.add_argument(
|
||
"--result-only",
|
||
action="store_true",
|
||
help="只输出最终结果 JSON(抑制中间 NDJSON 事件到 stdout,仍写回调)",
|
||
)
|
||
p.add_argument(
|
||
"--quiet",
|
||
action="store_true",
|
||
help="不向 stdout 打印事件流(最终结果仍打印)",
|
||
)
|
||
return p
|
||
|
||
|
||
def main(argv: Optional[Sequence[str]] = None) -> int:
|
||
parser = build_parser()
|
||
args = parser.parse_args(argv)
|
||
payload = _load_json_arg(args.json_input)
|
||
|
||
prompt = payload.get("prompt") or args.prompt
|
||
if not prompt:
|
||
parser.error("必须提供 --prompt 或 JSON.prompt")
|
||
|
||
images = payload.get("images") or args.images or []
|
||
if isinstance(images, str):
|
||
images = [images]
|
||
|
||
output = payload.get("output") or args.output
|
||
quiet_events = bool(args.quiet or args.result_only or payload.get("quiet"))
|
||
|
||
try:
|
||
result = generate(
|
||
prompt=prompt,
|
||
output=output,
|
||
images=images,
|
||
model=payload.get("model") or args.model,
|
||
api_key=payload.get("api_key") or args.api_key,
|
||
base_url=payload.get("base_url") or args.base_url,
|
||
aspect_ratio=payload.get("aspectRatio")
|
||
or payload.get("aspect_ratio")
|
||
or args.aspect_ratio,
|
||
quality=payload.get("quality") or args.quality,
|
||
reply_type=payload.get("replyType")
|
||
or payload.get("reply_type")
|
||
or args.reply_type,
|
||
background=payload.get("background") or args.background,
|
||
mask=payload.get("mask") or args.mask,
|
||
callback_url=payload.get("callback_url") or args.callback_url,
|
||
callback_file=payload.get("callback_file") or args.callback_file,
|
||
poll_interval=float(payload.get("poll_interval") or args.poll_interval),
|
||
poll_timeout=float(payload.get("poll_timeout") or args.poll_timeout),
|
||
quiet=quiet_events,
|
||
)
|
||
except GenerateError as exc:
|
||
err = {"ok": False, "error": str(exc)}
|
||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||
return 1
|
||
except Exception as exc: # noqa: BLE001
|
||
err = {"ok": False, "error": str(exc)}
|
||
print(json.dumps(err, ensure_ascii=False), flush=True)
|
||
return 2
|
||
|
||
# 最终结果单独一行,AI / 流程可取最后一行或 --result-only 模式
|
||
print(json.dumps(result, ensure_ascii=False), flush=True)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|