ai_generated_material.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  1. """AI generated creative image assets for module B.
  2. This module is intentionally isolated from the historical material recall path.
  3. External side effects happen only when an account explicitly uses
  4. material_source=ai_generated.
  5. """
  6. from __future__ import annotations
  7. import base64
  8. import hashlib
  9. import hmac
  10. import json
  11. import logging
  12. import mimetypes
  13. import os
  14. import re
  15. import uuid
  16. from dataclasses import dataclass
  17. from email.utils import formatdate
  18. from io import BytesIO
  19. from pathlib import Path
  20. from typing import Iterable, Optional
  21. from urllib.parse import quote, urlparse
  22. import httpx
  23. from PIL import Image
  24. from tools.material_recall import Material
  25. from tools.video_feature_query import VideoElementFeature, read_cached_video_element_features
  26. from tools.video_recall import LandingVideo
  27. logger = logging.getLogger(__name__)
  28. OPENROUTER_CHAT_COMPLETIONS_URL = os.getenv(
  29. "OPENROUTER_CHAT_COMPLETIONS_URL",
  30. "https://openrouter.ai/api/v1/chat/completions",
  31. )
  32. OPENROUTER_IMAGES_URL = os.getenv(
  33. "OPENROUTER_IMAGES_URL",
  34. "https://openrouter.ai/api/v1/images",
  35. )
  36. OPENROUTER_IMAGE_MODEL = os.getenv(
  37. "OPENROUTER_IMAGE_MODEL",
  38. "google/gemini-3-pro-image",
  39. )
  40. OPENROUTER_TEXT_MODEL = os.getenv(
  41. "OPENROUTER_TEXT_MODEL",
  42. "google/gemini-2.5-flash",
  43. )
  44. AI_IMAGE_OSS_PREFIX = os.getenv("AI_IMAGE_OSS_PREFIX", "auto_put_tencent/image").strip("/")
  45. AI_IMAGE_ASPECT_RATIO = os.getenv("AI_IMAGE_ASPECT_RATIO", "16:9")
  46. AI_IMAGE_RESOLUTION = os.getenv("AI_IMAGE_RESOLUTION", "1K")
  47. AI_IMAGE_OUTPUT_FORMAT = os.getenv("AI_IMAGE_OUTPUT_FORMAT", "jpeg")
  48. AI_IMAGE_TARGET_WIDTH = int(os.getenv("AI_IMAGE_TARGET_WIDTH", "1280"))
  49. AI_IMAGE_TARGET_HEIGHT = int(os.getenv("AI_IMAGE_TARGET_HEIGHT", "720"))
  50. DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH = (
  51. Path(__file__).resolve().parents[1] / "prompts" / "ai_generated_material.md"
  52. )
  53. DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH = (
  54. Path(__file__).resolve().parents[1] / "prompts" / "ai_sanitize_video_description.md"
  55. )
  56. DEFAULT_AI_IMAGE_OSS_BUCKET = "art-pubbucket"
  57. DEFAULT_AI_IMAGE_PUBLIC_BASE_URL = "https://rescdn.yishihui.com"
  58. FORBIDDEN_SANITIZED_DESCRIPTION_TERMS = (
  59. "公众号", "私信", "评论区", "扫码", "二维码", "加群", "关注",
  60. "回复", "关键词", "转发", "推广", "引导", "点击",
  61. "下载", "按钮", "弹窗", "底部固定", "领取", "能领", "赶紧",
  62. "通知", "已办成", "不错过", "社交分享", "分享给", "转发分享",
  63. "优惠券", "卡券", "领券", "下单", "购买", "价格", "订单",
  64. "商品售卖", "课程售卖", "App下载", "小程序推广",
  65. "真人相册", "私人相册", "小视频", "小短片", "寂寞", "性感",
  66. "风情", "舞女", "小姐", "软妹", "学妹", "女郎", "娇妇", "俏妹",
  67. )
  68. CREATE_AI_MATERIAL_TABLE_SQL = """
  69. CREATE TABLE IF NOT EXISTS ai_generated_material (
  70. id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '自增主键',
  71. account_id BIGINT NOT NULL COMMENT '腾讯广告账户ID',
  72. adgroup_id BIGINT DEFAULT NULL COMMENT '广告ID',
  73. crowd_package VARCHAR(200) NOT NULL COMMENT '投放人群包名称',
  74. landing_video_id BIGINT NOT NULL COMMENT '承接视频ID',
  75. landing_title VARCHAR(500) DEFAULT NULL COMMENT '承接视频标题',
  76. landing_category VARCHAR(200) DEFAULT NULL COMMENT '承接视频品类',
  77. prompt_type VARCHAR(50) NOT NULL COMMENT '生成角度:topic',
  78. prompt_text MEDIUMTEXT NOT NULL COMMENT '最终生成prompt',
  79. prompt_feature_json MEDIUMTEXT DEFAULT NULL COMMENT 'prompt使用的视频特征JSON',
  80. model VARCHAR(200) NOT NULL COMMENT 'OpenRouter模型',
  81. oss_object_key VARCHAR(500) DEFAULT NULL COMMENT 'OSS对象key',
  82. oss_url VARCHAR(1000) DEFAULT NULL COMMENT 'OSS公网URL',
  83. status VARCHAR(50) NOT NULL DEFAULT 'generated' COMMENT 'generated/prepared/approved/rejected/hold/skip/posted_ok/post_failed/error',
  84. approval_status VARCHAR(50) DEFAULT NULL COMMENT '人工审批状态',
  85. tencent_image_id VARCHAR(100) DEFAULT NULL COMMENT '腾讯图片ID',
  86. dynamic_creative_id BIGINT DEFAULT NULL COMMENT '腾讯动态创意ID',
  87. error TEXT DEFAULT NULL COMMENT '生成/上传/创建错误',
  88. raw_response MEDIUMTEXT DEFAULT NULL COMMENT '模型原始响应摘要',
  89. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  90. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  91. KEY idx_account_ad_landing_status (account_id, adgroup_id, landing_video_id, status),
  92. KEY idx_crowd_created (crowd_package, created_at),
  93. KEY idx_status_created (status, created_at)
  94. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI生成创意图片素材'
  95. """
  96. @dataclass(frozen=True)
  97. class GenerationPrompt:
  98. prompt_type: str
  99. prompt_text: str
  100. feature_hits: list[dict]
  101. @dataclass(frozen=True)
  102. class GeneratedMaterialAsset:
  103. id: int
  104. account_id: int
  105. adgroup_id: Optional[int]
  106. crowd_package: str
  107. landing_video_id: int
  108. prompt_type: str
  109. prompt_text: str
  110. model: str
  111. oss_url: str
  112. oss_object_key: str
  113. feature_hits: list[dict]
  114. @property
  115. def material_id(self) -> str:
  116. return f"ai:{self.id}"
  117. def to_material(self) -> Material:
  118. return Material(
  119. material_id=self.material_id,
  120. score=1.0,
  121. title=f"AI生成素材-{self.prompt_type}",
  122. cover=self.oss_url,
  123. cost=None,
  124. ctr=None,
  125. cvr=None,
  126. roi=None,
  127. impressions=None,
  128. quality_score=None,
  129. recall_strategy=f"AI生成-{self.prompt_type}",
  130. recall_query_text=self.prompt_text[:500],
  131. recall_config_code="AI_GENERATED_IMAGE",
  132. recall_element_dimension="AI生成素材",
  133. recall_point_type=self.prompt_type,
  134. recall_standard_element="",
  135. recall_hit_queries=self.feature_hits,
  136. raw={
  137. "ai_generated_material_id": self.id,
  138. "oss_url": self.oss_url,
  139. "prompt_type": self.prompt_type,
  140. "prompt_text": self.prompt_text,
  141. "model": self.model,
  142. },
  143. )
  144. def _feature_attr(feature, name: str, default=""):
  145. if isinstance(feature, dict):
  146. return feature.get(name, default)
  147. return getattr(feature, name, default)
  148. def _feature_to_hit(feature) -> dict:
  149. return {
  150. "element_dimension": str(_feature_attr(feature, "element_dimension") or ""),
  151. "point_type": str(_feature_attr(feature, "point_type") or ""),
  152. "standard_element": str(_feature_attr(feature, "standard_element") or ""),
  153. "contribution_score": float(_feature_attr(feature, "contribution_score", 0) or 0),
  154. "dt": str(_feature_attr(feature, "dt") or ""),
  155. }
  156. def _top_feature(features: Iterable, *, dimension: str, point_type: str = "") -> Optional[dict]:
  157. candidates = []
  158. for feature in features:
  159. hit = _feature_to_hit(feature)
  160. if hit["element_dimension"] != dimension:
  161. continue
  162. if point_type and hit["point_type"] != point_type:
  163. continue
  164. if not hit["standard_element"] or hit["standard_element"] == "-":
  165. continue
  166. candidates.append(hit)
  167. if not candidates:
  168. return None
  169. return sorted(candidates, key=lambda x: x["contribution_score"], reverse=True)[0]
  170. def _load_prompt_template() -> str:
  171. raw_path = os.getenv("AI_IMAGE_PROMPT_TEMPLATE_PATH", "").strip()
  172. path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_IMAGE_PROMPT_TEMPLATE_PATH
  173. return path.read_text(encoding="utf-8")
  174. def _load_sanitize_prompt_messages(raw_description: str) -> list[dict]:
  175. raw_path = os.getenv("AI_SANITIZE_PROMPT_TEMPLATE_PATH", "").strip()
  176. path = Path(raw_path).expanduser() if raw_path else DEFAULT_AI_SANITIZE_PROMPT_TEMPLATE_PATH
  177. template = path.read_text(encoding="utf-8")
  178. if "【system】" not in template or "【user】" not in template:
  179. raise RuntimeError(f"清洗prompt模板缺少【system】/【user】标记:{path}")
  180. system_part, user_part = template.split("【user】", 1)
  181. system_text = system_part.replace("【system】", "", 1).strip()
  182. user_text = user_part.replace("{{raw_description}}", raw_description).strip()
  183. if not system_text or not user_text:
  184. raise RuntimeError(f"清洗prompt模板为空:{path}")
  185. return [
  186. {"role": "system", "content": system_text},
  187. {"role": "user", "content": user_text},
  188. ]
  189. def _render_prompt(video_description: str) -> str:
  190. return (
  191. _load_prompt_template()
  192. .replace("{{video_description}}", video_description)
  193. .replace("{{aspect_ratio}}", AI_IMAGE_ASPECT_RATIO)
  194. )
  195. def _extract_chat_completion_text(data: dict) -> str:
  196. choices = data.get("choices") or []
  197. if not choices:
  198. raise RuntimeError("OpenRouter 清洗响应缺 choices")
  199. message = choices[0].get("message") or {}
  200. content = message.get("content")
  201. if isinstance(content, str):
  202. return content.strip()
  203. if isinstance(content, list):
  204. parts = []
  205. for item in content:
  206. if isinstance(item, dict) and isinstance(item.get("text"), str):
  207. parts.append(item["text"])
  208. return "\n".join(parts).strip()
  209. return ""
  210. def sanitize_video_description(raw_description: str, model: str = OPENROUTER_TEXT_MODEL) -> str:
  211. """Rewrite ODPS topic into a clean visual description for image generation."""
  212. raw = (raw_description or "").strip()
  213. if not raw:
  214. raise RuntimeError("缺少视频解构选题,无法清洗生成描述")
  215. body = {
  216. "model": model,
  217. "temperature": 0.2,
  218. "max_tokens": 180,
  219. "messages": _load_sanitize_prompt_messages(raw),
  220. }
  221. headers = {
  222. "authorization": f"Bearer {_openrouter_api_key()}",
  223. "content-type": "application/json",
  224. "accept": "application/json",
  225. }
  226. resp = httpx.post(OPENROUTER_CHAT_COMPLETIONS_URL, json=body, headers=headers, timeout=60)
  227. resp.raise_for_status()
  228. cleaned = _extract_chat_completion_text(resp.json())
  229. cleaned = cleaned.strip().strip("`").strip().strip("“”\"'").replace("\n", "")
  230. if not cleaned:
  231. raise RuntimeError("OpenRouter 清洗后描述为空")
  232. hit_terms = [term for term in FORBIDDEN_SANITIZED_DESCRIPTION_TERMS if term in cleaned]
  233. if hit_terms:
  234. raise RuntimeError(f"OpenRouter 清洗后仍包含互动/营销词:{','.join(hit_terms)}")
  235. return cleaned
  236. def build_generation_prompts(
  237. *,
  238. video_id: int,
  239. title: str,
  240. category: str,
  241. features: Iterable[VideoElementFeature],
  242. sanitized_description: str = "",
  243. ) -> list[GenerationPrompt]:
  244. """Build one generation prompt from the video's ODPS topic."""
  245. feature_list = list(features or [])
  246. topic = _top_feature(feature_list, dimension="解构选题")
  247. topic_text = (topic or {}).get("standard_element")
  248. if not topic_text:
  249. return []
  250. video_description = sanitized_description.strip() or topic_text
  251. topic_hit = dict(topic)
  252. topic_hit["original_standard_element"] = topic_text
  253. topic_hit["video_description"] = video_description
  254. return [
  255. GenerationPrompt(
  256. prompt_type="topic",
  257. prompt_text=_render_prompt(video_description),
  258. feature_hits=[topic_hit],
  259. )
  260. ]
  261. def ensure_ai_material_table() -> None:
  262. from db.connection import get_connection
  263. conn = get_connection()
  264. try:
  265. with conn.cursor() as cur:
  266. cur.execute(CREATE_AI_MATERIAL_TABLE_SQL)
  267. conn.commit()
  268. finally:
  269. conn.close()
  270. def _openrouter_api_key() -> str:
  271. key = os.getenv("OPEN_ROUTER_API_KEY") or os.getenv("OPENROUTER_API_KEY")
  272. if not key:
  273. raise RuntimeError("缺少 OPENROUTER_API_KEY/OPEN_ROUTER_API_KEY,无法生成AI素材")
  274. return key
  275. def _to_jpeg_bytes(image_bytes: bytes) -> bytes:
  276. """Normalize model output to RGB JPEG for Tencent material upload."""
  277. with Image.open(BytesIO(image_bytes)) as img:
  278. if img.mode not in ("RGB", "L"):
  279. background = Image.new("RGB", img.size, (255, 255, 255))
  280. if img.mode in ("RGBA", "LA"):
  281. alpha = img.getchannel("A")
  282. background.paste(img.convert("RGB"), mask=alpha)
  283. else:
  284. background.paste(img.convert("RGB"))
  285. out_img = background
  286. else:
  287. out_img = img.convert("RGB")
  288. target_ratio = AI_IMAGE_TARGET_WIDTH / AI_IMAGE_TARGET_HEIGHT
  289. image_ratio = out_img.width / out_img.height
  290. if image_ratio > target_ratio:
  291. new_width = int(out_img.height * target_ratio)
  292. left = (out_img.width - new_width) // 2
  293. out_img = out_img.crop((left, 0, left + new_width, out_img.height))
  294. elif image_ratio < target_ratio:
  295. new_height = int(out_img.width / target_ratio)
  296. top = (out_img.height - new_height) // 2
  297. out_img = out_img.crop((0, top, out_img.width, top + new_height))
  298. out_img = out_img.resize((AI_IMAGE_TARGET_WIDTH, AI_IMAGE_TARGET_HEIGHT), Image.Resampling.LANCZOS)
  299. out = BytesIO()
  300. out_img.save(out, format="JPEG", quality=92, optimize=True)
  301. return out.getvalue()
  302. def _extract_image_ref_from_openrouter(data: dict) -> str:
  303. choices = data.get("choices") or []
  304. if not choices:
  305. raise RuntimeError("OpenRouter 响应缺 choices")
  306. msg = choices[0].get("message") or {}
  307. images = msg.get("images") or []
  308. for image in images:
  309. image_url = image.get("image_url") if isinstance(image, dict) else None
  310. if isinstance(image_url, dict) and image_url.get("url"):
  311. return image_url["url"]
  312. if isinstance(image_url, str):
  313. return image_url
  314. content = msg.get("content")
  315. if isinstance(content, list):
  316. for part in content:
  317. if not isinstance(part, dict):
  318. continue
  319. image_url = part.get("image_url")
  320. if isinstance(image_url, dict) and image_url.get("url"):
  321. return image_url["url"]
  322. if part.get("type") in ("image_url", "output_image") and part.get("url"):
  323. return part["url"]
  324. if isinstance(content, str):
  325. match = re.search(r"!\[[^\]]*]\(([^)]+)\)", content)
  326. if match:
  327. return match.group(1)
  328. data_match = re.search(r"(data:image/[^;\s]+;base64,[A-Za-z0-9+/=]+)", content)
  329. if data_match:
  330. return data_match.group(1)
  331. url_match = re.search(r"https?://\S+", content)
  332. if url_match:
  333. return url_match.group(0).rstrip(").,,。")
  334. raise RuntimeError("OpenRouter 响应未包含可识别图片URL/base64")
  335. def generate_image_bytes(prompt: str, model: str = OPENROUTER_IMAGE_MODEL) -> tuple[bytes, str, dict]:
  336. """Generate one image through OpenRouter Images API.
  337. The Images API lets us pass aspect_ratio directly. We still normalize the
  338. returned raster to JPEG in code because not every model supports
  339. output_format.
  340. """
  341. body = {
  342. "model": model,
  343. "prompt": prompt,
  344. "aspect_ratio": AI_IMAGE_ASPECT_RATIO,
  345. "resolution": AI_IMAGE_RESOLUTION,
  346. "n": 1,
  347. }
  348. headers = {
  349. "authorization": f"Bearer {_openrouter_api_key()}",
  350. "content-type": "application/json",
  351. "accept": "application/json",
  352. }
  353. resp = httpx.post(OPENROUTER_IMAGES_URL, json=body, headers=headers, timeout=120)
  354. resp.raise_for_status()
  355. data = resp.json()
  356. images = data.get("data") or []
  357. if not images or not images[0].get("b64_json"):
  358. raise RuntimeError("OpenRouter Images API 响应缺少 data[0].b64_json")
  359. image_bytes = base64.b64decode(images[0]["b64_json"])
  360. if AI_IMAGE_OUTPUT_FORMAT.lower() in ("jpg", "jpeg"):
  361. return _to_jpeg_bytes(image_bytes), "image/jpeg", data
  362. media_type = images[0].get("media_type") or "image/png"
  363. return image_bytes, media_type, data
  364. def _public_oss_url(public_base_url: str, object_key: str) -> str:
  365. return f"{public_base_url.rstrip('/')}/{quote(object_key, safe='/')}"
  366. def _oss_env() -> tuple[str, str, str, str, str, str]:
  367. endpoint = os.getenv("ALIYUN_OSS_ENDPOINT", "").strip()
  368. bucket = (
  369. os.getenv("ALIYUN_OSS_BUCKET", "").strip()
  370. or os.getenv("AI_IMAGE_OSS_BUCKET", "").strip()
  371. or DEFAULT_AI_IMAGE_OSS_BUCKET
  372. )
  373. access_key_id = os.getenv("ALIYUN_OSS_ACCESS_KEY_ID", "").strip()
  374. access_key_secret = os.getenv("ALIYUN_OSS_ACCESS_KEY_SECRET", "").strip()
  375. public_base_url = (
  376. os.getenv("AI_IMAGE_PUBLIC_BASE_URL", "").strip()
  377. or DEFAULT_AI_IMAGE_PUBLIC_BASE_URL
  378. )
  379. object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
  380. missing = [
  381. name for name, value in [
  382. ("ALIYUN_OSS_ENDPOINT", endpoint),
  383. ("ALIYUN_OSS_BUCKET", bucket),
  384. ("ALIYUN_OSS_ACCESS_KEY_ID", access_key_id),
  385. ("ALIYUN_OSS_ACCESS_KEY_SECRET", access_key_secret),
  386. ]
  387. if not value
  388. ]
  389. if missing:
  390. raise RuntimeError(f"缺少 OSS 配置:{','.join(missing)}")
  391. return endpoint, bucket, access_key_id, access_key_secret, public_base_url.rstrip("/"), object_prefix
  392. def _normalize_oss_endpoint(endpoint: str, bucket: str) -> str:
  393. endpoint = endpoint.rstrip("/")
  394. if not endpoint.startswith(("http://", "https://")):
  395. endpoint = "https://" + endpoint
  396. parsed = urlparse(endpoint)
  397. host = parsed.netloc
  398. if not host.startswith(f"{bucket}."):
  399. host = f"{bucket}.{host}"
  400. return f"{parsed.scheme}://{host}"
  401. def upload_image_to_oss(image_bytes: bytes, content_type: str, object_key: str) -> str:
  402. endpoint, bucket, access_key_id, access_key_secret, public_base_url, _ = _oss_env()
  403. content_md5 = base64.b64encode(hashlib.md5(image_bytes).digest()).decode("ascii")
  404. date = formatdate(usegmt=True)
  405. canonical_resource = f"/{bucket}/{object_key}"
  406. string_to_sign = f"PUT\n{content_md5}\n{content_type}\n{date}\n{canonical_resource}"
  407. signature = base64.b64encode(
  408. hmac.new(
  409. access_key_secret.encode("utf-8"),
  410. string_to_sign.encode("utf-8"),
  411. hashlib.sha1,
  412. ).digest()
  413. ).decode("ascii")
  414. upload_base = _normalize_oss_endpoint(endpoint, bucket)
  415. url = f"{upload_base}/{quote(object_key, safe='/')}"
  416. resp = httpx.put(
  417. url,
  418. content=image_bytes,
  419. headers={
  420. "Date": date,
  421. "Content-Type": content_type,
  422. "Content-MD5": content_md5,
  423. "Authorization": f"OSS {access_key_id}:{signature}",
  424. },
  425. timeout=60,
  426. )
  427. resp.raise_for_status()
  428. return _public_oss_url(public_base_url, object_key)
  429. def build_ai_image_object_key(
  430. *,
  431. account_id: int | str,
  432. landing_video_id: int | str,
  433. prompt_type: str,
  434. extension: str,
  435. debug: bool = False,
  436. ) -> str:
  437. object_prefix = os.getenv("AI_IMAGE_OSS_PREFIX", AI_IMAGE_OSS_PREFIX).strip("/")
  438. ext = extension if extension.startswith(".") else f".{extension}"
  439. if debug:
  440. scope = "debug"
  441. else:
  442. scope = f"account_{account_id}/video_{landing_video_id}"
  443. return f"{object_prefix}/{scope}/{prompt_type}_{uuid.uuid4().hex}{ext}"
  444. def _insert_generated_material(
  445. *,
  446. account_id: int,
  447. adgroup_id: int,
  448. crowd_package: str,
  449. landing: LandingVideo,
  450. prompt: GenerationPrompt,
  451. model: str,
  452. object_key: str,
  453. oss_url: str,
  454. raw_response: dict,
  455. ) -> GeneratedMaterialAsset:
  456. ensure_ai_material_table()
  457. from db.connection import get_connection
  458. conn = get_connection()
  459. try:
  460. with conn.cursor() as cur:
  461. cur.execute(
  462. """
  463. INSERT INTO ai_generated_material
  464. (account_id, adgroup_id, crowd_package, landing_video_id,
  465. landing_title, landing_category, prompt_type, prompt_text,
  466. prompt_feature_json, model, oss_object_key, oss_url, raw_response)
  467. VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
  468. """,
  469. (
  470. account_id,
  471. adgroup_id,
  472. crowd_package,
  473. landing.video_id,
  474. landing.title,
  475. landing.category,
  476. prompt.prompt_type,
  477. prompt.prompt_text,
  478. json.dumps(prompt.feature_hits, ensure_ascii=False),
  479. model,
  480. object_key,
  481. oss_url,
  482. json.dumps(raw_response, ensure_ascii=False, default=str)[:16000000],
  483. ),
  484. )
  485. asset_id = int(cur.lastrowid)
  486. conn.commit()
  487. finally:
  488. conn.close()
  489. return GeneratedMaterialAsset(
  490. id=asset_id,
  491. account_id=account_id,
  492. adgroup_id=adgroup_id,
  493. crowd_package=crowd_package,
  494. landing_video_id=landing.video_id,
  495. prompt_type=prompt.prompt_type,
  496. prompt_text=prompt.prompt_text,
  497. model=model,
  498. oss_url=oss_url,
  499. oss_object_key=object_key,
  500. feature_hits=prompt.feature_hits,
  501. )
  502. def _rows_to_assets(rows: list[dict]) -> list[GeneratedMaterialAsset]:
  503. out = []
  504. for row in rows:
  505. try:
  506. feature_hits = json.loads(row.get("prompt_feature_json") or "[]")
  507. except Exception:
  508. feature_hits = []
  509. out.append(GeneratedMaterialAsset(
  510. id=int(row["id"]),
  511. account_id=int(row["account_id"]),
  512. adgroup_id=int(row["adgroup_id"]) if row.get("adgroup_id") else None,
  513. crowd_package=str(row.get("crowd_package") or ""),
  514. landing_video_id=int(row["landing_video_id"]),
  515. prompt_type=str(row.get("prompt_type") or ""),
  516. prompt_text=str(row.get("prompt_text") or ""),
  517. model=str(row.get("model") or ""),
  518. oss_url=str(row.get("oss_url") or ""),
  519. oss_object_key=str(row.get("oss_object_key") or ""),
  520. feature_hits=feature_hits,
  521. ))
  522. return out
  523. def load_available_generated_assets(
  524. account_id: int,
  525. adgroup_id: int,
  526. landing_video_id: int,
  527. ) -> list[GeneratedMaterialAsset]:
  528. ensure_ai_material_table()
  529. from db.connection import get_connection
  530. conn = get_connection()
  531. try:
  532. with conn.cursor() as cur:
  533. cur.execute(
  534. """
  535. SELECT *
  536. FROM ai_generated_material
  537. WHERE account_id=%s
  538. AND adgroup_id=%s
  539. AND landing_video_id=%s
  540. AND status='generated'
  541. AND oss_url IS NOT NULL
  542. AND oss_url <> ''
  543. ORDER BY id ASC
  544. """,
  545. (account_id, adgroup_id, landing_video_id),
  546. )
  547. rows = cur.fetchall() or []
  548. finally:
  549. conn.close()
  550. return _rows_to_assets(rows)
  551. def generate_assets_for_landing(
  552. *,
  553. account_id: int,
  554. adgroup_id: int,
  555. crowd_package: str,
  556. landing: LandingVideo,
  557. model: str = OPENROUTER_IMAGE_MODEL,
  558. ) -> list[GeneratedMaterialAsset]:
  559. db_features = read_cached_video_element_features([landing.video_id]).get(landing.video_id) or []
  560. if not db_features:
  561. logger.info(
  562. "[ai_generated_material] landing=%d no cached DB features for generation",
  563. landing.video_id,
  564. )
  565. return []
  566. topic = _top_feature(db_features, dimension="解构选题")
  567. topic_text = (topic or {}).get("standard_element")
  568. if not topic_text:
  569. logger.info(
  570. "[ai_generated_material] landing=%d no topic feature for generation",
  571. landing.video_id,
  572. )
  573. return []
  574. sanitized_description = sanitize_video_description(topic_text)
  575. logger.info(
  576. "[ai_generated_material] landing=%d sanitized description=%r",
  577. landing.video_id, sanitized_description,
  578. )
  579. prompts = build_generation_prompts(
  580. video_id=landing.video_id,
  581. title=landing.title,
  582. category=landing.category,
  583. features=db_features,
  584. sanitized_description=sanitized_description,
  585. )
  586. assets: list[GeneratedMaterialAsset] = []
  587. for prompt in prompts:
  588. image_bytes, content_type, raw_response = generate_image_bytes(prompt.prompt_text, model)
  589. ext = mimetypes.guess_extension(content_type) or ".jpg"
  590. if ext == ".jpe":
  591. ext = ".jpg"
  592. object_key = build_ai_image_object_key(
  593. account_id=account_id,
  594. landing_video_id=landing.video_id,
  595. prompt_type=prompt.prompt_type,
  596. extension=ext,
  597. )
  598. oss_url = upload_image_to_oss(image_bytes, content_type, object_key)
  599. asset = _insert_generated_material(
  600. account_id=account_id,
  601. adgroup_id=adgroup_id,
  602. crowd_package=crowd_package,
  603. landing=landing,
  604. prompt=prompt,
  605. model=model,
  606. object_key=object_key,
  607. oss_url=oss_url,
  608. raw_response=raw_response,
  609. )
  610. assets.append(asset)
  611. logger.info(
  612. "[ai_generated_material] generated account=%d adgroup=%d landing=%d asset=%d type=%s url=%s",
  613. account_id, adgroup_id, landing.video_id, asset.id, prompt.prompt_type, oss_url,
  614. )
  615. return assets
  616. def get_or_generate_assets_for_landing(
  617. *,
  618. account_id: int,
  619. adgroup_id: int,
  620. crowd_package: str,
  621. landing: LandingVideo,
  622. ) -> list[GeneratedMaterialAsset]:
  623. existing = load_available_generated_assets(account_id, adgroup_id, landing.video_id)
  624. if existing:
  625. return existing
  626. return generate_assets_for_landing(
  627. account_id=account_id,
  628. adgroup_id=adgroup_id,
  629. crowd_package=crowd_package,
  630. landing=landing,
  631. )
  632. def update_generated_material_status(
  633. record: dict,
  634. status: str,
  635. *,
  636. dynamic_creative_id: int | str | None = None,
  637. tencent_image_id: str = "",
  638. error: str = "",
  639. ) -> None:
  640. asset_id = record.get("_ai_generated_material_id")
  641. if not asset_id:
  642. return
  643. ensure_ai_material_table()
  644. from db.connection import get_connection
  645. normalized_status = {
  646. "approve": "approved",
  647. "reject": "rejected",
  648. }.get(status, status)
  649. approval_status = {
  650. "approve": "approved",
  651. "reject": "rejected",
  652. "hold": "hold",
  653. "skip": "skip",
  654. }.get(status, normalized_status)
  655. conn = get_connection()
  656. try:
  657. with conn.cursor() as cur:
  658. cur.execute(
  659. """
  660. UPDATE ai_generated_material
  661. SET status=%s,
  662. approval_status=%s,
  663. dynamic_creative_id=COALESCE(%s, dynamic_creative_id),
  664. tencent_image_id=COALESCE(NULLIF(%s, ''), tencent_image_id),
  665. error=COALESCE(NULLIF(%s, ''), error),
  666. updated_at=CURRENT_TIMESTAMP
  667. WHERE id=%s
  668. """,
  669. (
  670. normalized_status,
  671. approval_status,
  672. int(dynamic_creative_id) if dynamic_creative_id else None,
  673. tencent_image_id,
  674. error[:2000] if error else "",
  675. int(asset_id),
  676. ),
  677. )
  678. conn.commit()
  679. finally:
  680. conn.close()