baseline: Clover 独立仓库首次基线提交

将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
yangqianqian
2026-06-16 11:30:22 +08:00
commit 6a2632da70
253 changed files with 27467 additions and 0 deletions

View 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 内部有重试退避。
返回图片 bytesPNG/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)