from __future__ import annotations import base64 import json import mimetypes import os import traceback import time from dataclasses import dataclass from datetime import datetime from io import BytesIO from pathlib import Path from typing import Any from urllib.parse import urlparse import requests from PIL import Image, ImageFile DEFAULT_PROMPT = "高端电子产品海报,干净背景,商业摄影,细节清晰" DEFAULT_MODEL = "gpt-image-2" DEFAULT_PIXEL_SIZE = "4096x4096" DEFAULT_QUALITY = "high" DEFAULT_OUTPUT_FILE = "output.png" DEFAULT_RESPONSE_DIR = "outputs/image-demo-runs" DEFAULT_CONNECT_TIMEOUT = 10 DEFAULT_READ_TIMEOUT = 900 DEFAULT_IMAGE_FETCH_TIMEOUT = 120 DEFAULT_POLL_CONNECT_TIMEOUT = 10 DEFAULT_POLL_READ_TIMEOUT = 60 DEFAULT_POLL_INTERVAL = 5 DEFAULT_INITIAL_POLL_DELAY = 10 DEFAULT_MAX_POLL_WAIT = 1800 DIRECT_HOSTS = {"vip.auto-code.net","api.toskaxy.xyz","api.aoixx.com"} SUPPORTED_MODERATION_LEVELS = {"auto", "low"} SUPPORTED_OUTPUT_FORMATS = {"png", "jpeg", "webp"} STREAM_COMPLETED_EVENT_TYPES = {"image_generation.completed", "image_edit.completed"} STREAM_PARTIAL_EVENT_TYPES = {"image_generation.partial_image", "image_edit.partial_image"} SUPPORTED_ASPECT_RATIOS = { "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "5:4", "4:5", "2:1", "1:2", "21:9", "9:21", } OPENAI_RATIO_SIZE_MAP = { "1:1": "1024x1024", "16:9": "1536x864", "9:16": "864x1536", "4:3": "1536x1152", "3:4": "1152x1536", "3:2": "1536x1024", "2:3": "1024x1536", "5:4": "1280x1024", "4:5": "1024x1280", "2:1": "1536x768", "1:2": "768x1536", "21:9": "1680x720", "9:21": "720x1680", } @dataclass(frozen=True) class ImageInput: filename: str mime_type: str data: bytes class EmptyStreamResponseError(RuntimeError): pass def load_env_file(path: str = ".env") -> None: env_path = Path(path) if not env_path.exists(): return for raw_line in env_path.read_text(encoding="utf-8").splitlines(): line = raw_line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) key = key.strip() value = value.strip().strip('"').strip("'") if key and key not in os.environ: os.environ[key] = value def is_pixel_size(size: str) -> bool: parts = size.lower().split("x") return len(parts) == 2 and all(part.isdigit() for part in parts) def normalize_size(size: str) -> str: value = size.strip().lower().replace(" ", "") if is_pixel_size(value): width, height = value.split("x", 1) return f"{int(width)}x{int(height)}" return value def is_solid_black_image(img: Image.Image) -> bool: rgb = img.convert("RGB") return all(lo == 0 and hi == 0 for lo, hi in rgb.getextrema()) def env_int(name: str, default: int) -> int: raw_value = os.getenv(name, "").strip() if not raw_value: return default try: return int(raw_value) except ValueError as exc: raise RuntimeError(f"{name} 不是有效整数: {raw_value}") from exc def env_optional_int(name: str) -> int | None: raw_value = os.getenv(name, "").strip() if not raw_value: return None try: return int(raw_value) except ValueError as exc: raise RuntimeError(f"{name} 不是有效整数: {raw_value}") from exc def env_bool(name: str, default: bool = False) -> bool: raw_value = os.getenv(name, "").strip().lower() if not raw_value: return default if raw_value in {"1", "true", "yes", "y", "on"}: return True if raw_value in {"0", "false", "no", "n", "off"}: return False raise RuntimeError(f"{name} 不是有效布尔值: {raw_value}") def make_run_dir() -> Path: response_root = Path(os.getenv("IMAGE_RESPONSE_DIR", DEFAULT_RESPONSE_DIR)).expanduser() run_id = datetime.now().strftime("%Y%m%d-%H%M%S-%f") run_dir = response_root / run_id run_dir.mkdir(parents=True, exist_ok=False) return run_dir def dump_json(path: Path, data: Any) -> None: path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") def save_http_response(path: Path, response: requests.Response) -> dict[str, Any]: record: dict[str, Any] = { "url": response.url, "status_code": response.status_code, "headers": dict(response.headers), } try: record["json"] = response.json() except ValueError: record["body_text"] = response.text dump_json(path, record) return record def save_stream_response_metadata(path: Path, response: requests.Response) -> dict[str, Any]: record: dict[str, Any] = { "url": response.url, "status_code": response.status_code, "headers": dict(response.headers), } dump_json(path, record) return record def save_error_artifact(path: Path, error: BaseException, extra: dict[str, Any] | None = None) -> None: payload: dict[str, Any] = { "error_type": type(error).__name__, "error_message": str(error), "traceback": traceback.format_exc(), } if extra: payload.update(extra) dump_json(path, payload) def resolve_output_path(run_dir: Path, output_file: str) -> Path: output_path = Path(output_file).expanduser() if output_path.is_absolute(): return output_path return run_dir / output_path def normalize_base_url(base_url: str) -> str: normalized = base_url.rstrip("/") if normalized.endswith("/v1"): return normalized[:-3] return normalized def build_request_session(base_url: str) -> requests.Session: session = requests.Session() hostname = urlparse(base_url).hostname or "" if hostname in DIRECT_HOSTS: session.trust_env = False print(f"直连 {hostname},忽略本地代理环境变量") return session def resolve_request_style(base_url: str) -> str: request_style = os.getenv("IMAGE_REQUEST_STYLE", "auto").strip().lower() if request_style in {"openai", "apicodex"}: return request_style hostname = (urlparse(base_url).hostname or "").lower() if hostname.endswith("openai.com"): return "openai" return "apicodex" def resolve_api_size(size: str, request_style: str) -> str: if request_style == "openai" and size in OPENAI_RATIO_SIZE_MAP: return OPENAI_RATIO_SIZE_MAP[size] return size def is_gpt_image_model(model: str) -> bool: normalized = model.strip().lower() return normalized.startswith("gpt-image") or normalized == "chatgpt-image-latest" def resolve_image_source(prefix: str) -> str | None: candidates = { prefix: os.getenv(prefix, "").strip(), f"{prefix}_FILE": os.getenv(f"{prefix}_FILE", "").strip(), f"{prefix}_URL": os.getenv(f"{prefix}_URL", "").strip(), f"{prefix}_DATA_URL": os.getenv(f"{prefix}_DATA_URL", "").strip(), } provided = [(name, value) for name, value in candidates.items() if value] if len(provided) > 1: raise RuntimeError( f"{prefix} 相关变量只能设置一个,当前同时设置了: " + ", ".join(name for name, _ in provided) ) if not provided: return None return provided[0][1] def describe_image_source(source: str | None, fallback_name: str) -> dict[str, str] | None: if source is None: return None value = source.strip() if not value: return None lowered = value.lower() if lowered.startswith("data:image/"): source_kind = "data_url" filename = fallback_name elif lowered.startswith(("http://", "https://")): source_kind = "url" filename = Path(urlparse(value).path).name or fallback_name else: source_kind = "file" filename = Path(value).expanduser().name or fallback_name return { "source": value, "kind": source_kind, "filename": filename, } def safe_filename(filename: str, fallback: str, mime_type: str) -> str: candidate = Path(filename).name.strip() if not candidate: candidate = fallback if not Path(candidate).suffix: candidate += mimetypes.guess_extension(mime_type) or ".png" return candidate def resolve_output_extension(path: Path, output_format: str | None = None) -> str: if path.suffix: return path.suffix if output_format == "jpeg": return ".jpg" if output_format in SUPPORTED_OUTPUT_FORMATS: return f".{output_format}" return ".png" def data_url_to_image_input(data_url: str, fallback: str) -> ImageInput: if not data_url.startswith("data:image/") or "," not in data_url: raise RuntimeError("data URL 参考图格式无效") header, data_b64 = data_url.split(",", 1) if ";base64" not in header.lower(): raise RuntimeError("data URL 参考图必须使用 base64 编码") mime_type = header[5:].split(";", 1)[0] or "image/png" try: data = base64.b64decode(data_b64, validate=True) except ValueError as exc: raise RuntimeError("data URL 参考图 base64 无法解码") from exc return ImageInput( filename=safe_filename("", fallback, mime_type), mime_type=mime_type, data=data, ) def local_file_to_image_input(file_path: str, fallback: str) -> ImageInput: path = Path(file_path).expanduser() if not path.is_absolute(): path = Path.cwd() / path if not path.exists(): raise FileNotFoundError(f"找不到参考图文件: {file_path}") mime_type = mimetypes.guess_type(path.name)[0] or "image/png" return ImageInput( filename=safe_filename(path.name, fallback, mime_type), mime_type=mime_type, data=path.read_bytes(), ) def fetch_url_to_image_input(image_url: str, fallback: str, session: requests.Session) -> ImageInput: resp = session.get(image_url, timeout=(DEFAULT_CONNECT_TIMEOUT, DEFAULT_IMAGE_FETCH_TIMEOUT)) resp.raise_for_status() mime_type = (resp.headers.get("Content-Type") or "").split(";", 1)[0].strip() if not mime_type.startswith("image/"): mime_type = mimetypes.guess_type(urlparse(image_url).path)[0] or "image/png" filename = safe_filename(Path(urlparse(image_url).path).name, fallback, mime_type) return ImageInput(filename=filename, mime_type=mime_type, data=resp.content) def image_source_to_input(source: str, fallback: str, session: requests.Session) -> ImageInput: value = source.strip() lowered = value.lower() if lowered.startswith("data:image/"): return data_url_to_image_input(value, fallback) if lowered.startswith(("http://", "https://")): return fetch_url_to_image_input(value, fallback, session) return local_file_to_image_input(value, fallback) def resolve_optional_enum(name: str, allowed: set[str]) -> str | None: raw_value = os.getenv(name, "").strip().lower() if not raw_value: return None if raw_value not in allowed: allowed_text = ", ".join(sorted(allowed)) raise RuntimeError(f"{name} 仅支持: {allowed_text};当前值: {raw_value}") return raw_value def resolve_stream_options(model: str) -> tuple[bool, int | None]: stream_requested = env_bool("IMAGE_STREAM", False) partial_images = env_optional_int("IMAGE_PARTIAL_IMAGES") if partial_images is not None: if partial_images < 0 or partial_images > 3: raise RuntimeError("IMAGE_PARTIAL_IMAGES 仅支持 0-3") stream_requested = True if stream_requested and not is_gpt_image_model(model): raise RuntimeError(f"当前 model 不支持流式图片输出: {model}") return stream_requested, partial_images def build_payload(base_url: str) -> tuple[dict[str, Any], str, str, str, str, str | None, str | None]: model = os.getenv("IMAGE_MODEL", DEFAULT_MODEL).strip() prompt = os.getenv("IMAGE_PROMPT", DEFAULT_PROMPT).strip() or DEFAULT_PROMPT requested_size = normalize_size(os.getenv("IMAGE_SIZE", DEFAULT_PIXEL_SIZE)) resolution = os.getenv("IMAGE_RESOLUTION", "").strip() quality = os.getenv("IMAGE_QUALITY", DEFAULT_QUALITY).strip() background = os.getenv("IMAGE_BACKGROUND", "").strip().lower() or None input_fidelity = os.getenv("IMAGE_INPUT_FIDELITY", "").strip().lower() or None output_format = resolve_optional_enum("IMAGE_OUTPUT_FORMAT", SUPPORTED_OUTPUT_FORMATS) moderation = resolve_optional_enum("IMAGE_MODERATION", SUPPORTED_MODERATION_LEVELS) output_compression = env_optional_int("IMAGE_OUTPUT_COMPRESSION") output_file = os.getenv("OUTPUT_FILE", DEFAULT_OUTPUT_FILE).strip() request_style = resolve_request_style(base_url) if not (is_pixel_size(requested_size) or requested_size in SUPPORTED_ASPECT_RATIOS): raise RuntimeError(f"不支持的 IMAGE_SIZE: {requested_size}") api_size = resolve_api_size(requested_size, request_style) stream_requested, partial_images = resolve_stream_options(model) if moderation and not is_gpt_image_model(model): raise RuntimeError(f"当前 model 不支持 moderation 参数: {model}") if output_compression is not None: if output_compression < 0 or output_compression > 100: raise RuntimeError("IMAGE_OUTPUT_COMPRESSION 仅支持 0-100") if output_format not in {"jpeg", "webp"}: raise RuntimeError("IMAGE_OUTPUT_COMPRESSION 仅适用于 IMAGE_OUTPUT_FORMAT=jpeg/webp") payload: dict[str, Any] = { "model": model, "prompt": prompt, "n": int(os.getenv("IMAGE_N", "1")), "size": api_size, "quality": quality, } if not (request_style == "openai" and model.startswith("gpt-image")): payload["response_format"] = "b64_json" if not is_pixel_size(requested_size) and request_style != "openai": payload["resolution"] = resolution or "4K" if background: payload["background"] = background if input_fidelity: payload["input_fidelity"] = input_fidelity if output_format: payload["output_format"] = output_format if output_compression is not None: payload["output_compression"] = output_compression if moderation: payload["moderation"] = moderation if stream_requested: payload["stream"] = True if partial_images is not None: payload["partial_images"] = partial_images reference_source = resolve_image_source("REFERENCE_IMAGE") mask_source = resolve_image_source("MASK_IMAGE") if mask_source and not reference_source: raise RuntimeError("MASK_IMAGE 只能和 REFERENCE_IMAGE 一起使用") request_mode = "edit" if reference_source else "generate" return payload, model, requested_size, output_file, request_mode, reference_source, mask_source def payload_to_form_fields(payload: dict[str, Any]) -> dict[str, str]: fields: dict[str, str] = {} for key, value in payload.items(): if value is None: continue if isinstance(value, bool): fields[key] = str(value).lower() else: fields[key] = str(value) return fields def build_edit_files( request_style: str, reference_input: ImageInput, mask_input: ImageInput | None, ) -> list[tuple[str, tuple[str, bytes, str]]]: image_field_name = "image" if request_style == "openai" else "image[]" files: list[tuple[str, tuple[str, bytes, str]]] = [ ( image_field_name, (reference_input.filename, reference_input.data, reference_input.mime_type), ) ] if mask_input is not None: files.append(("mask", (mask_input.filename, mask_input.data, mask_input.mime_type))) return files def resolve_min_output_edge(requested_size: str, resolution: str | None, request_style: str) -> int | None: if is_pixel_size(requested_size): width, height = requested_size.split("x", 1) min_edge = max(int(width), int(height)) return min_edge if min_edge >= 3840 else None if resolution and resolution.strip().upper() == "4K" and request_style != "openai": return 3840 return None def save_image_bytes(image_bytes: bytes, output_path: Path, min_output_edge: int | None = None) -> tuple[int, int]: output_path.parent.mkdir(parents=True, exist_ok=True) previous_truncated_setting = ImageFile.LOAD_TRUNCATED_IMAGES ImageFile.LOAD_TRUNCATED_IMAGES = True try: with Image.open(BytesIO(image_bytes)) as img: img.load() width, height = img.size if is_solid_black_image(img): raise RuntimeError( "上游返回了疑似纯黑占位图 " f"({width}x{height}),通常表示请求没有走到正常生成链路。" "请检查 OPENAI_BASE_URL、IMAGE_REQUEST_STYLE、model 和 size," "必要时切换到 vip.auto-code.net。" ) if min_output_edge is not None and max(width, height) < min_output_edge: raise RuntimeError(f"生成结果未达到预期尺寸下限 {min_output_edge}px:{width}x{height}") finally: ImageFile.LOAD_TRUNCATED_IMAGES = previous_truncated_setting output_path.write_bytes(image_bytes) print(f"已保存 {output_path} ({width}x{height})") return width, height def extract_image_bytes_from_item(item: dict[str, Any], session: requests.Session) -> bytes: if item.get("b64_json"): return base64.b64decode(item["b64_json"]) url_value = item.get("url") if isinstance(url_value, list) and url_value: image_url = url_value[0] elif isinstance(url_value, str): image_url = url_value else: image_url = None if image_url: resp = session.get(image_url, timeout=(DEFAULT_CONNECT_TIMEOUT, DEFAULT_IMAGE_FETCH_TIMEOUT)) resp.raise_for_status() return resp.content raise RuntimeError(f"响应里没有可用的图片数据: {item}") def classify_stream_event(record: dict[str, Any], payload: dict[str, Any]) -> str | None: candidates = [ str(payload.get("type") or ""), str(record.get("event") or ""), str(payload.get("event") or ""), str(payload.get("status") or ""), ] normalized = [candidate.strip().lower() for candidate in candidates if candidate] for candidate in normalized: if candidate in STREAM_PARTIAL_EVENT_TYPES or candidate.endswith("partial_image"): return "partial" if candidate in STREAM_COMPLETED_EVENT_TYPES or candidate.endswith("completed"): return "completed" return None def extract_stream_image_item(payload: dict[str, Any]) -> dict[str, Any] | None: if payload.get("b64_json") or payload.get("url"): return payload data = payload.get("data") if isinstance(data, dict): if data.get("b64_json") or data.get("url"): return data images = payload.get("images") if isinstance(images, list): for item in images: if isinstance(item, dict) and (item.get("b64_json") or item.get("url")): return item result = payload.get("result") if isinstance(result, dict): result_images = result.get("images") if isinstance(result_images, list): for item in result_images: if isinstance(item, dict) and (item.get("b64_json") or item.get("url")): return item if isinstance(data, dict): nested_images = data.get("images") if isinstance(nested_images, list): for item in nested_images: if isinstance(item, dict) and (item.get("b64_json") or item.get("url")): return item return None def extract_api_error_message(data: Any) -> str | None: if not isinstance(data, dict): return None error = data.get("error") if isinstance(error, dict): message = error.get("message") if isinstance(message, str) and message.strip(): return message.strip() return str(error) if error: return str(error) nested = data.get("data") if isinstance(nested, dict): nested_error = nested.get("error") if isinstance(nested_error, dict): message = nested_error.get("message") if isinstance(message, str) and message.strip(): return message.strip() return str(nested_error) if nested_error: return str(nested_error) return None def is_event_stream_response(response: requests.Response) -> bool: content_type = response.headers.get("Content-Type", "") return "text/event-stream" in content_type.lower() def iter_sse_events(response: requests.Response): event_name = "" data_lines: list[str] = [] def flush() -> tuple[str, str] | None: nonlocal event_name, data_lines if event_name or data_lines: event = (event_name or "message", "\n".join(data_lines)) else: event = None event_name = "" data_lines = [] return event for raw_line in response.iter_lines(decode_unicode=True): if raw_line is None: continue line = raw_line if isinstance(raw_line, str) else raw_line.decode("utf-8", errors="replace") if line == "": event = flush() if event is not None: yield event continue if line.startswith(":"): continue field, _, value = line.partition(":") if value.startswith(" "): value = value[1:] if field == "event": event_name = value elif field == "data": data_lines.append(value) event = flush() if event is not None: yield event def iter_stream_event_records(path: Path, response: requests.Response): with path.open("w", encoding="utf-8") as handle: for index, (event_name, data_text) in enumerate(iter_sse_events(response), start=1): payload: Any try: payload = json.loads(data_text) except json.JSONDecodeError: payload = data_text record = { "index": index, "event": event_name, "data": payload, } handle.write(json.dumps(record, ensure_ascii=False) + "\n") handle.flush() yield record def poll_task( task_id: str, base_url: str, headers: dict[str, str], session: requests.Session, run_dir: Path, max_wait_seconds: int, initial_poll_delay: int, poll_interval: int, ) -> bytes: task_url = f"{base_url}/v1/tasks/{task_id}" deadline = time.monotonic() + max_wait_seconds poll_index = 0 time.sleep(initial_poll_delay) while True: if time.monotonic() >= deadline: raise TimeoutError(f"任务轮询超时: task_id={task_id}") poll_index += 1 task_resp = session.get( task_url, headers=headers, timeout=(DEFAULT_POLL_CONNECT_TIMEOUT, DEFAULT_POLL_READ_TIMEOUT), ) task_record = save_http_response(run_dir / f"poll-{poll_index:03d}.json", task_resp) task_resp.raise_for_status() task = task_record.get("json") if not isinstance(task, dict): raise RuntimeError(f"任务响应不是 JSON 对象: {task_record}") data = task.get("data") if not isinstance(data, dict): raise RuntimeError(f"任务响应格式异常: {task}") status = data.get("status") print(f"任务轮询 #{poll_index}: status={status}") if status == "completed": result = data.get("result", {}) images = result.get("images", []) if not images: raise RuntimeError(f"任务已完成,但没有返回图片: {task}") return extract_image_bytes_from_item(images[0], session) if status == "failed": error = data.get("error", {}) if isinstance(error, dict): message = error.get("message") or str(error) else: message = str(error) raise RuntimeError(message or "任务失败") time.sleep(poll_interval) def handle_streaming_single_response( response: requests.Response, session: requests.Session, run_dir: Path, output_path: Path, min_output_edge: int | None, ) -> None: save_stream_response_metadata(run_dir / "post-response.json", response) response.raise_for_status() partial_dir = run_dir / "partials" partial_dir.mkdir(parents=True, exist_ok=True) partial_paths: list[str] = [] final_event: dict[str, Any] | None = None record_count = 0 for record in iter_stream_event_records(run_dir / "stream-events.jsonl", response): record_count += 1 payload = record.get("data") if not isinstance(payload, dict): continue event_kind = classify_stream_event(record, payload) image_item = extract_stream_image_item(payload) if event_kind is None or image_item is None: continue image_bytes = extract_image_bytes_from_item(image_item, session) output_format = payload.get("output_format") if isinstance(payload.get("output_format"), str) else None if event_kind == "partial": partial_index = payload.get("partial_image_index") suffix = resolve_output_extension(output_path, output_format) if isinstance(partial_index, int): filename = f"{output_path.stem}-partial-{partial_index:02d}-event-{record['index']:03d}{suffix}" else: filename = f"{output_path.stem}-partial-{len(partial_paths):02d}-event-{record['index']:03d}{suffix}" partial_path = partial_dir / filename save_image_bytes(image_bytes, partial_path) partial_paths.append(str(partial_path)) continue if event_kind == "completed": save_image_bytes(image_bytes, output_path, min_output_edge=min_output_edge) final_event = payload if record_count == 0: raise EmptyStreamResponseError("流式响应为空,未收到任何 SSE 事件") if final_event is None: raise RuntimeError("流式响应结束,但没有收到最终 completed 图片事件") dump_json( run_dir / "result.json", { "image": str(output_path), "partial_images": partial_paths, "response_path": str(run_dir / "post-response.json"), "event_log_path": str(run_dir / "stream-events.jsonl"), "stream": True, }, ) def build_non_stream_payload(payload: dict[str, Any]) -> dict[str, Any]: fallback_payload = dict(payload) fallback_payload.pop("stream", None) fallback_payload.pop("partial_images", None) return fallback_payload def main() -> None: load_env_file() api_key = os.getenv("OPENAI_API_KEY") or os.getenv("API_KEY") base_url = normalize_base_url( os.getenv("OPENAI_BASE_URL") or os.getenv("BASE_URL") or "https://api.toskaxy.xyz" ) if not api_key: raise RuntimeError("未找到 API Key,请先在 .env 中设置 OPENAI_API_KEY") payload, model, size, output_file, request_mode, reference_source, mask_source = build_payload(base_url) resolution = payload.get("resolution") run_dir = make_run_dir() output_path = resolve_output_path(run_dir, output_file) session = build_request_session(base_url) request_style = resolve_request_style(base_url) request_timeout = env_int("IMAGE_REQUEST_TIMEOUT", DEFAULT_READ_TIMEOUT) max_poll_wait = env_int("IMAGE_MAX_POLL_WAIT", DEFAULT_MAX_POLL_WAIT) initial_poll_delay = env_int("IMAGE_INITIAL_POLL_DELAY", DEFAULT_INITIAL_POLL_DELAY) poll_interval = env_int("IMAGE_POLL_INTERVAL", DEFAULT_POLL_INTERVAL) stream_requested = bool(payload.get("stream")) endpoint_path = "/v1/images/edits" if request_mode == "edit" else "/v1/images/generations" min_output_edge = resolve_min_output_edge(size, resolution, request_style) reference_image_info = describe_image_source(reference_source, "reference-image") mask_image_info = describe_image_source(mask_source, "mask-image") print( "开始生成图片:", f"mode={request_mode}", f"request_style={request_style}", f"model={model}", f"size={size}", f"resolution={resolution or 'none'}", f"request_timeout={request_timeout}s", f"max_poll_wait={max_poll_wait}s", f"initial_poll_delay={initial_poll_delay}s", f"poll_interval={poll_interval}s", f"stream={stream_requested}", f"partial_images={payload.get('partial_images') if stream_requested else 'none'}", f"moderation={payload.get('moderation') or 'default'}", f"endpoint={base_url}{endpoint_path}", f"output={output_path}", f"responses={run_dir}", ) dump_json(run_dir / "request.json", { "base_url": base_url, "endpoint_path": endpoint_path, "request_mode": request_mode, "model": model, "size": size, "resolution": resolution, "output_file": str(output_path), "request_timeout_seconds": request_timeout, "max_poll_wait_seconds": max_poll_wait, "initial_poll_delay_seconds": initial_poll_delay, "poll_interval_seconds": poll_interval, "request_style": request_style, "min_output_edge": min_output_edge, "reference_image_present": bool(reference_source), "reference_image_source": reference_image_info["source"] if reference_image_info else None, "reference_image_source_kind": reference_image_info["kind"] if reference_image_info else None, "reference_image_filename": reference_image_info["filename"] if reference_image_info else None, "mask_image_present": bool(mask_source), "mask_image_source": mask_image_info["source"] if mask_image_info else None, "mask_image_source_kind": mask_image_info["kind"] if mask_image_info else None, "mask_image_filename": mask_image_info["filename"] if mask_image_info else None, "payload": payload, }) try: if request_mode == "edit": reference_input = image_source_to_input(reference_source or "", "reference-image", session) mask_input = ( image_source_to_input(mask_source, "mask-image", session) if mask_source else None ) resp = session.post( f"{base_url}{endpoint_path}", data=payload_to_form_fields(payload), files=build_edit_files(request_style, reference_input, mask_input), headers={"Authorization": f"Bearer {api_key}"}, stream=stream_requested, timeout=(DEFAULT_CONNECT_TIMEOUT, request_timeout), ) else: resp = session.post( f"{base_url}{endpoint_path}", json=payload, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, stream=stream_requested, timeout=(DEFAULT_CONNECT_TIMEOUT, request_timeout), ) if stream_requested and is_event_stream_response(resp): try: handle_streaming_single_response( resp, session, run_dir, output_path, min_output_edge=min_output_edge, ) return except EmptyStreamResponseError: fallback_payload = build_non_stream_payload(payload) print("上游返回空 EventStream,自动回退为非流式请求重试一次") if request_mode == "edit": resp = session.post( f"{base_url}{endpoint_path}", data=payload_to_form_fields(fallback_payload), files=build_edit_files(request_style, reference_input, mask_input), headers={"Authorization": f"Bearer {api_key}"}, timeout=(DEFAULT_CONNECT_TIMEOUT, request_timeout), ) else: resp = session.post( f"{base_url}{endpoint_path}", json=fallback_payload, headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, timeout=(DEFAULT_CONNECT_TIMEOUT, request_timeout), ) post_record = save_http_response(run_dir / "post-response.json", resp) resp.raise_for_status() data = post_record.get("json") if not isinstance(data, dict): raise RuntimeError(f"post response is not a JSON object: {post_record}") response_data = data.get("data") if isinstance(response_data, list) and response_data: image_bytes = extract_image_bytes_from_item(response_data[0], session) save_image_bytes(image_bytes, output_path, min_output_edge=min_output_edge) return if isinstance(response_data, dict) and response_data.get("id"): image_bytes = poll_task( str(response_data["id"]), base_url, {"Authorization": f"Bearer {api_key}"}, session, run_dir, max_poll_wait, initial_poll_delay, poll_interval, ) save_image_bytes(image_bytes, output_path, min_output_edge=min_output_edge) return error_message = extract_api_error_message(data) if error_message: raise RuntimeError(error_message) raise RuntimeError(f"unexpected response: {data}") except BaseException as exc: save_error_artifact(run_dir / "error.json", exc, {"output_file": str(output_path)}) raise if __name__ == "__main__": main()