fix: fail fast on gpt-image-2 timeouts
This commit is contained in:
1
RULES.md
1
RULES.md
@@ -73,6 +73,7 @@
|
|||||||
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
|
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
|
||||||
- `PRODUCT_VIEW_MODEL`:同一产品素材池的视角标注/自动识别模型;当前按项目要求强制使用 `gpt-image-2`
|
- `PRODUCT_VIEW_MODEL`:同一产品素材池的视角标注/自动识别模型;当前按项目要求强制使用 `gpt-image-2`
|
||||||
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`:OpenAI 兼容生图网关;当前所有生图入口一律强制使用 `gpt-image-2`,不做其他图片模型 fallback
|
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`:OpenAI 兼容生图网关;当前所有生图入口一律强制使用 `gpt-image-2`,不做其他图片模型 fallback
|
||||||
|
- `IMAGE_REQUEST_TIMEOUT_SECONDS`:单次图片网关请求超时,默认 60 秒;超时会直接把该视图标失败并继续下一张,避免主体 6 视图整包长时间无反馈
|
||||||
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2`
|
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2`
|
||||||
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError,可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
|
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError,可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
|
||||||
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;生产云端固定使用 cookies 文件 `/run/secrets/tiktok_cookies.txt`(宿主机 `./secrets/tiktok_cookies.txt` 挂载进容器),本地开发可临时用浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
|
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;生产云端固定使用 cookies 文件 `/run/secrets/tiktok_cookies.txt`(宿主机 `./secrets/tiktok_cookies.txt` 挂载进容器),本地开发可临时用浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ PRODUCT_VIEW_MODEL=gpt-image-2
|
|||||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||||
IMAGE_API_KEY=
|
IMAGE_API_KEY=
|
||||||
IMAGE_MODEL=gpt-image-2
|
IMAGE_MODEL=gpt-image-2
|
||||||
|
IMAGE_REQUEST_TIMEOUT_SECONDS=60
|
||||||
GPT_IMAGE_MODEL=gpt-image-2
|
GPT_IMAGE_MODEL=gpt-image-2
|
||||||
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
||||||
|
|||||||
103
api/main.py
103
api/main.py
@@ -104,6 +104,7 @@ IMAGE_MODEL = GPT_IMAGE_MODEL
|
|||||||
PRODUCT_VIEW_MODEL = GPT_IMAGE_MODEL
|
PRODUCT_VIEW_MODEL = GPT_IMAGE_MODEL
|
||||||
SUBJECT_ASSET_IMAGE_MODEL = GPT_IMAGE_MODEL
|
SUBJECT_ASSET_IMAGE_MODEL = GPT_IMAGE_MODEL
|
||||||
SUBJECT_ASSET_IMAGE_MODELS = [GPT_IMAGE_MODEL]
|
SUBJECT_ASSET_IMAGE_MODELS = [GPT_IMAGE_MODEL]
|
||||||
|
IMAGE_REQUEST_TIMEOUT_SECONDS = max(15, min(180, int(os.getenv("IMAGE_REQUEST_TIMEOUT_SECONDS", "60"))))
|
||||||
PRODUCT_ASSET_MAX_SIDE = max(1024, int(os.getenv("PRODUCT_ASSET_MAX_SIDE", "1600")))
|
PRODUCT_ASSET_MAX_SIDE = max(1024, int(os.getenv("PRODUCT_ASSET_MAX_SIDE", "1600")))
|
||||||
PRODUCT_ASSET_MIN_LONG_SIDE = max(512, int(os.getenv("PRODUCT_ASSET_MIN_LONG_SIDE", "900")))
|
PRODUCT_ASSET_MIN_LONG_SIDE = max(512, int(os.getenv("PRODUCT_ASSET_MIN_LONG_SIDE", "900")))
|
||||||
PRODUCT_ASSET_MIN_SHORT_SIDE = max(320, int(os.getenv("PRODUCT_ASSET_MIN_SHORT_SIDE", "600")))
|
PRODUCT_ASSET_MIN_SHORT_SIDE = max(320, int(os.getenv("PRODUCT_ASSET_MIN_SHORT_SIDE", "600")))
|
||||||
@@ -3516,6 +3517,13 @@ def _image_failure_message(kind: str, attempts: int, last_err: str, capacity_see
|
|||||||
f"{kind} failed after {attempts} attempts: gpt-image-2 上游负载饱和,"
|
f"{kind} failed after {attempts} attempts: gpt-image-2 上游负载饱和,"
|
||||||
f"已自动退避重试仍失败,请稍后点重试。最后错误:{last_err}"
|
f"已自动退避重试仍失败,请稍后点重试。最后错误:{last_err}"
|
||||||
)
|
)
|
||||||
|
if "timeout" in last_err.lower():
|
||||||
|
return (
|
||||||
|
f"{kind} failed after {attempts} attempts: gpt-image-2 图片网关响应超时"
|
||||||
|
f"(单次 {IMAGE_REQUEST_TIMEOUT_SECONDS}s),模型未更改。"
|
||||||
|
f"请检查 {IMAGE_BASE_URL or LLM_BASE_URL or 'image gateway'} 的 gpt-image-2 上游渠道或稍后重试。"
|
||||||
|
f"最后错误:{last_err}"
|
||||||
|
)
|
||||||
if _image_is_transport_error(last_err):
|
if _image_is_transport_error(last_err):
|
||||||
return (
|
return (
|
||||||
f"{kind} failed after {attempts} attempts: 图片网关网络/DNS 连接失败,"
|
f"{kind} failed after {attempts} attempts: 图片网关网络/DNS 连接失败,"
|
||||||
@@ -3542,6 +3550,38 @@ def _image_endpoint(path: str) -> str:
|
|||||||
return f"{base}/{path.lstrip('/')}"
|
return f"{base}/{path.lstrip('/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _image_generation_response(prompt: str, model: str) -> dict:
|
||||||
|
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||||
|
r = client.post(
|
||||||
|
_image_endpoint("/images/generations"),
|
||||||
|
headers={"Authorization": f"Bearer {IMAGE_API_KEY}"},
|
||||||
|
json={"model": model, "prompt": prompt, "n": 1},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _image_should_retry(
|
||||||
|
attempt: int,
|
||||||
|
total_attempts: int,
|
||||||
|
status_code: int,
|
||||||
|
body: str,
|
||||||
|
last_err: str,
|
||||||
|
next_mode_changed: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
if attempt >= total_attempts - 1:
|
||||||
|
return False
|
||||||
|
if next_mode_changed and status_code not in (401, 403):
|
||||||
|
if status_code == 0 and _image_is_transport_error(last_err):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
if status_code in (400, 401, 403, 404):
|
||||||
|
return False
|
||||||
|
if status_code == 0 and _image_is_transport_error(last_err):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _prepare_image_edit_bytes(image_path: Path, max_side: int) -> bytes:
|
def _prepare_image_edit_bytes(image_path: Path, max_side: int) -> bytes:
|
||||||
import io as _io
|
import io as _io
|
||||||
from PIL import Image as _PILImage
|
from PIL import Image as _PILImage
|
||||||
@@ -3590,14 +3630,16 @@ def _image_edit_call(
|
|||||||
resp_data: dict = {}
|
resp_data: dict = {}
|
||||||
effective_mode = "edit"
|
effective_mode = "edit"
|
||||||
capacity_seen = False
|
capacity_seen = False
|
||||||
|
attempts_done = 0
|
||||||
for attempt, current_mode in enumerate(plan):
|
for attempt, current_mode in enumerate(plan):
|
||||||
|
attempts_done = attempt + 1
|
||||||
current_model = models_cycle[min(attempt, len(models_cycle) - 1)]
|
current_model = models_cycle[min(attempt, len(models_cycle) - 1)]
|
||||||
status_code = 0
|
status_code = 0
|
||||||
body = ""
|
body = ""
|
||||||
retry_after: str | None = None
|
retry_after: str | None = None
|
||||||
try:
|
try:
|
||||||
if current_mode == "edit":
|
if current_mode == "edit":
|
||||||
with ai_http_client(timeout=120) as client:
|
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||||
r = client.post(
|
r = client.post(
|
||||||
_image_endpoint("/images/edits"),
|
_image_endpoint("/images/edits"),
|
||||||
headers={
|
headers={
|
||||||
@@ -3616,8 +3658,7 @@ def _image_edit_call(
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
resp_data = r.json()
|
resp_data = r.json()
|
||||||
else:
|
else:
|
||||||
resp = image_llm().images.generate(model=current_model, prompt=prompt, n=1)
|
resp_data = _image_generation_response(prompt, current_model)
|
||||||
resp_data = resp.model_dump() if hasattr(resp, "model_dump") else {"data": [{"b64_json": resp.data[0].b64_json}]}
|
|
||||||
if resp_data.get("data"):
|
if resp_data.get("data"):
|
||||||
effective_mode = current_mode
|
effective_mode = current_mode
|
||||||
model = current_model # 记录实际成功的 model
|
model = current_model # 记录实际成功的 model
|
||||||
@@ -3636,19 +3677,22 @@ def _image_edit_call(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = f"{type(e).__name__}: {e} · model={current_model}"
|
last_err = f"{type(e).__name__}: {e} · model={current_model}"
|
||||||
|
|
||||||
if attempt < len(plan) - 1:
|
next_mode_changed = attempt < len(plan) - 1 and plan[attempt + 1] != current_mode
|
||||||
|
if _image_should_retry(attempt, len(plan), status_code, body, last_err, next_mode_changed):
|
||||||
tag = f"retry {attempt + 1}/{len(plan)} → {GPT_IMAGE_MODEL}"
|
tag = f"retry {attempt + 1}/{len(plan)} → {GPT_IMAGE_MODEL}"
|
||||||
delay = _image_retry_delay(attempt, status_code, body, retry_after)
|
delay = _image_retry_delay(attempt, status_code, body, retry_after)
|
||||||
print(f"[image edit {tag}, sleep {delay:.0f}s] {last_err}", flush=True)
|
print(f"[image edit {tag}, sleep {delay:.0f}s] {last_err}", flush=True)
|
||||||
_time.sleep(delay)
|
_time.sleep(delay)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
data_arr = resp_data.get("data", [])
|
data_arr = resp_data.get("data", [])
|
||||||
if not data_arr:
|
if not data_arr:
|
||||||
raise RuntimeError(_image_failure_message("image edit", len(plan), last_err, capacity_seen))
|
raise RuntimeError(_image_failure_message("image edit", attempts_done, last_err, capacity_seen))
|
||||||
item = data_arr[0]
|
item = data_arr[0]
|
||||||
b64 = item.get("b64_json")
|
b64 = item.get("b64_json")
|
||||||
if not b64 and item.get("url"):
|
if not b64 and item.get("url"):
|
||||||
with ai_http_client(timeout=120) as client:
|
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||||
image_resp = client.get(item["url"])
|
image_resp = client.get(item["url"])
|
||||||
image_resp.raise_for_status()
|
image_resp.raise_for_status()
|
||||||
return image_resp.content, effective_mode
|
return image_resp.content, effective_mode
|
||||||
@@ -3666,35 +3710,51 @@ def _image_text_call(
|
|||||||
"""Text-only image generation. 生图模型强制使用 gpt-image-2。"""
|
"""Text-only image generation. 生图模型强制使用 gpt-image-2。"""
|
||||||
import base64 as b64lib
|
import base64 as b64lib
|
||||||
import time as _time
|
import time as _time
|
||||||
|
import httpx
|
||||||
if not IMAGE_API_KEY:
|
if not IMAGE_API_KEY:
|
||||||
raise RuntimeError("IMAGE_API_KEY 或 LLM_API_KEY 未配置")
|
raise RuntimeError("IMAGE_API_KEY 或 LLM_API_KEY 未配置")
|
||||||
models_cycle = [GPT_IMAGE_MODEL]
|
models_cycle = [GPT_IMAGE_MODEL]
|
||||||
last_err = ""
|
last_err = ""
|
||||||
resp_data: dict = {}
|
|
||||||
capacity_seen = False
|
capacity_seen = False
|
||||||
|
attempts_done = 0
|
||||||
for attempt in range(max_attempts):
|
for attempt in range(max_attempts):
|
||||||
|
attempts_done = attempt + 1
|
||||||
current_model = models_cycle[min(attempt, len(models_cycle) - 1)]
|
current_model = models_cycle[min(attempt, len(models_cycle) - 1)]
|
||||||
status_code = 0
|
status_code = 0
|
||||||
body = ""
|
body = ""
|
||||||
|
retry_after: str | None = None
|
||||||
try:
|
try:
|
||||||
resp = image_llm().images.generate(model=current_model, prompt=prompt, n=1)
|
resp_data = _image_generation_response(prompt, current_model)
|
||||||
resp_data = resp.model_dump() if hasattr(resp, "model_dump") else {"data": [{"b64_json": resp.data[0].b64_json}]}
|
|
||||||
if resp_data.get("data"):
|
if resp_data.get("data"):
|
||||||
b64 = resp_data["data"][0].get("b64_json")
|
item = resp_data["data"][0]
|
||||||
|
b64 = item.get("b64_json")
|
||||||
if b64:
|
if b64:
|
||||||
return b64lib.b64decode(b64), "text"
|
return b64lib.b64decode(b64), "text"
|
||||||
|
if item.get("url"):
|
||||||
|
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||||
|
image_resp = client.get(item["url"])
|
||||||
|
image_resp.raise_for_status()
|
||||||
|
return image_resp.content, "text"
|
||||||
err_obj = resp_data.get("error") or {}
|
err_obj = resp_data.get("error") or {}
|
||||||
last_err = f"empty data · {err_obj.get('code', '')} · {str(err_obj.get('message', ''))[:200]} · model={current_model}"
|
last_err = f"empty data · {err_obj.get('code', '')} · {str(err_obj.get('message', ''))[:200]} · model={current_model}"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
body = e.response.text
|
||||||
|
status_code = e.response.status_code
|
||||||
|
retry_after = e.response.headers.get("retry-after")
|
||||||
|
capacity_seen = capacity_seen or _image_is_capacity_error(status_code, body)
|
||||||
|
last_err = f"HTTP {status_code}: {body[:200]} · model={current_model}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = f"{type(e).__name__}: {e} · model={current_model}"
|
last_err = f"{type(e).__name__}: {e} · model={current_model}"
|
||||||
body = str(e)
|
body = str(e)
|
||||||
status_code = 429 if "429" in body or "saturated" in body.lower() or "饱和" in body else 0
|
status_code = 429 if "429" in body or "saturated" in body.lower() or "饱和" in body else 0
|
||||||
capacity_seen = capacity_seen or _image_is_capacity_error(status_code, body)
|
capacity_seen = capacity_seen or _image_is_capacity_error(status_code, body)
|
||||||
if attempt < max_attempts - 1:
|
if _image_should_retry(attempt, max_attempts, status_code, body, last_err):
|
||||||
delay = _image_retry_delay(attempt, status_code, body)
|
delay = _image_retry_delay(attempt, status_code, body, retry_after)
|
||||||
print(f"[image text retry {attempt + 1}/{max_attempts} → {GPT_IMAGE_MODEL}, sleep {delay:.0f}s] {last_err}", flush=True)
|
print(f"[image text retry {attempt + 1}/{max_attempts} → {GPT_IMAGE_MODEL}, sleep {delay:.0f}s] {last_err}", flush=True)
|
||||||
_time.sleep(delay)
|
_time.sleep(delay)
|
||||||
raise RuntimeError(_image_failure_message("image text", max_attempts, last_err, capacity_seen))
|
else:
|
||||||
|
break
|
||||||
|
raise RuntimeError(_image_failure_message("image text", attempts_done, last_err, capacity_seen))
|
||||||
|
|
||||||
|
|
||||||
def _image_path_to_data_url(path: Path) -> str:
|
def _image_path_to_data_url(path: Path) -> str:
|
||||||
@@ -4054,6 +4114,7 @@ def health() -> dict:
|
|||||||
"product_view": PRODUCT_VIEW_MODEL,
|
"product_view": PRODUCT_VIEW_MODEL,
|
||||||
"image": IMAGE_MODEL,
|
"image": IMAGE_MODEL,
|
||||||
"image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default",
|
"image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default",
|
||||||
|
"image_request_timeout_seconds": IMAGE_REQUEST_TIMEOUT_SECONDS,
|
||||||
"ai_proxy_configured": bool(AI_HTTP_PROXY),
|
"ai_proxy_configured": bool(AI_HTTP_PROXY),
|
||||||
"image_fallbacks": [GPT_IMAGE_MODEL],
|
"image_fallbacks": [GPT_IMAGE_MODEL],
|
||||||
"subject_image": SUBJECT_ASSET_IMAGE_MODEL,
|
"subject_image": SUBJECT_ASSET_IMAGE_MODEL,
|
||||||
@@ -4394,7 +4455,9 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
last_err = ""
|
last_err = ""
|
||||||
effective_mode = req.mode
|
effective_mode = req.mode
|
||||||
capacity_seen = False
|
capacity_seen = False
|
||||||
|
attempts_done = 0
|
||||||
for attempt, current_mode in enumerate(plan):
|
for attempt, current_mode in enumerate(plan):
|
||||||
|
attempts_done = attempt + 1
|
||||||
status_code = 0
|
status_code = 0
|
||||||
body = ""
|
body = ""
|
||||||
retry_after: str | None = None
|
retry_after: str | None = None
|
||||||
@@ -4402,7 +4465,7 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
if current_mode == "edit":
|
if current_mode == "edit":
|
||||||
if img_bytes_in is None:
|
if img_bytes_in is None:
|
||||||
raise RuntimeError("edit mode reference image missing")
|
raise RuntimeError("edit mode reference image missing")
|
||||||
with ai_http_client(timeout=120) as client:
|
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||||
r = client.post(
|
r = client.post(
|
||||||
_image_endpoint("/images/edits"),
|
_image_endpoint("/images/edits"),
|
||||||
headers={
|
headers={
|
||||||
@@ -4415,8 +4478,7 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
resp_data = r.json()
|
resp_data = r.json()
|
||||||
else:
|
else:
|
||||||
# text-only
|
# text-only
|
||||||
resp = image_llm().images.generate(model=model, prompt=full_prompt, n=1)
|
resp_data = _image_generation_response(full_prompt, model)
|
||||||
resp_data = resp.model_dump() if hasattr(resp, "model_dump") else {"data": [{"b64_json": resp.data[0].b64_json}]}
|
|
||||||
|
|
||||||
if resp_data.get("data"):
|
if resp_data.get("data"):
|
||||||
effective_mode = current_mode
|
effective_mode = current_mode
|
||||||
@@ -4442,22 +4504,25 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = f"{type(e).__name__}: {e}"
|
last_err = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
if attempt < len(plan) - 1:
|
next_mode_changed = attempt < len(plan) - 1 and plan[attempt + 1] != current_mode
|
||||||
|
if _image_should_retry(attempt, len(plan), status_code, body, last_err, next_mode_changed):
|
||||||
next_mode = plan[attempt + 1]
|
next_mode = plan[attempt + 1]
|
||||||
tag = f"fallback → {next_mode}" if next_mode != current_mode else f"retry {attempt + 1}/{len(plan)}"
|
tag = f"fallback → {next_mode}" if next_mode != current_mode else f"retry {attempt + 1}/{len(plan)}"
|
||||||
print(f"[image gen {tag}] {last_err}", flush=True)
|
print(f"[image gen {tag}] {last_err}", flush=True)
|
||||||
_time.sleep(_image_retry_delay(attempt, status_code, body, retry_after))
|
_time.sleep(_image_retry_delay(attempt, status_code, body, retry_after))
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
data_arr = resp_data.get("data", [])
|
data_arr = resp_data.get("data", [])
|
||||||
if not data_arr:
|
if not data_arr:
|
||||||
raise HTTPException(503 if capacity_seen else 500, _image_failure_message("image gen", len(plan), last_err, capacity_seen))
|
raise HTTPException(503 if capacity_seen else 500, _image_failure_message("image gen", attempts_done, last_err, capacity_seen))
|
||||||
|
|
||||||
item = data_arr[0]
|
item = data_arr[0]
|
||||||
b64 = item.get("b64_json")
|
b64 = item.get("b64_json")
|
||||||
if b64:
|
if b64:
|
||||||
out_bytes = b64lib.b64decode(b64)
|
out_bytes = b64lib.b64decode(b64)
|
||||||
elif item.get("url"):
|
elif item.get("url"):
|
||||||
with ai_http_client(timeout=120) as client:
|
with ai_http_client(timeout=IMAGE_REQUEST_TIMEOUT_SECONDS) as client:
|
||||||
image_resp = client.get(item["url"])
|
image_resp = client.get(item["url"])
|
||||||
image_resp.raise_for_status()
|
image_resp.raise_for_status()
|
||||||
out_bytes = image_resp.content
|
out_bytes = image_resp.content
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ PRODUCT_VIEW_MODEL=gpt-image-2
|
|||||||
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
|
||||||
IMAGE_API_KEY=
|
IMAGE_API_KEY=
|
||||||
IMAGE_MODEL=gpt-image-2
|
IMAGE_MODEL=gpt-image-2
|
||||||
|
IMAGE_REQUEST_TIMEOUT_SECONDS=60
|
||||||
GPT_IMAGE_MODEL=gpt-image-2
|
GPT_IMAGE_MODEL=gpt-image-2
|
||||||
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
|
||||||
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
|
||||||
|
|||||||
@@ -986,7 +986,7 @@ ProductRefStateItem {
|
|||||||
<tr><td>应用清洗</td><td><code>POST /cleanup/apply</code></td><td><code>applyCleanedFrame</code></td><td>物理覆盖 frames/{idx}.jpg,并备份原图。</td></tr>
|
<tr><td>应用清洗</td><td><code>POST /cleanup/apply</code></td><td><code>applyCleanedFrame</code></td><td>物理覆盖 frames/{idx}.jpg,并备份原图。</td></tr>
|
||||||
<tr><td>元素增改删</td><td><code>POST/PATCH/DELETE /elements</code></td><td><code>addElement/updateElement/deleteElement</code></td><td>让用户修正 Vision 错误,避免候选结果锁死。</td></tr>
|
<tr><td>元素增改删</td><td><code>POST/PATCH/DELETE /elements</code></td><td><code>addElement/updateElement/deleteElement</code></td><td>让用户修正 Vision 错误,避免候选结果锁死。</td></tr>
|
||||||
<tr><td>元素提取</td><td><code>POST /elements/{element_id}/cutout</code></td><td><code>cutoutElement</code></td><td>调用图像模型生成独立白底素材图,每次累积一张 cutout。</td></tr>
|
<tr><td>元素提取</td><td><code>POST /elements/{element_id}/cutout</code></td><td><code>cutoutElement</code></td><td>调用图像模型生成独立白底素材图,每次累积一张 cutout。</td></tr>
|
||||||
<tr><td>主体资产包</td><td><code>POST /elements/{element_id}/subject-assets</code><br><code>DELETE /elements/{element_id}/subject-assets/{asset_id}</code></td><td><code>generateSubjectAssets</code><br><code>deleteSubjectAsset</code></td><td>根据转换层里的参考帧重新绘制一个统一主体资产包;前端按真人重构、卡通重构、元素重构、自主描述四个方向分别管理 <code>source_frame_indices</code>,每个方向最多 3 张参考帧,固定请求 <code>front</code>、<code>three_quarter_left</code>、<code>left</code>、<code>back</code>、<code>right</code>、<code>three_quarter_right</code> 六个视图,不再暴露完整 10 / 常用 4 选择。当前源视频工作区使用 <code>subject_style=source_actor</code> 承接真人、元素和自主描述,使用 <code>subject_style=cartoon_subject</code> 承接卡通重构;旧 <code>transparent_human</code> 仍为兼容类型但不是当前转换层默认入口。<code>reconstruction_mode=similar</code> 是创新路径:后端先用 <code>VISION_MODEL</code> 把关键帧反推成非身份化文字 brief,再调用 <code>gpt-image-2</code> 的 <code>/images/generations</code> 文字生图,日志会显示 <code>image_refs=0</code>;这里是参考重构生成套图,不是抠图、复制或 image-edit 复刻。卡通重构在后端额外加入原创卡通/插画主体约束,明确不输出真实人物复制 likeness。生成完成后,后端会把生成视图反推/写入 <code>KeyElement.subject_consensus_brief</code>,作为后续首尾帧的唯一主体身份文字依据。<code>reconstruction_mode=same</code> 仍保留旧 image-edit 路径,用于确实需要精确复刻且有授权的场景。每个 <code>view</code> 单独调用一次生图,明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。<code>replace_views=true</code> 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。</td></tr>
|
<tr><td>主体资产包</td><td><code>POST /elements/{element_id}/subject-assets</code><br><code>DELETE /elements/{element_id}/subject-assets/{asset_id}</code></td><td><code>generateSubjectAssets</code><br><code>deleteSubjectAsset</code></td><td>根据转换层里的参考帧重新绘制一个统一主体资产包;前端按真人重构、卡通重构、元素重构、自主描述四个方向分别管理 <code>source_frame_indices</code>,每个方向最多 3 张参考帧,固定请求 <code>front</code>、<code>three_quarter_left</code>、<code>left</code>、<code>back</code>、<code>right</code>、<code>three_quarter_right</code> 六个视图,不再暴露完整 10 / 常用 4 选择。当前源视频工作区使用 <code>subject_style=source_actor</code> 承接真人、元素和自主描述,使用 <code>subject_style=cartoon_subject</code> 承接卡通重构;旧 <code>transparent_human</code> 仍为兼容类型但不是当前转换层默认入口。<code>reconstruction_mode=similar</code> 是创新路径:后端先用 <code>VISION_MODEL</code> 把关键帧反推成非身份化文字 brief,再调用 <code>gpt-image-2</code> 的 <code>/images/generations</code> 文字生图,日志会显示 <code>image_refs=0</code>;这里是参考重构生成套图,不是抠图、复制或 image-edit 复刻。卡通重构在后端额外加入原创卡通/插画主体约束,明确不输出真实人物复制 likeness。生成完成后,后端会把生成视图反推/写入 <code>KeyElement.subject_consensus_brief</code>,作为后续首尾帧的唯一主体身份文字依据。<code>reconstruction_mode=same</code> 仍保留旧 image-edit 路径,用于确实需要精确复刻且有授权的场景。每个 <code>view</code> 单独调用一次生图,明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版。单次图片请求受 <code>IMAGE_REQUEST_TIMEOUT_SECONDS</code> 控制,默认 60 秒;超时、DNS 或连接失败会让当前视图标失败并继续后续视图。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。<code>replace_views=true</code> 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。</td></tr>
|
||||||
<tr><td>主体套图状态</td><td><code>SubjectAsset.status</code><br><code>pack_id</code></td><td><code>web/app/page.tsx</code><br><code>SourceSubjectPipeline</code></td><td><code>generateSubjectAssets</code> 现在先写入同一个 <code>pack_id</code> 下的 queued 占位卡并立即返回,后台按视角逐张生成,单张完成就把该占位替换成 completed 图片。前端轮询会把 queued / in_progress 主体资产纳入运行状态;主体元素区按 pack 显示套图文件夹,点击某个文件夹后展开该套图,其他套图顺位进入下方可滚动列表。</td></tr>
|
<tr><td>主体套图状态</td><td><code>SubjectAsset.status</code><br><code>pack_id</code></td><td><code>web/app/page.tsx</code><br><code>SourceSubjectPipeline</code></td><td><code>generateSubjectAssets</code> 现在先写入同一个 <code>pack_id</code> 下的 queued 占位卡并立即返回,后台按视角逐张生成,单张完成就把该占位替换成 completed 图片。前端轮询会把 queued / in_progress 主体资产纳入运行状态;主体元素区按 pack 显示套图文件夹,点击某个文件夹后展开该套图,其他套图顺位进入下方可滚动列表。</td></tr>
|
||||||
<tr><td>首尾帧资产</td><td><code>POST /frames/{idx}/scene-asset</code></td><td><code>generateSceneAsset</code></td><td>同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 <code>asset_role=first_frame/last_frame</code>、<code>subject_brief</code> 和最多 1-2 张 <code>product_images</code>。首尾帧不再传主体图、不再把主体图和产品图拼成 contact sheet;主体只走文字 brief,允许新动作、新景别、新表情和新环境。若本条需要产品,后端只把产品参考图作为 <code>gpt-image-2</code> image-edit 的硬视觉真源;若不需要产品,则走纯文字生图。关键帧只作为行数据承载位置。生成结果保存在 <code>scene_assets</code>,前端再写入 <code>StoryboardScene.first_image/last_image</code>。</td></tr>
|
<tr><td>首尾帧资产</td><td><code>POST /frames/{idx}/scene-asset</code></td><td><code>generateSceneAsset</code></td><td>同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 <code>asset_role=first_frame/last_frame</code>、<code>subject_brief</code> 和最多 1-2 张 <code>product_images</code>。首尾帧不再传主体图、不再把主体图和产品图拼成 contact sheet;主体只走文字 brief,允许新动作、新景别、新表情和新环境。若本条需要产品,后端只把产品参考图作为 <code>gpt-image-2</code> image-edit 的硬视觉真源;若不需要产品,则走纯文字生图。关键帧只作为行数据承载位置。生成结果保存在 <code>scene_assets</code>,前端再写入 <code>StoryboardScene.first_image/last_image</code>。</td></tr>
|
||||||
<tr><td>产品图库</td><td><code>GET /product-library/skg</code></td><td><code>listProductLibrary</code></td><td>读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。</td></tr>
|
<tr><td>产品图库</td><td><code>GET /product-library/skg</code></td><td><code>listProductLibrary</code></td><td>读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。</td></tr>
|
||||||
@@ -1062,7 +1062,7 @@ ProductRefStateItem {
|
|||||||
<li>主体候选确认、改名、删除和主体资产包生成能力保留在底层旧面板和接口中,当前第一步主界面不主动展示。</li>
|
<li>主体候选确认、改名、删除和主体资产包生成能力保留在底层旧面板和接口中,当前第一步主界面不主动展示。</li>
|
||||||
<li>分镜工作台 4 图槽和改造说明自动保存。</li>
|
<li>分镜工作台 4 图槽和改造说明自动保存。</li>
|
||||||
<li>音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效;结果集中在右侧工作表展示。</li>
|
<li>音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效;结果集中在右侧工作表展示。</li>
|
||||||
<li>GPT Image 生图;当前 <code>IMAGE_MODEL</code> 和主体 6 视图链路默认使用 <code>gpt-image-2</code>。</li>
|
<li>GPT Image 生图;当前 <code>IMAGE_MODEL</code> 和主体 6 视图链路默认使用 <code>gpt-image-2</code>,单次图片网关请求默认 60 秒超时,超时后该视图标失败并继续后续视图。</li>
|
||||||
<li>三字段分镜候选生成:默认行左侧露文案、场景一句话、人物+产品+动作,右侧直接展示横向视频轨;中文镜像失焦后会自动优化英文主值;支持 AI 改写预览、单条选择数量生成、追加生成、选中候选和整片按行排队提交。</li>
|
<li>三字段分镜候选生成:默认行左侧露文案、场景一句话、人物+产品+动作,右侧直接展示横向视频轨;中文镜像失焦后会自动优化英文主值;支持 AI 改写预览、单条选择数量生成、追加生成、选中候选和整片按行排队提交。</li>
|
||||||
<li>全局资源中心:提示词库和素材库可从顶部“资源库”打开;提示词可复制并计数,素材应用到 job 时会复制成本 job 内普通 asset。</li>
|
<li>全局资源中心:提示词库和素材库可从顶部“资源库”打开;提示词可复制并计数,素材应用到 job 时会复制成本 job 内普通 asset。</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1113,6 +1113,19 @@ ProductRefStateItem {
|
|||||||
<h2>变更记录</h2>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-19 · gpt-image-2 请求超时改为快速失败</h3>
|
||||||
|
<span class="tag violet">API</span>
|
||||||
|
<span class="tag amber">Reliability</span>
|
||||||
|
<span class="tag rose">Config</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong><code>gpt-image-2</code> 上游图片网关无响应时,文字生图仍通过 SDK 默认等待,编辑生图也按 120 秒重复尝试;主体 6 视图第一张卡住后,用户侧长时间看不到逐张失败或后续进度。</p>
|
||||||
|
<p><strong>改动:</strong><code>api/main.py</code> 新增 <code>IMAGE_REQUEST_TIMEOUT_SECONDS</code>,默认 60 秒;<code>_image_text_call</code> 统一改为直接调用 <code>/images/generations</code>,<code>_image_edit_call</code> 和旧分镜生图也复用同一超时。超时、DNS、连接失败这类传输错误不再盲目重试三轮,会把当前视图标失败并继续处理后续视图。<code>/health</code> 回传当前图片超时配置。</p>
|
||||||
|
<p><strong>影响:</strong>这次不改模型,所有图片入口仍固定只使用 <code>gpt-image-2</code>;如果继续失败,错误会明确指向当前 <code>IMAGE_BASE_URL</code> 上的 <code>gpt-image-2</code> 通道超时或不可用。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article class="change">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-19 · 主体元素改为套图文件夹并逐张回填</h3>
|
<h3>2026-05-19 · 主体元素改为套图文件夹并逐张回填</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user