- 015-017迁移:image_candidate 文案复审/AI视觉分/重生标记 - constants C7素人感约束(反电商摆拍对齐真实笔记)+C3叠字口子 - celery visibility_timeout=2h 防长任务被误判重投重复烧钱(task75教训) - image_scorer 评图分(只筛选+展示,真实信号才进飞轮权重) - storyboard/image_gen/pipeline_io 生图存量 - task_actions/tasks/task_service/flywheel 审核回路+飞轮存量 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
9.8 KiB
Python
205 lines
9.8 KiB
Python
"""
|
||
生图通道 — gpt-image-2 主(edits 带产品图) / Gemini 备 + 重试退避
|
||
扒自:worker/src/image.js generateOneImage / requestProviderImage / imageProviderOrder
|
||
新增:asyncio 重试退避(上线版缺的,banana 有 _retry 思路)
|
||
|
||
铁律:
|
||
- IMAGE_PROVIDER_PRIMARY/FALLBACK 走环境变量,不写死
|
||
- GPT 主通道必须有产品参考图,无图报错(禁纯文生图防产品跑偏)
|
||
- key 不在本模块,由 worker 传入构造好的 async HTTP client
|
||
"""
|
||
from __future__ import annotations
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
from typing import Any, Protocol
|
||
|
||
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT
|
||
from .image_scorer import score_image
|
||
from .storyboard import plan_image_set, sanitize_text
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class ImageClient(Protocol):
|
||
"""worker 注入的图片生成客户端协议(隔离 key 细节)"""
|
||
async def gpt_edits(
|
||
self, prompt: str, reference_images: list[bytes], size: str, provider: str | None = None
|
||
) -> bytes: ...
|
||
async def gpt_generate(self, prompt: str, size: str, provider: str | None = None) -> bytes: ...
|
||
async def gemini_generate(
|
||
self, prompt: str, reference_images: list[bytes], model: str
|
||
) -> bytes: ...
|
||
|
||
|
||
def _image_provider_order() -> list[str]:
|
||
"""从环境变量读主备顺序(扒 imageProviderOrder)"""
|
||
primary = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt").lower()
|
||
fallback = os.environ.get("IMAGE_PROVIDER_FALLBACK", "gemini").lower()
|
||
seen: list[str] = []
|
||
for p in [primary, fallback]:
|
||
if p and p not in seen:
|
||
seen.append(p)
|
||
return seen
|
||
|
||
|
||
def _gemini_models() -> list[str]:
|
||
"""Gemini fallback 模型列表(多模型依次重试)"""
|
||
env_val = os.environ.get("GEMINI_IMAGE_MODELS", "gemini-2.0-flash-preview-image-generation,imagen-3.0-generate-002")
|
||
return [m.strip() for m in env_val.split(",") if m.strip()]
|
||
|
||
|
||
async def _retry(coro_fn, attempts: int = IMAGE_RETRY_ATTEMPTS, backoff: float = IMAGE_RETRY_BACKOFF_BASE) -> Any:
|
||
"""指数退避重试(扒 banana _retry 思路)"""
|
||
last_exc: Exception | None = None
|
||
for i in range(attempts):
|
||
try:
|
||
return await coro_fn()
|
||
except Exception as exc:
|
||
last_exc = exc
|
||
if i < attempts - 1:
|
||
wait = backoff ** i
|
||
logger.warning("生图失败第%d次,%.1fs后重试:%s", i + 1, wait, exc)
|
||
await asyncio.sleep(wait)
|
||
raise RuntimeError(f"重试{attempts}次均失败") from last_exc
|
||
|
||
|
||
async def _request_gpt(client: ImageClient, prompt: str, reference_images: list[bytes], provider: str | None = None) -> bytes:
|
||
if reference_images:
|
||
return await client.gpt_edits(prompt, reference_images, IMAGE_SIZE_DEFAULT, provider)
|
||
# 无产品参考图时降级为纯文生图(需 ALLOW_TEXT_ONLY_IMAGE=true 或 M2阶段)
|
||
allow_text_only = os.environ.get("ALLOW_TEXT_ONLY_IMAGE", "true").lower() == "true"
|
||
if allow_text_only:
|
||
logger.warning("无产品参考图,降级为纯文生图(可能产品跑偏,建议前端上传参考图)")
|
||
return await client.gpt_generate(prompt, IMAGE_SIZE_DEFAULT, provider)
|
||
raise ValueError("GPT 主通道缺产品图:禁止纯文生图以免产品跑偏(设 ALLOW_TEXT_ONLY_IMAGE=true 可解锁)")
|
||
|
||
|
||
async def _request_gemini(client: ImageClient, prompt: str, reference_images: list[bytes]) -> bytes:
|
||
errors: list[str] = []
|
||
for model in _gemini_models():
|
||
try:
|
||
return await client.gemini_generate(prompt, reference_images, model)
|
||
except Exception as exc:
|
||
errors.append(f"{model}: {exc}")
|
||
raise RuntimeError("Gemini 全部模型失败:" + ";".join(errors))
|
||
|
||
|
||
async def generate_one_image(
|
||
client: ImageClient,
|
||
prompt: str,
|
||
reference_images: list[bytes] | None = None,
|
||
) -> bytes:
|
||
"""
|
||
主入口:按主备顺序依次尝试,每个 provider 内部有重试退避。
|
||
返回图片 bytes(PNG/JPEG)。
|
||
"""
|
||
refs = reference_images or []
|
||
providers = _image_provider_order()
|
||
errors: list[str] = []
|
||
|
||
for provider in providers:
|
||
try:
|
||
# apiports/codeproxy/openai 都是 OpenAI 兼容中转站,走 gpt 协议,
|
||
# 但传 provider 进去 → client 按 provider 切到对应中转站的 base+key。
|
||
# 这才是真主备:apiports 503 → codeproxy 用独立 base+key 顶上。
|
||
if provider in ("apiports", "codeproxy", "openai"):
|
||
img = await _retry(lambda p=provider: _request_gpt(client, prompt, refs, p))
|
||
elif provider == "gpt":
|
||
img = await _retry(lambda: _request_gpt(client, prompt, refs, None))
|
||
elif provider == "gemini":
|
||
img = await _retry(lambda: _request_gemini(client, prompt, refs))
|
||
else:
|
||
raise ValueError(f"未知图片通道:{provider}")
|
||
return img
|
||
except Exception as exc:
|
||
errors.append(f"{provider}: {exc}")
|
||
logger.warning("图片通道 %s 失败,尝试下一个:%s", provider, exc)
|
||
|
||
raise RuntimeError("所有图片通道均失败:" + ";".join(errors))
|
||
|
||
|
||
async def generate_storyboard_images(
|
||
client: ImageClient,
|
||
note: dict,
|
||
product: dict,
|
||
image_count: int = 3,
|
||
reference_images: list[bytes] | None = None,
|
||
analysis: dict | None = None,
|
||
strategy: str | None = None,
|
||
target_role: str | None = None,
|
||
custom_prompt: str | None = None,
|
||
) -> list[dict]:
|
||
"""
|
||
按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。
|
||
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
|
||
target_role: 非空时只生成该 role 那一张(R2 单张重生)
|
||
custom_prompt: 非空时追加到每张 per_prompt 末尾(R2 人工提示词)
|
||
每项:{role, name, image_bytes, error}
|
||
"""
|
||
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
|
||
storyboard = plan["storyboard"]
|
||
base_prompt = plan["base_prompt"]
|
||
# R2 单张重生:只保留目标 role 的分镜(匹配不到则原样全跑,避免空结果)
|
||
if target_role:
|
||
_filtered = [it for it in storyboard if it.get("role") == target_role]
|
||
if _filtered:
|
||
storyboard = _filtered
|
||
|
||
async def _gen_one(item: dict) -> dict:
|
||
# 逐图 prompt 9 字段(扒 promptFromStoryboard:323-334),每张差异化
|
||
brand_line = ""
|
||
if item.get("brand_keyword"):
|
||
brand_line = f"品牌词=「{item['brand_keyword']}」,{item.get('brand_keyword_rule','自然植入')}。"
|
||
per_prompt = (
|
||
f"{base_prompt}\n"
|
||
f"本张名称={item['name']}。"
|
||
f"本张目标={item.get('goal') or item['focus']}。"
|
||
f"图上主文字=「{sanitize_text(item.get('overlay_text',''), 20)}」。"
|
||
f"使用卖点={item.get('selling_point','')}。"
|
||
f"文案依据={item.get('source_basis','')}。"
|
||
f"画面主体={item.get('visual_strategy','')}。"
|
||
f"素材使用={item.get('asset_use','')}。"
|
||
f"{brand_line}"
|
||
f"禁止事项={item.get('forbidden','')}。"
|
||
"排版要求:独立小红书3:4图文海报,画面完整;标题只出现一次,不与其他页重复;"
|
||
"中文文字少而清晰,主标题+最多3个短点位;可自然用✅✨🌿💧🪞🧴📦🔍种草符号但不堆砌;"
|
||
"不要生成App截图或笔记详情页界面。"
|
||
)
|
||
# R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束
|
||
if custom_prompt:
|
||
per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}。"
|
||
try:
|
||
img_bytes = await generate_one_image(client, per_prompt, reference_images)
|
||
# 注:gpt-image-2 渲染中文偶发错别字(约1/6)。vision/OCR 文字校验闸门实测
|
||
# 不可靠(漏报形近字+幻觉误伤品牌词),倩倩姐2026-06-16拍板先撤,纯生图,
|
||
# 错别字作已知问题记录,后续迭代再处理。详见记忆 clover-image-text-check-shelved。
|
||
#
|
||
# C3 canvas 叠字口子(倩倩姐2026-06-12拍板"只留口子不实现"):
|
||
# 当 OVERLAY_TEXT_RENDER_ENABLED=True 时,此处由 PIL 在模型出的干净底图上
|
||
# 叠 item['overlay_text']/brand_keyword(字体资源+排版坐标后续补),彻底解决中文乱码。
|
||
# TODO(C3-overlay): from .constants import OVERLAY_TEXT_RENDER_ENABLED
|
||
# if OVERLAY_TEXT_RENDER_ENABLED: img_bytes = overlay_text_on_image(img_bytes, item)
|
||
# E12 AI评图分:只做展示+排序,绝不进飞轮权重,失败返 None 不阻断(倩倩姐2026-06-16)。
|
||
visual = await score_image(client, img_bytes)
|
||
return {"role": item["role"], "name": item["name"], "image_bytes": img_bytes,
|
||
"error": None, "text_review": None,
|
||
"ai_visual_score": (visual or {}).get("score"),
|
||
"ai_visual_note": (visual or {}).get("note")}
|
||
except Exception as exc:
|
||
logger.error("分镜 %s 生图失败: %s", item["role"], exc)
|
||
return {"role": item["role"], "name": item["name"], "image_bytes": None,
|
||
"error": str(exc), "text_review": None,
|
||
"ai_visual_score": None, "ai_visual_note": None}
|
||
|
||
# 限并发:apiports 图片接口有 QPS 限制,6 张全并发会撞 429/503
|
||
concurrency = int(os.environ.get("IMAGE_CONCURRENCY", "2"))
|
||
sem = asyncio.Semaphore(max(1, concurrency))
|
||
|
||
async def _gen_guarded(item: dict) -> dict:
|
||
async with sem:
|
||
return await _gen_one(item)
|
||
|
||
results = await asyncio.gather(*(_gen_guarded(item) for item in storyboard))
|
||
return list(results)
|