From 3756259850d45aed901812e68e9b5ba5c197ee7e Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 19 May 2026 23:56:20 +0800 Subject: [PATCH] feat: add Gemini image fallback circuit breaker --- RULES.md | 6 +- api/.env.example | 6 +- api/main.py | 202 ++++++++++++++++++++----- deploy/.env.production.example | 6 +- docs/source-analysis.html | 28 +++- web/components/ad-recreation-board.tsx | 8 +- web/lib/api.ts | 10 ++ 7 files changed, 212 insertions(+), 54 deletions(-) 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.mjsNext.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-brandskg-stat-cardskg-primary-actionskg-secondary-actionskg-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,空状态复用 AnimatedLoginCharactersbuildWorkflowSteps 仍统一生成 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,空状态复用 AnimatedLoginCharactersbuildWorkflowSteps 仍统一生成 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 展开为完整 StoryboardScenegenerateStoryboardVideocount 可由单行数字控件选择,候选新生成后持续向右追加,不再用 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/loginGET /auth/checkPOST /auth/logoutweb/app/login/page.tsx、Nginx auth_request登录页提交账号密码到 /api/auth/login,后端设置 HttpOnly 会话 Cookie;生产 Nginx 对工作台和 /api//auth/check 做统一校验,未登录页面跳 /login/,API 返回 JSON 401。 - 运行配置 / 模型标注GET /healthgetRuntimeHealthModelTrace返回 models:ASR、asr_languageasr_base_urlasr_remote_enabledasr_local_fallback_enabledasr_audio_fallback_enabledfaster_whisper、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 product_view、GPT 图像模型、主体 6 视图 GPT 图像模型、Azure OpenAI TTS、视频别名和 Seedance 服务商。当前 REWRITE_MODELAUDIO_REWRITE_MODELVISION_MODEL 默认使用 gpt-4o;如果旧环境变量仍写 gemini-*,后端会归一化回 GPT_TEXT_MODEL / REWRITE_MODEL。语音只走 Azure OpenAI TTS,models.voice_tts_paths 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。 + 运行配置 / 模型标注GET /healthgetRuntimeHealthModelTrace返回 models:ASR、asr_languageasr_base_urlasr_remote_enabledasr_local_fallback_enabledasr_audio_fallback_enabledfaster_whisper、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 product_view、主图像模型 gpt-image-2、图片故障兜底 image_fallbacks、短时熔断状态 image_circuit、主体 6 视图模型链路、Azure OpenAI TTS、视频别名和 Seedance 服务商。当前 REWRITE_MODELAUDIO_REWRITE_MODELVISION_MODEL 默认使用 gpt-4o;如果旧环境变量仍写 gemini-*,后端会归一化回 GPT_TEXT_MODEL / REWRITE_MODEL。语音只走 Azure OpenAI TTS,models.voice_tts_paths 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。 历史列表GET /jobslistJobs所有 job 精简列表(id/url/status/thumbnail/mtime…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填全部历史;带 limit 可截断。 创建任务POST /jobscreateJob提交 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/retryretryJobDownload用于 TK 链接下载失败且没有 video_url 的素材;清空错误、重新进入下载状态,并在后台再次执行 pipeline_download。上传视频不能重下载,需要重新上传文件。 @@ -986,14 +986,14 @@ ProductRefStateItem { 应用清洗POST /cleanup/applyapplyCleanedFrame物理覆盖 frames/{idx}.jpg,并备份原图。 元素增改删POST/PATCH/DELETE /elementsaddElement/updateElement/deleteElement让用户修正 Vision 错误,避免候选结果锁死。 元素提取POST /elements/{element_id}/cutoutcutoutElement调用图像模型生成独立白底素材图,每次累积一张 cutout。 - 主体资产包POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id}generateSubjectAssets
deleteSubjectAsset根据转换层里的参考帧重新绘制一个统一主体资产包;前端按真人重构、卡通重构、元素重构、自主描述四个方向分别管理 source_frame_indices,每个方向最多 3 张参考帧,固定请求 frontthree_quarter_leftleftbackrightthree_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 张参考帧,固定请求 frontthree_quarter_leftleftbackrightthree_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_idweb/app/page.tsx
SourceSubjectPipelinegenerateSubjectAssets 现在先写入同一个 pack_id 下的 queued 占位卡并立即返回,后台按视角逐张生成,单张完成就把该占位替换成 completed 图片。前端轮询会把 queued / in_progress 主体资产纳入运行状态;主体元素区按 pack 显示套图文件夹,点击某个文件夹后展开该套图,其他套图顺位进入下方可滚动列表。 首尾帧资产POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 asset_role=first_frame/last_framesubject_brief 和最多 1-2 张 product_images。首尾帧不再传主体图、不再把主体图和产品图拼成 contact sheet;主体只走文字 brief,允许新动作、新景别、新表情和新环境。若本条需要产品,后端只把产品参考图作为 gpt-image-2 image-edit 的硬视觉真源;若不需要产品,则走纯文字生图。关键帧只作为行数据承载位置。生成结果保存在 scene_assets,前端再写入 StoryboardScene.first_image/last_image。 产品图库GET /product-library/skglistProductLibrary读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 产品图入库到 jobPOST /jobs/{id}/assetsPOST /jobs/{id}/assets/product-libraryuploadStoryboardAssetcopyProductLibraryAsset上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。注意该接口只写图片文件,产品素材池列表另由 PUT /jobs/{id}/product-refs 持久化。 产品素材池保存PUT /jobs/{id}/product-refssaveProductRefs把当前 job 的产品素材池列表、识别视角、用途标签、方向、结构点、备注、AI 补图和删除结果保存到 Job.product_refs / state.json。前端上传、识别完成、补角度、编辑备注和删除时都会同步保存;刷新页面或热更新后从 job 恢复,不再要求重新上传和重新识别。 产品视角识别POST /jobs/{id}/assets/product-views/analyzeanalyzeProductViews读取同一产品素材池,按批次把多张图一次性提交给 PRODUCT_VIEW_MODEL=gpt-image-2 做视角标注,不限制只看前 6 张;识别对象被固定为套在脖子上的 U 形肩颈按摩仪。返回 viewbackgrounduse_tagsorientationlandmarks、中文备注、生成风险和置信度;orientation 明确佩戴者左/右、上/下、内外侧和开口方向对应图中哪边,避免把图片左右误当产品左右。批量识别失败会按单图重试,仍失败或文件缺失时写入本地默认视角,并在 risk/note 标明兜底原因。前端不再要求用户手动选择视角,也不做不同产品身份判断。 - 产品缺角度补图POST /jobs/{id}/assets/product-anglegenerateProductAngleAsset用当前同一产品素材池作为参考,通过 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-anglegenerateProductAngleAsset用当前同一产品素材池作为参考,通过 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/skglistCharacterLibrary读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图,以及用于相似主体文字生图的 prompt_brief。 主体模板库GET /subject-templates
GET /subject-templates/images/{filename}
POST /jobs/{id}/subject-templateslistSubjectTemplates
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}/uselistPromptLibrary
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-previewIMAGE_FALLBACK_ENABLEDIMAGE_CIRCUIT_FAILURE_THRESHOLDIMAGE_CIRCUIT_COOLDOWN_SECONDS。只有主模型超时、429、5xx 或网络错误时才兜底;400/401/403/404 和参数错误不兜底。连续 2 次主模型上游类失败后,600 秒内直接走 Gemini;主模型成功后自动清空失败计数。主体同一套图内一旦触发 Gemini,后续视图沿用 Gemini。

    +

    影响:/health 返回 image_fallbacksimage_circuitModelTrace 显示 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.pyGPT_IMAGE_MODELIMAGE_MODELSUBJECT_ASSET_IMAGE_MODELSUBJECT_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.pyweb/components/ad-recreation-board.tsxweb/components/dashboard.tsxapi/.env.exampledeploy/.env.production.exampleRULES.mddocs/source-analysis.html。后续凡是“生图”,包括清洗、元素提取、主体 6 视图、产品补角度、首尾帧/场景图和旧分镜生图,都只能走 gpt-image-2

    +

    影响:api/main.pyweb/components/ad-recreation-board.tsxweb/components/dashboard.tsxapi/.env.exampledeploy/.env.production.exampleRULES.mddocs/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