diff --git a/RULES.md b/RULES.md
index 1ecb3a2..27ac9fe 100644
--- a/RULES.md
+++ b/RULES.md
@@ -73,9 +73,11 @@
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;如果旧环境仍写 `gemini-*`,后端会自动改用 `REWRITE_MODEL`
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
- `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`
- `IMAGE_REQUEST_TIMEOUT_SECONDS`:单次图片网关请求超时,默认 60 秒;超时会直接把该视图标失败并继续下一张,避免主体 6 视图整包长时间无反馈
-- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2`
+- `IMAGE_FALLBACK_ENABLED` / `IMAGE_FALLBACK_MODEL`:图片主模型故障兜底;当前允许在 `gpt-image-2` 超时、429、5xx 或网络错误时临时使用 `gemini-3-pro-image-preview`,400/401/403/404 和参数错误不兜底
+- `IMAGE_CIRCUIT_FAILURE_THRESHOLD` / `IMAGE_CIRCUIT_COOLDOWN_SECONDS`:短时熔断配置,默认 `gpt-image-2` 连续 2 次上游类失败后 600 秒内直接走 Gemini 兜底;成功恢复后自动清空失败计数
+- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名;主体 6 视图先用 `gpt-image-2`,同一套图内一旦触发 Gemini 兜底,后续视图沿用 Gemini,避免一张张等待主模型超时
- `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 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`;旧环境若写 `minimax` 会被忽略
diff --git a/api/.env.example b/api/.env.example
index 6795d19..eec9ab6 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -25,9 +25,13 @@ IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
IMAGE_API_KEY=
IMAGE_MODEL=gpt-image-2
IMAGE_REQUEST_TIMEOUT_SECONDS=60
+IMAGE_FALLBACK_ENABLED=true
+IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
+IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
+IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
GPT_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,gemini-3-pro-image-preview
# 可选:本地网络需要代理访问 ai.skg.com 时配置;launchd 不一定继承 shell 代理变量。
AI_HTTP_PROXY=
YTDLP_COOKIES_FILE=
diff --git a/api/main.py b/api/main.py
index 3390a38..728f984 100644
--- a/api/main.py
+++ b/api/main.py
@@ -97,14 +97,24 @@ AI_HTTP_PROXY = (
or os.getenv("http_proxy")
or ""
).strip()
-# Product decision: every image-generation/editing path is locked to gpt-image-2.
-# Environment variables may still choose the gateway URL/key, but not the model.
+# Product decision: gpt-image-2 remains the primary image model. Gemini is only
+# allowed as an outage fallback when the primary gateway times out or returns
+# transient upstream failures.
GPT_IMAGE_MODEL = "gpt-image-2"
+IMAGE_FALLBACK_MODEL = os.getenv("IMAGE_FALLBACK_MODEL", "gemini-3-pro-image-preview").strip() or ""
+IMAGE_FALLBACK_ENABLED = os.getenv("IMAGE_FALLBACK_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"}
IMAGE_MODEL = GPT_IMAGE_MODEL
PRODUCT_VIEW_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_FALLBACK_MODEL] if IMAGE_FALLBACK_ENABLED and IMAGE_FALLBACK_MODEL and IMAGE_FALLBACK_MODEL != GPT_IMAGE_MODEL else []
+)
IMAGE_REQUEST_TIMEOUT_SECONDS = max(15, min(180, int(os.getenv("IMAGE_REQUEST_TIMEOUT_SECONDS", "60"))))
+IMAGE_CIRCUIT_FAILURE_THRESHOLD = max(1, int(os.getenv("IMAGE_CIRCUIT_FAILURE_THRESHOLD", "2")))
+IMAGE_CIRCUIT_COOLDOWN_SECONDS = max(60, int(os.getenv("IMAGE_CIRCUIT_COOLDOWN_SECONDS", "600")))
+_IMAGE_CIRCUIT_LOCK = threading.Lock()
+_IMAGE_PRIMARY_FAILURES = 0
+_IMAGE_PRIMARY_OPEN_UNTIL = 0.0
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_SHORT_SIDE = max(320, int(os.getenv("PRODUCT_ASSET_MIN_SHORT_SIDE", "600")))
@@ -3511,6 +3521,83 @@ def _image_is_transport_error(message: str) -> bool:
)
+def _image_fallback_models() -> list[str]:
+ if not IMAGE_FALLBACK_ENABLED or not IMAGE_FALLBACK_MODEL or IMAGE_FALLBACK_MODEL == GPT_IMAGE_MODEL:
+ return []
+ return [IMAGE_FALLBACK_MODEL]
+
+
+def _image_circuit_snapshot() -> dict:
+ now = time.time()
+ with _IMAGE_CIRCUIT_LOCK:
+ open_until = _IMAGE_PRIMARY_OPEN_UNTIL
+ return {
+ "primary": GPT_IMAGE_MODEL,
+ "fallbacks": _image_fallback_models(),
+ "failure_threshold": IMAGE_CIRCUIT_FAILURE_THRESHOLD,
+ "cooldown_seconds": IMAGE_CIRCUIT_COOLDOWN_SECONDS,
+ "primary_failures": _IMAGE_PRIMARY_FAILURES,
+ "primary_open": open_until > now,
+ "primary_open_until": open_until if open_until > now else 0,
+ "primary_open_remaining_seconds": max(0, int(open_until - now)),
+ }
+
+
+def _image_primary_circuit_open() -> bool:
+ return _image_circuit_snapshot()["primary_open"]
+
+
+def _image_model_candidates(force_fallback: bool = False) -> list[str]:
+ fallbacks = _image_fallback_models()
+ if not fallbacks:
+ return [GPT_IMAGE_MODEL]
+ if force_fallback or _image_primary_circuit_open():
+ return fallbacks
+ return [GPT_IMAGE_MODEL, *fallbacks]
+
+
+def _image_failure_can_fallback(status_code: int, body: str, last_err: str) -> bool:
+ if status_code in (400, 401, 403, 404):
+ return False
+ return (
+ status_code == 429
+ or status_code >= 500
+ or _image_is_capacity_error(status_code, body)
+ or _image_is_transport_error(last_err)
+ or "timeout" in (body or "").lower()
+ )
+
+
+def _image_record_primary_success() -> None:
+ global _IMAGE_PRIMARY_FAILURES, _IMAGE_PRIMARY_OPEN_UNTIL
+ with _IMAGE_CIRCUIT_LOCK:
+ if _IMAGE_PRIMARY_FAILURES or _IMAGE_PRIMARY_OPEN_UNTIL:
+ print(f"[image circuit] primary {GPT_IMAGE_MODEL} recovered", flush=True)
+ _IMAGE_PRIMARY_FAILURES = 0
+ _IMAGE_PRIMARY_OPEN_UNTIL = 0.0
+
+
+def _image_record_primary_failure(reason: str) -> None:
+ global _IMAGE_PRIMARY_FAILURES, _IMAGE_PRIMARY_OPEN_UNTIL
+ if not _image_fallback_models():
+ return
+ with _IMAGE_CIRCUIT_LOCK:
+ _IMAGE_PRIMARY_FAILURES += 1
+ if _IMAGE_PRIMARY_FAILURES >= IMAGE_CIRCUIT_FAILURE_THRESHOLD:
+ _IMAGE_PRIMARY_OPEN_UNTIL = time.time() + IMAGE_CIRCUIT_COOLDOWN_SECONDS
+ print(
+ f"[image circuit] primary {GPT_IMAGE_MODEL} opened for {IMAGE_CIRCUIT_COOLDOWN_SECONDS}s "
+ f"after {_IMAGE_PRIMARY_FAILURES} failures; fallback={IMAGE_FALLBACK_MODEL}; reason={reason[:220]}",
+ flush=True,
+ )
+ else:
+ print(
+ f"[image circuit] primary {GPT_IMAGE_MODEL} failure {_IMAGE_PRIMARY_FAILURES}/{IMAGE_CIRCUIT_FAILURE_THRESHOLD}; "
+ f"fallback={IMAGE_FALLBACK_MODEL}; reason={reason[:220]}",
+ flush=True,
+ )
+
+
def _image_failure_message(kind: str, attempts: int, last_err: str, capacity_seen: bool) -> str:
if capacity_seen:
return (
@@ -3604,36 +3691,37 @@ def _image_edit_call(
fallback_text: bool = False,
max_attempts: int = 3,
max_side: int = 1024,
+ force_fallback_model: bool = False,
) -> tuple[bytes, str]:
"""通用 image edit 调用 · 失败重试 + 可选 text fallback。
返回 (image_bytes, effective_mode) where effective_mode in {"edit","text"}。
失败 raise RuntimeError。
输入图自动 resize 到 max_side(默认 1024)边长后再用 multipart 上传;多参考图使用 image[]。
- 生图模型按产品规则强制使用 gpt-image-2;model/models 参数只保留兼容旧调用。"""
+ 生图模型主路径使用 gpt-image-2;Gemini 只在主模型上游异常时兜底。model/models 参数只保留兼容旧调用。"""
import base64 as b64lib
import time as _time
import httpx
if not IMAGE_API_KEY:
raise RuntimeError("IMAGE_API_KEY 或 LLM_API_KEY 未配置")
- models_cycle = [GPT_IMAGE_MODEL]
model = GPT_IMAGE_MODEL
image_paths = image_path if isinstance(image_path, list) else [image_path]
image_paths = [path for path in image_paths if path and path.exists()][:10]
if not image_paths:
raise RuntimeError("image edit reference image missing")
img_bytes_list = [_prepare_image_edit_bytes(path, max_side) for path in image_paths]
- plan: list[str] = ["edit"] * max_attempts
+ model_candidates = _image_model_candidates(force_fallback=force_fallback_model)
+ mode_plan: list[str] = ["edit"] if model_candidates != [GPT_IMAGE_MODEL] else ["edit"] * max_attempts
if fallback_text:
- plan.append("text")
+ mode_plan.append("text")
+ attempt_steps = [(current_mode, current_model) for current_mode in mode_plan for current_model in model_candidates]
last_err = ""
resp_data: dict = {}
effective_mode = "edit"
capacity_seen = False
attempts_done = 0
- for attempt, current_mode in enumerate(plan):
+ for attempt, (current_mode, current_model) in enumerate(attempt_steps):
attempts_done = attempt + 1
- current_model = models_cycle[min(attempt, len(models_cycle) - 1)]
status_code = 0
body = ""
retry_after: str | None = None
@@ -3660,8 +3748,10 @@ def _image_edit_call(
else:
resp_data = _image_generation_response(prompt, current_model)
if resp_data.get("data"):
- effective_mode = current_mode
+ effective_mode = f"{current_mode}:{current_model}"
model = current_model # 记录实际成功的 model
+ if current_model == GPT_IMAGE_MODEL:
+ _image_record_primary_success()
break
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}"
@@ -3677,9 +3767,15 @@ def _image_edit_call(
except Exception as e:
last_err = f"{type(e).__name__}: {e} · model={current_model}"
- 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}"
+ fallbackable = current_model == GPT_IMAGE_MODEL and _image_failure_can_fallback(status_code, body, last_err)
+ if fallbackable:
+ _image_record_primary_failure(last_err)
+ if any(next_model != GPT_IMAGE_MODEL for _next_mode, next_model in attempt_steps[attempt + 1:]):
+ print(f"[image edit fallback → {IMAGE_FALLBACK_MODEL}] {last_err}", flush=True)
+ continue
+ next_mode_changed = attempt < len(attempt_steps) - 1 and attempt_steps[attempt + 1][0] != current_mode
+ if _image_should_retry(attempt, len(attempt_steps), status_code, body, last_err, next_mode_changed):
+ tag = f"retry {attempt + 1}/{len(attempt_steps)} → {current_model}"
delay = _image_retry_delay(attempt, status_code, body, retry_after)
print(f"[image edit {tag}, sleep {delay:.0f}s] {last_err}", flush=True)
_time.sleep(delay)
@@ -3706,20 +3802,21 @@ def _image_text_call(
model: str | None = None,
models: list[str] | None = None,
max_attempts: int = 3,
+ force_fallback_model: bool = False,
) -> tuple[bytes, str]:
- """Text-only image generation. 生图模型强制使用 gpt-image-2。"""
+ """Text-only image generation. gpt-image-2 primary, Gemini only as outage fallback."""
import base64 as b64lib
import time as _time
import httpx
if not IMAGE_API_KEY:
raise RuntimeError("IMAGE_API_KEY 或 LLM_API_KEY 未配置")
- models_cycle = [GPT_IMAGE_MODEL]
+ candidates = _image_model_candidates(force_fallback=force_fallback_model)
+ attempt_models = candidates if candidates != [GPT_IMAGE_MODEL] else [GPT_IMAGE_MODEL] * max_attempts
last_err = ""
capacity_seen = False
attempts_done = 0
- for attempt in range(max_attempts):
+ for attempt, current_model in enumerate(attempt_models):
attempts_done = attempt + 1
- current_model = models_cycle[min(attempt, len(models_cycle) - 1)]
status_code = 0
body = ""
retry_after: str | None = None
@@ -3729,12 +3826,16 @@ def _image_text_call(
item = resp_data["data"][0]
b64 = item.get("b64_json")
if b64:
- return b64lib.b64decode(b64), "text"
+ if current_model == GPT_IMAGE_MODEL:
+ _image_record_primary_success()
+ return b64lib.b64decode(b64), f"text:{current_model}"
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"
+ if current_model == GPT_IMAGE_MODEL:
+ _image_record_primary_success()
+ return image_resp.content, f"text:{current_model}"
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}"
except httpx.HTTPStatusError as e:
@@ -3748,9 +3849,15 @@ def _image_text_call(
body = str(e)
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)
- if _image_should_retry(attempt, max_attempts, status_code, body, last_err):
+ fallbackable = current_model == GPT_IMAGE_MODEL and _image_failure_can_fallback(status_code, body, last_err)
+ if fallbackable:
+ _image_record_primary_failure(last_err)
+ if any(next_model != GPT_IMAGE_MODEL for next_model in attempt_models[attempt + 1:]):
+ print(f"[image text fallback → {IMAGE_FALLBACK_MODEL}] {last_err}", flush=True)
+ continue
+ if _image_should_retry(attempt, len(attempt_models), status_code, body, last_err):
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}/{len(attempt_models)} → {current_model}, sleep {delay:.0f}s] {last_err}", flush=True)
_time.sleep(delay)
else:
break
@@ -4116,7 +4223,8 @@ def health() -> dict:
"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),
- "image_fallbacks": [GPT_IMAGE_MODEL],
+ "image_fallbacks": _image_fallback_models(),
+ "image_circuit": _image_circuit_snapshot(),
"subject_image": SUBJECT_ASSET_IMAGE_MODEL,
"subject_image_fallbacks": SUBJECT_ASSET_IMAGE_MODELS,
"voice_provider": VOICE_PROVIDER,
@@ -4447,16 +4555,18 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
if req.mode == "edit":
img_bytes_in = reference_path.read_bytes()
- # 尝试 i2i 最多 3 次,全失败时降级 text-only 再试 1 次
- plan: list[str] = ([req.mode] * 3) if req.mode == "edit" else [req.mode]
+ # 尝试 i2i;主模型上游异常时允许 Gemini 兜底。无兜底时保留旧的多次重试。
+ model_candidates = _image_model_candidates()
+ plan: list[str] = ([req.mode] if model_candidates != [GPT_IMAGE_MODEL] else [req.mode] * 3) if req.mode == "edit" else [req.mode]
if req.mode == "edit":
plan.append("text") # i2i 都失败时自动降级
+ attempt_steps = [(current_mode, current_model) for current_mode in plan for current_model in model_candidates]
resp_data: dict = {}
last_err = ""
effective_mode = req.mode
capacity_seen = False
attempts_done = 0
- for attempt, current_mode in enumerate(plan):
+ for attempt, (current_mode, current_model) in enumerate(attempt_steps):
attempts_done = attempt + 1
status_code = 0
body = ""
@@ -4471,20 +4581,23 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
headers={
"Authorization": f"Bearer {IMAGE_API_KEY}",
},
- data={"model": model, "prompt": full_prompt, "n": "1"},
+ data={"model": current_model, "prompt": full_prompt, "n": "1"},
files={"image": ("reference.jpg", img_bytes_in, "image/jpeg")},
)
r.raise_for_status()
resp_data = r.json()
else:
# text-only
- resp_data = _image_generation_response(full_prompt, model)
+ resp_data = _image_generation_response(full_prompt, current_model)
if resp_data.get("data"):
- effective_mode = current_mode
+ effective_mode = f"{current_mode}:{current_model}"
+ model = current_model
+ if current_model == GPT_IMAGE_MODEL:
+ _image_record_primary_success()
break
err_obj = resp_data.get("error") or {}
- last_err = f"empty data · {err_obj.get('code', '')} · {str(err_obj.get('message', ''))[:200]}"
+ 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
@@ -4498,16 +4611,22 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
or "timeout" in body.lower()
or _image_is_capacity_error(status_code, body)
)
- last_err = f"HTTP {status_code}: {body[:200]}"
+ last_err = f"HTTP {status_code}: {body[:200]} · model={current_model}"
if not transient:
raise HTTPException(500, f"image gen HTTP {status_code}: {body[:300]}")
except Exception as e:
- last_err = f"{type(e).__name__}: {e}"
+ last_err = f"{type(e).__name__}: {e} · model={current_model}"
- 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]
- tag = f"fallback → {next_mode}" if next_mode != current_mode else f"retry {attempt + 1}/{len(plan)}"
+ fallbackable = current_model == GPT_IMAGE_MODEL and _image_failure_can_fallback(status_code, body, last_err)
+ if fallbackable:
+ _image_record_primary_failure(last_err)
+ if any(next_model != GPT_IMAGE_MODEL for _next_mode, next_model in attempt_steps[attempt + 1:]):
+ print(f"[image gen fallback → {IMAGE_FALLBACK_MODEL}] {last_err}", flush=True)
+ continue
+ next_mode_changed = attempt < len(attempt_steps) - 1 and attempt_steps[attempt + 1][0] != current_mode
+ if _image_should_retry(attempt, len(attempt_steps), status_code, body, last_err, next_mode_changed):
+ next_mode = attempt_steps[attempt + 1][0]
+ tag = f"fallback → {next_mode}" if next_mode != current_mode else f"retry {attempt + 1}/{len(attempt_steps)}"
print(f"[image gen {tag}] {last_err}", flush=True)
_time.sleep(_image_retry_delay(attempt, status_code, body, retry_after))
else:
@@ -5677,10 +5796,11 @@ def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: G
"Avoid bulky collars, scarves, hair, hoods, props, or poses that hide the neck/shoulder placement area. "
"For back and close-up views, prioritize the cervical spine, shoulder blades, upper trapezius, and clean wearable-device contact area. "
)
- models = [GPT_IMAGE_MODEL]
+ models = SUBJECT_ASSET_IMAGE_MODELS
generated: list[SubjectAsset] = []
generation_errors: list[str] = []
first_generation_error: RuntimeError | None = None
+ pack_force_fallback_model = _image_primary_circuit_open()
try:
for view, view_label in _subject_view_labels(req.subject_kind, req.views):
closeup_view = view in {"bust", "back_detail", "bust_front", "bust_left_45", "bust_right_45", "back_neck_detail"} or "detail" in view
@@ -5741,14 +5861,18 @@ def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: G
try:
if similar_mode:
print(
- f"[subject assets] reconstruction_mode=similar endpoint=/images/generations view={view} image_refs=0 model={GPT_IMAGE_MODEL}",
+ f"[subject assets] reconstruction_mode=similar endpoint=/images/generations view={view} image_refs=0 model={'fallback' if pack_force_fallback_model else GPT_IMAGE_MODEL}",
flush=True,
)
- img_bytes, _mode = _image_text_call(prompt, models=models, max_attempts=3)
+ img_bytes, _mode = _image_text_call(prompt, models=models, max_attempts=3, force_fallback_model=pack_force_fallback_model)
+ if _mode.endswith(f":{IMAGE_FALLBACK_MODEL}"):
+ pack_force_fallback_model = True
else:
if model_src is None:
raise RuntimeError("subject asset edit reference image missing")
- img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1280)
+ img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1280, force_fallback_model=pack_force_fallback_model)
+ if _mode.endswith(f":{IMAGE_FALLBACK_MODEL}"):
+ pack_force_fallback_model = True
except RuntimeError as e:
if first_generation_error is None:
first_generation_error = e
diff --git a/deploy/.env.production.example b/deploy/.env.production.example
index b6ac124..f9cd7ab 100644
--- a/deploy/.env.production.example
+++ b/deploy/.env.production.example
@@ -43,9 +43,13 @@ IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
IMAGE_API_KEY=
IMAGE_MODEL=gpt-image-2
IMAGE_REQUEST_TIMEOUT_SECONDS=60
+IMAGE_FALLBACK_ENABLED=true
+IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview
+IMAGE_CIRCUIT_FAILURE_THRESHOLD=2
+IMAGE_CIRCUIT_COOLDOWN_SECONDS=600
GPT_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,gemini-3-pro-image-preview
# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking.
AI_HTTP_PROXY=
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 0bf4344..4e3b8d7 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -593,7 +593,7 @@
web/next.config.mjs | Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 |
web/app/globals.css | 全局主题变量、登录页视觉样式、信息流工作台同源品牌 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内定义 --skg-gold-1、--skg-gold-2、--skg-cream、--skg-bg-*、--skg-text-*、--skg-radius-* 和按钮阴影等变量,并新增 skg-board-brand、skg-stat-card、skg-primary-action、skg-secondary-action、skg-empty-state 等样式。暗色工作台复用登录页金色聚焦、米白主按钮和弱暖光氛围;明亮模式通过 skg-board-theme--light 复用同一套结构,改成暖白底、白色 panel、黑底主 CTA 和深色文本,不另起一套界面。 |
web/app/page.tsx | 产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;音频失败时会忽略失败状态下残留的半成品 transcript,允许再次触发音频解析;底部吸附音频条和旧全局浮动主题按钮不再从主界面渲染,避免和工作台内的明暗模式切换重复。 |
- web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharacters。buildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、主体重构、产品素材池”四个状态条不再默认渲染在工作区顶部。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;主工作区左侧宽度调整为 430-460px,上方是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧,播放器下方是逐句时间轴,英文和中文都最多显示两行;右侧上方是无标题的波形与切点参考框,下方是三栏主体管线。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部把低/中/高密度按钮和当前播放秒数、总时长、鼠标指针停点秒数直接放在波形上方。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方同框渲染无标题的 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,并按 frame.time / duration 的百分比定位到和波形同一条时间轴上;波形与胶片之间不显示分隔横线,胶片轨道贴近波形,缩略图轻微上下错落并倾斜重叠排列,hover 时用同一张胶片卡在原位置生成固定顶层克隆,约 4.8 倍放大并自动限制在视口内,避免被工作区、滚动容器或相邻面板遮挡;单击胶片只跳转原视频时间,不写入任务数据,双击胶片或拖进参考帧池时才调用手动抽帧并正式加入 job.frames,已加入的胶片显示“已添加”;胶片预览按 job、视频、密度和时长缓存,未切换低/中/高时返回页面不重新扫视频。右侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。转换层不再暴露“生成 10 张高清图”、透明骨架/真人或完整/常用视图开关,改成真人重构、卡通重构、元素重构、自主描述四个投放区;每区最多 3 张参考帧,拖入只加入该区参考队列,用户放好参考和文字后点击按钮才调用 generateSubjectAssets 固定生成 6 视图,卡通重构可选择具体卡通风格,文字方向会进入 prompt。主体元素区按重构类型分组显示结果;只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端当前对真人/元素/自主描述传 subject_style=source_actor,对卡通重构传 subject_style=cartoon_subject,并使用 reconstruction_mode=similar;后端会把关键帧反推成非身份化文字 brief,再走 gpt-image-2 文字生图,避免复制原人、原脸和原画面。主体生成完成后会形成 subject_consensus_brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写。每条音频分镜默认是左侧三字段、右侧横向视频候选轨;高级区仍保留首尾帧 prompt、产品出现方式和旧 6 字段。ModelTrace 会在音频解析、产品识别/补图、主体重构视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
+ web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharacters。buildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、主体重构、产品素材池”四个状态条不再默认渲染在工作区顶部。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;主工作区左侧宽度调整为 430-460px,上方是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧,播放器下方是逐句时间轴,英文和中文都最多显示两行;右侧上方是无标题的波形与切点参考框,下方是三栏主体管线。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部把低/中/高密度按钮和当前播放秒数、总时长、鼠标指针停点秒数直接放在波形上方。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方同框渲染无标题的 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,并按 frame.time / duration 的百分比定位到和波形同一条时间轴上;波形与胶片之间不显示分隔横线,胶片轨道贴近波形,缩略图轻微上下错落并倾斜重叠排列,hover 时用同一张胶片卡在原位置生成固定顶层克隆,约 4.8 倍放大并自动限制在视口内,避免被工作区、滚动容器或相邻面板遮挡;单击胶片只跳转原视频时间,不写入任务数据,双击胶片或拖进参考帧池时才调用手动抽帧并正式加入 job.frames,已加入的胶片显示“已添加”;胶片预览按 job、视频、密度和时长缓存,未切换低/中/高时返回页面不重新扫视频。右侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。转换层不再暴露“生成 10 张高清图”、透明骨架/真人或完整/常用视图开关,改成真人重构、卡通重构、元素重构、自主描述四个投放区;每区最多 3 张参考帧,拖入只加入该区参考队列,用户放好参考和文字后点击按钮才调用 generateSubjectAssets 固定生成 6 视图,卡通重构可选择具体卡通风格,文字方向会进入 prompt。主体元素区按重构类型分组显示结果;只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端当前对真人/元素/自主描述传 subject_style=source_actor,对卡通重构传 subject_style=cartoon_subject,并使用 reconstruction_mode=similar;后端会把关键帧反推成非身份化文字 brief,再走 gpt-image-2 文字生图,只有主模型超时、429、5xx 或网络错误时才短时熔断并兜底 gemini-3-pro-image-preview,避免复制原人、原脸和原画面。主体生成完成后会形成 subject_consensus_brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写。每条音频分镜默认是左侧三字段、右侧横向视频候选轨;高级区仍保留首尾帧 prompt、产品出现方式和旧 6 字段。ModelTrace 会在音频解析、产品识别/补图、主体重构视图包、脚本改写等入口旁直接展示模型名;生图入口会显示 gpt-image-2 / gemini-3-pro-image-preview 链路和短时熔断规则,点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
SourceSubjectPipeline | 源视频工作区右侧主体管线主路径:三栏分别是竖向 参考帧池、转换层 和 主体元素。参考帧池保留自动 12 张、胶片拖入正式成帧、点击勾选和删除;参考帧缩略图保持小尺寸固定宽度、aspect-[9/16] 和 object-contain 显示,hover 预览通过 MediaAssetTile 的左侧紧凑浮层显示。转换层取消旧的“透明骨架 / 真人”和“完整 10 / 常用 4”开关,改成真人重构、卡通重构、元素重构、自主描述四个投放区;每个区最多保留 3 张参考帧,拖入只加入参考队列,不自动调用生成;用户放好参考和文字后点击按钮才调用 generateSubjectAssets 生成固定 6 视图。文字输入会参与 prompt,卡通重构额外提供 3D 动画、潮玩公仔、日系清爽、美式插画、黏土玩具、极简扁平等风格。四种模式都强调参考重构:不抠图区、不复制原人原脸、不复刻原画面。主体元素区按每次生成的 pack_id 组织成“套图文件夹”:顶部展开当前选中套图,下面是可滚动的套图包列表;同一方向可保留多套,生成中按 pack 显示 2/6 这类进度,单张完成就替换对应占位卡。缩略图复用 MediaAssetTile,支持 hover 放大、单张重生和删除。旧下方 SourceReferenceBuildPanel 不再主路径渲染。 |
AudioStoryboardPlanPanel 三字段候选生成 | 当前分镜主路径:每行是左右双栏,左侧默认显示 skg_copy_*、scene_one_line_*、action_one_line_* 三组中英字段,右侧直接显示视频候选横向轨。用户改中文镜像后,字段失焦会通过 refineStoryboard 优化对应英文主值,失败时退回 translateText;英文仍是后续 prompt 主值。quickPlanStoryboard 把三字段和主体 brief 展开为完整 StoryboardScene,generateStoryboardVideo 的 count 可由单行数字控件选择,候选新生成后持续向右追加,不再用 4-grid 撑高每行。整片生成同样可选择每行数量,并以 concurrency=1 按行排队提交。产品素材池、批量控制、每行主体区和高级区都可折叠,高级抽屉仍展示旧 6 字段、首尾帧 prompt 和首尾帧资产槽,但客户默认不用先处理首尾帧。 |
web/components/resource-library/library-drawer.tsx | 全局资源中心浮窗:由工作台顶部“资源库”按钮打开,叠加在工作台上方但不阻塞主界面;尺寸、位置和当前 Tab 写入 localStorage["skg-resource-library-drawer"]。提示词 Tab 固定 5 列(场景描述、视频描述、主体描述、SKG 文案、产品角度),每列先显示 use_count 排名前 5 的“常用”,再按月份倒序分组;提示词节点常驻复制按钮,hover 可选英文/中文/双语复制,并调用 use 接口。素材 Tab 固定 4 列(主体、产品、场景、视频),节点不可拖动,按月份倒序硬编码排列;“应用到当前 job”只调用后端复制接口,得到普通 ImageRef(kind="asset") 后再写入产品素材池或复制 ID。浮窗顶部最近 24 小时横条混合显示提示词和素材;新建提示词、上传素材、删除前查引用、详情侧栏都在该组件内完成。 |
@@ -967,7 +967,7 @@ ProductRefStateItem {
| 网页登录 | POST /auth/login、GET /auth/check、POST /auth/logout | web/app/login/page.tsx、Nginx auth_request | 登录页提交账号密码到 /api/auth/login,后端设置 HttpOnly 会话 Cookie;生产 Nginx 对工作台和 /api/ 调 /auth/check 做统一校验,未登录页面跳 /login/,API 返回 JSON 401。 |
- | 运行配置 / 模型标注 | GET /health | getRuntimeHealth、ModelTrace | 返回 models:ASR、asr_language、asr_base_url、asr_remote_enabled、asr_local_fallback_enabled、asr_audio_fallback_enabled、faster_whisper、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 product_view、GPT 图像模型、主体 6 视图 GPT 图像模型、Azure OpenAI TTS、视频别名和 Seedance 服务商。当前 REWRITE_MODEL、AUDIO_REWRITE_MODEL 和 VISION_MODEL 默认使用 gpt-4o;如果旧环境变量仍写 gemini-*,后端会归一化回 GPT_TEXT_MODEL / REWRITE_MODEL。语音只走 Azure OpenAI TTS,models.voice_tts_paths 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。 |
+ | 运行配置 / 模型标注 | GET /health | getRuntimeHealth、ModelTrace | 返回 models:ASR、asr_language、asr_base_url、asr_remote_enabled、asr_local_fallback_enabled、asr_audio_fallback_enabled、faster_whisper、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 product_view、主图像模型 gpt-image-2、图片故障兜底 image_fallbacks、短时熔断状态 image_circuit、主体 6 视图模型链路、Azure OpenAI TTS、视频别名和 Seedance 服务商。当前 REWRITE_MODEL、AUDIO_REWRITE_MODEL 和 VISION_MODEL 默认使用 gpt-4o;如果旧环境变量仍写 gemini-*,后端会归一化回 GPT_TEXT_MODEL / REWRITE_MODEL。语音只走 Azure OpenAI TTS,models.voice_tts_paths 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。 |
| 历史列表 | GET /jobs | listJobs | 所有 job 精简列表(id/url/status/thumbnail/mtime…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填全部历史;带 limit 可截断。 |
| 创建任务 | POST /jobs | createJob | 提交 TK 链接,后台开始下载;前端“开始”队列会在 downloaded 后自动触发音频解析。下载阶段默认不带 cookies;生产环境必须显式保持 YTDLP_COOKIES_FILE= 和 YTDLP_COOKIES_FROM_BROWSER= 为空,避免容器内误读被打进镜像的开发 api/.env。只有 TikTok 明确要求登录态时,才把宿主机 ./secrets/tiktok_cookies.txt 挂载进容器并设置 YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt。生产容器没有 Chrome cookies 数据库,不能配置 YTDLP_COOKIES_FROM_BROWSER=chrome。 |
| 重试下载 | POST /jobs/{id}/download/retry | retryJobDownload | 用于 TK 链接下载失败且没有 video_url 的素材;清空错误、重新进入下载状态,并在后台再次执行 pipeline_download。上传视频不能重下载,需要重新上传文件。 |
@@ -986,14 +986,14 @@ ProductRefStateItem {
| 应用清洗 | POST /cleanup/apply | applyCleanedFrame | 物理覆盖 frames/{idx}.jpg,并备份原图。 |
| 元素增改删 | POST/PATCH/DELETE /elements | addElement/updateElement/deleteElement | 让用户修正 Vision 错误,避免候选结果锁死。 |
| 元素提取 | POST /elements/{element_id}/cutout | cutoutElement | 调用图像模型生成独立白底素材图,每次累积一张 cutout。 |
- | 主体资产包 | POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id} | generateSubjectAssets
deleteSubjectAsset | 根据转换层里的参考帧重新绘制一个统一主体资产包;前端按真人重构、卡通重构、元素重构、自主描述四个方向分别管理 source_frame_indices,每个方向最多 3 张参考帧,固定请求 front、three_quarter_left、left、back、right、three_quarter_right 六个视图,不再暴露完整 10 / 常用 4 选择。当前源视频工作区使用 subject_style=source_actor 承接真人、元素和自主描述,使用 subject_style=cartoon_subject 承接卡通重构;旧 transparent_human 仍为兼容类型但不是当前转换层默认入口。reconstruction_mode=similar 是创新路径:后端先用 VISION_MODEL 把关键帧反推成非身份化文字 brief,再调用 gpt-image-2 的 /images/generations 文字生图,日志会显示 image_refs=0;这里是参考重构生成套图,不是抠图、复制或 image-edit 复刻。卡通重构在后端额外加入原创卡通/插画主体约束,明确不输出真实人物复制 likeness。生成完成后,后端会把生成视图反推/写入 KeyElement.subject_consensus_brief,作为后续首尾帧的唯一主体身份文字依据。reconstruction_mode=same 仍保留旧 image-edit 路径,用于确实需要精确复刻且有授权的场景。每个 view 单独调用一次生图,明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版。单次图片请求受 IMAGE_REQUEST_TIMEOUT_SECONDS 控制,默认 60 秒;超时、DNS 或连接失败会让当前视图标失败并继续后续视图。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 |
+ | 主体资产包 | POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id} | generateSubjectAssets
deleteSubjectAsset | 根据转换层里的参考帧重新绘制一个统一主体资产包;前端按真人重构、卡通重构、元素重构、自主描述四个方向分别管理 source_frame_indices,每个方向最多 3 张参考帧,固定请求 front、three_quarter_left、left、back、right、three_quarter_right 六个视图,不再暴露完整 10 / 常用 4 选择。当前源视频工作区使用 subject_style=source_actor 承接真人、元素和自主描述,使用 subject_style=cartoon_subject 承接卡通重构;旧 transparent_human 仍为兼容类型但不是当前转换层默认入口。reconstruction_mode=similar 是创新路径:后端先用 VISION_MODEL 把关键帧反推成非身份化文字 brief,再调用 gpt-image-2 的 /images/generations 文字生图,日志会显示 image_refs=0;这里是参考重构生成套图,不是抠图、复制或 image-edit 复刻。卡通重构在后端额外加入原创卡通/插画主体约束,明确不输出真实人物复制 likeness。生成完成后,后端会把生成视图反推/写入 KeyElement.subject_consensus_brief,作为后续首尾帧的唯一主体身份文字依据。reconstruction_mode=same 仍保留旧 image-edit 路径,用于确实需要精确复刻且有授权的场景。每个 view 单独调用一次生图,明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版。单次图片请求受 IMAGE_REQUEST_TIMEOUT_SECONDS 控制,默认 60 秒;gpt-image-2 超时、429、5xx、DNS 或连接失败时可兜底 gemini-3-pro-image-preview,连续 2 次主模型上游类失败后 600 秒内短时熔断。主体同一套图内一旦触发 Gemini,后续视图沿用 Gemini,避免风格混杂和重复等待主模型超时。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 |
| 主体套图状态 | SubjectAsset.status
pack_id | web/app/page.tsx
SourceSubjectPipeline | generateSubjectAssets 现在先写入同一个 pack_id 下的 queued 占位卡并立即返回,后台按视角逐张生成,单张完成就把该占位替换成 completed 图片。前端轮询会把 queued / in_progress 主体资产纳入运行状态;主体元素区按 pack 显示套图文件夹,点击某个文件夹后展开该套图,其他套图顺位进入下方可滚动列表。 |
| 首尾帧资产 | POST /frames/{idx}/scene-asset | generateSceneAsset | 同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 asset_role=first_frame/last_frame、subject_brief 和最多 1-2 张 product_images。首尾帧不再传主体图、不再把主体图和产品图拼成 contact sheet;主体只走文字 brief,允许新动作、新景别、新表情和新环境。若本条需要产品,后端只把产品参考图作为 gpt-image-2 image-edit 的硬视觉真源;若不需要产品,则走纯文字生图。关键帧只作为行数据承载位置。生成结果保存在 scene_assets,前端再写入 StoryboardScene.first_image/last_image。 |
| 产品图库 | GET /product-library/skg | listProductLibrary | 读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 |
| 产品图入库到 job | POST /jobs/{id}/assets、POST /jobs/{id}/assets/product-library | uploadStoryboardAsset、copyProductLibraryAsset | 上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。注意该接口只写图片文件,产品素材池列表另由 PUT /jobs/{id}/product-refs 持久化。 |
| 产品素材池保存 | PUT /jobs/{id}/product-refs | saveProductRefs | 把当前 job 的产品素材池列表、识别视角、用途标签、方向、结构点、备注、AI 补图和删除结果保存到 Job.product_refs / state.json。前端上传、识别完成、补角度、编辑备注和删除时都会同步保存;刷新页面或热更新后从 job 恢复,不再要求重新上传和重新识别。 |
| 产品视角识别 | POST /jobs/{id}/assets/product-views/analyze | analyzeProductViews | 读取同一产品素材池,按批次把多张图一次性提交给 PRODUCT_VIEW_MODEL=gpt-image-2 做视角标注,不限制只看前 6 张;识别对象被固定为套在脖子上的 U 形肩颈按摩仪。返回 view、background、use_tags、orientation、landmarks、中文备注、生成风险和置信度;orientation 明确佩戴者左/右、上/下、内外侧和开口方向对应图中哪边,避免把图片左右误当产品左右。批量识别失败会按单图重试,仍失败或文件缺失时写入本地默认视角,并在 risk/note 标明兜底原因。前端不再要求用户手动选择视角,也不做不同产品身份判断。 |
- | 产品缺角度补图 | POST /jobs/{id}/assets/product-angle | generateProductAngleAsset | 用当前同一产品素材池作为参考,通过 gpt-image-2 自动补全缺失视角,输出新的 ImageRef(kind="asset")。前端不再固定传第一张图,而是按目标视角给已上传/已标注参考图打分,优先选择真实上传图、目标相邻视角、侧厚/触点/底部对应用途标签和低风险高置信图,最多传 6 张;后端通过 /images/edits multipart 的多张 image[] 直接提交给 gpt-image-2,不再把参考图拼成一张板,降低模型误解成拼图/多产品的概率。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例,并禁止输出拼图/多产品;遇到上游 429 / saturated 会按退避节奏重试,最终仍失败时返回 503 和可读提示;遇到 DNS / ConnectError 也返回 503,并提示配置 AI_HTTP_PROXY / IMAGE_HTTP_PROXY。 |
+ | 产品缺角度补图 | POST /jobs/{id}/assets/product-angle | generateProductAngleAsset | 用当前同一产品素材池作为参考,通过 gpt-image-2 自动补全缺失视角,输出新的 ImageRef(kind="asset")。前端不再固定传第一张图,而是按目标视角给已上传/已标注参考图打分,优先选择真实上传图、目标相邻视角、侧厚/触点/底部对应用途标签和低风险高置信图,最多传 6 张;后端通过 /images/edits multipart 的多张 image[] 直接提交给主模型,不再把参考图拼成一张板,降低模型误解成拼图/多产品的概率。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例,并禁止输出拼图/多产品;遇到上游 429 / saturated、5xx、超时或网络错误会按熔断规则兜底 gemini-3-pro-image-preview;400/401/403/404 和参数错误不兜底。 |
| 角色库 | GET /character-library/skg | listCharacterLibrary | 读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图,以及用于相似主体文字生图的 prompt_brief。 |
| 主体模板库 | GET /subject-templates
GET /subject-templates/images/{filename}
POST /jobs/{id}/subject-templates | listSubjectTemplates
subjectTemplateImageUrl
saveSubjectTemplate | 数据库化可复用主体库。前端模板库展示这里保存的主体模板;“保存为主体模板”会把当前 job 的相似主体白底视图按名称、备注、主体类型、原 job/frame/element 和 asset 列表复制到 JOBS_DIR/_subject_templates,并由后端用 Vision LLM 从这些图反推 prompt_brief。以后相似生成通过 subject_template_id 读取这个 brief 作为文字创意方向,不再把模板图直接上传给 image-edit。 |
| 全局提示词库 | GET /prompt-library
GET /prompt-library/{id}
POST /prompt-library
PATCH /prompt-library/{id}
DELETE /prompt-library/{id}
POST /prompt-library/{id}/use | listPromptLibrary
createPromptLibraryItem
usePromptLibraryItem
deletePromptLibraryItem | 浮窗提示词 Tab 的 5 类文本资源。prompt_en 是实际复制/提交给模型的英文主值,prompt_zh 只给团队阅读;点击复制会调用 /use 增加使用次数。节点位置由前端按“常用 + 月份倒序”硬编码排列,不接受拖拽或自定义排序。 |
@@ -1062,7 +1062,7 @@ ProductRefStateItem {
主体候选确认、改名、删除和主体资产包生成能力保留在底层旧面板和接口中,当前第一步主界面不主动展示。
分镜工作台 4 图槽和改造说明自动保存。
音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效;结果集中在右侧工作表展示。
- GPT Image 生图;当前 IMAGE_MODEL 和主体 6 视图链路默认使用 gpt-image-2,单次图片网关请求默认 60 秒超时,超时后该视图标失败并继续后续视图。
+ GPT Image 生图;当前 IMAGE_MODEL 和主体 6 视图链路默认使用 gpt-image-2,单次图片网关请求默认 60 秒超时;主模型超时、429、5xx 或网络错误时允许 gemini-3-pro-image-preview 兜底,并有 2 次失败 / 600 秒短时熔断。
三字段分镜候选生成:默认行左侧露文案、场景一句话、人物+产品+动作,右侧直接展示横向视频轨;中文镜像失焦后会自动优化英文主值;支持 AI 改写预览、单条选择数量生成、追加生成、选中候选和整片按行排队提交。
全局资源中心:提示词库和素材库可从顶部“资源库”打开;提示词可复制并计数,素材应用到 job 时会复制成本 job 内普通 asset。
@@ -1113,6 +1113,20 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-19 · 生图增加 Gemini 故障兜底和短时熔断
+ API
+ Reliability
+ Config
+ UI
+
+
+
问题:gpt-image-2 当前上层通道会读超时,但完全禁用兜底会导致主体套图和补图无法继续;同时不能把主模型直接改成 Gemini。
+
改动:api/main.py 保持 gpt-image-2 为主模型,新增 IMAGE_FALLBACK_MODEL=gemini-3-pro-image-preview、IMAGE_FALLBACK_ENABLED、IMAGE_CIRCUIT_FAILURE_THRESHOLD 和 IMAGE_CIRCUIT_COOLDOWN_SECONDS。只有主模型超时、429、5xx 或网络错误时才兜底;400/401/403/404 和参数错误不兜底。连续 2 次主模型上游类失败后,600 秒内直接走 Gemini;主模型成功后自动清空失败计数。主体同一套图内一旦触发 Gemini,后续视图沿用 Gemini。
+
影响:/health 返回 image_fallbacks 和 image_circuit;ModelTrace 显示 gpt-image-2 / gemini-3-pro-image-preview 和熔断规则。该变更是故障兜底,不是默认改模型。
+
+
2026-05-19 · gpt-image-2 请求超时改为快速失败
@@ -1123,7 +1137,7 @@ ProductRefStateItem {
问题:gpt-image-2 上游图片网关无响应时,文字生图仍通过 SDK 默认等待,编辑生图也按 120 秒重复尝试;主体 6 视图第一张卡住后,用户侧长时间看不到逐张失败或后续进度。
改动:api/main.py 新增 IMAGE_REQUEST_TIMEOUT_SECONDS,默认 60 秒;_image_text_call 统一改为直接调用 /images/generations,_image_edit_call 和旧分镜生图也复用同一超时。超时、DNS、连接失败这类传输错误不再盲目重试三轮,会把当前视图标失败并继续处理后续视图。/health 回传当前图片超时配置。
-
影响:这次不改模型,所有图片入口仍固定只使用 gpt-image-2;如果继续失败,错误会明确指向当前 IMAGE_BASE_URL 上的 gpt-image-2 通道超时或不可用。
+
影响:当时不改模型,所有图片入口仍固定只使用 gpt-image-2;后续已增加 Gemini 故障兜底和短时熔断,但错误仍会明确指向当前 IMAGE_BASE_URL 上的主模型通道超时或不可用。
@@ -1787,7 +1801,7 @@ ProductRefStateItem {
问题:部分生图路径仍保留 gpt-image-1.5 或 Gemini 图片模型 fallback,旧分镜生图区也还能选择 Gemini 模型;这会导致同一流程里不同图片入口走不同模型。
改动:api/main.py 把 GPT_IMAGE_MODEL、IMAGE_MODEL、SUBJECT_ASSET_IMAGE_MODEL 和 SUBJECT_ASSET_IMAGE_MODELS 全部锁定为 gpt-image-2;_image_edit_call、_image_text_call 和 /frames/{idx}/generate 即使收到旧前端 model 字段或旧 models 列表,也只使用 gpt-image-2。旧 Dashboard 生图选择器改成只展示 gpt-image-2,模型链路标注不再显示图片模型 fallback。
-
影响:api/main.py、web/components/ad-recreation-board.tsx、web/components/dashboard.tsx、api/.env.example、deploy/.env.production.example、RULES.md、docs/source-analysis.html。后续凡是“生图”,包括清洗、元素提取、主体 6 视图、产品补角度、首尾帧/场景图和旧分镜生图,都只能走 gpt-image-2。
+
影响:api/main.py、web/components/ad-recreation-board.tsx、web/components/dashboard.tsx、api/.env.example、deploy/.env.production.example、RULES.md、docs/source-analysis.html。该阶段凡是“生图”,包括清洗、元素提取、主体 6 视图、产品补角度、首尾帧/场景图和旧分镜生图,都只能走 gpt-image-2;后续在保持 gpt-image-2 主模型不变的前提下增加了 Gemini 故障兜底。
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index f6facbf..c644b88 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -1189,11 +1189,11 @@ function modelList(values: Array) {
}
function imageModelChain(models?: RuntimeModels) {
- return modelList([models?.image || "gpt-image-2"])
+ return modelList([models?.image || "gpt-image-2", ...(models?.image_fallbacks || [])])
}
function subjectImageModelChain(models?: RuntimeModels) {
- return modelList([models?.subject_image || "gpt-image-2"])
+ return modelList([models?.subject_image || "gpt-image-2", ...(models?.subject_image_fallbacks || [])])
}
function resolveVideoModelLabel(models: RuntimeModels | undefined, model: string) {
@@ -1224,7 +1224,7 @@ function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
chain: [
`批量视角识别:${modelValue(models?.product_view)} 多图读取同一产品素材,标注视角、佩戴者左右、上下、内外侧、用途和风险`,
"识别兜底:批量失败会按单图重试;仍失败或文件缺失时写入本地默认视角,并在 risk/note 标明兜底原因",
- `缺角度补图:${imageModelChain(models)} 走 /images/edits,最多读取 6 张已上传参考图补齐缺失视角;失败保留重试入口,不自动换模型`,
+ `缺角度补图:${imageModelChain(models)} 走 /images/edits,最多读取 6 张已上传参考图补齐缺失视角;只有 gpt-image-2 超时、限流或 5xx 上游异常时才自动兜底`,
"前端只保存标注和 AI 补图结果;后续首尾帧/视频规划每条最多挑 6 张相关产品图",
],
note: "上传产品图、重新识别、缺视角重试都会使用这组模型链路。",
@@ -1244,7 +1244,7 @@ function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyl
`视觉 brief:${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief;失败时继续用用户方向和模板文字`,
`主体类型:${typeLabel}`,
"主体设定:前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile",
- `图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
+ `图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;gpt-image-2 是主模型,超时、429 或 5xx 时短时熔断并兜底 Gemini;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
],
note: "这是生成类似但创新的主体,不是复制、抠出或复刻源视频人物身份;内置形象也只作为方向参考。",
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 634ba72..7374bee 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -272,6 +272,16 @@ export interface RuntimeModels {
image?: string
image_base_url?: string
image_fallbacks?: string[]
+ image_circuit?: {
+ primary?: string
+ fallbacks?: string[]
+ failure_threshold?: number
+ cooldown_seconds?: number
+ primary_failures?: number
+ primary_open?: boolean
+ primary_open_until?: number
+ primary_open_remaining_seconds?: number
+ }
subject_image?: string
subject_image_fallbacks?: string[]
voice_provider?: string