Files
beige/backend/app/services/ai_engine/image_gen.py
yangqianqian 4bed7425a8 A8多套打包+M4归档+R5多图:存量功能备份
A8 多套交付包(packaging_task.py):
- 修复交付包只打第1条混乱note的bug,按ImageCandidate.strategy分A/B/C组
- 每组生独立note_0N夹(6图+文案.txt),同seq留最新去重,老数据兼容
- task74端到端验:3套各6图,独立agent7项交叉验证全过

M4 归档(tasks.py/exports.py/前端):
- list_tasks加date_from/date_to/product_id筛选+product_name批量填(防N+1)
- 新增exports.py:产品JSON导出+标杆CSV导出(UTF-8 BOM)
- 前端HistoryFilters日期/产品筛选+产品列+打回原因红banner
- response.py加raise_param_error;独立agent验A1/A2/A9通过

R5 产品多图(product_images.py/020迁移/前端):
- product_images表+5端点(上传/列/改场景/设主图/删图)
- 生图按ROLE_SCENE_PREFERENCE选对应场景图,回落primary
- 前端ProductImageManager多图画廊

R6 账号config拆页(settings/):
- 配置页按角色拆/settings(运营+组长+admin)+/config(仅admin)
- Key只显末4位不显余额(守红线)

核销表对齐真实代码状态:D1改稿框/M7裂变/E12评图分纠偏为已完成(曾漏回写)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:32:49 +08:00

246 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
生图通道 — 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, ROLE_SCENE_PREFERENCE
from .image_scorer import score_image
from .storyboard import plan_image_set, sanitize_text
logger = logging.getLogger(__name__)
def _pick_reference_for_role(
role: str,
images_by_scene: dict[str, list[bytes]] | None,
fallback: list[bytes] | None,
) -> tuple[list[bytes] | None, str]:
"""R5多图按分镜 role 选该场景的产品图。取不到回落主图。
返回 (参考图bytes列表, 命中scene标签用于日志)。
"""
if images_by_scene:
for scene in ROLE_SCENE_PREFERENCE.get(role, ["primary"]):
imgs = images_by_scene.get(scene)
if imgs:
return imgs, scene
# 偏好全落空:用任意可用图兜底(仍优先 primary
if images_by_scene.get("primary"):
return images_by_scene["primary"], "primary"
for scene, imgs in images_by_scene.items():
if imgs:
return imgs, f"{scene}(兜底)"
return fallback, "fallback"
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,
target_role: str | None = None,
custom_prompt: str | None = None,
images_by_scene: dict[str, list[bytes]] | None = None,
flywheel_fragment: str | None = None,
) -> list[dict]:
"""
按 storyboard 逐张生图asyncio.gather 并发),返回每张结果列表。
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
target_role: 非空时只生成该 role 那一张R2 单张重生)
custom_prompt: 非空时追加到每张 per_prompt 末尾R2 人工提示词)
images_by_scene: R5多图{scene: [bytes]},按分镜 role 选对应场景图;
为空则全分镜共用 reference_images向后兼容
flywheel_fragment: R7 飞轮偏好片段(最近选图/打回真实信号聚合),注入图片
排版偏好;仅影响文字角度/版式取向,绝不改瓶身(合规红线)。
每项:{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)}"
# R7 飞轮偏好:仅作用于文字角度/版式取向参考,绝不改瓶身(合规+真实红线)
if flywheel_fragment:
per_prompt += (
f"\n历史偏好参考(仅影响标题文字角度与排版取向,不得据此改动产品瓶身):"
f"{sanitize_text(flywheel_fragment, 300)}"
)
try:
# R5多图按本张分镜 role 选对应场景产品图;无多图则共用 reference_images
ref_for_item, _scene_hit = _pick_reference_for_role(
item["role"], images_by_scene, reference_images
)
if images_by_scene:
logger.info("分镜 %s 选用产品图场景=%s", item["role"], _scene_hit)
img_bytes = await generate_one_image(client, per_prompt, ref_for_item)
# 注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)