fix(image): support stream and moderation options

This commit is contained in:
2026-05-25 18:42:47 +08:00
parent ca2fa1b03d
commit 7b9b96e332
9 changed files with 609 additions and 4 deletions

View File

@@ -8,5 +8,7 @@ IMAGE_N=1
IMAGE_OUTPUT_STEM=gpt-image-2-multi
IMAGE_RESPONSE_DIR=outputs/image-multi-runs
REFERENCE_IMAGE=./input.png
IMAGE_STREAM=true
IMAGE_MODERATION=low
# Multi-image alternative:
# REFERENCE_IMAGES=["./a.png","./b.png"]

View File

@@ -39,6 +39,9 @@ bash scripts/run_openai_gpt_image2_multi.sh
- `IMAGE_PROMPT`
- `IMAGE_SIZE`
- `REFERENCE_IMAGE``REFERENCE_IMAGES`
- `IMAGE_STREAM`
- `IMAGE_PARTIAL_IMAGES`
- `IMAGE_MODERATION`
`IMAGE_SIZE` 可选值:
@@ -58,6 +61,26 @@ bash scripts/run_openai_gpt_image2_multi.sh
- 需要更稳定的输出尺寸时,优先直接写像素值。
- `4096x2304``4096x4096` 这类超过 `3840px` 边长上限的尺寸,不属于 OpenAI 官方 `gpt-image-2` 的可选值。
## Stream 与 Moderation
- `IMAGE_STREAM=true` 时,请求里会发送 `stream=true`
- `IMAGE_PARTIAL_IMAGES=1` 时,请求里会额外发送 `partial_images=1`,要求先返回 1 张中间图。
- `IMAGE_MODERATION` 目前只支持 `auto``low`,其他值会在本地校验阶段直接报错。
- 如果上游真的返回 `text/event-stream`,脚本会逐条处理 SSE 事件,并在运行目录里保存:
- `post-response.json`
- `stream-events.jsonl`
- `partials/*.png`
- 如果上游没有返回 `EventStream`,脚本会自动回退到原来的 JSON / 轮询处理逻辑。
常用示例:
```bash
IMAGE_STREAM=true \
IMAGE_PARTIAL_IMAGES=1 \
IMAGE_MODERATION=low \
bash scripts/run_openai_gpt_image2_edit.sh
```
详细说明见 [docs/usage.md](docs/usage.md)。
## 仓库约定

View File

@@ -287,7 +287,48 @@ else:
raise RuntimeError(f"unexpected response: {data}")
```
## 15. 图片接口常见问题
## 15. `stream`、`partial_images`、`moderation`
`gpt-image-2` 的图片接口可额外尝试下面几个字段:
- `stream``true` / `false`
- `partial_images`:整数,常见值 `1`
- `moderation``auto``low`
示例:
```json
{
"model": "gpt-image-2",
"prompt": "火影忍者真人版海报",
"size": "3840x1280",
"quality": "high",
"output_format": "png",
"moderation": "low",
"stream": true,
"partial_images": 1
}
```
说明:
- `stream=true` 时,服务端可能返回 `text/event-stream`
- `partial_images=1` 表示在最终图之前,先推 1 张中间图。
- `moderation` 当前只观察到 `auto``low` 两个枚举值。
- 不是所有兼容网关都会真的返回 SSE有些网关即使接受 `stream=true`,仍然返回普通 JSON。
仓库里的环境变量映射:
- `IMAGE_STREAM=true`
- `IMAGE_PARTIAL_IMAGES=1`
- `IMAGE_MODERATION=low`
脚本处理策略:
- 收到 `EventStream`:逐条解析事件,保存 `stream-events.jsonl``partials/`
- 没收到 `EventStream`:自动回退到同步 JSON 或异步任务轮询逻辑。
## 16. 图片接口常见问题
- 只传 `size: "16:9"` 不能保证一定是 4K要 4K推荐直接传 `4096x2304` 等像素尺寸。
- `gpt-image-1``gpt-image-1.5` 不支持真正 4K网关会自动降级。
@@ -295,7 +336,7 @@ else:
- 单张图可能耗时较长,客户端 `timeout` 建议至少 120 秒4K 建议 240 秒。
- 如果返回 `b64_json`,说明当前是同步模式,不需要再轮询 `task_id`
## 16. 直连测试命令
## 17. 直连测试命令
- 运行 `scripts/run_figma_direct_test.sh` 会先读取当前 `.env`,再临时清空本地代理并直连 `vip.auto-code.net`
- 它只强制 `IMAGE_REQUEST_STYLE=apicodex``IMAGE_SIZE=3840x2160` 和长超时,其余参数继续沿用 `.env``gpt_image2_generate.py` 的默认值。

View File

@@ -32,6 +32,10 @@ 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",
@@ -71,6 +75,10 @@ class ImageInput:
data: bytes
class EmptyStreamResponseError(RuntimeError):
pass
def load_env_file(path: str = ".env") -> None:
env_path = Path(path)
if not env_path.exists():
@@ -117,6 +125,28 @@ def env_int(name: str, default: int) -> int:
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")
@@ -145,6 +175,16 @@ def save_http_response(path: Path, response: requests.Response) -> dict[str, Any
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__,
@@ -196,6 +236,11 @@ def resolve_api_size(size: str, request_style: str) -> str:
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(),
@@ -249,6 +294,16 @@ def safe_filename(filename: str, fallback: str, mime_type: str) -> str:
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 参考图格式无效")
@@ -307,17 +362,56 @@ def image_source_to_input(source: str, fallback: str, session: requests.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,
@@ -331,6 +425,20 @@ def build_payload(base_url: str) -> tuple[dict[str, Any], str, str, str, str, st
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")
@@ -346,7 +454,10 @@ def payload_to_form_fields(payload: dict[str, Any]) -> dict[str, str]:
for key, value in payload.items():
if value is None:
continue
fields[key] = str(value)
if isinstance(value, bool):
fields[key] = str(value).lower()
else:
fields[key] = str(value)
return fields
@@ -425,6 +536,148 @@ def extract_image_bytes_from_item(item: dict[str, Any], session: requests.Sessio
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,
@@ -480,6 +733,75 @@ def poll_task(
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()
@@ -500,6 +822,7 @@ def main() -> None:
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")
@@ -516,6 +839,9 @@ def main() -> None:
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}",
@@ -559,6 +885,7 @@ def main() -> None:
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:
@@ -569,8 +896,41 @@ def main() -> None:
"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")
@@ -597,6 +957,10 @@ def main() -> None:
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)})

View File

@@ -22,24 +22,41 @@ from gpt_image2_generate import (
DEFAULT_POLL_CONNECT_TIMEOUT,
DEFAULT_POLL_INTERVAL,
DEFAULT_POLL_READ_TIMEOUT,
EmptyStreamResponseError,
ImageInput,
build_non_stream_payload,
build_request_session,
classify_stream_event,
dump_json,
env_int,
env_optional_int,
extract_api_error_message,
extract_stream_image_item,
extract_image_bytes_from_item,
is_event_stream_response,
is_gpt_image_model,
image_source_to_input,
iter_stream_event_records,
is_pixel_size,
load_env_file,
make_run_dir,
normalize_base_url,
normalize_size,
payload_to_form_fields,
resolve_optional_enum,
resolve_api_size,
resolve_min_output_edge,
resolve_request_style,
resolve_stream_options,
resolve_output_extension,
save_error_artifact,
save_http_response,
save_image_bytes,
save_stream_response_metadata,
SUPPORTED_MODERATION_LEVELS,
SUPPORTED_OUTPUT_FORMATS,
STREAM_COMPLETED_EVENT_TYPES,
STREAM_PARTIAL_EVENT_TYPES,
)
@@ -151,11 +168,21 @@ def build_payload(base_url: str, reference_sources: list[str]) -> tuple[dict[str
resolution = os.getenv("IMAGE_RESOLUTION", "").strip()
background = os.getenv("IMAGE_BACKGROUND", "").strip() or None
input_fidelity = os.getenv("IMAGE_INPUT_FIDELITY", "").strip() or None
output_format = os.getenv("IMAGE_OUTPUT_FORMAT", "").strip() 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")
stream_requested, partial_images = resolve_stream_options(model)
n = env_int("IMAGE_N", max(1, len(reference_sources)))
if n < 1:
raise RuntimeError("IMAGE_N 必须大于等于 1")
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,
@@ -174,6 +201,14 @@ def build_payload(base_url: str, reference_sources: list[str]) -> tuple[dict[str
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
mask_source = collect_mask_source()
if mask_source and not reference_sources:
@@ -309,6 +344,61 @@ def save_output_images(
return paths
def handle_streaming_multi_response(
response: requests.Response,
session: requests.Session,
run_dir: Path,
stem: str,
min_output_edge: int | None,
) -> tuple[list[str], list[str]]:
partial_dir = run_dir / "partials"
partial_dir.mkdir(parents=True, exist_ok=True)
partial_paths: list[str] = []
image_paths: list[str] = []
record_count = 0
save_stream_response_metadata(run_dir / "post-response.json", response)
response.raise_for_status()
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(Path(stem), output_format)
if isinstance(partial_index, int):
filename = f"{stem}-partial-{partial_index:02d}-event-{record['index']:03d}{suffix}"
else:
filename = f"{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":
suffix = resolve_output_extension(Path(stem), output_format)
output_path = run_dir / f"{stem}-{len(image_paths) + 1}{suffix}"
save_image_bytes(image_bytes, output_path, min_output_edge=min_output_edge)
image_paths.append(str(output_path))
if record_count == 0:
raise EmptyStreamResponseError("流式响应为空,未收到任何 SSE 事件")
if not image_paths:
raise RuntimeError("流式响应结束,但没有收到 completed 图片事件")
return image_paths, partial_paths
def main() -> None:
load_env_file()
@@ -331,6 +421,7 @@ def main() -> None:
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(requested_size, payload.get("resolution"), request_style)
prompt = payload["prompt"]
@@ -349,6 +440,9 @@ def main() -> None:
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"responses={run_dir}",
)
@@ -391,6 +485,7 @@ def main() -> None:
data=payload_to_form_fields(payload),
files=build_edit_files(request_style, reference_inputs, mask_input),
headers={"Authorization": f"Bearer {api_key}"},
stream=stream_requested,
timeout=(DEFAULT_CONNECT_TIMEOUT, request_timeout),
)
else:
@@ -401,9 +496,59 @@ def main() -> None:
"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:
image_paths, partial_paths = handle_streaming_multi_response(
resp,
session,
run_dir,
stem,
min_output_edge=min_output_edge,
)
dump_json(
run_dir / "result.json",
{
"images": image_paths,
"partial_images": partial_paths,
"response_path": str(run_dir / "post-response.json"),
"event_log_path": str(run_dir / "stream-events.jsonl"),
"request_mode": request_mode,
"request_style": request_style,
"model": model,
"size": requested_size,
"n": payload["n"],
"reference_count": len(reference_sources),
"stream": True,
},
)
print(_json_dumps({"images": image_paths, "partial_images": partial_paths, "output_dir": str(run_dir)}))
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_inputs, 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")
@@ -459,6 +604,10 @@ def main() -> None:
print(_json_dumps({"images": image_paths, "output_dir": str(run_dir)}))
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_dir": str(run_dir)})

View File

@@ -34,6 +34,8 @@ export IMAGE_REQUEST_TIMEOUT="${IMAGE_REQUEST_TIMEOUT:-900}"
export IMAGE_MAX_POLL_WAIT="${IMAGE_MAX_POLL_WAIT:-1800}"
export IMAGE_INITIAL_POLL_DELAY="${IMAGE_INITIAL_POLL_DELAY:-10}"
export IMAGE_POLL_INTERVAL="${IMAGE_POLL_INTERVAL:-5}"
export IMAGE_STREAM="${IMAGE_STREAM:-true}"
export IMAGE_PARTIAL_IMAGES="${IMAGE_PARTIAL_IMAGES:-1}"
# Direct generation tests should ignore any edit inputs coming from .env.
for name in \

View File

@@ -16,6 +16,13 @@ for name in \
IMAGE_QUALITY \
IMAGE_N \
OUTPUT_FILE \
IMAGE_BACKGROUND \
IMAGE_INPUT_FIDELITY \
IMAGE_OUTPUT_FORMAT \
IMAGE_OUTPUT_COMPRESSION \
IMAGE_MODERATION \
IMAGE_STREAM \
IMAGE_PARTIAL_IMAGES \
REFERENCE_IMAGE \
REFERENCE_IMAGE_FILE \
REFERENCE_IMAGE_URL \
@@ -66,6 +73,8 @@ export IMAGE_REQUEST_TIMEOUT="${IMAGE_REQUEST_TIMEOUT:-900}"
export IMAGE_MAX_POLL_WAIT="${IMAGE_MAX_POLL_WAIT:-1800}"
export IMAGE_INITIAL_POLL_DELAY="${IMAGE_INITIAL_POLL_DELAY:-10}"
export IMAGE_POLL_INTERVAL="${IMAGE_POLL_INTERVAL:-5}"
export IMAGE_STREAM="${IMAGE_STREAM:-true}"
export IMAGE_PARTIAL_IMAGES="${IMAGE_PARTIAL_IMAGES:-1}"
export IMAGE_PROMPT="${IMAGE_PROMPT:-A realistic Figma design board screenshot for a digital transformation system, with a left layers panel, center canvas of nested frames and components, and right properties panel. Enterprise product design, Chinese labels, visible grid, alignment guides, annotations, components, variants, spacing notes, dashboard cards, workflow timeline, and system modules. Clean blue-gray palette, crisp typography, premium product design presentation, highly organized layered structure, realistic Figma workspace aesthetic.}"
python3 gpt_image2_generate.py "$@"

View File

@@ -16,6 +16,13 @@ for name in \
IMAGE_QUALITY \
IMAGE_N \
OUTPUT_FILE \
IMAGE_BACKGROUND \
IMAGE_INPUT_FIDELITY \
IMAGE_OUTPUT_FORMAT \
IMAGE_OUTPUT_COMPRESSION \
IMAGE_MODERATION \
IMAGE_STREAM \
IMAGE_PARTIAL_IMAGES \
REFERENCE_IMAGE \
REFERENCE_IMAGE_FILE \
REFERENCE_IMAGE_URL \
@@ -53,5 +60,7 @@ export IMAGE_REQUEST_TIMEOUT="${IMAGE_REQUEST_TIMEOUT:-900}"
export IMAGE_MAX_POLL_WAIT="${IMAGE_MAX_POLL_WAIT:-1800}"
export IMAGE_INITIAL_POLL_DELAY="${IMAGE_INITIAL_POLL_DELAY:-10}"
export IMAGE_POLL_INTERVAL="${IMAGE_POLL_INTERVAL:-5}"
export IMAGE_STREAM="${IMAGE_STREAM:-true}"
export IMAGE_PARTIAL_IMAGES="${IMAGE_PARTIAL_IMAGES:-1}"
python3 gpt_image2_generate.py "$@"

View File

@@ -20,6 +20,10 @@ for name in \
IMAGE_BACKGROUND \
IMAGE_INPUT_FIDELITY \
IMAGE_OUTPUT_FORMAT \
IMAGE_OUTPUT_COMPRESSION \
IMAGE_MODERATION \
IMAGE_STREAM \
IMAGE_PARTIAL_IMAGES \
REFERENCE_IMAGES \
REFERENCE_IMAGE_LIST \
REFERENCE_IMAGE \
@@ -67,5 +71,7 @@ export IMAGE_REQUEST_TIMEOUT="${IMAGE_REQUEST_TIMEOUT:-900}"
export IMAGE_MAX_POLL_WAIT="${IMAGE_MAX_POLL_WAIT:-1800}"
export IMAGE_INITIAL_POLL_DELAY="${IMAGE_INITIAL_POLL_DELAY:-10}"
export IMAGE_POLL_INTERVAL="${IMAGE_POLL_INTERVAL:-5}"
export IMAGE_STREAM="${IMAGE_STREAM:-true}"
export IMAGE_PARTIAL_IMAGES="${IMAGE_PARTIAL_IMAGES:-1}"
python3 gpt_image2_generate_multi.py "$@"