Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 712464f030 | |||
| c4ca302242 | |||
| 500203db5d | |||
| 2a9089edb0 | |||
| 6a3fbba813 |
@@ -0,0 +1,23 @@
|
||||
"""add tone column to viral_video_jobs
|
||||
|
||||
Revision ID: 110
|
||||
Revises: 109_add_viral_video_param_guides
|
||||
Create Date: 2026-10-14
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "110"
|
||||
down_revision = "109_add_viral_video_param_guides"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE viral_video_jobs ADD COLUMN IF NOT EXISTS tone VARCHAR(50) NOT NULL DEFAULT ''")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE viral_video_jobs DROP COLUMN IF EXISTS tone")
|
||||
@@ -127,6 +127,7 @@ def _to_response(job) -> ViralVideoJobResponse:
|
||||
persona_id=job.persona_id,
|
||||
viral_structure=job.viral_structure,
|
||||
marketing_purpose=job.marketing_purpose,
|
||||
tone=getattr(job, "tone", "") or "",
|
||||
bgm_preference=job.bgm_preference,
|
||||
duration=job.duration or 15,
|
||||
user_copy_text=job.user_copy_text,
|
||||
@@ -404,6 +405,7 @@ def generate_copy(
|
||||
job.persona_id = request.persona_id or job.persona_id
|
||||
job.viral_structure = request.viral_structure or job.viral_structure
|
||||
job.marketing_purpose = request.marketing_purpose or job.marketing_purpose
|
||||
job.tone = getattr(request, "tone", "") or job.tone
|
||||
job.bgm_preference = request.bgm_preference or job.bgm_preference
|
||||
if request.duration:
|
||||
job.duration = max(5, min(30, int(request.duration)))
|
||||
|
||||
@@ -36,6 +36,7 @@ class ShotScript(BaseModel):
|
||||
action_details: str = Field(default="", description="人物动作、表情、物品操作细节")
|
||||
audio_bgm: str = Field(default="", description="环境音+BGM提示")
|
||||
transition: str = Field(default="硬切", description="转场方式:硬切/淡入淡出/叠化")
|
||||
voiceover: str = Field(default="", description="该镜头口播台词(纯对白,不含画面描述)")
|
||||
reference_image_index: int | None = Field(
|
||||
default=None, description="参考图片索引(0-based,对应上传的第几张产品图)"
|
||||
)
|
||||
@@ -91,6 +92,7 @@ class GenerateCopyRequest(BaseModel):
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
tone: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = Field(default=15, ge=5, le=30)
|
||||
user_copy_text: str = ""
|
||||
@@ -156,6 +158,7 @@ class ViralVideoJobResponse(BaseModel):
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
tone: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 15
|
||||
user_copy_text: str = ""
|
||||
|
||||
@@ -358,7 +358,7 @@ def _build_sparse_trusted_urls(
|
||||
|
||||
|
||||
def _validate_copy_result(normalized: dict, dur: int) -> str | None:
|
||||
"""校验 _script_from_xml 解析后的 copy_result:字数 / 镜头数 / 时间轴。
|
||||
"""校验 _parse_script 解析后的 copy_result:字数 / 镜头数 / 时间轴。
|
||||
|
||||
返回 None 表示通过;否则返回中文 reason 字符串(用于日志)。
|
||||
"""
|
||||
@@ -444,6 +444,7 @@ def _call_llm_once(
|
||||
# 这里用外层 deadline + 迭代超时双重保护。
|
||||
_stream_deadline = time.time() + max(timeout or 120, 30)
|
||||
stream_ok = False
|
||||
_length_truncated = False # finish_reason=length 时标记为被截断(raw 不完整,parser 必败)
|
||||
try:
|
||||
stream_iter = client.chat_completion_stream(
|
||||
messages, temperature=temperature, max_tokens=max_tokens, timeout=timeout or 120
|
||||
@@ -457,9 +458,15 @@ def _call_llm_once(
|
||||
chunk = next(stream_iter)
|
||||
except StopIteration:
|
||||
stream_ok = True
|
||||
# 流结束时再检查一次 finish_reason(最后一个 chunk 可能带 finish_reason 但无 content)
|
||||
if getattr(client, "last_finish_reason", "") == "length":
|
||||
_length_truncated = True
|
||||
logger.warning(
|
||||
"[爆款视频] 流式结束时检测到 finish_reason=length(received=%d chars),丢弃走下一 attempt",
|
||||
sum(len(p) for p in full_parts),
|
||||
)
|
||||
break
|
||||
except Exception as inner_e:
|
||||
# 超时类异常直接 break(不降级同步,因为已耗尽超时配额)
|
||||
err_msg = str(inner_e).lower()
|
||||
if any(k in err_msg for k in ("timeout", "timed out", "deadline", "超时")):
|
||||
logger.warning("[爆款视频] 流式调用SDK超时: %s", inner_e)
|
||||
@@ -474,9 +481,54 @@ def _call_llm_once(
|
||||
)
|
||||
if should:
|
||||
_flush(force=False)
|
||||
# 早期格式预判:>800字仍无任何结构化标记(<script/<clips/<clip/镜头1/镜头一/第1镜),
|
||||
# 基本可以判定 LLM 跑题输出自然语言,parser 必败,早停省 15~30s
|
||||
_acc_len = sum(len(p) for p in full_parts)
|
||||
if _acc_len > 800:
|
||||
_full_so_far = "".join(full_parts)
|
||||
if not any(
|
||||
k in _full_so_far
|
||||
for k in (
|
||||
"<script",
|
||||
"<clips",
|
||||
"<clip",
|
||||
"镜头1",
|
||||
"镜头 1",
|
||||
"镜头一",
|
||||
"第1镜",
|
||||
"第 1 镜",
|
||||
"0-",
|
||||
"0~",
|
||||
"1-",
|
||||
"2-",
|
||||
"3-",
|
||||
"4-",
|
||||
"5-",
|
||||
)
|
||||
):
|
||||
logger.warning(
|
||||
"[爆款视频] 流式 early-break:前%d字无结构化标记 head=%r",
|
||||
_acc_len,
|
||||
_full_so_far[:80].replace("\n", "\\n"),
|
||||
)
|
||||
break
|
||||
# 检测 finish_reason=length(max_tokens 截断)——chunk 是 text,finish_reason 由
|
||||
# DoubaoClient 在 yield 后写入 self.last_finish_reason。截断时 XML/双段式缺闭合标签,
|
||||
# parser 必败,直接丢 raw 走下一 attempt(自带更大 max_tokens)。
|
||||
if getattr(client, "last_finish_reason", "") == "length":
|
||||
_length_truncated = True
|
||||
logger.warning(
|
||||
"[爆款视频] 流式输出被 max_tokens=%d 截断(received=%d chars),丢弃走下一 attempt",
|
||||
max_tokens,
|
||||
sum(len(p) for p in full_parts),
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频] 流式调用失败,降级同步: %s", e, exc_info=True)
|
||||
full_parts = []
|
||||
if _length_truncated:
|
||||
# max_tokens 截断:raw 不完整,不降级同步(同步也同样 max_tokens 照样截断)
|
||||
return None
|
||||
if not stream_ok:
|
||||
try:
|
||||
raw = client.chat_completion(messages, temperature=temperature, max_tokens=max_tokens, timeout=timeout)
|
||||
@@ -486,7 +538,7 @@ def _call_llm_once(
|
||||
if raw:
|
||||
full_parts = [raw]
|
||||
try:
|
||||
on_delta(raw, raw) # 一次性推送
|
||||
on_delta(raw, raw)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
@@ -1005,33 +1057,6 @@ def _build_images_summary(image_analysis: dict) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _safe_json_loads(raw: str | dict | list | None):
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (dict, list)):
|
||||
return raw
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
s = raw.strip()
|
||||
if s.startswith("```"):
|
||||
s = s.strip("`")
|
||||
if s.startswith("json"):
|
||||
s = s[4:].lstrip()
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
# 尝试截取第一个 { ... } 或 [ ... ]
|
||||
try:
|
||||
for open_c, close_c in (("{", "}"), ("[", "]")):
|
||||
i = s.find(open_c)
|
||||
j = s.rfind(close_c)
|
||||
if i >= 0 and j > i:
|
||||
return json.loads(s[i : j + 1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _fallback_script(job: ViralVideoJob) -> dict:
|
||||
"""脚本生成失败时的极简兜底脚本。基于image_analysis注入真实商品/门店信息,避免生成脱离用户输入的通用文案。"""
|
||||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
@@ -1083,6 +1108,7 @@ def _fallback_script(job: ViralVideoJob) -> dict:
|
||||
"audio_bgm": "轻快流行BGM",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 0 if job.images else None,
|
||||
"voiceover": voiceover,
|
||||
}
|
||||
]
|
||||
base["voiceover_script"] = voiceover
|
||||
@@ -1093,29 +1119,63 @@ def _fallback_script(job: ViralVideoJob) -> dict:
|
||||
return base
|
||||
|
||||
|
||||
def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
"""解析 v3 XML 分镜为 copy_result;只解析、补字段,不改 LLM 文案。
|
||||
def _parse_script(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
"""通用分镜解析器:支持三种 LLM 输出格式。
|
||||
|
||||
v3 结构:copy_display_markdown / clips>clip(index)[time_range,voiceover,
|
||||
visual,reference_image_index] / voiceover_script / theme。
|
||||
格式A(纯 XML):整段都是 <script>...</script> XML(v3/v4 格式)
|
||||
格式B(双段式):中文可读说明 + 末尾 <script>...</script> XML 块
|
||||
格式C(纯中文 markdown 编号式):无 <script> 标签,按"镜头1/镜头2..."分段
|
||||
格式D(纯中文 markdown 时间式):无 <script> 标签,按"0-4秒""4-8秒"时间范围开头分段(v6 格式)
|
||||
|
||||
解析策略:
|
||||
1. 正则提取最后一个 <script>...</script> 块
|
||||
2. 有 XML 块 → 走 XML 解析
|
||||
3. 无 XML 块但有 v3 结构标签 → 裸 XML 解析
|
||||
4. 都没有 → 走中文 markdown 兜底解析(C/D 两种分块方式自动识别)
|
||||
5. 都解析不出 → 返回 None
|
||||
"""
|
||||
from packages.application.viral_video import xml_parser as xp
|
||||
|
||||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
dur = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
|
||||
ratio = getattr(job, "video_ratio", None) or "9:16"
|
||||
image_analysis = normalize_image_analysis(getattr(job, "image_analysis", None))
|
||||
default_theme = _determine_theme(image_analysis, getattr(job, "marketing_purpose", "") or "")
|
||||
theme = xp.text_of(raw, "theme") or default_theme
|
||||
|
||||
# ── 策略1: 提取 <script>...</script> 块(取最后一个)──
|
||||
script_blocks = re.findall(r"<script[^>]*>(.*?)</script>", raw, re.DOTALL)
|
||||
xml_body = script_blocks[-1] if script_blocks else ""
|
||||
|
||||
if xml_body:
|
||||
return _parse_script_xml(xml_body, raw, job, dur, ratio, default_theme)
|
||||
|
||||
# ── 策略1.5: 无 <script> 包裹,但含 v3 结构标签(<clips>/<voiceover_script>)──
|
||||
if re.search(r"<(?:clips|voiceover_script|copy_display_markdown)\b", raw):
|
||||
return _parse_script_xml(raw, raw, job, dur, ratio, default_theme)
|
||||
|
||||
# ── 策略2: 纯中文 markdown 兜底 ──
|
||||
return _parse_script_markdown(raw, job, dur, ratio, default_theme)
|
||||
|
||||
|
||||
def _parse_script_xml(
|
||||
xml_body: str,
|
||||
raw_full: str,
|
||||
job: ViralVideoJob,
|
||||
dur: int,
|
||||
ratio: str,
|
||||
default_theme: str,
|
||||
) -> dict | None:
|
||||
"""XML 路径解析(格式A/B):从 <script> 块内提取 clip 结构。"""
|
||||
from packages.application.viral_video import xml_parser as xp
|
||||
|
||||
theme = xp.text_of(xml_body, "theme") or xp.text_of(raw_full, "theme") or default_theme
|
||||
base = _empty_copy_result(dur, ratio, theme=theme)
|
||||
base["overview"]["total_duration"] = dur
|
||||
base["title"] = theme
|
||||
|
||||
display_md = xp.text_of(raw, "copy_display_markdown")
|
||||
display_md = xp.text_of(xml_body, "copy_display_markdown")
|
||||
|
||||
clips = xp.find_all(raw, "clip")
|
||||
clips = xp.find_all(xml_body, "clip")
|
||||
shots: list[dict] = []
|
||||
voice_parts: list[str] = []
|
||||
for i, c in enumerate(clips):
|
||||
@@ -1138,6 +1198,7 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
"audio_bgm": xp.text_of(body, "audio_bgm") or "轻快BGM",
|
||||
"transition": xp.text_of(body, "transition") or ("硬切" if i < len(clips) - 1 else "结束"),
|
||||
"reference_image_index": ref_idx,
|
||||
"voiceover": voice or "",
|
||||
}
|
||||
shots.append(shot)
|
||||
|
||||
@@ -1145,7 +1206,7 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
return None
|
||||
base["shots"] = shots
|
||||
|
||||
voiceover = xp.text_of(raw, "voiceover_script") or "。".join(voice_parts)
|
||||
voiceover = xp.text_of(xml_body, "voiceover_script") or "。".join(voice_parts)
|
||||
if not voiceover.strip():
|
||||
return None
|
||||
base["voiceover_script"] = voiceover
|
||||
@@ -1155,14 +1216,190 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
return base
|
||||
|
||||
|
||||
# ── 中文 markdown 兜底解析正则(模块级常量,避免重复编译)────────────
|
||||
_MD_SHOT_SPLIT_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:#{1,4}\s+)?(?:镜头\s*([一二三四五六七八九十\d]+)|第\s*([\d]+)\s*镜)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# 时间范围开头作为分镜分割标记(格式D兜底):匹配行首的「0-4秒」「4~8秒」「12-20秒」等,
|
||||
# 可选前面带 markdown 标题符号或「镜头」字样
|
||||
_MD_TIME_SPLIT_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:#{1,4}\s+)?(?:镜头[\s::]*)?(\d+)\s*[-~到至]\s*(\d+)\s*秒",
|
||||
)
|
||||
_MD_TIME_RE = re.compile(r"(\d+)\s*[-~到至]\s*(\d+)\s*秒?")
|
||||
_MD_FIELD_RE = {
|
||||
"visual": re.compile(r"(?:画面|视觉|镜头画面|shot_type_angle_movement)\s*[::]\s*([^\n]+)"),
|
||||
"camera": re.compile(r"(?:景别|运镜|镜头运动)\s*[::]\s*([^\n]+)"),
|
||||
"voiceover": re.compile(r"""(?:口播|对白|台词|配音|旁白|voiceover)\s*[::]\s*""" r"""(?:"([^"]+)"|'([^']+)'|(\S[^
|
||||
]*?)(?=\n\s*(?:镜头|#|$)|\s*$))"""),
|
||||
"action": re.compile(r"(?:动作|动作细节|action)\s*[::]\s*([^\n]+)"),
|
||||
"audio_bgm": re.compile(r"(?:音效|BGM|背景音乐|audio)\s*[::]\s*([^\n]+)"),
|
||||
"transition": re.compile(r"(?:转场|过渡)\s*[::]\s*([^\n]+)"),
|
||||
"reference": re.compile(r"参考@图片(\d+)|参考图[::]?\s*(\d+)"),
|
||||
"theme": re.compile(r"(?:视频主题|主题|title)\s*[::]\s*([^\n]+)"),
|
||||
"full_voiceover": re.compile(r"(?:口播全文|完整口播|口播稿)\s*[::]\s*\n?([\s\S]+?)(?=\n\s*(?:镜头|#{1,4}\s|\Z))"),
|
||||
}
|
||||
|
||||
|
||||
def _parse_script_markdown(
|
||||
raw: str,
|
||||
job: ViralVideoJob,
|
||||
dur: int,
|
||||
ratio: str,
|
||||
default_theme: str,
|
||||
) -> dict | None:
|
||||
"""中文 markdown 兜底解析(格式C):按"镜头1/镜头一/第1镜"等分割。"""
|
||||
# 提取 theme
|
||||
theme = default_theme
|
||||
theme_match = _MD_FIELD_RE["theme"].search(raw)
|
||||
if theme_match:
|
||||
theme = theme_match.group(1).strip() or default_theme
|
||||
|
||||
# 按镜头标识分割文本(优先"镜头N/第N镜",兜底"0-4秒"时间范围开头)
|
||||
boundaries = []
|
||||
for m in _MD_SHOT_SPLIT_RE.finditer(raw):
|
||||
shot_num_str = m.group(1) or m.group(2)
|
||||
# 中文数字转阿拉伯数字
|
||||
cn_map = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
|
||||
if shot_num_str in cn_map:
|
||||
shot_num = cn_map[shot_num_str]
|
||||
else:
|
||||
try:
|
||||
shot_num = int(shot_num_str)
|
||||
except (ValueError, TypeError):
|
||||
shot_num = 0
|
||||
boundaries.append((m.start(), shot_num))
|
||||
|
||||
if not boundaries:
|
||||
# 兜底:按行首时间范围标记分割(v6 中文分镜格式如「0-4秒」「4-8秒」)
|
||||
# _MD_TIME_SPLIT_RE 已要求 (?:^|\n)\s*,天然是行首级匹配,不会误识别字段值内的时间范围
|
||||
boundaries = [(m.start(), i + 1) for i, m in enumerate(_MD_TIME_SPLIT_RE.finditer(raw))]
|
||||
|
||||
if not boundaries:
|
||||
return None
|
||||
|
||||
# 分割各镜头块
|
||||
shot_blocks: list[tuple[int, str]] = []
|
||||
for idx, (start, num) in enumerate(boundaries):
|
||||
end = boundaries[idx + 1][0] if idx + 1 < len(boundaries) else len(raw)
|
||||
block_text = raw[start:end]
|
||||
shot_blocks.append((num, block_text))
|
||||
|
||||
base = _empty_copy_result(dur, ratio, theme=theme)
|
||||
base["overview"]["total_duration"] = dur
|
||||
base["title"] = theme
|
||||
|
||||
shots: list[dict] = []
|
||||
voice_parts: list[str] = []
|
||||
|
||||
for i, (_, block) in enumerate(shot_blocks):
|
||||
# 时间范围
|
||||
time_match = _MD_TIME_RE.search(block)
|
||||
if time_match:
|
||||
t_start, t_end = time_match.group(1), time_match.group(2)
|
||||
time_range = f"{t_start}-{t_end}秒"
|
||||
else:
|
||||
n = len(shot_blocks)
|
||||
seg = dur / n if n else dur
|
||||
time_range = f"{int(i * seg)}-{int((i + 1) * seg)}秒"
|
||||
|
||||
# 景别/运镜
|
||||
visual = ""
|
||||
camera = ""
|
||||
vis_m = _MD_FIELD_RE["visual"].search(block)
|
||||
if vis_m:
|
||||
visual = vis_m.group(1).strip()
|
||||
cam_m = _MD_FIELD_RE["camera"].search(block)
|
||||
if cam_m:
|
||||
camera = cam_m.group(1).strip()
|
||||
shot_type = camera or visual or "中景平视,固定镜头"
|
||||
|
||||
# 口播/对白
|
||||
voice = ""
|
||||
vo_m = _MD_FIELD_RE["voiceover"].search(block)
|
||||
if vo_m:
|
||||
voice = (vo_m.group(1) or vo_m.group(2) or vo_m.group(3) or "").strip()
|
||||
# 去除末尾可能的标点
|
||||
voice = voice.rstrip(",。、;;")
|
||||
|
||||
# 动作
|
||||
action = ""
|
||||
act_m = _MD_FIELD_RE["action"].search(block)
|
||||
if act_m:
|
||||
action = act_m.group(1).strip()
|
||||
|
||||
# 音效/BGM
|
||||
audio = ""
|
||||
audio_m = _MD_FIELD_RE["audio_bgm"].search(block)
|
||||
if audio_m:
|
||||
audio = audio_m.group(1).strip()
|
||||
|
||||
# 转场
|
||||
trans = ""
|
||||
trans_m = _MD_FIELD_RE["transition"].search(block)
|
||||
if trans_m:
|
||||
trans = trans_m.group(1).strip()
|
||||
|
||||
# 参考图
|
||||
ref_idx = None
|
||||
ref_m = _MD_FIELD_RE["reference"].search(block)
|
||||
if ref_m:
|
||||
ref_str = ref_m.group(1) or ref_m.group(2)
|
||||
try:
|
||||
ref_idx = int(ref_str) - 1 # 用户视角从1开始,内部从0开始
|
||||
if ref_idx < 0:
|
||||
ref_idx = None
|
||||
except (ValueError, TypeError):
|
||||
ref_idx = None
|
||||
|
||||
if voice:
|
||||
voice_parts.append(voice)
|
||||
|
||||
# scene_and_dialogue 保持原有拼接格式
|
||||
visual_desc = visual or "中景平视,固定镜头"
|
||||
scene_and_dialogue = (voice or "") if not visual_desc else f"{visual_desc}|{voice or ''}"
|
||||
|
||||
shot = {
|
||||
"time_range": time_range,
|
||||
"shot_type_angle_movement": shot_type,
|
||||
"scene_and_dialogue": scene_and_dialogue,
|
||||
"action_details": action,
|
||||
"audio_bgm": audio or "轻快BGM",
|
||||
"transition": trans or ("硬切" if i < len(shot_blocks) - 1 else "结束"),
|
||||
"reference_image_index": ref_idx,
|
||||
"voiceover": voice,
|
||||
}
|
||||
shots.append(shot)
|
||||
|
||||
if not shots:
|
||||
return None
|
||||
base["shots"] = shots
|
||||
|
||||
# 顶层 voiceover_script:先尝试从"口播全文"段提取,否则拼接各镜头口播
|
||||
full_vo_match = _MD_FIELD_RE["full_voiceover"].search(raw)
|
||||
if full_vo_match:
|
||||
voiceover = full_vo_match.group(1).strip()
|
||||
else:
|
||||
voiceover = "".join(voice_parts)
|
||||
if not voiceover.strip():
|
||||
return None
|
||||
|
||||
base["voiceover_script"] = voiceover
|
||||
base["final_copy"] = voiceover
|
||||
base["suggested_copy"] = voiceover
|
||||
base["copy_display_markdown"] = voiceover
|
||||
return base
|
||||
|
||||
|
||||
def _step_script_generation(
|
||||
job: ViralVideoJob,
|
||||
image_analysis: dict,
|
||||
on_delta: Optional[Callable[[str, str], None]] = None,
|
||||
) -> dict:
|
||||
"""步骤: 意图理解 + 分镜生成一次完成(PR2 直线化版:单一重试 for 循环 + XML-only 解析)。
|
||||
"""步骤: 意图理解 + 分镜生成一次完成(单一重试 for 循环 + 双格式解析)。
|
||||
|
||||
重试链路:fast@0.8 → fast@0.6 → pro@0.7 → _finalize_fallback_script。
|
||||
- _parse_script 支持纯 XML / 双段式(中文+XML)/ 纯中文 markdown 三种格式;
|
||||
- 删除 JSON fallback 解析路径(PR1 已统一 XML);
|
||||
- 删除 compress-retry 冗余分支(前3次失败再压缩意义不大);
|
||||
- _try_gen / _try_gen_stream / _stream_chat_with_fallback 合并到 _call_llm_once + _validate_copy_result。
|
||||
@@ -1247,8 +1484,6 @@ def _step_script_generation(
|
||||
video_style_section=style_section,
|
||||
duration_constraint_text=_duration_constraint_text,
|
||||
)
|
||||
llm_messages[0]["content"]
|
||||
llm_messages[-1]["content"]
|
||||
|
||||
# primary 模型(DB 配置的首选,豆包 Seed 2.1 Pro,稳但稍慢)
|
||||
client_primary = ai_router.get_llm_client("storyboard", variant="primary")
|
||||
@@ -1289,10 +1524,26 @@ def _step_script_generation(
|
||||
on_delta=on_delta,
|
||||
)
|
||||
if not raw:
|
||||
logger.warning("[爆款视频] 分镜生成返回空 label=%s", label)
|
||||
continue
|
||||
copy_result = _script_from_xml(raw, job)
|
||||
# 记录 raw 头部/尾部/finish_reason,便于排查格式问题(截断 / 非XML / 跑题等)
|
||||
logger.info(
|
||||
"[爆款视频] LLM raw label=%s len=%d head=%r tail=%r finish=%s",
|
||||
label,
|
||||
len(raw),
|
||||
raw[:120].replace("\n", "\\n"),
|
||||
raw[-120:].replace("\n", "\\n"),
|
||||
getattr(client, "last_finish_reason", ""),
|
||||
)
|
||||
copy_result = _parse_script(raw, job)
|
||||
if copy_result is None:
|
||||
logger.warning("[viral][script] attempt=%s XML解析失败 raw_len=%d", label, len(raw))
|
||||
logger.warning(
|
||||
"[爆款视频] 分镜脚本解析失败 label=%s raw_len=%d head=%r tail=%r",
|
||||
label,
|
||||
len(raw),
|
||||
raw[:80].replace("\n", "\\n"),
|
||||
raw[-80:].replace("\n", "\\n"),
|
||||
)
|
||||
continue
|
||||
reason = _validate_copy_result(copy_result, dur)
|
||||
voiceover_len = len((copy_result.get("voiceover_script") or "").strip())
|
||||
|
||||
@@ -948,6 +948,7 @@ class ViralVideoJobModel(Base):
|
||||
persona_id = Column(String(36), nullable=False, default="")
|
||||
viral_structure = Column(String(50), nullable=False, default="")
|
||||
marketing_purpose = Column(String(100), nullable=False, default="")
|
||||
tone = Column(String(50), nullable=False, default="")
|
||||
bgm_preference = Column(String(50), nullable=False, default="")
|
||||
duration = Column(Integer, nullable=False, default=30)
|
||||
user_copy_text = Column(Text, nullable=False, default="")
|
||||
|
||||
@@ -38,6 +38,7 @@ def _to_domain(model: ViralVideoJobModel) -> ViralVideoJob:
|
||||
persona_id=model.persona_id or "",
|
||||
viral_structure=model.viral_structure or "",
|
||||
marketing_purpose=model.marketing_purpose or "",
|
||||
tone=getattr(model, "tone", "") or "",
|
||||
bgm_preference=model.bgm_preference or "",
|
||||
duration=model.duration or 15,
|
||||
user_copy_text=model.user_copy_text or "",
|
||||
@@ -91,6 +92,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
persona_id=job.persona_id,
|
||||
viral_structure=job.viral_structure,
|
||||
marketing_purpose=job.marketing_purpose,
|
||||
tone=getattr(job, "tone", "") or "",
|
||||
bgm_preference=job.bgm_preference,
|
||||
duration=job.duration,
|
||||
user_copy_text=job.user_copy_text,
|
||||
@@ -159,6 +161,7 @@ class SQLAlchemyViralVideoJobRepository:
|
||||
model.persona_id = job.persona_id
|
||||
model.viral_structure = job.viral_structure
|
||||
model.marketing_purpose = job.marketing_purpose
|
||||
model.tone = getattr(job, "tone", "") or ""
|
||||
model.bgm_preference = job.bgm_preference
|
||||
model.duration = job.duration
|
||||
model.fusion_level = job.fusion_level
|
||||
|
||||
@@ -202,9 +202,9 @@ class SharedSettings(BaseSettings):
|
||||
default=120,
|
||||
validation_alias=AliasChoices("DITTO_REQUEST_TIMEOUT", "ditto_request_timeout"),
|
||||
)
|
||||
# Ditto 句间过渡帧数(平滑表情/口型切换)
|
||||
# Ditto 句间过渡帧数(平滑表情/口型切换);默认 6 与 GPU 端 clamp 最优值对齐(表情切换更灵敏)
|
||||
ditto_blend_frames: int = Field(
|
||||
default=12,
|
||||
default=6,
|
||||
validation_alias=AliasChoices("DITTO_BLEND_FRAMES", "ditto_blend_frames"),
|
||||
)
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ class ViralVideoJob:
|
||||
persona_id: str = ""
|
||||
viral_structure: str = ""
|
||||
marketing_purpose: str = ""
|
||||
tone: str = ""
|
||||
bgm_preference: str = ""
|
||||
duration: int = 15 # v1.6: 默认15秒,上限30秒(Seedance 2.5 单次最大30s)
|
||||
user_copy_text: str = ""
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# 爆款视频模块全面审计报告
|
||||
|
||||
**审计日期**: 2026-10-14
|
||||
**审计分支**: fix/viral-script-dual-format-parser
|
||||
**审计人**: CodeAct Agent
|
||||
**基准 commit**: 5e5a9b6 (#2294)
|
||||
|
||||
---
|
||||
|
||||
## 一、Parser 改造说明
|
||||
|
||||
### 改造内容
|
||||
将 `_script_from_xml`(仅支持纯 XML)改造为 `_parse_script`,支持三种 LLM 输出格式:
|
||||
|
||||
| 格式 | 描述 | 解析路径 |
|
||||
|------|------|----------|
|
||||
| A: 纯 XML | `<script>...</script>` 包裹(v3/v4) | `_parse_script_xml` |
|
||||
| B: 双段式 | 中文 markdown 说明 + 末尾 `<script>` XML 块 | `_parse_script_xml`(提取最后一个 script 块) |
|
||||
| C: 纯中文 markdown | 无 `<script>` 标签,按"镜头1/镜头2..."分段 | `_parse_script_markdown` |
|
||||
| (兼容) 无包裹 v3 XML | 含 `<clips>/<voiceover_script>` 但无 `<script>` 包裹 | `_parse_script_xml`(整个 raw 当 XML body) |
|
||||
|
||||
### 解析策略
|
||||
1. 正则 `r'<script[^>]*>(.*?)</script>'` (DOTALL) 提取所有 `<script>` 块,取最后一个
|
||||
2. 有 XML 块 → 走 XML 解析(`_parse_script_xml`)
|
||||
3. 无 `<script>` 但含 v3 结构标签(`<clips>/<voiceover_script>/<copy_display_markdown>`)→ 整个 raw 走 XML 解析(兼容旧格式)
|
||||
4. 都不匹配 → 走中文 markdown 兜底解析(`_parse_script_markdown`)
|
||||
5. 都解析不出 → 返回 None
|
||||
|
||||
### 代码变更
|
||||
- `_script_from_xml` → `_parse_script`(入口函数)
|
||||
- 新增 `_parse_script_xml`(XML 路径解析,从原函数抽出)
|
||||
- 新增 `_parse_script_markdown`(中文 markdown 兜底解析)
|
||||
- 模块级常量:`_MD_SHOT_SPLIT_RE`, `_MD_TIME_RE`, `_MD_FIELD_RE` 等正则
|
||||
|
||||
---
|
||||
|
||||
## 二、Seedance voiceover bug 修复说明
|
||||
|
||||
### 问题
|
||||
`_assemble_seedance_prompt`(~line 1990/2010)循环 shots 时读 `s.get("voiceover", "")`,但原 `_script_from_xml` 输出的 shot dict 没有 `voiceover` 字段——口播被拼进了 `scene_and_dialogue = f"{visual}|{voice}"`,导致 Seedance/Wan prompt 中口播为空。
|
||||
|
||||
### 修复
|
||||
1. `_parse_script_xml`: 每个 shot dict 显式设置 `"voiceover": voice or ""`
|
||||
2. `_parse_script_markdown`: 同样输出 `"voiceover": voice` 字段
|
||||
3. `_fallback_script`: shot dict 加 `"voiceover": voiceover`(就是那一句 voiceover)
|
||||
4. `_assemble_seedance_prompt` 的 Wan 和 Seedance 两个分支都已正确读 `s.get("voiceover")`,无需改动
|
||||
|
||||
### 验证
|
||||
- 新增测试 `TestShotVoiceoverField`(3 个用例)确认 XML/Markdown/Fallback 三条路径的 shot 都有 `voiceover`
|
||||
- 新增测试 `test_assemble_seedance_uses_shot_voiceover` 确认 Seedance prompt 能取到口播
|
||||
|
||||
---
|
||||
|
||||
## 三、冗余代码清理情况
|
||||
|
||||
| 项目 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `_script_from_xml` → `_parse_script` 重命名 | ✅ 完成 | 调用点(line ~1484)已同步更新 |
|
||||
| `_safe_json_loads` | ⚠️ 保留 | 已不在主流程使用(JSON fallback 路径已删),但 2 个单测引用它,暂保留 |
|
||||
| `_validate_copy_result` docstring | ✅ 更新 | 引用从 `_script_from_xml` 改为 `_parse_script` |
|
||||
| `_step_script_generation` docstring | ✅ 更新 | 反映双格式解析支持 |
|
||||
| reviewer.py voiceover_script XML 标签 | ✅ 确认无需改 | `<voiceover_script>` 仅存在于 reviewer 自己构造的 messages 里,跟 storyboard 输出格式无关 |
|
||||
| JSON fallback 残留 | ✅ 无残留 | 主流程无 JSON 解析路径,`_safe_json_loads` 仅测试引用 |
|
||||
|
||||
---
|
||||
|
||||
## 四、全面审计结果
|
||||
|
||||
### 审计清单 9 项
|
||||
|
||||
| # | 审计项 | 验证结果 | 发现问题 | 修复情况 |
|
||||
|---|--------|----------|----------|----------|
|
||||
| 1 | **用户配置参数链路** | ✅ 基本完整 | `tone` 字段未在 API schema (GenerateCopyRequest) 中定义,但 worker 用 `getattr(job, "tone", "") or "亲切自然"` 优雅降级,不影响功能 | 记录为 minor issue,不影响本次 PR |
|
||||
| 2 | **图片分析→文案→review→渲染全流程** | ✅ 链路完整 | `_step_image_analysis` → `_step_script_generation` → `_step_review` → `_step_tts` → `_step_render` → `_step_upload`,每步有错误处理和降级 | 无需修复 |
|
||||
| 3 | **三视图机制** | ✅ 链路完整 | `pre_trusted_images` → `_build_character_section` → Seedream 三视图 → reference_images 通道。代码注释明确标注"当前为空实现,仅保留扩展点" | 无需修复 |
|
||||
| 4 | **few-shot example_output** | ✅ 正确使用 | `prompt_loader.build_messages` 在 storyboard 和 reviewer 两个场景都正确注入 few-shot(system→user示例→assistant示例→user任务) | 无需修复 |
|
||||
| 5 | **字数/时长约束** | ✅ 链路正确 | `constants.DURATION_SPEC` → `build_duration_constraint_text` → `{duration_constraint_text}` 占位符注入 → `_validate_copy_result` 校验 → `_truncate_voiceover` 截断 | 无需修复 |
|
||||
| 6 | **重试/超时机制** | ✅ 链路正确 | deadline 300s + fast@0.8 → fast@0.6 → pro@0.7 三档重试 + 流式外层 deadline + next() 超时 + 口播超长自动截断(`_truncate_voiceover`)| 无需修复 |
|
||||
| 7 | **fallback 脚本** | ✅ brand==name 去重生效 | `_fallback_script` 中 `_brand_str == _name_str or _brand_str in _name_str or _name_str in _brand_str` 三重判断确保去重(#2294 修复) | 本次新增 `voiceover` 字段到 shot dict |
|
||||
| 8 | **TTS/口型驱动链路** | ✅ 链路完整 | `voiceover_script` → `_step_tts` (CosyVoice) → 音频 URL → `_step_render` (Seedance) → 视频 → Ditto/MuseTalk 口型。voiceover 字段修复后链路更完整 | voiceover bug 修复(本次 PR) |
|
||||
| 9 | **review 审核阶段** | ✅ 逻辑正确 | `reviewer.py` 的 `_llm_review_inner` 用 `build_messages(template, voiceover_script=...)` 送审,XML 解析 `<passed>/<issues>`,审核失败合并本地规则后返回;LLM 不可用时降级放行(带 warning issue)| 无需修复 |
|
||||
|
||||
### 发现的 Minor Issues(不阻塞本次 PR)
|
||||
|
||||
1. **`tone` 参数未暴露到 API**: `GenerateCopyRequest` schema 没有 `tone` 字段,但 worker 用 `getattr` 优雅降级到默认值 "亲切自然"。前端如需自定义语气,需后续版本补 schema 字段。
|
||||
2. **`_safe_json_loads` 死代码**: 主流程已不使用(JSON fallback 在 PR1 已删),但 2 个单测 (`test_safe_json_loads_*`) 引用它。建议下个 PR 清理。
|
||||
3. **API ShotScript schema 缺 `voiceover` 字段**: `apps/api/app/schemas/viral_video.py` 的 `ShotScript` 模型没有 `voiceover` 字段定义。前端目前从 `scene_and_dialogue` 拆口播,后续可考虑暴露 `voiceover` 字段给前端。
|
||||
|
||||
---
|
||||
|
||||
## 五、测试覆盖情况
|
||||
|
||||
### 新增测试文件: `tests/unit/test_viral_video_dual_parser.py`
|
||||
|
||||
| 测试类 | 用例数 | 覆盖范围 |
|
||||
|--------|--------|----------|
|
||||
| `TestDualFormatParsing` | 2 | 双段式(中文+XML)解析 + voiceover 字段 |
|
||||
| `TestPureXmlParsing` | 2 | 纯 XML 兼容性 + voiceover 字段 |
|
||||
| `TestPureMarkdownParsing` | 9 | 纯中文 markdown 解析(shots/theme/voiceover/time_range/audio/transition/reference/拼接/中文数字) |
|
||||
| `TestShotVoiceoverField` | 3 | XML/Fallback/Seedance 三条路径的 voiceover 字段 |
|
||||
| `TestParameterPassing` | 3 | marketing_purpose/target_audience/fusion_level 参数传递 |
|
||||
| `TestMarkdownParseFailures` | 4 | 解析失败返回 None(纯文本/空字符串/None/无口播) |
|
||||
|
||||
**合计**: 23 个新增用例,全部通过。
|
||||
|
||||
### 回归测试
|
||||
- 全量 viral 相关测试: **269 passed, 2 skipped**
|
||||
- 覆盖了 test_viral_video.py / test_viral_video_p0.py / test_viral_video_p2_errors.py / test_viral_video_prompt_system.py / test_viral_video_routes.py / test_viral_video_v8_bugs.py / test_viral_video_wiring.py / test_viral_video_ws.py / test_viral_video_models.py
|
||||
|
||||
---
|
||||
|
||||
## 六、PR 信息
|
||||
|
||||
- **分支**: `fix/viral-script-dual-format-parser`
|
||||
- **Base**: `develop`
|
||||
- **PR**: 待创建
|
||||
@@ -0,0 +1,562 @@
|
||||
"""双格式 parser + voiceover 字段 + markdown 兜底解析测试。
|
||||
|
||||
覆盖:
|
||||
1. 双段式(中文 markdown + <script> XML 块)解析
|
||||
2. 纯 XML 解析(已有测试覆盖,确认兼容)
|
||||
3. 纯中文 markdown 解析(镜头1/镜头2/关键字段)
|
||||
4. Shot voiceover 字段存在性 + _assemble_seedance_prompt 取值
|
||||
5. 参数传递集成测试(marketing_purpose/target_audience/fusion_level)
|
||||
6. markdown 解析失败返回 None
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# ── 通用 fixtures ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_job():
|
||||
"""构造一个最小可用的 ViralVideoJob mock。"""
|
||||
job = MagicMock()
|
||||
job.duration = 15
|
||||
job.video_ratio = "9:16"
|
||||
job.marketing_purpose = "品牌推广"
|
||||
job.image_analysis = {"images": []}
|
||||
job.images = []
|
||||
job.user_copy_text = ""
|
||||
job.fusion_level = "ai_polish"
|
||||
return job
|
||||
|
||||
|
||||
def _import_wv(monkeypatch):
|
||||
"""导入 worker viral_video 模块(mock celery/db 依赖)。"""
|
||||
for mod in ["worker_app.celery_app", "worker_app.db"]:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
import celery
|
||||
|
||||
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
|
||||
from worker_app.tasks import viral_video as wv
|
||||
|
||||
return wv
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# 1. 双段式解析:中文 markdown + <script> XML 块
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestDualFormatParsing:
|
||||
"""格式B:中文可读说明 + 末尾 <script>...</script> XML 块。"""
|
||||
|
||||
DUAL_FORMAT_INPUT = """## 视频总览
|
||||
|
||||
这是一个关于口红试色的短视频,目标受众是年轻女性。
|
||||
|
||||
### 逐秒镜头拆解
|
||||
|
||||
- 整体风格:暖色调、近景特写
|
||||
- 节奏:前3秒给出钩子,中段展示产品,结尾引导行动
|
||||
|
||||
### 硬性约束
|
||||
- 无字幕无水印
|
||||
- 人物一致性
|
||||
|
||||
---
|
||||
|
||||
以下是符合规范的分镜脚本:
|
||||
|
||||
<script>
|
||||
<copy_display_markdown><![CDATA[今天试一支超显白的口红,姐妹们看过来]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-5秒</time_range>
|
||||
<voiceover>姐妹们,今天试一支超显白的色号</voiceover>
|
||||
<visual>近景平视,暖光下手持口红缓慢展示</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>5-10秒</time_range>
|
||||
<voiceover>你看这个颜色,涂上去整个手都亮了</voiceover>
|
||||
<visual>特写,嘴唇涂抹口红过程</visual>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
</clip>
|
||||
<clip index="3">
|
||||
<time_range>10-15秒</time_range>
|
||||
<voiceover>真的超好看,推荐给你们</voiceover>
|
||||
<visual>中景,微笑展示最终效果</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>姐妹们,今天试一支超显白的色号。你看这个颜色,涂上去整个手都亮了。真的超好看,推荐给你们。</voiceover_script>
|
||||
<theme>口红试色分享</theme>
|
||||
</script>"""
|
||||
|
||||
def test_dual_format_extracts_xml_block(self, monkeypatch, mock_job):
|
||||
"""双段式输入应正确提取 <script> 块并按 XML 路径解析。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.DUAL_FORMAT_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert result["voiceover_script"]
|
||||
assert "姐妹们" in result["voiceover_script"]
|
||||
assert len(result["shots"]) == 3
|
||||
assert result["title"] == "口红试色分享"
|
||||
|
||||
def test_dual_format_shots_have_voiceover(self, monkeypatch, mock_job):
|
||||
"""双段式解析后每个 shot 都应有独立 voiceover 字段。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.DUAL_FORMAT_INPUT, mock_job)
|
||||
assert result is not None
|
||||
for shot in result["shots"]:
|
||||
assert "voiceover" in shot
|
||||
assert isinstance(shot["voiceover"], str)
|
||||
assert "超显白" in result["shots"][0]["voiceover"]
|
||||
assert "推荐" in result["shots"][2]["voiceover"]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# 2. 纯 XML 解析(兼容性测试)
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPureXmlParsing:
|
||||
"""格式A:纯 XML,与原有 v3 格式完全一致。"""
|
||||
|
||||
PURE_XML = """<script>
|
||||
<copy_display_markdown><![CDATA[分享一款口红]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-5秒</time_range>
|
||||
<voiceover>大家好,今天分享一款口红</voiceover>
|
||||
<visual>近景平视,缓慢推镜</visual>
|
||||
<action_details>手持口红特写</action_details>
|
||||
<audio_bgm>轻快流行BGM</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>5-10秒</time_range>
|
||||
<voiceover>颜色特别好看</voiceover>
|
||||
<visual>特写,固定镜头</visual>
|
||||
<audio_bgm>轻快BGM继续</audio_bgm>
|
||||
<transition>硬切</transition>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
</clip>
|
||||
<clip index="3">
|
||||
<time_range>10-15秒</time_range>
|
||||
<voiceover>很显白,推荐给大家</voiceover>
|
||||
<visual>中景,微笑展示</visual>
|
||||
<audio_bgm>轻快BGM结束</audio_bgm>
|
||||
<transition>结束</transition>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>大家好呀,今天来给大家分享一款超显白的口红。颜色特别好看很显气质,真心推荐给姐妹们</voiceover_script>
|
||||
<theme>口红分享</theme>
|
||||
</script>"""
|
||||
|
||||
def test_pure_xml_parses_correctly(self, monkeypatch, mock_job):
|
||||
"""纯 XML 格式应能正确解析出所有字段。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.PURE_XML, mock_job)
|
||||
assert result is not None
|
||||
assert result["title"] == "口红分享"
|
||||
assert len(result["shots"]) == 3
|
||||
assert "口红" in result["voiceover_script"]
|
||||
|
||||
def test_pure_xml_shots_have_voiceover(self, monkeypatch, mock_job):
|
||||
"""纯 XML 路径也应为每个 shot 生成 voiceover 字段。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.PURE_XML, mock_job)
|
||||
assert result is not None
|
||||
assert result["shots"][0]["voiceover"] == "大家好,今天分享一款口红"
|
||||
assert result["shots"][1]["voiceover"] == "颜色特别好看"
|
||||
assert result["shots"][2]["voiceover"] == "很显白,推荐给大家"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# 3. 纯中文 markdown 解析(格式C 兜底)
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPureMarkdownParsing:
|
||||
"""格式C:纯中文结构化 markdown,无 <script> 标签。"""
|
||||
|
||||
MARKDOWN_INPUT = """## 视频主题:咖啡店探店
|
||||
|
||||
这是一段关于咖啡店探店的短视频分镜。
|
||||
|
||||
### 镜头1
|
||||
- 时间范围:0-5秒
|
||||
- 景别:近景平视
|
||||
- 运镜:缓慢推镜
|
||||
- 口播:"大家好,今天带你们来一家超有氛围的咖啡店"
|
||||
- 画面:暖光木质装修的咖啡店门面,镜头缓缓推近
|
||||
- 动作:推门进入
|
||||
- 音效:轻爵士BGM
|
||||
- 转场:硬切
|
||||
- 参考图:1
|
||||
|
||||
### 镜头2
|
||||
- 时间范围:5-10秒
|
||||
- 景别:特写
|
||||
- 运镜:固定镜头
|
||||
- 口播:"你看这杯拿铁,拉花也太好看了吧"
|
||||
- 画面:咖啡师制作拿铁特写,奶泡拉花清晰可见
|
||||
- 动作:咖啡师拉花过程
|
||||
- 音效:咖啡机声+轻爵士
|
||||
- 转场:叠化
|
||||
- 参考图:2
|
||||
|
||||
### 镜头3
|
||||
- 时间范围:10-15秒
|
||||
- 景别:中景
|
||||
- 运镜:缓慢拉远
|
||||
- 口播:"推荐给你们,周末来坐坐吧"
|
||||
- 画面:店内全景,客人悠闲品咖啡,暖光氛围
|
||||
- 动作:端起杯子品尝
|
||||
- 音效:轻爵士BGM渐弱
|
||||
- 转场:结束
|
||||
- 参考@图片1"""
|
||||
|
||||
def test_markdown_parses_shots(self, monkeypatch, mock_job):
|
||||
"""纯中文 markdown 应能解析出3个镜头。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert len(result["shots"]) == 3
|
||||
|
||||
def test_markdown_extracts_theme(self, monkeypatch, mock_job):
|
||||
"""应从"视频主题"提取 theme。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert "咖啡店" in result["title"]
|
||||
|
||||
def test_markdown_extracts_voiceover(self, monkeypatch, mock_job):
|
||||
"""每个镜头应提取口播到 voiceover 字段。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert "咖啡店" in result["shots"][0]["voiceover"]
|
||||
assert "拿铁" in result["shots"][1]["voiceover"]
|
||||
assert "推荐" in result["shots"][2]["voiceover"]
|
||||
|
||||
def test_markdown_extracts_time_range(self, monkeypatch, mock_job):
|
||||
"""应正确提取各镜头的 time_range。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert result["shots"][0]["time_range"] == "0-5秒"
|
||||
assert result["shots"][1]["time_range"] == "5-10秒"
|
||||
assert result["shots"][2]["time_range"] == "10-15秒"
|
||||
|
||||
def test_markdown_extracts_audio_bgm(self, monkeypatch, mock_job):
|
||||
"""应正确提取音效/BGM。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert "爵士" in result["shots"][0]["audio_bgm"] or "BGM" in result["shots"][0]["audio_bgm"]
|
||||
|
||||
def test_markdown_extracts_transition(self, monkeypatch, mock_job):
|
||||
"""应正确提取转场。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert result["shots"][0]["transition"] == "硬切"
|
||||
assert result["shots"][1]["transition"] == "叠化"
|
||||
assert result["shots"][2]["transition"] == "结束"
|
||||
|
||||
def test_markdown_extracts_reference_image(self, monkeypatch, mock_job):
|
||||
"""应正确提取参考图索引(从1开始转为0-based)。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert result["shots"][0]["reference_image_index"] == 0 # 参考图:1 -> 0
|
||||
assert result["shots"][1]["reference_image_index"] == 1 # 参考图:2 -> 1
|
||||
# 镜头3用"参考@图片1"格式 -> 0
|
||||
assert result["shots"][2]["reference_image_index"] == 0
|
||||
|
||||
def test_markdown_voiceover_script_concatenated(self, monkeypatch, mock_job):
|
||||
"""voiceover_script 应是各镜头口播的拼接。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(self.MARKDOWN_INPUT, mock_job)
|
||||
assert result is not None
|
||||
assert "咖啡店" in result["voiceover_script"]
|
||||
assert "拿铁" in result["voiceover_script"]
|
||||
assert "推荐" in result["voiceover_script"]
|
||||
|
||||
def test_markdown_chinese_number_shots(self, monkeypatch, mock_job):
|
||||
"""支持中文数字镜头标识(镜头一/镜头二/...)。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
md_cn = """## 视频主题:测试
|
||||
|
||||
### 镜头一
|
||||
- 时间范围:0-5秒
|
||||
- 口播:"第一段口播内容很长足够通过校验"
|
||||
- 画面:近景展示
|
||||
|
||||
### 镜头二
|
||||
- 时间范围:5-10秒
|
||||
- 口播:"第二段口播内容也要足够长才行"
|
||||
- 画面:特写展示
|
||||
|
||||
### 镜头三
|
||||
- 时间范围:10-15秒
|
||||
- 口播:"第三段口播内容结尾"
|
||||
- 画面:中景展示
|
||||
"""
|
||||
result = wv._parse_script(md_cn, mock_job)
|
||||
assert result is not None
|
||||
assert len(result["shots"]) == 3
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# 4. Shot voiceover 字段 + _assemble_seedance_prompt
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestShotVoiceoverField:
|
||||
"""确认 parser 输出的每个 shot dict 都有独立 voiceover 字段。"""
|
||||
|
||||
def test_xml_path_shot_voiceover(self, monkeypatch, mock_job):
|
||||
"""XML 路径解析后 shot 有 voiceover 字段。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
xml = """<script>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-5秒</time_range>
|
||||
<voiceover>第一句口播</voiceover>
|
||||
<visual>近景展示</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>5-10秒</time_range>
|
||||
<voiceover>第二句口播</voiceover>
|
||||
<visual>特写展示</visual>
|
||||
<reference_image_index>-1</reference_image_index>
|
||||
</clip>
|
||||
<clip index="3">
|
||||
<time_range>10-15秒</time_range>
|
||||
<voiceover>第三句口播</voiceover>
|
||||
<visual>中景展示</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>第一句口播。第二句口播。第三句口播。</voiceover_script>
|
||||
<theme>测试主题</theme>
|
||||
</script>"""
|
||||
result = wv._parse_script(xml, mock_job)
|
||||
assert result is not None
|
||||
for shot in result["shots"]:
|
||||
assert "voiceover" in shot
|
||||
assert result["shots"][0]["voiceover"] == "第一句口播"
|
||||
assert result["shots"][1]["voiceover"] == "第二句口播"
|
||||
assert result["shots"][2]["voiceover"] == "第三句口播"
|
||||
|
||||
def test_fallback_script_shot_voiceover(self, monkeypatch, mock_job):
|
||||
"""_fallback_script 的 shot dict 也应有 voiceover 字段。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
fb = wv._fallback_script(mock_job)
|
||||
assert fb["shots"]
|
||||
for shot in fb["shots"]:
|
||||
assert "voiceover" in shot
|
||||
assert shot["voiceover"] == fb["voiceover_script"]
|
||||
|
||||
def test_assemble_seedance_uses_shot_voiceover(self, monkeypatch, mock_job):
|
||||
"""_assemble_seedance_prompt 应能取到 shot 的 voiceover 字段。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
mock_job.video_model = "seedance-2.5"
|
||||
cr = {
|
||||
"overview": {"theme": "测试", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "明亮",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-5秒",
|
||||
"shot_type_angle_movement": "近景",
|
||||
"scene_and_dialogue": "画面描述|测试口播",
|
||||
"action_details": "展示",
|
||||
"audio_bgm": "BGM",
|
||||
"transition": "硬切",
|
||||
"reference_image_index": 0,
|
||||
"voiceover": "这是第一句口播",
|
||||
},
|
||||
{
|
||||
"time_range": "5-10秒",
|
||||
"shot_type_angle_movement": "特写",
|
||||
"scene_and_dialogue": "画面描述",
|
||||
"action_details": "",
|
||||
"audio_bgm": "BGM",
|
||||
"transition": "硬切",
|
||||
"reference_image_index": 0,
|
||||
"voiceover": "这是第二句口播",
|
||||
},
|
||||
{
|
||||
"time_range": "10-15秒",
|
||||
"shot_type_angle_movement": "中景",
|
||||
"scene_and_dialogue": "画面描述",
|
||||
"action_details": "",
|
||||
"audio_bgm": "BGM",
|
||||
"transition": "结束",
|
||||
"reference_image_index": 0,
|
||||
"voiceover": "这是第三句口播",
|
||||
},
|
||||
],
|
||||
"hard_constraints": ["无字幕"],
|
||||
"negative_prompts": ["水印"],
|
||||
}
|
||||
prompt = wv._assemble_seedance_prompt(cr, mock_job)
|
||||
# Seedance 格式应包含口播内容
|
||||
assert "第一句口播" in prompt
|
||||
assert "第二句口播" in prompt
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# 5. 参数传递集成测试(3个核心参数)
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestParameterPassing:
|
||||
"""验证核心用户参数从 job -> build_messages 的传递链路。"""
|
||||
|
||||
def test_marketing_purpose_in_messages(self, monkeypatch):
|
||||
"""marketing_purpose='门店引流' 应出现在 build_messages 的 user 消息中。"""
|
||||
from packages.application.viral_video.prompt_loader import build_messages, get_template, invalidate
|
||||
|
||||
invalidate()
|
||||
template = get_template("storyboard")
|
||||
assert template is not None
|
||||
messages = build_messages(
|
||||
template,
|
||||
marketing_purpose="门店引流",
|
||||
marketing_purpose_hint="【营销目的:门店引流】以到店为核心",
|
||||
industry="美容",
|
||||
image_summary="",
|
||||
theme_hint="门店引流",
|
||||
duration="15",
|
||||
image_count="2",
|
||||
aspect_ratio="9:16",
|
||||
tone="亲切自然",
|
||||
target_audience="25-35岁女性",
|
||||
target_customer_hint="【目标人群】25-35岁女性",
|
||||
persona_hint="【人设】亲切自然",
|
||||
viral_structure_hint="【爆款结构】自由组织",
|
||||
language_hint="【语言】中文",
|
||||
extra_requirements="(AI 创作)",
|
||||
video_style_section="",
|
||||
duration_constraint_text="视频时长15秒。口播总字数严格控制在38~75字之间",
|
||||
)
|
||||
user_msg = messages[-1]["content"]
|
||||
assert "门店引流" in user_msg
|
||||
|
||||
def test_target_audience_in_messages(self, monkeypatch):
|
||||
"""target_audience='年轻妈妈' 应出现在 build_messages 的 user 消息中。"""
|
||||
from packages.application.viral_video.prompt_loader import build_messages, get_template, invalidate
|
||||
|
||||
invalidate()
|
||||
template = get_template("storyboard")
|
||||
assert template is not None
|
||||
messages = build_messages(
|
||||
template,
|
||||
marketing_purpose="品牌推广",
|
||||
marketing_purpose_hint="【营销目的】品牌推广",
|
||||
industry="母婴",
|
||||
image_summary="",
|
||||
theme_hint="母婴品牌",
|
||||
duration="15",
|
||||
image_count="1",
|
||||
aspect_ratio="9:16",
|
||||
tone="温柔亲切",
|
||||
target_audience="年轻妈妈",
|
||||
target_customer_hint="【目标人群】年轻妈妈群体",
|
||||
persona_hint="【人设】温柔",
|
||||
viral_structure_hint="【爆款结构】自由",
|
||||
language_hint="【语言】中文",
|
||||
extra_requirements="(AI 创作)",
|
||||
video_style_section="",
|
||||
duration_constraint_text="视频时长15秒。",
|
||||
)
|
||||
user_msg = messages[-1]["content"]
|
||||
assert "年轻妈妈" in user_msg
|
||||
|
||||
def test_fusion_level_user_primary_in_messages(self, monkeypatch):
|
||||
"""fusion_level=user_primary + user_copy_text 应生成'必须原封不动使用'字样。"""
|
||||
from packages.application.viral_video.prompt_loader import build_messages, get_template, invalidate
|
||||
|
||||
invalidate()
|
||||
template = get_template("storyboard")
|
||||
assert template is not None
|
||||
# 模拟 worker 里 fusion_level=user_primary 时构造的 extra_req
|
||||
extra_req = (
|
||||
"【用户指定文案,必须原封不动使用,一个字都不要改,"
|
||||
"你只需要根据文案生成分镜脚本的镜头描述和画面,"
|
||||
"copy_display_markdown里的文案必须与用户原文完全一致】:\n"
|
||||
"我们的新店开业啦,全场八折优惠"
|
||||
)
|
||||
messages = build_messages(
|
||||
template,
|
||||
marketing_purpose="促销活动",
|
||||
marketing_purpose_hint="【营销目的】促销",
|
||||
industry="零售",
|
||||
image_summary="",
|
||||
theme_hint="新店开业",
|
||||
duration="15",
|
||||
image_count="0",
|
||||
aspect_ratio="9:16",
|
||||
tone="热情",
|
||||
target_audience="周边居民",
|
||||
target_customer_hint="【目标人群】周边居民",
|
||||
persona_hint="【人设】热情",
|
||||
viral_structure_hint="【爆款结构】自由",
|
||||
language_hint="【语言】中文",
|
||||
extra_requirements=extra_req,
|
||||
video_style_section="",
|
||||
duration_constraint_text="视频时长15秒。",
|
||||
)
|
||||
user_msg = messages[-1]["content"]
|
||||
assert "必须原封不动使用" in user_msg
|
||||
assert "新店开业" in user_msg
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
# 6. markdown 解析失败返回 None
|
||||
# ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestMarkdownParseFailures:
|
||||
"""无法解析的输入应返回 None。"""
|
||||
|
||||
def test_plain_text_returns_none(self, monkeypatch, mock_job):
|
||||
"""纯文本不含镜头标识,应返回 None。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script("这是一段普通文本,没有任何镜头标识。", mock_job)
|
||||
assert result is None
|
||||
|
||||
def test_empty_string_returns_none(self, monkeypatch, mock_job):
|
||||
"""空字符串应返回 None。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script("", mock_job)
|
||||
assert result is None
|
||||
|
||||
def test_none_returns_none(self, monkeypatch, mock_job):
|
||||
"""None 输入应返回 None。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
result = wv._parse_script(None, mock_job)
|
||||
assert result is None
|
||||
|
||||
def test_markdown_without_voiceover_returns_none(self, monkeypatch, mock_job):
|
||||
"""有镜头标识但没有口播内容,voiceover_script 为空,应返回 None。"""
|
||||
wv = _import_wv(monkeypatch)
|
||||
md = """### 镜头1
|
||||
- 画面:近景展示产品
|
||||
### 镜头2
|
||||
- 画面:特写展示产品
|
||||
### 镜头3
|
||||
- 画面:中景展示产品
|
||||
"""
|
||||
result = wv._parse_script(md, mock_job)
|
||||
assert result is None
|
||||
@@ -84,20 +84,6 @@ class TestScriptGenerationV16:
|
||||
assert out["final_copy"] == out["voiceover_script"]
|
||||
assert len(out["shots"]) >= 1
|
||||
|
||||
def test_safe_json_loads_parses_fenced_code(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
|
||||
|
||||
fenced = '```json\n{"voiceover_script": "你好", "shots": []}\n```'
|
||||
out = _safe_json_loads(fenced)
|
||||
assert out is not None
|
||||
assert out["voiceover_script"] == "你好"
|
||||
|
||||
def test_safe_json_loads_handles_none(self):
|
||||
from apps.worker.worker_app.tasks.viral_video import _safe_json_loads
|
||||
|
||||
assert _safe_json_loads(None) is None
|
||||
assert _safe_json_loads("not json") is None
|
||||
|
||||
def test_assemble_seedance_prompt_contains_sections(self, mock_job):
|
||||
from apps.worker.worker_app.tasks.viral_video import _assemble_seedance_prompt
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"persona_id": "",
|
||||
"viral_structure": "",
|
||||
"marketing_purpose": "",
|
||||
"tone": "",
|
||||
"bgm_preference": "",
|
||||
"duration": 15,
|
||||
"user_copy_text": "",
|
||||
|
||||
@@ -34,6 +34,7 @@ def _make_job(job_id: str = "job-1", user_id: str = "u1", status: str = "pending
|
||||
"persona_id": "",
|
||||
"viral_structure": "",
|
||||
"marketing_purpose": "",
|
||||
"tone": "",
|
||||
"bgm_preference": "",
|
||||
"duration": 15,
|
||||
"user_copy_text": "",
|
||||
@@ -1253,3 +1254,198 @@ class TestV4ValidationDeadlineFixes:
|
||||
assert len(out) <= 25
|
||||
assert out.endswith("。")
|
||||
assert "推荐一款好产品" in out
|
||||
|
||||
|
||||
class TestStreamFinishReasonLength:
|
||||
"""流式finish_reason=length截断检测,确保截断raw被丢弃走下一attempt(P0 2026-10-14)"""
|
||||
|
||||
def _make_client(self, chunks, finish_after=None, finish_reason="stop"):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
client = MagicMock()
|
||||
client.is_available = True
|
||||
client.model = "test-model"
|
||||
client.last_finish_reason = ""
|
||||
|
||||
def _stream_iter(*a, **kw):
|
||||
for i, ch in enumerate(chunks):
|
||||
yield ch
|
||||
if finish_after is not None and i + 1 >= finish_after:
|
||||
client.last_finish_reason = finish_reason
|
||||
return
|
||||
|
||||
client.chat_completion_stream = MagicMock(side_effect=lambda *a, **kw: _stream_iter())
|
||||
client.chat_completion = MagicMock(return_value=None)
|
||||
return client
|
||||
|
||||
def test_length_truncation_returns_none(self):
|
||||
"""流式被max_tokens截断时应返回None,不降级同步,让外层重试"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
partial = '<script><copy_display_markdown>文案</copy_display_markdown><clips><clip index="1"><time_range>0-5秒</time_range>'
|
||||
client = self._make_client([partial], finish_after=1, finish_reason="length")
|
||||
on_delta = MagicMock()
|
||||
raw = vv._call_llm_once(
|
||||
client,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
temperature=0.8,
|
||||
max_tokens=2500,
|
||||
timeout=60,
|
||||
on_delta=on_delta,
|
||||
)
|
||||
assert raw is None, "截断时应返回None让外层重试"
|
||||
client.chat_completion.assert_not_called()
|
||||
|
||||
def test_normal_stop_returns_raw(self):
|
||||
"""正常finish_reason=stop时应返回完整raw"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
full = "<script><copy_display_markdown>文案</copy_display_markdown><clips></clips></script>"
|
||||
client = self._make_client([full], finish_after=1, finish_reason="stop")
|
||||
on_delta = MagicMock()
|
||||
raw = vv._call_llm_once(
|
||||
client,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
temperature=0.8,
|
||||
max_tokens=2500,
|
||||
timeout=60,
|
||||
on_delta=on_delta,
|
||||
)
|
||||
assert raw == full
|
||||
|
||||
def test_noop_lines_removed(self):
|
||||
"""确认死代码行已删除"""
|
||||
import inspect
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
src = inspect.getsource(vv._step_script_generation)
|
||||
assert 'llm_messages[0]["content"]' not in src
|
||||
assert 'llm_messages[-1]["content"]' not in src
|
||||
|
||||
|
||||
class TestMarkdownTimeRangeSplit:
|
||||
"""markdown 解析器支持按"0-4秒/4-8秒"时间范围开头分镜(v6 中文格式)"""
|
||||
|
||||
def test_time_range_split_produces_correct_shots(self):
|
||||
"""纯中文 markdown 用「0-4秒」「4-8秒」分段,应正确切分镜头"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=20, marketing_purpose="产品展示")
|
||||
raw = (
|
||||
"视频主题: 御众堂产后修复\n"
|
||||
"0-4秒\n"
|
||||
"画面: 门店招牌特写,阳光照射\n"
|
||||
"景别: 近景平拍,缓慢推进\n"
|
||||
'口播: "宝妈们别划走"\n'
|
||||
"BGM: 轻快钢琴\n"
|
||||
"4-8秒\n"
|
||||
"画面: 店内环境,技师微笑迎客\n"
|
||||
"景别: 中景平视,固定镜头\n"
|
||||
'口播: "御众堂产后修复,专业呵护"\n'
|
||||
"BGM: 轻快钢琴\n"
|
||||
"8-14秒\n"
|
||||
"画面: 修复仪器操作特写\n"
|
||||
"景别: 特写俯拍,缓慢横移\n"
|
||||
'口播: "先进设备,科学恢复"\n'
|
||||
"转场: 硬切\n"
|
||||
"14-20秒\n"
|
||||
"画面: 宝妈满意笑容+门店地址字幕\n"
|
||||
"景别: 中景平视,缓慢拉远\n"
|
||||
'口播: "点击下方预约,到店体验"\n'
|
||||
)
|
||||
result = vv._parse_script_markdown(raw, job, 20, "9:16", "产后修复")
|
||||
assert result is not None, "时间范围分块应解析成功"
|
||||
assert len(result["shots"]) == 4, f"应切分4个镜头,实际{len(result['shots'])}"
|
||||
assert result["shots"][0]["time_range"] == "0-4秒"
|
||||
assert result["shots"][1]["time_range"] == "4-8秒"
|
||||
assert result["shots"][2]["time_range"] == "8-14秒"
|
||||
assert result["shots"][3]["time_range"] == "14-20秒"
|
||||
assert "宝妈们别划走" in result["shots"][0]["voiceover"]
|
||||
assert "御众堂产后修复" in result["shots"][1]["voiceover"]
|
||||
assert "先进设备" in result["shots"][2]["voiceover"]
|
||||
assert "点击下方预约" in result["shots"][3]["voiceover"]
|
||||
assert result["title"] == "御众堂产后修复"
|
||||
full_vo = result["voiceover_script"]
|
||||
assert "宝妈们别划走" in full_vo
|
||||
assert "点击下方预约" in full_vo
|
||||
|
||||
def test_time_range_with_dash_and_tilde(self):
|
||||
"""支持「0-4秒」「4~8秒」「12到20秒」多种连接符"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=12)
|
||||
raw = (
|
||||
'0~4秒\n画面: 产品开箱\n口播: "快看这个"\n'
|
||||
'4-8秒\n画面: 产品细节\n口播: "质感满分"\n'
|
||||
'8到12秒\n画面: 使用效果\n口播: "赶紧下单"\n'
|
||||
)
|
||||
result = vv._parse_script_markdown(raw, job, 12, "9:16", "开箱")
|
||||
assert result is not None
|
||||
assert len(result["shots"]) == 3
|
||||
assert result["shots"][0]["time_range"] == "0-4秒"
|
||||
assert result["shots"][1]["time_range"] == "4-8秒"
|
||||
assert result["shots"][2]["time_range"] == "8-12秒"
|
||||
|
||||
def test_shot_number_marker_takes_priority_over_time(self):
|
||||
"""同时有「镜头1」和「0-4秒」时,优先按镜头编号分割(不重复切分)"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=8)
|
||||
raw = '镜头1 0-4秒\n画面: 开场\n口播: "开场话"\n' '镜头2 4-8秒\n画面: 主体\n口播: "主体话"\n'
|
||||
result = vv._parse_script_markdown(raw, job, 8, "9:16", "测试")
|
||||
assert result is not None
|
||||
assert len(result["shots"]) == 2
|
||||
assert result["shots"][0]["time_range"] == "0-4秒"
|
||||
|
||||
def test_no_markers_returns_none(self):
|
||||
"""没有任何分镜标记时返回None"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=10)
|
||||
raw = "今天给大家推荐一个好物,真的非常好用,价格实惠。"
|
||||
result = vv._parse_script_markdown(raw, job, 10, "9:16", "推荐")
|
||||
assert result is None
|
||||
|
||||
def test_parse_script_routes_to_time_markdown(self):
|
||||
"""_parse_script 调度:无XML时自动走时间范围markdown解析"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = _make_job(duration=8)
|
||||
raw = '0-4秒\n画面: 开场\n口播: "你好"\n' '4-8秒\n画面: 结束\n口播: "再见"\n'
|
||||
result = vv._parse_script(raw, job)
|
||||
assert result is not None
|
||||
assert len(result["shots"]) == 2
|
||||
assert "你好" in result["voiceover_script"]
|
||||
|
||||
Reference in New Issue
Block a user