054e81c3e7
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 3s
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 1m1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 8s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m9s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 53s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m9s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m26s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m48s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 56s
CI/CD Pipeline / Validate - Style (push) Successful in 4m57s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m59s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m10s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
428 lines
18 KiB
Python
428 lines
18 KiB
Python
"""爆款视频 5 步编排:图片分析 → 意图解析 → 文案融合 → 分镜 → 审核重写。
|
||
|
||
所有 LLM 调用走 DoubaoClient,单测通过 client 参数注入 mock,不真调 API。
|
||
任何一步解析失败都走规则 fallback,不抛异常阻断。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Optional
|
||
|
||
from packages.application.viral_video import xml_parser as xp
|
||
from packages.application.viral_video.prompt_loader import (
|
||
PromptTemplate,
|
||
get_template,
|
||
render_system_prompt,
|
||
render_user_prompt,
|
||
)
|
||
from packages.application.viral_video.prompts import (
|
||
FUSION_INSTRUCTIONS,
|
||
GLOBAL_CONSTRAINTS,
|
||
NEGATIVE_RULES,
|
||
)
|
||
from packages.application.viral_video.reviewer import Reviewer
|
||
from packages.application.viral_video.schemas import (
|
||
BodyPoint,
|
||
Clip,
|
||
ColorItem,
|
||
CoreMessage,
|
||
FusionResult,
|
||
ImageAnalysis,
|
||
IntentResult,
|
||
KenBurns,
|
||
PersonalBrand,
|
||
ProductItem,
|
||
ReviewResult,
|
||
ScriptSegment,
|
||
Storyboard,
|
||
TextItem,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class CopyGenerator:
|
||
"""5 步 Prompt 编排器。"""
|
||
|
||
def __init__(self, client=None, reviewer: Optional[Reviewer] = None):
|
||
if client is None:
|
||
from packages.shared.ai_client import get_doubao_client
|
||
|
||
client = get_doubao_client()
|
||
self.client = client
|
||
self.reviewer = reviewer or Reviewer(client)
|
||
|
||
# ── 底层调用 ────────────────────────────────────────────────────────
|
||
def _chat(self, template: PromptTemplate, system_kwargs: dict | None, **user_kwargs) -> str:
|
||
system = render_system_prompt(template, **(system_kwargs or {}))
|
||
user = render_user_prompt(template, **user_kwargs)
|
||
result = self.client.chat_completion(
|
||
[
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": user},
|
||
],
|
||
temperature=0.7,
|
||
max_tokens=2048,
|
||
)
|
||
return result or ""
|
||
|
||
# ── 步骤1:图片多模态分析 ───────────────────────────────────────────
|
||
def analyze_images(self, images: list[str], industry: str = "") -> ImageAnalysis:
|
||
template = get_template("image_analysis")
|
||
image_urls = "\n".join(f"第{i + 1}张:{url}" for i, url in enumerate(images))
|
||
system = render_system_prompt(template)
|
||
user = render_user_prompt(template, image_count=len(images), industry=industry or "通用", image_urls=image_urls)
|
||
raw = self.client.vision_completion(
|
||
[
|
||
{"role": "system", "content": system},
|
||
{"role": "user", "content": user},
|
||
],
|
||
images=images,
|
||
max_tokens=2048,
|
||
temperature=0.3,
|
||
)
|
||
analysis = self._parse_image_analysis(raw or "")
|
||
if not analysis.products and not analysis.key_selling_points:
|
||
logger.warning("图片分析标签解析失败,走规则 fallback")
|
||
return self._fallback_image_analysis(images, raw or "")
|
||
return analysis
|
||
|
||
def _parse_image_analysis(self, raw: str) -> ImageAnalysis:
|
||
products = [
|
||
ProductItem(
|
||
name=n["attrs"].get("name", "无法判断"),
|
||
features=n["attrs"].get("features", "无法判断"),
|
||
position=n["attrs"].get("position", "secondary"),
|
||
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
|
||
)
|
||
for n in xp.find_all(raw, "product")
|
||
]
|
||
colors = [
|
||
ColorItem(
|
||
hex=c["attrs"].get("hex", "#000000"),
|
||
name=c["attrs"].get("name", "无法判断"),
|
||
coverage=xp.attr_float(c["attrs"].get("coverage"), 0.0),
|
||
)
|
||
for c in xp.find_all(raw, "color")
|
||
]
|
||
people = xp.find_first(raw, "people")
|
||
visible_text = [
|
||
TextItem(text=t["attrs"].get("text", ""), position=t["attrs"].get("position", ""))
|
||
for t in xp.find_all(raw, "text_item")
|
||
]
|
||
quality_node = xp.find_first(raw, "quality")
|
||
selling_points = [n["text"] or n["attrs"].get("text", "") for n in xp.find_all(raw, "point")]
|
||
return ImageAnalysis(
|
||
products=products,
|
||
colors=colors,
|
||
has_person=xp.attr_bool(people["attrs"].get("has_person")) if people else False,
|
||
person_count=xp.attr_int(people["attrs"].get("count"), 0) if people else 0,
|
||
people=people["attrs"] if people else {},
|
||
mood=xp.text_of(raw, "mood"),
|
||
visible_text=visible_text,
|
||
scene=xp.text_of(raw, "scene"),
|
||
quality=quality_node["attrs"] if quality_node else {},
|
||
key_selling_points=[p for p in selling_points if p],
|
||
raw=raw,
|
||
)
|
||
|
||
def _fallback_image_analysis(self, images: list[str], raw: str) -> ImageAnalysis:
|
||
return ImageAnalysis(
|
||
products=[ProductItem(name="无法判断(视觉分析不可用)", image_index=0)],
|
||
scene="无法判断",
|
||
raw=raw,
|
||
)
|
||
|
||
# ── 步骤2:意图解析 ─────────────────────────────────────────────────
|
||
def parse_intent(self, user_copy_text: str, image_analysis: ImageAnalysis, industry: str = "") -> IntentResult:
|
||
template = get_template("intent_parsing")
|
||
raw = self._chat(
|
||
template,
|
||
None,
|
||
user_copy_text=user_copy_text or "(用户没有提供文案)",
|
||
industry=industry or "通用",
|
||
image_analysis=self._image_brief(image_analysis),
|
||
)
|
||
intent = self._parse_intent(raw)
|
||
if not intent.intent_summary and not intent.core_messages:
|
||
logger.warning("意图解析标签解析失败,走规则 fallback")
|
||
return self._fallback_intent(user_copy_text, raw)
|
||
return intent
|
||
|
||
def _parse_intent(self, raw: str) -> IntentResult:
|
||
messages = [
|
||
CoreMessage(
|
||
text=n["text"],
|
||
must_keep=xp.attr_bool(n["attrs"].get("must_keep"), default=False),
|
||
confidence=xp.attr_float(n["attrs"].get("confidence"), 0.0),
|
||
)
|
||
for n in xp.find_all(raw, "message")
|
||
if n["text"]
|
||
]
|
||
brands = [
|
||
PersonalBrand(text=n["text"], category=n["attrs"].get("category", "brand"))
|
||
for n in xp.find_all(raw, "brand")
|
||
if n["text"]
|
||
]
|
||
missing = [n["text"] for n in xp.find_all(raw, "info") if n["text"]]
|
||
return IntentResult(
|
||
intent_summary=xp.text_of(raw, "intent_summary"),
|
||
core_messages=messages,
|
||
personal_brands=brands,
|
||
emotion_tone=xp.text_of(raw, "emotion_tone"),
|
||
missing_info=missing,
|
||
raw=raw,
|
||
)
|
||
|
||
def _fallback_intent(self, user_copy_text: str, raw: str) -> IntentResult:
|
||
text = (user_copy_text or "").strip()
|
||
messages = [CoreMessage(text=text[:80], must_keep=True, confidence=1.0)] if text else []
|
||
return IntentResult(
|
||
intent_summary=text[:30] or "未提供文案,按产品图片自由创作",
|
||
core_messages=messages,
|
||
personal_brands=[],
|
||
raw=raw,
|
||
)
|
||
|
||
# ── 步骤3:文案融合生成(三档)──────────────────────────────────────
|
||
def fuse(
|
||
self,
|
||
fusion_level: str,
|
||
image_analysis: ImageAnalysis,
|
||
intent: IntentResult,
|
||
industry: str = "",
|
||
target_customer: str = "",
|
||
marketing_purpose: str = "",
|
||
duration: int = 15,
|
||
) -> FusionResult:
|
||
template = get_template("copy_fusion")
|
||
system_kwargs = {
|
||
"fusion_instruction": FUSION_INSTRUCTIONS.get(fusion_level, FUSION_INSTRUCTIONS["ai_polish"]),
|
||
"global_constraints": GLOBAL_CONSTRAINTS,
|
||
"negative_rules": NEGATIVE_RULES,
|
||
}
|
||
raw = self._chat(
|
||
template,
|
||
system_kwargs,
|
||
industry=industry or "通用",
|
||
target_customer=target_customer or "通用消费者",
|
||
marketing_purpose=marketing_purpose or "产品种草",
|
||
duration=duration,
|
||
image_analysis=self._image_brief(image_analysis),
|
||
intent_result=self._intent_brief(intent),
|
||
)
|
||
result = self._parse_fusion(raw)
|
||
if not result.title and not result.script_segments:
|
||
logger.warning("文案融合标签解析失败(fusion=%s),走规则 fallback", fusion_level)
|
||
return self._fallback_fusion(fusion_level, image_analysis, intent, duration, raw)
|
||
return result
|
||
|
||
def _parse_fusion(self, raw: str) -> FusionResult:
|
||
body_points = [
|
||
BodyPoint(
|
||
text=n["text"],
|
||
elaboration=n["attrs"].get("elaboration", ""),
|
||
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
|
||
)
|
||
for n in xp.find_all(raw, "point")
|
||
if n["text"]
|
||
]
|
||
segments = [
|
||
ScriptSegment(
|
||
text=n["text"],
|
||
duration_sec=xp.attr_float(n["attrs"].get("duration_sec"), 0.0),
|
||
image_index=xp.attr_int(n["attrs"].get("image_index"), 0),
|
||
)
|
||
for n in xp.find_all(raw, "segment")
|
||
if n["text"]
|
||
]
|
||
return FusionResult(
|
||
title=xp.text_of(raw, "title"),
|
||
hook=xp.text_of(raw, "hook"),
|
||
body_points=body_points,
|
||
cta=xp.text_of(raw, "cta"),
|
||
script_segments=segments,
|
||
word_count=xp.attr_int(xp.text_of(raw, "word_count"), 0),
|
||
estimated_duration=xp.attr_int(xp.text_of(raw, "estimated_duration"), 0),
|
||
raw=raw,
|
||
)
|
||
|
||
def _fallback_fusion(
|
||
self,
|
||
fusion_level: str,
|
||
image_analysis: ImageAnalysis,
|
||
intent: IntentResult,
|
||
duration: int,
|
||
raw: str,
|
||
) -> FusionResult:
|
||
product_name = image_analysis.products[0].name if image_analysis.products else "这款产品"
|
||
selling = image_analysis.key_selling_points[:2]
|
||
if fusion_level == "ai_full":
|
||
title = f"{product_name},很多人用完都回购了"
|
||
hook = f"这个{product_name},我想认真说说"
|
||
body = selling or ["图片可见的产品卖点"]
|
||
cta = "感兴趣的可以了解一下"
|
||
elif fusion_level == "user_primary":
|
||
user_text = intent.intent_summary or product_name
|
||
title = user_text[:20]
|
||
hook = user_text[:15]
|
||
body = [m.text for m in intent.core_messages] or [user_text]
|
||
cta = "想了解的可以看看"
|
||
else:
|
||
title = intent.intent_summary[:20] or product_name
|
||
hook = intent.core_messages[0].text[:15] if intent.core_messages else product_name
|
||
body = [m.text for m in intent.core_messages] or selling or [product_name]
|
||
cta = "有需要的可以了解一下"
|
||
|
||
brand_texts = [b.text for b in intent.personal_brands]
|
||
points = [BodyPoint(text=b) for b in body]
|
||
lines = [hook] + body + brand_texts[:2] + [cta]
|
||
joined = ",".join(lines)
|
||
per = max(3, duration // max(1, len(lines)))
|
||
segments = [ScriptSegment(text=line, duration_sec=per, image_index=0) for line in lines]
|
||
return FusionResult(
|
||
title=title,
|
||
hook=hook,
|
||
body_points=points,
|
||
cta=cta,
|
||
script_segments=segments,
|
||
word_count=len(joined),
|
||
estimated_duration=duration,
|
||
raw=raw,
|
||
)
|
||
|
||
# ── 步骤4:编导级分镜 ───────────────────────────────────────────────
|
||
def storyboard(
|
||
self, fusion: FusionResult, image_analysis: ImageAnalysis, images: list[str], duration: int
|
||
) -> Storyboard:
|
||
template = get_template("storyboard")
|
||
raw = self._chat(
|
||
template,
|
||
None,
|
||
duration=duration,
|
||
image_count=len(images),
|
||
fusion_result=self._fusion_brief(fusion),
|
||
image_analysis=self._image_brief(image_analysis),
|
||
)
|
||
board = self._parse_storyboard(raw)
|
||
if not board.clips:
|
||
logger.warning("分镜标签解析失败,走规则 fallback")
|
||
return self._fallback_storyboard(fusion, duration, raw)
|
||
return board
|
||
|
||
def _parse_storyboard(self, raw: str) -> Storyboard:
|
||
clips: list[Clip] = []
|
||
for node in xp.find_all(raw, "clip"):
|
||
attrs = node["attrs"]
|
||
body = node["text"]
|
||
kb = xp.find_first(node["text"] and f"<root>{node['text']}</root>", "ken_burns")
|
||
clips.append(
|
||
Clip(
|
||
image_index=xp.attr_int(attrs.get("image_index"), 0),
|
||
transition=attrs.get("transition", "cut"),
|
||
zoom=(None if attrs.get("zoom") in (None, "null", "None", "") else attrs.get("zoom")),
|
||
duration_sec=xp.attr_float(attrs.get("duration_sec"), 0.0),
|
||
bgm_note=attrs.get("bgm_note", ""),
|
||
voice_text=xp.text_of(body and f"<root>{body}</root>", "voice_text"),
|
||
subtitle_text=xp.text_of(body and f"<root>{body}</root>", "subtitle_text"),
|
||
ken_burns=KenBurns(
|
||
start=kb["attrs"].get("start", "0,0") if kb else "0,0",
|
||
end=kb["attrs"].get("end", "0,0") if kb else "0,0",
|
||
ease=kb["attrs"].get("ease", "linear") if kb else "linear",
|
||
),
|
||
)
|
||
)
|
||
return Storyboard(clips=clips, raw=raw)
|
||
|
||
def _fallback_storyboard(self, fusion: FusionResult, duration: int, raw: str) -> Storyboard:
|
||
segments = fusion.script_segments or [ScriptSegment(text=fusion.hook or fusion.title, duration_sec=duration)]
|
||
total = sum(s.duration_sec for s in segments) or duration
|
||
clips = [
|
||
Clip(
|
||
image_index=min(s.image_index, 0),
|
||
transition="cut",
|
||
duration_sec=max(2.0, s.duration_sec * duration / total if total else duration / len(segments)),
|
||
voice_text=s.text,
|
||
subtitle_text=s.text[:20],
|
||
)
|
||
for s in segments
|
||
]
|
||
return Storyboard(clips=clips, raw=raw)
|
||
|
||
# ── 步骤5:审核(不通过自动重写1次)─────────────────────────────────
|
||
def review_and_rewrite(
|
||
self, fusion: FusionResult, intent: IntentResult, fusion_level: str
|
||
) -> tuple[FusionResult, ReviewResult, int]:
|
||
"""返回最终文案、最后一次审核结果、重写次数(0或1)。"""
|
||
review = self.reviewer.review(fusion, intent, fusion_level)
|
||
if review.passed:
|
||
return fusion, review, 0
|
||
|
||
logger.info("文案审核不通过,自动重写 1 次:%s", [i.text for i in review.issues])
|
||
rewritten = self.reviewer.rewrite(fusion, review, intent, fusion_level)
|
||
second = self.reviewer.review(rewritten, intent, fusion_level)
|
||
if second.passed:
|
||
return rewritten, second, 1
|
||
# 二次仍不通过:带上重写结果和问题返回,由上游决定是否交给前端
|
||
return rewritten, second, 1
|
||
|
||
# ── 全流程编排 ──────────────────────────────────────────────────────
|
||
def generate(
|
||
self,
|
||
images: list[str],
|
||
*,
|
||
industry: str = "",
|
||
target_customer: str = "",
|
||
marketing_purpose: str = "",
|
||
duration: int = 15,
|
||
user_copy_text: str = "",
|
||
fusion_level: str = "ai_polish",
|
||
) -> dict:
|
||
image_analysis = self.analyze_images(images, industry)
|
||
intent = self.parse_intent(user_copy_text, image_analysis, industry)
|
||
fusion = self.fuse(
|
||
fusion_level,
|
||
image_analysis,
|
||
intent,
|
||
industry=industry,
|
||
target_customer=target_customer,
|
||
marketing_purpose=marketing_purpose,
|
||
duration=duration,
|
||
)
|
||
fusion, review, rewrites = self.review_and_rewrite(fusion, intent, fusion_level)
|
||
board = self.storyboard(fusion, image_analysis, images, duration)
|
||
return {
|
||
"image_analysis": image_analysis,
|
||
"intent_result": intent,
|
||
"fusion_result": fusion,
|
||
"review_result": review,
|
||
"storyboard": board,
|
||
"rewrite_count": rewrites,
|
||
}
|
||
|
||
# ── 简报工具 ────────────────────────────────────────────────────────
|
||
@staticmethod
|
||
def _image_brief(a) -> str:
|
||
if a is None:
|
||
return "无图片分析信息"
|
||
lines = [f"产品:{p.name}({p.features})" for p in a.products]
|
||
lines += [f"卖点:{s}" for s in a.key_selling_points]
|
||
lines.append(f"场景:{a.scene}")
|
||
return "\n".join(lines) or "无图片分析信息"
|
||
|
||
@staticmethod
|
||
def _intent_brief(i: IntentResult) -> str:
|
||
lines = [f"意图:{i.intent_summary}"]
|
||
lines += [f"核心信息[must_keep={m.must_keep}]:{m.text}" for m in i.core_messages]
|
||
lines += [f"事实({b.category}):{b.text}" for b in i.personal_brands]
|
||
return "\n".join(lines)
|
||
|
||
@staticmethod
|
||
def _fusion_brief(f: FusionResult) -> str:
|
||
lines = [f"标题:{f.title}", f"钩子:{f.hook}"]
|
||
lines += [f"要点:{p.text}" for p in f.body_points]
|
||
lines += [f"配音:{s.text}" for s in f.script_segments]
|
||
lines.append(f"行动号召:{f.cta}")
|
||
return "\n".join(lines)
|