fix(image): support stream and moderation options
This commit is contained in:
@@ -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"]
|
||||
|
||||
23
README.md
23
README.md
@@ -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)。
|
||||
|
||||
## 仓库约定
|
||||
|
||||
@@ -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` 的默认值。
|
||||
|
||||
@@ -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,6 +454,9 @@ def payload_to_form_fields(payload: dict[str, Any]) -> 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
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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,6 +496,56 @@ 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),
|
||||
)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user