baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。 当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包), M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
175
backend/app/services/ai_engine/image_gen.py
Normal file
175
backend/app/services/ai_engine/image_gen.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
生图通道 — 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 .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,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。
|
||||
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
|
||||
每项:{role, name, image_bytes, error}
|
||||
"""
|
||||
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
|
||||
storyboard = plan["storyboard"]
|
||||
base_prompt = plan["base_prompt"]
|
||||
|
||||
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截图或笔记详情页界面。"
|
||||
)
|
||||
try:
|
||||
img_bytes = await generate_one_image(client, per_prompt, reference_images)
|
||||
return {"role": item["role"], "name": item["name"], "image_bytes": img_bytes, "error": None}
|
||||
except Exception as exc:
|
||||
logger.error("分镜 %s 生图失败: %s", item["role"], exc)
|
||||
return {"role": item["role"], "name": item["name"], "image_bytes": None, "error": str(exc)}
|
||||
|
||||
# 限并发: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)
|
||||
Reference in New Issue
Block a user