Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 154e480db0 | |||
| 76c59e49e7 | |||
| fed80115c2 | |||
| 6d40ca3b04 | |||
| 786d4946f3 | |||
| bb7464a937 | |||
| b41217d3ba | |||
| 2167444df0 | |||
| 638213ab2a | |||
| 468f6b74a1 | |||
| d1e9cf3ff4 | |||
| f4c611a21f | |||
| 4e4afd80e9 | |||
| 9dc4ff17a3 | |||
| fde6c0b483 | |||
| 2ccfbe4a9c | |||
| 4766d9cacb | |||
| 2d25e9989a | |||
| ddfd38efcf | |||
| 7935cb6a25 | |||
| df8d32391d | |||
| 006e58ae54 | |||
| 7f825b7e55 | |||
| 410d686579 | |||
| 4879029c46 | |||
| 6cfab71d6e | |||
| 96786c4f85 | |||
| bf4b11c49d | |||
| 5b6079964e | |||
| 0e668fda8c | |||
| b4b6b32211 | |||
| 50e833eeeb | |||
| 79624587c4 | |||
| ee871ac4aa | |||
| 50c8618b87 | |||
| 712464f030 | |||
| c4ca302242 | |||
| 500203db5d | |||
| 2a9089edb0 | |||
| 6a3fbba813 | |||
| 5e5a9b6cda | |||
| ec2e2249fd | |||
| cfc584ad11 | |||
| afc89142c1 | |||
| 6ee4d93d03 |
@@ -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")
|
||||
@@ -0,0 +1,257 @@
|
||||
"""#2312 storyboard v5 (GLOBAL_CONSTRAINTS+健康行业红线) + review v3 (医疗合规维度) — 保底插入版
|
||||
|
||||
问题:#2308三字段拆分、#2311音频重构、#2312合规修复都只更新了代码默认 prompts.py,
|
||||
没有写 alembic 同步 viral_video_prompt_templates 表。但后台管理端
|
||||
(/viral-prompt-templates)允许运营在线编辑模板,运营手改的内容不能被迁移覆盖。
|
||||
|
||||
策略(重要):
|
||||
- 本迁移**不** deactivate 任何已有的 active 模板;运营配置的 v6 等高版本保持原样。
|
||||
- 仅当某个 prompt_type 当前**没有**任何 is_active=true 的模板时(即空库/异常状态),
|
||||
才把本次插入的 v5/v3 设为 is_active=true 作为保底。
|
||||
- 已存在 active 模板时,本迁移插入的 v5/v3 以 is_active=false 存档,不影响线上。
|
||||
- 合规底线不靠本迁移覆盖模板来生效,而是由 prompt_loader.render_system_prompt 的
|
||||
防御性注入(代码侧自动追加 GLOBAL_CONSTRAINTS + 行业红线)兜底,这样:
|
||||
① 运营未来在后台随便改模板都不会丢合规底线;
|
||||
② 新库/空库拿到的保底模板本身就合规。
|
||||
- 配套 reviewer.py 的本地医疗正则兜底 + 阶段 2 LLM 审核自动重试,形成三层防线。
|
||||
|
||||
Revision ID: 111_vv_compliance_prompts
|
||||
Revises: 110_add_tone_to_viral_video_jobs
|
||||
Create Date: 2026-10-15
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "111_vv_compliance_prompts"
|
||||
down_revision = "110"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# ── v5 storyboard(编导级分镜 v3.2-合规增强版)────────────────────────────
|
||||
V5_STORYBOARD_SYSTEM = (
|
||||
"你是一名懂短视频的编导和口播文案高手。你会拿到图片的真实观察、营销目的和用户参数,"
|
||||
'请一次性完成对营销意图的理解,并产出可直接拍摄/生成的分镜脚本。不要单独输出"意图解析",'
|
||||
"意图要直接体现在台词和分镜里。\n\n"
|
||||
"## 输出格式(XML,严格按结构输出,不要输出额外解释)\n"
|
||||
"<script>\n"
|
||||
" <copy_display_markdown><![CDATA[直接展示给用户看的成片文案,用 Markdown 写成流畅叙述]]></copy_display_markdown>\n"
|
||||
" <clips>\n"
|
||||
' <clip index="1">\n'
|
||||
" <time_range>0-3秒</time_range>\n"
|
||||
" <voiceover>这一镜的口播台词</voiceover>\n"
|
||||
" <shot>景别、机位高度、焦段、光圈、运镜(6项摄影参数,简短,如『中近景平视,50mm f/1.8,缓慢推镜』)</shot>\n"
|
||||
" <visual>画面主体、场景、光线、氛围(只写画面内容,不含景别/运镜参数,如『暖光木色门店,店员微笑递茶,背景虚化』)</visual>\n"
|
||||
" <action_details>人物动作、表情、物品操作细节(真人出镜镜头必填,纯产品/空镜填空线『-』)</action_details>\n"
|
||||
" <reference_image_index>0</reference_image_index>\n"
|
||||
" </clip>\n"
|
||||
" </clips>\n"
|
||||
" <voiceover_script>把所有 clip 的voiceover 连成完整口播稿</voiceover_script>\n"
|
||||
" <theme>一句话主题</theme>\n"
|
||||
" <negative>【反套路化要求】\n"
|
||||
'禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;\n'
|
||||
'禁止"很近"等错别字,应使用"最近";\n'
|
||||
"禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。\n"
|
||||
"禁止医疗诊疗类词汇(治疗/治愈/根治/疗效/特效/药到病除/一用就好/对症/偏方/秘方/一搭手就知道/帮你看看等)。\n"
|
||||
"禁止效果承诺(包治/根治/立刻见效/永不复发/一用就好等)。</negative>\n"
|
||||
"</script>\n\n"
|
||||
"【必须遵守的硬约束】\n"
|
||||
'1. 不编造时间:不写"今年最新""2024 爆款"等会过时的时间表述。\n'
|
||||
'2. 不承诺效果:不写"保证""一定""100%有效""包治百病"等绝对化用语,不写任何效果承诺。\n'
|
||||
"3. 不编造价格、销量、认证、奖项:除非用户在文案中明确给出,否则一律不写。\n"
|
||||
"4. 符合广告法及平台社区规范,不触碰医疗/养生/金融等敏感行业红线。\n"
|
||||
"5. 只描述图片中真实可见的内容,看不到的不瞎猜。\n"
|
||||
"6. 不出现医疗/诊疗/治疗/治愈/疗效/根治/对症/药效/偏方/秘方/药到病除/一用就好/立刻见效/特效/克星/急救/救星/神药/祖传/宫廷秘方/一贴灵/一抹就好等宣称诊疗或疗效的词汇。\n"
|
||||
'7. 养生/按摩/理疗/推拿/养生馆/调理类门店文案:只写"放松""舒缓""缓解疲劳""舒服""松快""解乏""轻松""舒展""按完轻松多了"等感受类表达,严禁暗示治病、治病根、诊疗、根治、对症、疗效、专家诊断、老师一搭手就知道问题在哪等越界表述。\n'
|
||||
'8. 不冒充医生/专家/老师做诊疗判断,不写"老师帮你看看""一搭手就知道""哪儿疼治哪儿""对症调理"等暗示诊断/治疗的表述。\n\n'
|
||||
"{industry_redlines}\n\n"
|
||||
"## 写作要求\n"
|
||||
"1. 口播台词:像真人面对镜头说话,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。\n"
|
||||
"2. <shot> 只写6项摄影参数(景别/机位高度/焦段/光圈/运镜),简短不超过20字;<visual> 写观众会看到的画面主体/场景/光线/氛围;<action_details> 写人物动作/表情/物品操作细节(真人出镜必填)。三字段各司其职,不要混写。\n"
|
||||
'3. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案,可用小标题与短句组织;不要做字段列表,不要出现"镜头一/台词:"这类制作说明。\n'
|
||||
"4. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。\n"
|
||||
"5. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。\n"
|
||||
"6. 分镜数量与时长匹配总时长,节奏紧凑。\n"
|
||||
"7. 【字数/镜头数/时间轴硬约束】:严格遵守 user_prompt 里给出的 {duration_constraint_text},宁少勿多。\n"
|
||||
"8. 每个 clip 必须分配一个 reference_image_index(从 0 开始的图片序号),没有合适图片填 -1。\n"
|
||||
"9. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜,不能忽略任何一项用户参数。\n"
|
||||
'10. 禁止使用"很近"等错别字,统一使用"最近"。'
|
||||
)
|
||||
|
||||
V5_STORYBOARD_USER = (
|
||||
"<marketing_purpose>{marketing_purpose}</marketing_purpose>\n"
|
||||
"<industry>{industry}</industry>\n"
|
||||
"<image_analysis>\n{image_summary}\n</image_analysis>\n"
|
||||
"<user_parameters>\n"
|
||||
"<theme_hint>{theme_hint}</theme_hint>\n"
|
||||
"<duration>{duration}秒</duration>\n"
|
||||
"<aspect_ratio>{aspect_ratio}</aspect_ratio>\n"
|
||||
"<tone>{tone}</tone>\n"
|
||||
"<target_audience>{target_audience}</target_audience>\n"
|
||||
"<persona>{persona_hint}</persona>\n"
|
||||
"<viral_structure>{viral_structure_hint}</viral_structure>\n"
|
||||
"<language>{language_hint}</language>\n"
|
||||
"<extra_requirements>{extra_requirements}</extra_requirements>\n"
|
||||
"</user_parameters>\n"
|
||||
"{video_style_section}\n"
|
||||
"<duration_constraints>\n{duration_constraint_text}\n</duration_constraints>\n"
|
||||
"请严格按 XML 结构输出分镜脚本。"
|
||||
)
|
||||
|
||||
V5_STORYBOARD_EXAMPLE = (
|
||||
"<script>\n"
|
||||
" <copy_display_markdown><![CDATA[# 在御众堂,把松弛的自己一点点找回来\n"
|
||||
"产后妈妈最懂那种力不从心,推开门,暖光和一杯热茶先接住了你。\n"
|
||||
"最近连抱娃都觉得腰发硬,过来躺一会儿,按完整个背都松了,带娃也有力气了。]]></copy_display_markdown>\n"
|
||||
" <clips>\n"
|
||||
' <clip index="1">\n'
|
||||
" <time_range>0-4秒</time_range>\n"
|
||||
" <voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>\n"
|
||||
" <shot>中近景平视,50mm f/2.0,缓慢推镜</shot>\n"
|
||||
" <visual>暖光浴室镜前,妈妈略显疲惫地看向镜中自己,镜灯打在侧脸</visual>\n"
|
||||
" <action_details>妈妈手扶洗手台,微微低头再抬眼看镜,眼眶微红</action_details>\n"
|
||||
" <reference_image_index>0</reference_image_index>\n"
|
||||
" </clip>\n"
|
||||
' <clip index="2">\n'
|
||||
" <time_range>4-10秒</time_range>\n"
|
||||
" <voiceover>推开门,暖光和一杯热茶先接住了你。</voiceover>\n"
|
||||
" <shot>中景平视,35mm f/2.8,镜头缓慢平移</shot>\n"
|
||||
" <visual>门店前台暖光木色装修,茶水冒热气,背景柔和虚化</visual>\n"
|
||||
" <action_details>店员微笑双手递上茶杯,妈妈伸手接过,指尖轻触杯沿</action_details>\n"
|
||||
" <reference_image_index>1</reference_image_index>\n"
|
||||
" </clip>\n"
|
||||
' <clip index="3">\n'
|
||||
" <time_range>10-15秒</time_range>\n"
|
||||
" <voiceover>来御众堂躺一会儿,整个人都松了。</voiceover>\n"
|
||||
" <shot>近景缓慢拉远至中景,85mm f/1.8,匀速后拉</shot>\n"
|
||||
" <visual>护理床暖光打在妈妈侧脸,最后画面露出御众堂品牌LOGO</visual>\n"
|
||||
" <action_details>妈妈闭眼放松,嘴角微微上扬,肩膀缓缓下沉</action_details>\n"
|
||||
" <reference_image_index>2</reference_image_index>\n"
|
||||
" </clip>\n"
|
||||
" </clips>\n"
|
||||
" <voiceover_script>生完娃,是不是连照镜子的勇气都没了?推开门,暖光和一杯热茶先接住了你。来御众堂躺一会儿,整个人都松了。</voiceover_script>\n"
|
||||
" <theme>产后妈妈走进御众堂重拾状态</theme>\n"
|
||||
' <negative>模糊、畸变、夸大疗效、绝对化用语、"很近"等错别字</negative>\n'
|
||||
"</script>"
|
||||
)
|
||||
|
||||
# ── v3 review(文案审核 v3-医疗合规加强版)─────────────────────────────────
|
||||
V3_REVIEW_SYSTEM = (
|
||||
"你是一名短视频广告合规审核与文案优化专家。请审核下面这段口播文案:\n"
|
||||
"1) 广告法与平台合规(绝对化用语、虚假承诺、导流违规、套路烂梗词);\n"
|
||||
'2) **医疗/养生/健康红线(重点审查)**:是否出现诊疗暗示("帮你看看""一搭手就知道""老师诊断""检查"等)、'
|
||||
'疗效/治愈承诺("治好""根治""见效""一用就好""药到病除""特效""克星""永不复发"等)、'
|
||||
'医疗术语("疗程""对症""膏药""针灸""中药""偏方""秘方""诊疗""治疗""就诊"等)、'
|
||||
'冒充医生/专家/老师做诊断、贬低正规医疗("别贴膏药""别吃药""别去医院"等);\n'
|
||||
'3) 养生/按摩/理疗/推拿/保健类文案是否用了纯感受类表达(放松/舒缓/舒服/松快/解乏/轻松),而不是"治病/根治/疗效"类表达;\n'
|
||||
"4) 口播是否自然、口语化、无书面腔;\n"
|
||||
'5) 是否有明显错别字(例如把"最近"写成"很近")。\n\n'
|
||||
"只输出 XML,结构:\n"
|
||||
"<review>\n"
|
||||
" <passed>true 或 false</passed>\n"
|
||||
" <issues>\n"
|
||||
' <issue dimension="违规词/夸大承诺/错别字/其他" severity="error或warning">具体问题</issue>\n'
|
||||
" </issues>\n"
|
||||
"</review>\n"
|
||||
"没有问题时 issues 留空、passed 为 true。不要输出 rewrite/rewrite_suggestion 字段。"
|
||||
)
|
||||
|
||||
V3_REVIEW_USER = "<voiceover_script>\n{voiceover_script}\n</voiceover_script>\n\n请审核以上口播文案。"
|
||||
|
||||
V3_REVIEW_EXAMPLE = (
|
||||
"<review>\n"
|
||||
" <passed>false</passed>\n"
|
||||
" <issues>\n"
|
||||
' <issue dimension="夸大承诺" severity="error">使用绝对化用语"100%有效",违反广告法</issue>\n'
|
||||
' <issue dimension="违规词" severity="warning">使用套路词"绝绝子"</issue>\n'
|
||||
" </issues>\n"
|
||||
"</review>"
|
||||
)
|
||||
|
||||
|
||||
def _has_active(conn, prompt_type: str) -> bool:
|
||||
row = conn.execute(
|
||||
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
|
||||
{"pt": prompt_type},
|
||||
).scalar()
|
||||
return bool(row and row > 0)
|
||||
|
||||
|
||||
def _version_exists(conn, prompt_type: str, version: int) -> bool:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates WHERE prompt_type = :pt AND version = :v"),
|
||||
{"pt": prompt_type, "v": version},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _insert(conn, prompt_type: str, version: int, name: str, sys_p: str, usr_p: str, ex_p: str, active: bool) -> None:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates (name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active)"
|
||||
" VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{"name": name, "pt": prompt_type, "ver": version, "sys": sys_p, "usr": usr_p, "ex": ex_p, "active": active},
|
||||
)
|
||||
|
||||
|
||||
def _next_version(conn, prompt_type: str, minimum: int) -> int:
|
||||
mx = (
|
||||
conn.execute(
|
||||
text("SELECT COALESCE(MAX(version), 0) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": prompt_type},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
return max(minimum, int(mx) + 1)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# storyboard v5:如果同 version 已存在(前次失败重跑)就跳过
|
||||
sb_ver = 5
|
||||
if not _version_exists(conn, "storyboard", sb_ver):
|
||||
sb_active = not _has_active(conn, "storyboard")
|
||||
_insert(
|
||||
conn,
|
||||
"storyboard",
|
||||
sb_ver,
|
||||
"编导级分镜v3.2-合规增强版(#2312)",
|
||||
V5_STORYBOARD_SYSTEM,
|
||||
V5_STORYBOARD_USER,
|
||||
V5_STORYBOARD_EXAMPLE,
|
||||
active=sb_active,
|
||||
)
|
||||
|
||||
# review v3
|
||||
rv_ver = 3
|
||||
if not _version_exists(conn, "review", rv_ver):
|
||||
rv_active = not _has_active(conn, "review")
|
||||
_insert(
|
||||
conn,
|
||||
"review",
|
||||
rv_ver,
|
||||
"文案审核v3-医疗合规加强版(#2312)",
|
||||
V3_REVIEW_SYSTEM,
|
||||
V3_REVIEW_USER,
|
||||
V3_REVIEW_EXAMPLE,
|
||||
active=rv_active,
|
||||
)
|
||||
|
||||
# 注意:不 deactivate 任何已有模板。运营在后台编辑的高版本(如 v6)保持 active。
|
||||
# 合规底线由 prompt_loader.render_system_prompt 的防御性注入兜底:
|
||||
# 检测到模板缺"不承诺效果"指纹则自动追加 GLOBAL_CONSTRAINTS,
|
||||
# 缺 {industry_redlines} 占位符则直接追加行业红线内容,
|
||||
# 因此不管 active 模板是 v5/v6/未来 v7,都会被自动补上合规底线。
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
# 删除本次迁移插入的 v5/v3 行(按 name 精确匹配,不误删运营自定义)
|
||||
conn.execute(
|
||||
text("DELETE FROM viral_video_prompt_templates WHERE name LIKE :n AND version IN (5, 3)"),
|
||||
{"n": "%#2312%"},
|
||||
)
|
||||
# 不改动其他任何版本的 is_active 状态,避免误恢复被运营手动停用的版本。
|
||||
@@ -0,0 +1,125 @@
|
||||
"""#2312 恢复运营v6模板为active + 存档#2312合规模板
|
||||
|
||||
背景:旧版 alembic 111(commit 7935cb6a)错误地 _deactivate_all() 了所有现有模板,
|
||||
把运营在后台 /viral-prompt-templates 配置的 v6(编导级分镜 v6)is_active 改成了 false,
|
||||
并把 #2312 合规版(storyboard version=5 / review version=3)设为 active,覆盖了运营配置。
|
||||
|
||||
本迁移(112)的任务:
|
||||
1. storyboard 类型:恢复 v6 为 is_active=true,把 #2312 插入的 v5 和运营试配的 v7 设为 is_active=false
|
||||
2. review 类型:找到运营配置的最高版本(不是我们 alembic 插的 v3),恢复其 is_active=true;
|
||||
把我们 alembic 插入的 v3 设为 is_active=false
|
||||
3. 全部操作加 IF EXISTS 防护,幂等可重入。
|
||||
4. 防御注入兜底依然生效(prompt_loader 自动追加合规底线到任何 active 模板),
|
||||
运营 v6 恢复后仍会被自动追加 GLOBAL_CONSTRAINTS + 行业红线,合规不丢。
|
||||
|
||||
Revision ID: 112_restore_v6_prompt
|
||||
Revises: 111_vv_compliance_prompts
|
||||
Create Date: 2026-10-15
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "112_restore_v6_prompt"
|
||||
down_revision = "111_vv_compliance_prompts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# 我们 alembic 111 插入的版本号(用于识别哪些是代码插入的保底版本)
|
||||
_OUR_SB_VERSION = 5 # #2312 storyboard v5
|
||||
_OUR_RV_VERSION = 3 # #2312 review v3
|
||||
# 运营期望恢复为 active 的版本
|
||||
_OPERATOR_SB_ACTIVE_VERSION = 6 # 编导级分镜 v6
|
||||
# 运营在旧111之后可能试配过的合规版本(需要存档)
|
||||
_OPERATOR_SB_ARCHIVE_VERSIONS = [7]
|
||||
|
||||
|
||||
def _has_row(conn, prompt_type: str, version: int) -> bool:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type=:pt AND version=:v"),
|
||||
{"pt": prompt_type, "v": version},
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _set_active(conn, prompt_type: str, version: int, active: bool) -> int:
|
||||
"""设置某个 (type, version) 的 is_active;返回受影响行数(0 表示该行不存在)。"""
|
||||
res = conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active=:a " "WHERE prompt_type=:pt AND version=:v"),
|
||||
{"a": bool(active), "pt": prompt_type, "v": version},
|
||||
)
|
||||
return res.rowcount or 0
|
||||
|
||||
|
||||
def _ensure_only_one_active(conn, prompt_type: str, active_version: int) -> None:
|
||||
"""确保 prompt_type 类型下只有 active_version 一个是 is_active=true。
|
||||
其他所有该类型模板统一设为 false。"""
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active=false " "WHERE prompt_type=:pt AND version!=:av"),
|
||||
{"pt": prompt_type, "av": active_version},
|
||||
)
|
||||
# 再确保目标行是 true(之前可能本来就是 false)
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active=true " "WHERE prompt_type=:pt AND version=:av"),
|
||||
{"pt": prompt_type, "av": active_version},
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── 1. storyboard:恢复运营 v6 ──────────────────────────────────────
|
||||
if _has_row(conn, "storyboard", _OPERATOR_SB_ACTIVE_VERSION):
|
||||
_ensure_only_one_active(conn, "storyboard", _OPERATOR_SB_ACTIVE_VERSION)
|
||||
# 额外保险:v5/v7 明确设为 false(_ensure_only_one_active 已做,再次确认)
|
||||
for v in [_OUR_SB_VERSION] + _OPERATOR_SB_ARCHIVE_VERSIONS:
|
||||
if _has_row(conn, "storyboard", v):
|
||||
_set_active(conn, "storyboard", v, False)
|
||||
else:
|
||||
# 极端情况:v6 不存在(库没跑过旧111或者被删了),保持 #2312 v5 active 作为保底
|
||||
# 不做任何变更,避免把一个能用的状态改坏
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active=true " "WHERE prompt_type=:pt AND version=:v"),
|
||||
{"pt": "storyboard", "v": _OUR_SB_VERSION},
|
||||
)
|
||||
|
||||
# ── 2. review:恢复运营版本,存档我们的 v3 ──────────────────────────
|
||||
# 找 review 类型下、不是我们插入的 v3 的最高版本号
|
||||
op_row = conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates " "WHERE prompt_type=:pt AND version!=:ev"),
|
||||
{"pt": "review", "ev": _OUR_RV_VERSION},
|
||||
).fetchone()
|
||||
op_max_ver = op_row[0] if op_row else None
|
||||
|
||||
if op_max_ver is not None and _has_row(conn, "review", int(op_max_ver)):
|
||||
# 有运营版本,恢复它为 active,我们 v3 存档
|
||||
_ensure_only_one_active(conn, "review", int(op_max_ver))
|
||||
if _has_row(conn, "review", _OUR_RV_VERSION):
|
||||
_set_active(conn, "review", _OUR_RV_VERSION, False)
|
||||
else:
|
||||
# 没有运营版本(空库/新库),保持我们 v3 作为保底 active
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active=true " "WHERE prompt_type=:pt AND version=:v"),
|
||||
{"pt": "review", "v": _OUR_RV_VERSION},
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""downgrade 回滚到旧111跑完后的状态:我们的 v5/v3 为 active,其他全部 false。
|
||||
注意:这会再次 deactivate 运营 v6,仅用于回滚测试场景,生产不要执行 downgrade。"""
|
||||
conn = op.get_bind()
|
||||
# storyboard
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active=false WHERE prompt_type=:pt"),
|
||||
{"pt": "storyboard"},
|
||||
)
|
||||
if _has_row(conn, "storyboard", _OUR_SB_VERSION):
|
||||
_set_active(conn, "storyboard", _OUR_SB_VERSION, True)
|
||||
# review
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active=false WHERE prompt_type=:pt"),
|
||||
{"pt": "review"},
|
||||
)
|
||||
if _has_row(conn, "review", _OUR_RV_VERSION):
|
||||
_set_active(conn, "review", _OUR_RV_VERSION, True)
|
||||
File diff suppressed because one or more lines are too long
@@ -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 = ""
|
||||
|
||||
@@ -623,6 +623,12 @@ class LipsyncService:
|
||||
# 预合成模式:script_text 可空(因为 timings 已自带句子文本),但仍建议传
|
||||
if not isinstance(sentence_timings, list) or len(sentence_timings) == 0:
|
||||
raise MediaKitError("预合成模式 sentence_timings 不能为空", code="InvalidInput")
|
||||
# 文案为空时从 sentence_timings 拼接兜底(Ditto 情绪分析依赖 script_text;
|
||||
# 前端 #1875 起预合成 payload 不带 script_text,导致 job.script_text 落库为空)
|
||||
if not (script_text or "").strip():
|
||||
script_text = "".join(
|
||||
str(item.get("text") or "") for item in sentence_timings if isinstance(item, dict)
|
||||
).strip()
|
||||
|
||||
# 0.5 lip_sync 计费预扣(全局 points 开关 + 功能开关均开才扣)
|
||||
prepaid_credits = 0.0
|
||||
|
||||
+47
-7
@@ -4,17 +4,57 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- 标题字体(#2001 / #font-selection 修复):Google Fonts CDN 引入中文字体,保证优设标题黑/抖音美好体/阿里普惠体等fallback可用 -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&family=ZCOOL+QingKe+HuangYou&family=Ma+Shan+Zheng&family=Long+Cang&family=Liu+Jian+Mao+Cao&family=Zhi+Mang+Xing&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- 标题字体(#2001 / #font-selection 修复):Google Fonts 中文字体完全延迟加载
|
||||
关键设计:所有 Google Fonts 子资源(CSS + 字体文件)必须在 window load 之后才发起请求。
|
||||
- 原因:fonts.googleapis.com/gstatic.com 在境内访问间歇性挂起,若在关键路径内
|
||||
(<link rel=stylesheet>/preload/preconnect 等任何会被计入文档子资源完成的形式),
|
||||
会拖死 window.load 事件,导致 Playwright page.goto 默认 waitUntil:'load' 90s 超时,
|
||||
同时真实用户首屏也可能因等字体而白屏。
|
||||
- 实现:head 里完全不出现 Google 域的 <link>,首屏用系统字体兜底
|
||||
(font-family fallback 链:Noto Sans SC → PingFang SC → Microsoft YaHei → sans-serif);
|
||||
页面 load 后通过 requestIdleCallback 动态注入 <link rel=stylesheet>,字体异步加载生效。
|
||||
- noscript 降级:JS 禁用时仍然阻塞加载(可接受,这类用户极少)。
|
||||
- 参考:运维 2026-10-14 E2E 失败根因分析,#2300 preload/media=print 方案不治本,
|
||||
preload 与 media=print 样式表仍计入子资源完成,会阻塞 load 事件。 -->
|
||||
<title>小虾 SaaS - 自动化视频剪辑平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- Google Fonts 延迟注入:必须在 main.tsx 之后,用独立 <script>(非 module)在页面解析
|
||||
完成时注册 load 回调;window.load 触发后再 requestIdleCallback 插 <link>,
|
||||
确保字体请求不进入首屏关键路径,也不阻塞 Playwright load 等待 -->
|
||||
<script>
|
||||
;(function () {
|
||||
var FONT_HREF =
|
||||
"https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&family=ZCOOL+QingKe+HuangYou&family=Ma+Shan+Zheng&family=Long+Cang&family=Liu+Jian+Mao+Cao&family=Zhi+Mang+Xing&display=swap"
|
||||
function inject() {
|
||||
if (document.getElementById("__gfonts_link")) return
|
||||
var l = document.createElement("link")
|
||||
l.id = "__gfonts_link"
|
||||
l.rel = "stylesheet"
|
||||
l.href = FONT_HREF
|
||||
l.setAttribute("data-lazy", "1")
|
||||
document.head.appendChild(l)
|
||||
}
|
||||
function schedule() {
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(inject, { timeout: 2000 })
|
||||
} else {
|
||||
setTimeout(inject, 500)
|
||||
}
|
||||
}
|
||||
if (document.readyState === "complete") {
|
||||
schedule()
|
||||
} else {
|
||||
window.addEventListener("load", schedule, { once: true })
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
<noscript
|
||||
><link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700;900&family=Noto+Serif+SC:wght@400;700;900&family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&family=ZCOOL+QingKe+HuangYou&family=Ma+Shan+Zheng&family=Long+Cang&family=Liu+Jian+Mao+Cao&family=Zhi+Mang+Xing&display=swap"
|
||||
/></noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -255,6 +255,8 @@ const AiAvatarPage: React.FC = () => {
|
||||
audio_url: state.ttsPreview.audioUrl!,
|
||||
audio_duration: state.ttsPreview.duration,
|
||||
sentence_timings: state.ttsPreview.sentenceTimings,
|
||||
// 预合成也必须带文案:后端落库 job.script_text,Ditto 情绪分析依赖它
|
||||
script_text: state.scriptText,
|
||||
enable_video_loop: true,
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -126,4 +126,5 @@ export const finalizeRenderJob = (renderId: string, payload?: { cover_url?: stri
|
||||
apiClient.post<{ video_id: string; cover_url: string; status: string }>(
|
||||
`/ai-avatar/render/${renderId}/finalize`,
|
||||
payload ?? {},
|
||||
{ timeout: 120_000 }, // 后端兜底抽帧/写入成片库可能耗时较长
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ export const FALLBACK_VIDEO_MODELS: ViralVideoModel[] = [
|
||||
display_name: "Wan 3.0 — 通义万相全能",
|
||||
supports_audio: true,
|
||||
supported_resolutions: ["480p", "720p", "1080p"],
|
||||
max_duration: 15,
|
||||
max_duration: 30,
|
||||
},
|
||||
]
|
||||
export const RESOLUTION_ORDER = ["480p", "720p", "1080p", "4k"]
|
||||
|
||||
@@ -79,7 +79,7 @@ export const StoryboardCard: React.FC<StoryboardCardProps> = ({
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<VVSpinner />
|
||||
<span className="vv-copy-loading-text">{stageMsg}</span>
|
||||
<span className="vv-copy-loading-hint">预计 10-30 秒,请稍候</span>
|
||||
<span className="vv-copy-loading-hint">预计 5-15 秒,请稍候</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export default defineConfig({
|
||||
"src/pages/assets/AssetLibrary.tsx",
|
||||
"src/pages/voice-materials/VoiceMaterialLibrary.tsx",
|
||||
"src/pages/viral-video/ViralVideoPage.tsx",
|
||||
"src/pages/ai-avatar/AiAvatarPage.tsx",
|
||||
"src/pages/ai-avatar/components/**",
|
||||
],
|
||||
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标)
|
||||
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
|
||||
|
||||
@@ -302,11 +302,15 @@ def _legacy_upload_to_oss(local_path: Path, storage_key: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
|
||||
"""上传文件到 OSS,返回公网 URL。"""
|
||||
def upload_to_oss(
|
||||
local_path: Path | str,
|
||||
storage_key: str,
|
||||
extra_headers: dict | None = None,
|
||||
) -> str | None:
|
||||
"""上传文件到 OSS,返回公网 URL。extra_headers 可传 x-oss-object-acl 等自定义头。"""
|
||||
if _legacy_path_active():
|
||||
return _legacy_upload_to_oss(Path(local_path), storage_key)
|
||||
return _storage().upload_file_smart(local_path, storage_key)
|
||||
return _storage().upload_file_smart(local_path, storage_key, extra_headers=extra_headers)
|
||||
|
||||
|
||||
def get_signed_download_url(storage_key_or_url: str, expires_seconds: int = 3600) -> str | None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,8 @@ COPY alembic.ini /app/alembic.ini
|
||||
COPY alembic/ /app/alembic/
|
||||
COPY migrations/ /app/migrations/
|
||||
COPY packages/ /app/packages/
|
||||
# #2311: 预置音色样本 seed 脚本等运维/部署脚本需在容器内可用
|
||||
COPY scripts/ /app/scripts/
|
||||
# PR #1844 起,worker 还需要加载 apps.api.app.tasks.lipsync_tts,
|
||||
# 该 task 依赖 app.services.* 与 app.core.celery_app(PYTHONPATH=/app/apps/api 下解析)。
|
||||
# 为避免后续新增 task 再次漏 COPY,直接把整个 apps/api/app/ 复制进 worker 镜像。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
1. 分句:先按 。!?; 句末标点切分;整段无句末标点且超长时按逗号等语义停顿二次切分
|
||||
2. 调 DoubaoClient.chat_completion 分析每句表情(emo: 0-7 八种全部开放, intensity: 0-1)
|
||||
3. 结果 LRU 缓存(文案 hash → 情绪列表)
|
||||
4. LLM 失败/超时/格式错 → 返回空列表(降级中性表情,不阻塞生成)
|
||||
4. LLM 失败/超时/格式错 → analyze 层预分句全中性兜底;build_timeline 识别全兜底特征后
|
||||
返回空串不传 emo_timeline(避免压制 GPU 端自身表情逻辑),不阻塞生成
|
||||
5. TTS 完成后按字数比例或 sentence_timings 对齐成秒级 timeline
|
||||
"""
|
||||
|
||||
@@ -31,6 +32,11 @@ EMO_SURPRISE = 6
|
||||
EMO_CONTEMPT = 7
|
||||
ALLOWED_EMOS = set(range(8)) # 8 种表情全部开放(0-7);超出范围的非法值仍会被降级为中性
|
||||
|
||||
# 传给 Ditto 的 emo_timeline 强度下限:GPU 端表情混合公式修复后,intensity>=0.3
|
||||
# 目标情绪才能压过中性;LLM 常给陈述句打 0.1-0.2,信号太弱、表情效果一眼假。
|
||||
# 构造 timeline entry 时统一保底到该值(不影响 analyze 层 LLM 原始输出与日志排查)
|
||||
MIN_TIMELINE_INTENSITY = 0.35
|
||||
|
||||
# ── 分句正则 ─────────────────────────────────────────────────────
|
||||
_SENT_SPLIT_RE = re.compile(r"(?<=[。!?;!?;])\s*")
|
||||
# 二次切分:逗号/分号/顿号/冒号等语义停顿(lookbehind 保留标点在前句末尾)
|
||||
@@ -82,7 +88,8 @@ class EmotionTimelineEntry:
|
||||
self.start = round(start, 2)
|
||||
self.end = round(end, 2)
|
||||
self.emo = emo
|
||||
self.intensity = round(intensity, 2)
|
||||
# 强度保底:低于 MIN_TIMELINE_INTENSITY 的信号在 GPU 混合公式中压不过中性
|
||||
self.intensity = round(max(intensity, MIN_TIMELINE_INTENSITY), 2)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -361,6 +368,18 @@ class DittoEmotionService:
|
||||
segments = self.analyze(text)
|
||||
if not segments:
|
||||
return ""
|
||||
# LLM 失败/异常时 _cached_analyze 会兜底返回"全中性(emo=4)+低强度(<=0.1)"的
|
||||
# 预分句结果;把这种"明确的全中性指令"传给 GPU 会压制 Ditto 端
|
||||
# use_script_emo/emo_global 的自身处理,比不传更糟糕。识别全兜底特征后
|
||||
# 返回空串(payload 不带 emo_timeline),交由 GPU 端自行决定表情。
|
||||
# 注意:LLM 真实分析即使全中性,intensity 一般 >0.1,不会被误伤。
|
||||
if all(s.emo == EMO_NEUTRAL and s.intensity <= 0.1 for s in segments):
|
||||
logger.info(
|
||||
"[ditto_emotion] 分析结果全为中性低强度(疑似 LLM 兜底),"
|
||||
"emo_timeline 置空交由 GPU 端处理: text=%.80s",
|
||||
text,
|
||||
)
|
||||
return ""
|
||||
if sentence_timings:
|
||||
entries = align_timeline_by_timings(segments, sentence_timings, audio_duration)
|
||||
else:
|
||||
|
||||
@@ -7,11 +7,11 @@ from __future__ import annotations
|
||||
|
||||
# 时长规格:key=duration(秒),value=对应约束
|
||||
DURATION_SPEC: dict[int, dict[str, int]] = {
|
||||
5: {"min_chars": 12, "max_chars": 20, "min_shots": 1, "max_shots": 2},
|
||||
10: {"min_chars": 25, "max_chars": 40, "min_shots": 2, "max_shots": 3},
|
||||
15: {"min_chars": 38, "max_chars": 60, "min_shots": 3, "max_shots": 4},
|
||||
20: {"min_chars": 50, "max_chars": 80, "min_shots": 4, "max_shots": 5},
|
||||
30: {"min_chars": 75, "max_chars": 120, "min_shots": 6, "max_shots": 8},
|
||||
5: {"min_chars": 12, "max_chars": 25, "min_shots": 1, "max_shots": 2},
|
||||
10: {"min_chars": 25, "max_chars": 50, "min_shots": 2, "max_shots": 3},
|
||||
15: {"min_chars": 38, "max_chars": 75, "min_shots": 3, "max_shots": 4},
|
||||
20: {"min_chars": 50, "max_chars": 100, "min_shots": 4, "max_shots": 5},
|
||||
30: {"min_chars": 75, "max_chars": 150, "min_shots": 6, "max_shots": 8},
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ def build_duration_constraint_text(dur: int) -> str:
|
||||
spec = get_duration_spec(dur)
|
||||
return (
|
||||
f"视频时长{dur}秒。口播总字数严格控制在{spec['min_chars']}~{spec['max_chars']}字之间"
|
||||
f"(中文正常口播语速约2.5~4字/秒,{dur}秒={dur * 4}字为硬上限,宁可少写也不要多写,"
|
||||
f"超长会导致TTS配音超出视频时长而失败)。"
|
||||
f"(中文口播语速约3~5字/秒,{dur}秒={spec['max_chars']}字为硬上限,必须严格遵守,"
|
||||
f"超长会被截断导致语义不完整)。"
|
||||
f"分镜镜头数{spec['min_shots']}~{spec['max_shots']}个,每个镜头的time_range首尾相接,"
|
||||
f"不得跳秒或重叠,每个镜头时长≥2秒。"
|
||||
)
|
||||
|
||||
@@ -30,14 +30,20 @@ _warned: dict[str, float] = {}
|
||||
_warn_lock = threading.Lock()
|
||||
|
||||
|
||||
def _warn_once(key: str, template_type: str, e: BaseException | str | None = None) -> None:
|
||||
def _warn_once(key: str, context: str, detail: BaseException | str | None = None) -> None:
|
||||
"""冷却告警通用方法:5 分钟内同 key 只打一次,避免日志刷屏。
|
||||
|
||||
- key: 告警分组键(去重用)
|
||||
- context: 本条告警的简短场景描述(直接拼进日志,不要再写死 'fallback to default' 字样)
|
||||
- detail: 具体异常或描述
|
||||
"""
|
||||
now = time.monotonic()
|
||||
with _warn_lock:
|
||||
last = _warned.get(key, 0.0)
|
||||
if now - last < _WARN_COOLDOWN_SECONDS:
|
||||
return
|
||||
_warned[key] = now
|
||||
logger.warning("viral_video prompt template %s fallback to default: %s", template_type, e)
|
||||
logger.warning("viral_video prompt %s: %s", context, detail)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -85,10 +91,10 @@ def _get_session():
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once("session_config", "db_config", e)
|
||||
_warn_once("session_config", "db config failed (will fallback)", e)
|
||||
return None
|
||||
if not url:
|
||||
_warn_once("session_url", "db_url", "database_url empty")
|
||||
_warn_once("session_url", "db_url empty (will fallback)", "database_url empty")
|
||||
return None
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
|
||||
if url.startswith("postgresql://") and "psycopg" not in url:
|
||||
@@ -128,14 +134,14 @@ def _load_from_db(prompt_type: str) -> Optional[PromptTemplate]:
|
||||
is_active=bool(row[6]),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||||
_warn_once(f"load_db:{prompt_type}", prompt_type, e)
|
||||
_warn_once(f"load_db:{prompt_type}", f"db load failed for {prompt_type} (will fallback to code default)", e)
|
||||
return None
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once(f"session_close:{prompt_type}", prompt_type, e)
|
||||
_warn_once(f"session_close:{prompt_type}", f"session close failed for {prompt_type}", e)
|
||||
|
||||
|
||||
def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[PromptTemplate]:
|
||||
@@ -149,12 +155,32 @@ def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[P
|
||||
if not force_refresh and cached and now - cached[0] < CACHE_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
template = _load_from_db(prompt_type) or _fallback(prompt_type)
|
||||
template = _load_from_db(prompt_type)
|
||||
_from = "db"
|
||||
if template is None:
|
||||
_warn_once(f"no_template:{prompt_type}", prompt_type, "no template in db or code defaults")
|
||||
if template is not None:
|
||||
with _lock:
|
||||
_cache[prompt_type] = (now, template)
|
||||
template = _fallback(prompt_type)
|
||||
_from = "code-default"
|
||||
if template is None:
|
||||
_warn_once(
|
||||
f"no_template:{prompt_type}",
|
||||
f"NO TEMPLATE for {prompt_type} (neither db nor code defaults)",
|
||||
"no template in db or code defaults",
|
||||
)
|
||||
return None
|
||||
# #2312 实证日志:每次缓存刷新都打印实际加载的模板来源/版本/名称/sp前100字,
|
||||
# 便于确认后台运营改动是否真的生效(30秒TTL缓存刷新时会打)
|
||||
_sp_preview = (template.system_prompt or "").replace(chr(10), " ")[:100]
|
||||
logger.info(
|
||||
"[prompt_loader] 加载模板 type=%s 来源=%s version=%d name=%s is_active=%s sp_preview=%r",
|
||||
prompt_type,
|
||||
_from,
|
||||
template.version,
|
||||
template.name,
|
||||
template.is_active,
|
||||
_sp_preview,
|
||||
)
|
||||
with _lock:
|
||||
_cache[prompt_type] = (now, template)
|
||||
return template
|
||||
|
||||
|
||||
@@ -166,24 +192,171 @@ def invalidate() -> None:
|
||||
_warned.clear()
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def _safe_format(text: str, kwargs: dict) -> str:
|
||||
try:
|
||||
return text.format_map(_SafeDict(kwargs))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once("safe_format", "format", e)
|
||||
return text
|
||||
"""安全格式化模板:任何占位符异常(缺key/属性访问失败/格式符非法/JSON花括号样例)
|
||||
都原样保留,不抛异常、不静默回落默认模板,只打一条warning告警。
|
||||
|
||||
不用 str.format_map(_SafeDict):_SafeDict 只能防 KeyError,无法防 AttributeError
|
||||
(如 {duration.1格式写全6项参数} 访问 str.1格式... 会抛 AttributeError),
|
||||
也无法防非法 format_spec(如 JSON 样例 {"key":"val"} 里冒号被当格式符)。
|
||||
"""
|
||||
import string as _string
|
||||
|
||||
def render_user_prompt(template: PromptTemplate, **kwargs) -> str:
|
||||
"""填充 user_prompt_template 占位符,缺键原样保留不报错。"""
|
||||
return _safe_format(template.user_prompt_template, kwargs)
|
||||
out: list[str] = []
|
||||
i, n = 0, len(text)
|
||||
errors: list[str] = []
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if c == "{":
|
||||
if i + 1 < n and text[i + 1] == "{":
|
||||
out.append("{")
|
||||
i += 2
|
||||
continue
|
||||
j = i + 1
|
||||
depth = 1
|
||||
while j < n and depth > 0:
|
||||
if text[j] == "{":
|
||||
depth += 1
|
||||
elif text[j] == "}":
|
||||
depth -= 1
|
||||
j += 1
|
||||
if depth == 0:
|
||||
inner = text[i + 1 : j - 1]
|
||||
try:
|
||||
val = _string.Formatter().vformat("{" + inner + "}", (), kwargs)
|
||||
out.append(val)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 非法占位符原样保留,避免模板里的样例JSON/说明文本炸format
|
||||
errors.append(f"{inner!r}: {type(e).__name__}")
|
||||
out.append(text[i:j])
|
||||
i = j
|
||||
continue
|
||||
if c == "}":
|
||||
if i + 1 < n and text[i + 1] == "}":
|
||||
out.append("}")
|
||||
i += 2
|
||||
continue
|
||||
out.append(c)
|
||||
i += 1
|
||||
if errors:
|
||||
# 模板里花括号炸format分两种:
|
||||
# 1. 配置bug(如v5事故{duration.1格式写全6项参数}非法属性访问)→ 需要排查
|
||||
# 2. 样例文本花括号(如{大远景/远景/全景}给LLM看的景别选项、JSON示例、XML说明)→ 原样保留属正常
|
||||
# 注意:本函数只原样保留,不会让template回落到DEFAULT_TEMPLATES;
|
||||
# 真正的回落到代码默认模板只在DB查询失败/无激活模板时发生(见_load_from_db/get_template),
|
||||
# 那里会打独立ERROR日志并带job_id(见viral_video.py中DEFAULT_TEMPLATES检测块)。
|
||||
# #2310: 降级为WARNING,因为大部分是DB模板中的样例花括号正常保留,不是bug;
|
||||
# 仅在错误数异常多(>10)或包含明显属性访问错误(.xxx/[xxx])时提级到WARNING加关注
|
||||
_has_real_bug = any("." in e or "[" in e for e in errors[:5])
|
||||
_log_fn = logger.warning if _has_real_bug else logger.debug
|
||||
_log_fn(
|
||||
"[viral][prompt] 模板格式化有%d个花括号原样保留(示例:%s)——"
|
||||
"样例文本(景别选项/JSON示例)可忽略;若应为变量请检查占位符拼写",
|
||||
len(errors),
|
||||
errors[0],
|
||||
)
|
||||
_warn_once(
|
||||
"safe_format",
|
||||
f"format-keep({len(errors)}placeholders)",
|
||||
f"{len(errors)}个占位符原样保留(首条{errors[0]}),未回落默认模板",
|
||||
)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def render_system_prompt(template: PromptTemplate, **kwargs) -> str:
|
||||
"""copy_fusion 等 system_prompt 含运行时变量时填充。"""
|
||||
return _safe_format(template.system_prompt, kwargs)
|
||||
"""system_prompt 含运行时变量时填充;example_output 存在时作为 few-shot 样例追加到末尾。
|
||||
|
||||
#2312 防御:DB 配置的模板(viral_video_prompt_templates)若漏掉 GLOBAL_CONSTRAINTS 或
|
||||
# industry_redlines 占位符,代码侧兜底追加,避免再次出现"代码加了规则但 DB 旧模板没带"
|
||||
# 的静默失效问题(#2312 根因:#2308/#2311/#2312 连续改了 prompts.py 默认值但没写 alembic)。
|
||||
"""
|
||||
# #2312 E2E问题2: 占位符别名兼容——运营DB模板可能用{fusion_result}等历史命名,
|
||||
# 代码传 voiceover_script=。自动补别名避免"未提供待审核文案"。
|
||||
_alias_map = {
|
||||
"voiceover_script": ("fusion_result", "script", "copy_text"),
|
||||
"storyboard_input": ("input_text", "brief", "用户需求"),
|
||||
}
|
||||
for _src_key, _aliases in _alias_map.items():
|
||||
if _src_key in kwargs and kwargs[_src_key]:
|
||||
for _alias in _aliases:
|
||||
kwargs.setdefault(_alias, kwargs[_src_key])
|
||||
body = _safe_format(template.system_prompt, kwargs)
|
||||
|
||||
# 防御性注入:GLOBAL_CONSTRAINTS 核心规则——若 DB 模板完全没有这些关键词则追加。
|
||||
# 判定指纹用多个关键词组合,避免运营改了措辞后漏判。
|
||||
if template.prompt_type in ("storyboard",) and body:
|
||||
try:
|
||||
from packages.application.viral_video.prompts import GLOBAL_CONSTRAINTS
|
||||
|
||||
_need_global = True
|
||||
_fp_compliance = ("不承诺", "效果", "绝对化")
|
||||
_fp_medical = ("医疗", "诊疗", "根治", "疗效", "治愈")
|
||||
_has_c = any(k in body for k in _fp_compliance)
|
||||
_has_m = any(k in body for k in _fp_medical)
|
||||
if _has_c and _has_m:
|
||||
_need_global = False
|
||||
if _need_global:
|
||||
body = body.rstrip() + "\n\n" + GLOBAL_CONSTRAINTS
|
||||
except Exception:
|
||||
pass
|
||||
# {industry_redlines} 占位符缺失时,把运行时传入的红线内容直接追加到末尾。
|
||||
ir = kwargs.get("industry_redlines", "")
|
||||
_ir_fps = (
|
||||
"【养生/健康/理疗行业合规红线",
|
||||
"【上次生成审核未通过的具体问题",
|
||||
"【通用合规】",
|
||||
"(非医疗健康行业,遵守通用广告法",
|
||||
)
|
||||
_already_has_ir = any(k in body for k in _ir_fps)
|
||||
if ir and "{industry_redlines}" not in body and not _already_has_ir:
|
||||
body = body.rstrip() + "\n\n" + ir
|
||||
|
||||
example = (template.example_output or "").strip()
|
||||
if not example:
|
||||
return body
|
||||
# 追加一个 assistant 角色样例消息由调用方拼成多轮;system 里只能带规则,example 统一由 user 侧追加
|
||||
return body
|
||||
|
||||
|
||||
def render_user_prompt(template: PromptTemplate, **kwargs) -> str:
|
||||
r"""填充 user_prompt_template 占位符;example_output 存在时作为 few-shot 样例追加到 user 消息末尾,
|
||||
配合 \_build_example_exchange 拼多轮 messages,LLM 收到完整「示例输入→示例输出」对齐格式。"""
|
||||
# #2312 E2E问题2: 同 render_system_prompt,补占位符别名
|
||||
_alias_map = {
|
||||
"voiceover_script": ("fusion_result", "script", "copy_text"),
|
||||
"storyboard_input": ("input_text", "brief", "用户需求"),
|
||||
}
|
||||
for _src_key, _aliases in _alias_map.items():
|
||||
if _src_key in kwargs and kwargs[_src_key]:
|
||||
for _alias in _aliases:
|
||||
kwargs.setdefault(_alias, kwargs[_src_key])
|
||||
body = _safe_format(template.user_prompt_template, kwargs)
|
||||
example = (template.example_output or "").strip()
|
||||
if not example:
|
||||
return body
|
||||
return body
|
||||
|
||||
|
||||
def build_messages(template: PromptTemplate, **kwargs) -> list[dict]:
|
||||
"""构造传给 LLM 的 messages 列表:system + (user, assistant[example]) + user。
|
||||
|
||||
- 第 1 条 system:规则
|
||||
- 如果 template.example_output 非空,追加一轮 few-shot:user 给示例 user_prompt 渲染结果,assistant 给示例输出
|
||||
- 最后 user:当前任务真实 user_prompt
|
||||
这样 LLM 既能看到规则,又能看到一个完整「输入→输出」样例,格式遵从度大幅提升。
|
||||
"""
|
||||
sys_body = render_system_prompt(template, **kwargs)
|
||||
user_body = render_user_prompt(template, **kwargs)
|
||||
example = (template.example_output or "").strip()
|
||||
messages: list[dict] = [{"role": "system", "content": sys_body}]
|
||||
if example:
|
||||
# 用一个通用「请参考以下示例输出格式」作为 few-shot user 前缀
|
||||
messages.append(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "以下是一个符合规范的完整示例输出,请严格参照其格式、字段、风格来生成:",
|
||||
}
|
||||
)
|
||||
messages.append({"role": "assistant", "content": example})
|
||||
messages.append({"role": "user", "content": user_body})
|
||||
return messages
|
||||
|
||||
@@ -15,19 +15,67 @@ example_output / is_active。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# 所有文案类 Prompt 自动注入的硬约束
|
||||
# 所有文案类 Prompt 自动注入的硬约束(#2312: 加强医疗/养生行业红线)
|
||||
GLOBAL_CONSTRAINTS = """【必须遵守的硬约束】
|
||||
1. 不编造时间:不写"今年最新""2024 爆款"等会过时的时间表述。
|
||||
2. 不承诺效果:不写"保证""一定""100%有效""包治百病"等绝对化用语。
|
||||
2. 不承诺效果:不写"保证""一定""100%有效""包治百病"等绝对化用语,不写任何效果承诺。
|
||||
3. 不编造价格、销量、认证、奖项:除非用户在文案中明确给出,否则一律不写。
|
||||
4. 符合广告法及平台社区规范。
|
||||
5. 只描述图片中真实可见的内容,看不到的不瞎猜。"""
|
||||
4. 符合广告法及平台社区规范,不触碰医疗/养生/金融等敏感行业红线。
|
||||
5. 只描述图片中真实可见的内容,看不到的不瞎猜。
|
||||
6. 不出现医疗/诊疗/治疗/治愈/疗效/根治/对症/药效/偏方/秘方/药到病除/一用就好/立刻见效/特效/克星/急救/救星/神药/祖传/宫廷秘方/一贴灵/一抹就好等宣称诊疗或疗效的词汇。
|
||||
7. 养生/按摩/理疗/推拿/养生馆/调理类门店文案:只写"放松""舒缓""缓解疲劳""舒服""松快""解乏""轻松""舒展""按完轻松多了"等感受类表达,严禁暗示治病、治病根、诊疗、根治、对症、疗效、专家诊断、老师一搭手就知道问题在哪等越界表述。
|
||||
8. 不冒充医生/专家/老师做诊疗判断,不写"老师帮你看看""一搭手就知道""哪儿疼治哪儿""对症调理"等暗示诊断/治疗的表述。"""
|
||||
|
||||
# 医疗/养生/保健/理疗行业强约束(当 industry 命中 HEALTH_INDUSTRY_KEYWORDS 时额外注入)
|
||||
HEALTH_INDUSTRY_REDLINES = """【养生/健康/理疗行业合规红线(本行业必须严格遵守)】
|
||||
1. 严禁暗示诊疗/治疗/治愈:禁止出现"治疗""治愈""治好""根治""对症""诊疗""看病""就诊""疗效""药效""见效""克星""救星""药到病除""一用就好""立刻见效""特效""一贴灵""一抹就好"等词。
|
||||
2. 严禁诊断暗示:禁止出现"帮你看看""一搭手就知道""哪儿有问题""查出""诊断""判断"等暗示专业诊断的表述;工作人员不能被描述成具备诊断能力的"老师""大夫""医生""专家"。
|
||||
3. 严禁功效承诺:禁止承诺"能治好""贴了就好""按完就不疼""根治""永不复发""药到病除""立刻缓解"等。
|
||||
4. 身体部位疼痛描述只能作为**顾客主观感受的共情钩子**,不能接"我们能治/能好/能根除"的承诺。正确示范:「肩膀酸了吧?来这儿按一按,松快松快」;错误示范:「肩颈疼别忍着,让老师帮你看看,一搭手就知道问题在哪」。
|
||||
5. 门店/服务只能描述成"放松""舒缓""缓解疲劳""舒服""松快""解乏""轻松""舒展""休息一下"这类纯感受/体验类词。
|
||||
6. 不要出现"膏药""针灸""艾灸""中药""方子""疗程""对症""偏方""秘方""祖传""宫廷秘方"等医疗/药方相关词。
|
||||
7. 不要引导"别贴膏药""别吃药""别去医院"等贬低正规医疗的表述。
|
||||
8. 不要使用"颈肩腰腿,哪儿酸哪儿胀"这类像中医问诊的话术,改为"肩膀酸、腰背紧,想松快一下"等消费者自我感受的口语化表达。"""
|
||||
|
||||
# 判定健康/养生行业的关键词(命中其一即注入 HEALTH_INDUSTRY_REDLINES)
|
||||
HEALTH_INDUSTRY_KEYWORDS: tuple[str, ...] = (
|
||||
"养生",
|
||||
"健康",
|
||||
"理疗",
|
||||
"推拿",
|
||||
"按摩",
|
||||
"艾灸",
|
||||
"针灸",
|
||||
"正骨",
|
||||
"康复",
|
||||
"中医",
|
||||
"经络",
|
||||
"穴位",
|
||||
"肩颈",
|
||||
"腰腿",
|
||||
"筋骨",
|
||||
"调理",
|
||||
"保健",
|
||||
"SPA",
|
||||
"spa",
|
||||
"养生馆",
|
||||
"按摩店",
|
||||
"御众堂",
|
||||
"月子",
|
||||
"产后修复",
|
||||
"产康",
|
||||
"足疗",
|
||||
"足浴",
|
||||
"盲人按摩",
|
||||
)
|
||||
|
||||
# 负向提示(注入 storyboard / 视频生成负面词)
|
||||
NEGATIVE_RULES = """【反套路化要求】
|
||||
禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;
|
||||
禁止使用"很近"等错别字,应使用"最近";
|
||||
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。"""
|
||||
禁止"很近"等错别字,应使用"最近";
|
||||
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。
|
||||
禁止医疗诊疗类词汇(治疗/治愈/根治/疗效/特效/药到病除/一用就好/对症/偏方/秘方/一搭手就知道/帮你看看等)。
|
||||
禁止效果承诺(包治/根治/立刻见效/永不复发/一用就好等)。"""
|
||||
|
||||
# 输出禁用套路词(测试/reviewer/worker 三处共享)
|
||||
# 注:广告法绝对化用语(如"最""第一""100%")在 worker 侧 _quick_compliance_blacklist_check 单独做本地替换,
|
||||
@@ -113,7 +161,9 @@ _STORYBOARD_SYSTEM = (
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<voiceover>这一镜的口播台词</voiceover>
|
||||
<visual>具体、有画面感的镜头描述(主体/动作/镜头运动/景别/光线)</visual>
|
||||
<shot>景别、机位高度、焦段、光圈、运镜(6项摄影参数,简短,如『中近景平视,50mm f/1.8,缓慢推镜』)</shot>
|
||||
<visual>画面主体、场景、光线、氛围(只写画面内容,不含景别/运镜参数,如『暖光木色门店,店员微笑递茶,背景虚化』)</visual>
|
||||
<action_details>人物动作、表情、物品操作细节(真人出镜镜头必填,纯产品/空镜填空线『-』)</action_details>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
@@ -124,17 +174,64 @@ _STORYBOARD_SYSTEM = (
|
||||
+ """</negative>
|
||||
</script>
|
||||
|
||||
## 写作要求
|
||||
1. 口播台词:像真人面对镜头说话,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。
|
||||
2. 画面描述:写清"观众会看到什么",有动作、有镜头运动、有景别和光线,具体可拍;不堆砌形容词,不写无法实现的画面。
|
||||
3. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案,可用小标题与短句组织;不要做字段列表,不要出现"镜头一/台词:"这类制作说明。
|
||||
4. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
|
||||
5. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
|
||||
6. 分镜数量与时长匹配总时长,节奏紧凑。
|
||||
7. 【字数/镜头数/时间轴硬约束】:严格遵守 user_prompt 里给出的 {duration_constraint_text},宁少勿多。
|
||||
8. 每个 clip 必须分配一个 reference_image_index(从 0 开始的图片序号),没有合适图片填 -1。
|
||||
9. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜,不能忽略任何一项用户参数。
|
||||
10. 禁止使用"很近"等错别字,统一使用"最近"。"""
|
||||
"""
|
||||
+ GLOBAL_CONSTRAINTS
|
||||
+ """
|
||||
|
||||
{industry_redlines}
|
||||
|
||||
## 【文案创作四铁律】(最优先级,必须严格遵守)
|
||||
|
||||
### 铁律一:开头3秒——必须是正在发生的事,禁止亮广告身份
|
||||
- 第一句话/第一个画面必须是「正在发生的事」:正在进行的对话、冲突、悬念、反常识瞬间、当场抓包、具体场景中的人
|
||||
- ❌ 绝对禁止以下开头(这些是广告身份证,观众0.5秒划走):
|
||||
「大家好/我是XX/我干这行XX年了/你是否/很多人/人过XX/你有没有发现/今天给大家推荐/家人们」等自我介绍、痛点科普、泛泛而谈开场
|
||||
- 前3秒不要亮店名/品牌名/「来找我」「来我店」这类推销词
|
||||
- 开头台词要短、有冲击力、让人想继续看(2-8个字最佳),像"开个价吧""又疼?""你这肩膀硬得跟石板似的""昨天来个顾客进门就哭"
|
||||
|
||||
### 铁律二:内容——像真人讲故事,禁止抽象词堆砌
|
||||
- 文案要像真人在对话或讲故事,不是在念广告;不要一个人干巴巴讲道理,尽量有"人物"和"对话"感(独白也要有场景代入,像在跟人掏心窝子说话)
|
||||
- 必须有情绪起伏:不能从头到尾一个腔调,全片至少要有2-3个情绪转折点(惊讶/好笑/好奇/感动/反转)
|
||||
- 禁止抽象形容词堆砌:❌「专业/靠谱/实惠/舒服/松快/效果好/品质好/值得信赖/服务周到/环境好」这些空词一律不准写
|
||||
- 卖点必须场景化、数字具体化、有反差梗:
|
||||
✅ 不说「肩膀放松」→说「按完你今晚能扛着孙子上三楼不喘气」
|
||||
✅ 不说「效果好」→说「昨天张哥按完在我这睡了半小时,呼噜打隔壁来敲门」
|
||||
✅ 不说「专业」→说「这手劲我练了二十三年,比你老公给你揉的准十倍」
|
||||
✅ 不说「舒服」→说「按完那一下,你整个人跟面条似的软在那」
|
||||
- 可以用反套路/反转/自嘲/幽默/当场抓包/顾客反应来增加记忆点;人物对话比单人独白吸引力高3倍
|
||||
- 所有描述要能"看见画面"——读者脑子里能浮现出具体场景、人物表情、动作,而不是抽象评价
|
||||
|
||||
### 铁律三:结尾CTA——好奇心驱动,禁止指令式推销
|
||||
- ❌ 禁止指令式硬推CTA:「快来吧/赶紧来/欢迎光临/不要错过/点击购买/赶紧试试/走过路过不要错过/机不可失/先到先得/错过后悔一辈子」
|
||||
- ✅ 用好奇心/互动钩子驱动CTA:
|
||||
- 提问互动式:「你摸摸你肩膀这根筋,是不是一按就酸?评论区告诉我」
|
||||
- 悬念留钩式:「昨天那个张哥,按完当场睡了半小时——你猜他啥情况?」
|
||||
- 轻量推荐式:「路过可以进来试试,不好算我的」
|
||||
- 破梗自嘲式:「我知道你以为我要推销了——其实真不是,你试试就知道」
|
||||
- CTA要自然,像朋友随口说一句,不要像销售在逼单
|
||||
|
||||
### 铁律四:语言风格——像朋友聊天,禁止广告腔/服务腔/说教腔
|
||||
- 短句为主(每句5-15字最佳),像口语不像书面语;不要长句/排比句/对仗句/朗诵腔
|
||||
- 禁止以下腔调:
|
||||
- 服务腔:「竭诚为您服务/欢迎莅临/您的满意是我们的追求/宾至如归」
|
||||
- 广告腔:「限时特惠/重磅来袭/好评如潮/销量领先/品质保证」
|
||||
- 长辈说教腔:「听我一句劝/我跟你说/你不懂/年轻人啊/过来人告诉你」
|
||||
- 主播带货腔:「家人们/宝子们/321上链接/买它/OMG」
|
||||
- 要像:朋友聊天、当场发生的事、一个小故事、两个熟人在拌嘴、师傅边干活边随口搭话
|
||||
- 【重要】以上铁律不能覆盖GLOBAL_CONSTRAINTS合规规则,医疗/养生类仍不能有诊疗/疗效承诺;但表达方式必须变——合规的前提下,把"松快""舒服"变成具体场景描述("按完在我这睡着了""出门时脚步轻了"),而不是删掉所有具体内容只留抽象词
|
||||
|
||||
## 写作要求(格式与技术规范)
|
||||
1. 口播台词必须遵守上方【文案创作四铁律】,像真人对话、短句口语、有情绪起伏、有场景、有反转、有钩子
|
||||
2. <shot> 只写6项摄影参数(景别/机位高度/焦段/光圈/运镜),简短不超过20字;<visual> 写观众会看到的画面主体/场景/光线/氛围;<action_details> 写人物动作/表情/物品操作细节(真人出镜必填)。三字段各司其职,不要混写
|
||||
3. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有故事感的成片文案,可用小标题与短句组织;不要做字段列表,不要出现"镜头一/台词:"这类制作说明
|
||||
4. 内容必须来自图片观察与用户给出的信息,不编造卖点/价格/销量/认证/奖项、不夸大、不使用绝对化用语和虚假承诺;卖点要场景化表达(铁律二)
|
||||
5. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1
|
||||
6. 分镜数量与时长匹配总时长,节奏紧凑(平均每镜2-3秒,开头1-2镜要更快)
|
||||
7. 【字数/镜头数/时间轴硬约束】:严格遵守 user_prompt 里给出的 {duration_constraint_text},宁少勿多
|
||||
8. 每个 clip 必须分配一个 reference_image_index(从 0 开始的图片序号),没有合适图片填 -1
|
||||
9. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜,不能忽略任何一项用户参数
|
||||
10. 禁止使用"很近"等错别字,统一使用"最近"
|
||||
11. 前3秒第一镜画面必须是具体场景/人物动作/对话瞬间,禁止空镜/产品特写/品牌logo开场;第一镜必须有人有动作有情绪"""
|
||||
)
|
||||
|
||||
_STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
|
||||
@@ -167,19 +264,25 @@ _STORYBOARD_EXAMPLE = """<script>
|
||||
<clip index="1">
|
||||
<time_range>0-4秒</time_range>
|
||||
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
|
||||
<visual>中近景,暖光下一位妈妈略显疲惫地看向镜中,镜头缓缓推近</visual>
|
||||
<shot>中近景平视,50mm f/2.0,缓慢推镜</shot>
|
||||
<visual>暖光浴室镜前,妈妈略显疲惫地看向镜中自己,镜灯打在侧脸</visual>
|
||||
<action_details>妈妈手扶洗手台,微微低头再抬眼看镜,眼眶微红</action_details>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>4-10秒</time_range>
|
||||
<voiceover>推开门,暖光和一杯热茶先接住了你。</voiceover>
|
||||
<visual>中景,门店前台暖光木色装修,店员微笑递上茶水,镜头平移扫过环境</visual>
|
||||
<shot>中景平视,35mm f/2.8,镜头缓慢平移</shot>
|
||||
<visual>门店前台暖光木色装修,茶水冒热气,背景柔和虚化</visual>
|
||||
<action_details>店员微笑双手递上茶杯,妈妈伸手接过,指尖轻触杯沿</action_details>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
</clip>
|
||||
<clip index="3">
|
||||
<time_range>10-15秒</time_range>
|
||||
<voiceover>来御众堂躺一会儿,整个人都松了。</voiceover>
|
||||
<visual>特写,妈妈闭眼放松做护理,暖光打在侧脸,最后镜头拉远露出品牌LOGO</visual>
|
||||
<shot>近景缓慢拉远至中景,85mm f/1.8,匀速后拉</shot>
|
||||
<visual>护理床暖光打在妈妈侧脸,最后画面露出御众堂品牌LOGO</visual>
|
||||
<action_details>妈妈闭眼放松,嘴角微微上扬,肩膀缓缓下沉</action_details>
|
||||
<reference_image_index>2</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
@@ -190,9 +293,11 @@ _STORYBOARD_EXAMPLE = """<script>
|
||||
|
||||
# ── 模板3:文案审核(合规/质量门禁)───────────────────────────────────
|
||||
_REVIEW_SYSTEM = """你是一名短视频广告合规审核与文案优化专家。请审核下面这段口播文案:
|
||||
1) 广告法与平台合规(绝对化用语、虚假承诺、医疗功效宣称、导流违规、套路烂梗词);
|
||||
2) 口播是否自然、口语化、无书面腔;
|
||||
3) 是否有明显错别字(例如把"最近"写成"很近")。
|
||||
1) 广告法与平台合规(绝对化用语、虚假承诺、导流违规、套路烂梗词);
|
||||
2) **医疗/养生/健康红线(重点审查)**:是否出现诊疗暗示("帮你看看""一搭手就知道""老师诊断""检查"等)、疗效/治愈承诺("治好""根治""见效""一用就好""药到病除""特效""克星""永不复发"等)、医疗术语("疗程""对症""膏药""针灸""中药""偏方""秘方""诊疗""治疗""就诊"等)、冒充医生/专家/老师做诊断、贬低正规医疗("别贴膏药""别吃药""别去医院"等);
|
||||
3) 养生/按摩/理疗/推拿/保健类文案是否用了纯感受类表达(放松/舒缓/舒服/松快/解乏/轻松),而不是"治病/根治/疗效"类表达;
|
||||
4) 口播是否自然、口语化、无书面腔;
|
||||
5) 是否有明显错别字(例如把"最近"写成"很近")。
|
||||
|
||||
只输出 XML,结构:
|
||||
<review>
|
||||
@@ -229,18 +334,18 @@ DEFAULT_TEMPLATES: list[dict] = [
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "编导级分镜 v3.1",
|
||||
"name": "编导级分镜 v3.2",
|
||||
"prompt_type": "storyboard",
|
||||
"version": 4,
|
||||
"version": 5,
|
||||
"system_prompt": _STORYBOARD_SYSTEM,
|
||||
"user_prompt_template": _STORYBOARD_USER,
|
||||
"example_output": _STORYBOARD_EXAMPLE,
|
||||
"is_active": True,
|
||||
},
|
||||
{
|
||||
"name": "文案审核 v2",
|
||||
"name": "文案审核 v3",
|
||||
"prompt_type": "review",
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"system_prompt": _REVIEW_SYSTEM,
|
||||
"user_prompt_template": _REVIEW_USER,
|
||||
"example_output": _REVIEW_EXAMPLE,
|
||||
|
||||
@@ -17,9 +17,8 @@ from typing import Optional
|
||||
|
||||
from packages.application.viral_video import xml_parser as xp
|
||||
from packages.application.viral_video.prompt_loader import (
|
||||
build_messages,
|
||||
get_template,
|
||||
render_system_prompt,
|
||||
render_user_prompt,
|
||||
)
|
||||
from packages.application.viral_video.prompts import COMPLIANCE_BLACKLIST
|
||||
from packages.application.viral_video.schemas import ReviewIssue, ReviewResult
|
||||
@@ -40,6 +39,44 @@ _EXAGGERATION_PATTERNS = [
|
||||
r"一喷(就|全|100)",
|
||||
]
|
||||
|
||||
# 本地规则:医疗/养生/诊疗红线(#2312:出片前最后一道本地兜底,命中即 error)
|
||||
# 注意:这些词/句式出现在养生/理疗/按摩/保健/康复类文案中几乎必然违规,直接挡
|
||||
# 本地规则做快速兜底,LLM 审核是主防线(review prompt v3 已加强医疗维度,语义判断更准)
|
||||
_MEDICAL_REDLINE_PATTERNS = [
|
||||
# 疗效/治愈/根治/效果承诺
|
||||
(r"包治(百病|各种|颈肩|腰腿|风湿|颈椎|腰椎)?", "出现诊疗承诺:'{hit}',暗示包治违反广告法与医疗合规"),
|
||||
(r"根[治除](?!本)", "出现根治/根除表述:'{hit}',非医疗机构不得承诺根治"),
|
||||
(r"(永不复发|药到病除)", "出现疗效承诺:'{hit}'"),
|
||||
(
|
||||
r"一(?:用|贴|抹|喷|吃|服|针|喝)[^,。!?]{0,3}(?:就|即|立)(?:好|见效|灵|愈|止痛)",
|
||||
"出现'一X就好'类即时疗效承诺:'{hit}'",
|
||||
),
|
||||
(r"(?:立刻|马上|立即|瞬间|当天|一次就)(?:见效|止痛|不痛|康复|止?痛|治好)", "出现即时效果承诺:'{hit}'"),
|
||||
(r"特[效药贴针酒]|神药|神贴|神针|妙药", "出现神/特效类违规表述:'{hit}'"),
|
||||
# 诊断暗示(工作人员"搭手就知道""帮你看看哪有问题")
|
||||
(r"一搭手就知道", "出现诊断暗示:'{hit}',非医疗机构按摩师/老师不能做诊断"),
|
||||
(
|
||||
r"(?:老师|师傅|大夫|医生|专家|医师)(?:帮你|给你|为你|一)(?:看看|检查|诊断|判断|瞧瞧|搭手)",
|
||||
"出现诊断暗示:'{hit}',工作人员不能被描述为做诊断",
|
||||
),
|
||||
(r"(?:帮你|给你|为你)(?:看看(?:问题|毛病|病|哪儿|哪)?|检查(?:一下)?|诊断)", "出现诊断暗示:'{hit}'"),
|
||||
# 贬低正规医疗/引导弃医
|
||||
(
|
||||
r"别(?:急着|忙着|光|总|一直)?(?:贴膏药|吃药|上医院|去医院|看医生|打针|拖|扛|忍(?:着|着不)?|硬扛)|不要(?:急着|忙着)?(?:贴膏药|吃药|上医院|去医院|看医生|打针)",
|
||||
"出现贬低/规避正规医疗的引导:'{hit}'",
|
||||
),
|
||||
# 医疗术语(按摩/养生门店正常营销不应出现)
|
||||
(
|
||||
r"(?:疗[程效]|对症(?:下药)?|诊疗|就诊|就医|医嘱|病(?:灶|情|症)|处[方箋])",
|
||||
"出现医疗术语:'{hit}',非医疗机构文案应使用感受类表达",
|
||||
),
|
||||
(
|
||||
r"哪儿酸哪儿胀|哪(?:儿|里)(?:疼|酸|胀|痛|不舒服|不适)[^,。!?]{0,8}(?:就知道|一看就知|老师|师傅|大夫|把下脉)",
|
||||
"出现中医问诊式诊断话术:'{hit}',应改为顾客自我感受表达",
|
||||
),
|
||||
(r"(?:偏方|秘方|宫廷秘方|祖传秘方)", "出现偏方/秘方类违规表述:'{hit}'"),
|
||||
]
|
||||
|
||||
# 本地规则:套路烂梗/明显错别字(与 prompts.COMPLIANCE_BLACKLIST 同步)
|
||||
_VIOLATION_PATTERNS = [re.escape(p) for p in COMPLIANCE_BLACKLIST] + [
|
||||
r"最(好|强|牛|便宜|美|棒)", # 广告法极限词(最X)
|
||||
@@ -119,16 +156,12 @@ class Reviewer:
|
||||
if not self.client or not getattr(self.client, "is_available", True):
|
||||
return None
|
||||
template = get_template("review")
|
||||
system = render_system_prompt(template)
|
||||
user = render_user_prompt(template, voiceover_script=voiceover_script)
|
||||
messages = build_messages(template, voiceover_script=voiceover_script)
|
||||
raw = self.client.chat_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
messages,
|
||||
temperature=0.2,
|
||||
max_tokens=1024,
|
||||
timeout=25,
|
||||
timeout=15, # #2307: 25s过长,超时降级放行
|
||||
)
|
||||
if not raw:
|
||||
return None
|
||||
@@ -178,6 +211,19 @@ class Reviewer:
|
||||
text="出现违规/套路词或错别字:" + self._hit(text, pattern),
|
||||
)
|
||||
)
|
||||
# #2312: 医疗/养生/诊疗红线扫描(命中即 error)
|
||||
for pattern, msg_tpl in _MEDICAL_REDLINE_PATTERNS:
|
||||
m = re.search(pattern, text)
|
||||
if m:
|
||||
hit = m.group(0)
|
||||
issues.append(
|
||||
ReviewIssue(
|
||||
dimension="医疗合规",
|
||||
severity="error",
|
||||
location="voiceover_script",
|
||||
text=msg_tpl.format(hit=hit),
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -159,10 +159,39 @@ VIRAL_VIDEO_MODEL_CONFIG: dict[str, dict] = {
|
||||
|
||||
|
||||
def get_viral_video_model_config(model_key: str | None) -> dict:
|
||||
"""获取模型配置,未知 key 回落到默认 seedance-2.5。"""
|
||||
key = (model_key or "").strip().lower()
|
||||
if key and key in VIRAL_VIDEO_MODEL_CONFIG:
|
||||
"""获取模型配置,未知 key 回落到默认 seedance-2.5。
|
||||
|
||||
#2312 E2E问题4: 支持多种别名/变体查找,防止前端或内部传model_id/大小写/横杠变体
|
||||
# (如 wan3.0-video / Wan-3.0 / wan_3_0 等)误回落 seedance-2.5。
|
||||
"""
|
||||
raw = (model_key or "").strip()
|
||||
if not raw:
|
||||
return VIRAL_VIDEO_MODEL_CONFIG["seedance-2.5"]
|
||||
key = raw.lower()
|
||||
if key in VIRAL_VIDEO_MODEL_CONFIG:
|
||||
return VIRAL_VIDEO_MODEL_CONFIG[key]
|
||||
# 别名归一化:横杠/下划线/点/空格统一去掉,做模糊匹配
|
||||
_norm = key.replace("-", "").replace("_", "").replace(".", "").replace(" ", "")
|
||||
# 同时支持 model_id 反向查找(防止传 wan3.0-video 这类 model_id 串)
|
||||
for _k, _cfg in VIRAL_VIDEO_MODEL_CONFIG.items():
|
||||
_k_norm = _k.replace("-", "").replace("_", "").replace(".", "").replace(" ", "")
|
||||
if _k_norm == _norm:
|
||||
return _cfg
|
||||
_mid = str(_cfg.get("model_id", "")).lower().replace("-", "").replace("_", "").replace(".", "")
|
||||
if _mid and _mid == _norm:
|
||||
return _cfg
|
||||
# wan 系列兜底(任何包含 wan 的未知key归到 wan-3.0,而不是 seedance)
|
||||
if "wan" in key:
|
||||
logger = __import__("logging").getLogger(__name__)
|
||||
logger.warning("[points_rules] 未知wan模型key=%r,归一化为wan-3.0", model_key)
|
||||
return VIRAL_VIDEO_MODEL_CONFIG["wan-3.0"]
|
||||
# seedance 系列同理
|
||||
if "seedance" in key and "2.0" in key and "fast" in key:
|
||||
return VIRAL_VIDEO_MODEL_CONFIG["seedance-2.0-fast"]
|
||||
if "seedance" in key and "mini" in key:
|
||||
return VIRAL_VIDEO_MODEL_CONFIG["seedance-2.0-mini"]
|
||||
if "seedance" in key and "2.0" in key:
|
||||
return VIRAL_VIDEO_MODEL_CONFIG["seedance-2.0"]
|
||||
return VIRAL_VIDEO_MODEL_CONFIG["seedance-2.5"]
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ class PresetVoice:
|
||||
description: 音色描述
|
||||
gender: 性别(male/female)
|
||||
language: 语言代码(如 zh-CN)
|
||||
preview_url: 预览音频 URL(可选)
|
||||
preview_url: 预览音频 URL(可选,前端试听用,短文本)
|
||||
sample_audio_url: 音色样本音频 URL(#2311,reference_audio用,8秒固定文案,部署预生成)
|
||||
tags: 标签列表
|
||||
"""
|
||||
|
||||
@@ -31,6 +32,9 @@ class PresetVoice:
|
||||
gender: str
|
||||
language: str = "zh-CN"
|
||||
preview_url: str = ""
|
||||
sample_audio_url: str = (
|
||||
"" # #2311: 用于reference_audio的8秒静态音色样本公网URL(部署时由seed_preset_voice_samples.py预生成)
|
||||
)
|
||||
tags: list[str] | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
@@ -42,6 +46,7 @@ class PresetVoice:
|
||||
"gender": self.gender,
|
||||
"language": self.language,
|
||||
"preview_url": self.preview_url,
|
||||
"sample_audio_url": self.sample_audio_url,
|
||||
"tags": self.tags or [],
|
||||
}
|
||||
|
||||
@@ -56,6 +61,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/a8fb29a2-7d44-9f16-9dab-f17fa806cf3d.mp3?Expires=1785716123&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=c74kFX%2BcDpmuBny2dPBkvak01oY%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longxiaochun_v3_sample.mp3",
|
||||
tags=["知性", "女声", "助手"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -65,6 +71,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/d065a6da-de0d-9ad2-b64a-00aaf9aa3dea.mp3?Expires=1785716203&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=9hRh7xbmS8eDdiBDDFUatvad1jg%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longxiaoxia_v3_sample.mp3",
|
||||
tags=["沉稳", "女声", "播报"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -74,6 +81,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="male",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/b139d925-4669-979d-89db-2dc16945b577.mp3?Expires=1785730686&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=X2p%2FA7Dm%2BTNfeFilW%2FQO9VoYPLQ%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longsanshu_v3_sample.mp3",
|
||||
tags=["沉稳", "男声", "有声书"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -83,6 +91,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/1ea86db3-4dc9-927e-9b76-c83d89f6788c.mp3?Expires=1785716207&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=Vg0ckgSgPzgNQ%2FNdeJzrE%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longyue_v3_sample.mp3",
|
||||
tags=["温暖", "女声", "广告"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -92,6 +101,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="male",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/6b47a5ce-ae21-995a-ac8b-5e55ed04ec45.mp3?Expires=1785716209&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=zm6WGmthpwTjf8PzgNQ%2FNdeJzrE%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longshu_v3_sample.mp3",
|
||||
tags=["沉稳", "男声", "教育"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -101,6 +111,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/13bf9f6a-72a9-949c-a896-ec3e6b46338b.mp3?Expires=1785730692&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=pxrP2rYmOumc4dw9TD8TZf%2B27gk%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longyingjing_v3_sample.mp3",
|
||||
tags=["冷静", "女声", "纪录片"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -110,6 +121,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="male",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/5e69f0c1-45fa-92ca-ae5c-38272f231efb.mp3?Expires=1785730692&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=uREIiIUFrN7CpJ7avXKWCo04A6I%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longshuo_v3_sample.mp3",
|
||||
tags=["干练", "男声", "科技"],
|
||||
),
|
||||
PresetVoice(
|
||||
@@ -119,6 +131,7 @@ PRESET_VOICES: list[PresetVoice] = [
|
||||
gender="female",
|
||||
language="zh-CN",
|
||||
preview_url="http://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/prod/cosyvoice-v3-flash/20260802/1320180660510733/a38e4c05-25bf-9640-92d6-0b4a14555cdb.mp3?Expires=1785716215&OSSAccessKeyId=LTAI5tGzqbGcEmE58b221XQy&Signature=WtbsqqfR4A6OhpL%2B%2Fe0lh7YBMWM%3D",
|
||||
sample_audio_url="https://xiaoxia-autocut.oss-cn-hangzhou.aliyuncs.com/static/voice-samples/preset/longtian_v3_sample.mp3",
|
||||
tags=["活泼", "女声", "短视频"],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -26,6 +26,7 @@ class StoragePort(ABC):
|
||||
file_or_path: str | Path | object,
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
extra_headers: dict | None = None,
|
||||
) -> str:
|
||||
"""上传文件到存储,返回公开 URL。
|
||||
|
||||
@@ -33,6 +34,7 @@ class StoragePort(ABC):
|
||||
file_or_path: 本地文件路径(str/Path)或类文件对象
|
||||
storage_key: 目标存储键
|
||||
content_type: MIME 类型
|
||||
extra_headers: 额外请求头(如 x-oss-object-acl)
|
||||
|
||||
Returns:
|
||||
公开访问 URL
|
||||
|
||||
@@ -286,6 +286,11 @@ class DoubaoClient:
|
||||
if not self.is_available:
|
||||
return None
|
||||
|
||||
# #2304 追加修复:每次调用必须重置 finish_reason 标记,避免跨调用泄漏导致误判截断(Bug:
|
||||
# attempt1 收到 finish=length 后 last_finish_reason='length',attempt2 开头 chunk 处理后
|
||||
# 立即读到这个陈旧值误判截断、丢弃好内容)。
|
||||
self.last_finish_reason = ""
|
||||
|
||||
url = f"{self.base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
@@ -302,6 +307,11 @@ class DoubaoClient:
|
||||
if self.extra_params:
|
||||
payload.update(self.extra_params)
|
||||
if kwargs:
|
||||
# #2303 追加修复:豆包 ark 端点不认 enable_thinking 布尔参数,
|
||||
# 必须用 thinking={"type":"disabled"} 才会真正关闭 reasoning(A/B实测TTFT从100s+→2s)。
|
||||
# 调用方传 enable_thinking=False 时,统一翻译为标准字段。
|
||||
if kwargs.pop("enable_thinking", None) is False:
|
||||
payload["thinking"] = {"type": "disabled"}
|
||||
payload.update(kwargs)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
@@ -405,10 +415,21 @@ class DoubaoClient:
|
||||
if self.extra_params:
|
||||
payload.update(self.extra_params)
|
||||
if kwargs:
|
||||
# 同同步路径:enable_thinking=False 翻译为 thinking={"type":"disabled"}
|
||||
_thinking_disabled = kwargs.pop("enable_thinking", None) is False
|
||||
payload.update(kwargs)
|
||||
if _thinking_disabled:
|
||||
payload["thinking"] = {"type": "disabled"}
|
||||
|
||||
# 每次调用 reset finish_reason 标记(同 chat_completion,避免跨 attempt 泄漏误判截断)
|
||||
self.last_finish_reason = ""
|
||||
|
||||
_t0 = time.time()
|
||||
for attempt in range(self.max_retries + 1):
|
||||
# #2303 追加修复:流式路径 SDK 内重试对 read timeout 翻倍耗时(单 attempt 外层120s +
|
||||
# SDK 重试120s*2 ≈ 360-420s),把两个 attempt 总预算吃光。外层 _step_script_generation
|
||||
# 已实现 fast→fast→pro 三级attempt兜底,这里禁 SDK 内重试,超时/错误直接抛给外层。
|
||||
_stream_max_retries = 0
|
||||
for attempt in range(_stream_max_retries + 1):
|
||||
try:
|
||||
_req_timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
if timeout:
|
||||
@@ -439,6 +460,8 @@ class DoubaoClient:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content_piece = delta.get("content") or ""
|
||||
# reasoning_content 由豆包 seed-2-1-pro 思考模式返回,
|
||||
# 对模板填充式生成无用(会占满 deadline),调用方可传 enable_thinking=False 关闭
|
||||
if content_piece:
|
||||
yield content_piece
|
||||
finish_reason = choices[0].get("finish_reason")
|
||||
@@ -453,19 +476,13 @@ class DoubaoClient:
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < self.max_retries:
|
||||
wait = 0.5 * (2**attempt)
|
||||
logger.warning(
|
||||
"豆包流式API失败,%.1fs后重试 (%d/%d, elapsed=%.1fs): %s",
|
||||
wait,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
time.time() - _t0,
|
||||
e,
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
logger.error("豆包流式API最终失败: elapsed=%.1fs err=%s", time.time() - _t0, e)
|
||||
# 流式路径禁 SDK 内重试(外层 attempt 机制兜底,避免单 attempt 被 SDK 重试拖到 300s+)
|
||||
err_msg = str(e).lower()
|
||||
is_timeout = any(k in err_msg for k in ("timeout", "timed out", "deadline", "超时", "read timeout"))
|
||||
if is_timeout:
|
||||
logger.warning("[doubao] 流式 read timeout elapsed=%.1fs,交外层attempt处理", time.time() - _t0)
|
||||
else:
|
||||
logger.error("豆包流式API失败: elapsed=%.1fs err=%s", time.time() - _t0, e)
|
||||
return
|
||||
|
||||
def vision_completion(
|
||||
@@ -536,8 +553,12 @@ class DoubaoClient:
|
||||
if self.extra_params:
|
||||
payload.update(self.extra_params)
|
||||
if kwargs:
|
||||
if kwargs.pop("enable_thinking", None) is False:
|
||||
payload["thinking"] = {"type": "disabled"}
|
||||
payload.update(kwargs)
|
||||
|
||||
self.last_finish_reason = "" # reset,避免跨调用泄漏
|
||||
|
||||
req_timeout = self._resolve_timeout(timeout or self.timeout)
|
||||
last_error: Optional[Exception] = None
|
||||
_t0 = time.time()
|
||||
|
||||
@@ -180,7 +180,7 @@ class DashScopeClient:
|
||||
"audio": bool(generate_audio),
|
||||
}
|
||||
if ratio and ratio != "adaptive":
|
||||
params["aspect_ratio"] = ratio
|
||||
params["ratio"] = ratio
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
|
||||
@@ -183,22 +183,26 @@ class SharedStorageService(StoragePort):
|
||||
file_or_path: str | Path | object,
|
||||
storage_key: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
extra_headers: dict | None = None,
|
||||
) -> str:
|
||||
if self.bucket is None:
|
||||
raise RuntimeError("OSS storage is not configured")
|
||||
try:
|
||||
headers: dict = {"Content-Type": content_type}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
if isinstance(file_or_path, (str, Path)):
|
||||
self.bucket.put_object_from_file(
|
||||
storage_key,
|
||||
str(file_or_path),
|
||||
headers={"Content-Type": content_type},
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
file_or_path.seek(0)
|
||||
self.bucket.put_object(
|
||||
storage_key,
|
||||
file_or_path,
|
||||
headers={"Content-Type": content_type},
|
||||
headers=headers,
|
||||
)
|
||||
return f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
@@ -208,6 +212,7 @@ class SharedStorageService(StoragePort):
|
||||
self,
|
||||
local_path: str | Path,
|
||||
storage_key: str,
|
||||
extra_headers: dict | None = None,
|
||||
) -> Optional[str]:
|
||||
local_path = Path(local_path)
|
||||
if not local_path.exists():
|
||||
@@ -230,6 +235,9 @@ class SharedStorageService(StoragePort):
|
||||
use_multipart = False
|
||||
file_size = 0
|
||||
|
||||
headers: dict = {}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
if use_multipart:
|
||||
logger.info(
|
||||
"大文件分片上传(internal): key=%s size=%.1fMB part=%dMB threads=%d",
|
||||
@@ -245,9 +253,10 @@ class SharedStorageService(StoragePort):
|
||||
multipart_threshold=OSS_MULTIPART_THRESHOLD,
|
||||
part_size=OSS_PART_SIZE,
|
||||
num_threads=OSS_MULTIPART_NUM_THREADS,
|
||||
headers=headers or None,
|
||||
)
|
||||
else:
|
||||
self.bucket.put_object_from_file(storage_key, str(local_path))
|
||||
self.bucket.put_object_from_file(storage_key, str(local_path), headers=headers or None)
|
||||
result["url"] = f"{self.public_url}/{storage_key}"
|
||||
except Exception as e:
|
||||
result["error"] = e
|
||||
|
||||
@@ -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**: 待创建
|
||||
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""#2311 部署脚本:为所有预置音色生成 8 秒静态音色样本并上传 OSS。
|
||||
|
||||
用途:
|
||||
部署 release 时执行,为 8 个 CosyVoice 预置音色各合成一段 8s 固定样本 mp3,
|
||||
上传到 OSS 永久路径 static/voice-samples/preset/{voice_id}_sample.mp3(公共读),
|
||||
并回填 packages/domain/preset_voices.py 中 PresetVoice.sample_audio_url 字段。
|
||||
|
||||
出片流程通过 PresetVoice.sample_audio_url 直接读取,零 TTS、零冷启动。
|
||||
|
||||
用法(在 API/Worker 容器内执行):
|
||||
cd /path/to/project && python scripts/seed_preset_voice_samples.py
|
||||
|
||||
可选参数:
|
||||
--dry-run 只打印要做的操作,不实际上传/回填
|
||||
--force 强制重新生成(即使 OSS 已有文件也覆盖)
|
||||
--no-public 上传后不设公共读 ACL(默认公共读)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import oss2 # type: ignore
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 固定 8 秒样本文案(覆盖中文四声、开合齐撮、常见声母韵母)
|
||||
VOICE_SAMPLE_SCRIPT_ZH = "你好,很高兴认识你。今天天气真不错,我们一起来看看吧。"
|
||||
VOICE_SAMPLE_SCRIPT_EN = "Hi there, nice to meet you. The weather is great today, let's take a look together."
|
||||
|
||||
# OSS 路径前缀
|
||||
OSS_PREFIX = "static/voice-samples/preset/"
|
||||
|
||||
|
||||
def _project_root() -> Path:
|
||||
"""定位项目根目录(兼容容器/软链/不同 cwd 场景)。"""
|
||||
script_dir = Path(__file__).resolve().parent
|
||||
candidate = script_dir.parent
|
||||
if (candidate / "packages" / "domain" / "preset_voices.py").exists():
|
||||
return candidate
|
||||
cur = Path.cwd().resolve()
|
||||
for _ in range(8):
|
||||
if (cur / "packages" / "domain" / "preset_voices.py").exists():
|
||||
return cur
|
||||
if cur.parent == cur:
|
||||
break
|
||||
cur = cur.parent
|
||||
raise RuntimeError("无法定位项目根目录,请在项目根目录或 scripts/ 下执行本脚本")
|
||||
|
||||
|
||||
PROJECT_ROOT = _project_root()
|
||||
PRESET_VOICES_PY = PROJECT_ROOT / "packages" / "domain" / "preset_voices.py"
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
def _get_cosyvoice_service():
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
|
||||
return CosyVoiceService()
|
||||
|
||||
|
||||
def _get_oss_bucket():
|
||||
endpoint = os.environ.get("OSS_ENDPOINT", "")
|
||||
ak = os.environ.get("OSS_ACCESS_KEY_ID", "")
|
||||
sk = os.environ.get("OSS_ACCESS_KEY_SECRET", "")
|
||||
bucket_name = os.environ.get("OSS_BUCKET_NAME", "")
|
||||
|
||||
if not (endpoint and ak and sk and bucket_name):
|
||||
try:
|
||||
from packages.shared.config import get_shared_settings
|
||||
|
||||
s = get_shared_settings()
|
||||
endpoint = endpoint or getattr(s, "oss_endpoint", "")
|
||||
ak = ak or getattr(s, "oss_access_key_id", "")
|
||||
sk = sk or getattr(s, "oss_access_key_secret", "")
|
||||
bucket_name = bucket_name or getattr(s, "oss_bucket_name", "")
|
||||
except Exception as e:
|
||||
logger.warning("从settings读取OSS配置失败: %s", e)
|
||||
|
||||
if not (endpoint and ak and sk and bucket_name):
|
||||
logger.error("缺少 OSS 凭证,请设置 OSS_ENDPOINT/OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET/OSS_BUCKET_NAME")
|
||||
sys.exit(3)
|
||||
|
||||
ep_host = endpoint.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
auth = oss2.Auth(ak, sk)
|
||||
bucket = oss2.Bucket(auth, f"https://{ep_host}", bucket_name)
|
||||
public_base = f"https://{bucket_name}.{ep_host}"
|
||||
return bucket, public_base
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="#2311 预生成预置音色静态样本")
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
ap.add_argument("--force", action="store_true")
|
||||
ap.add_argument("--no-public", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
from packages.domain.preset_voices import PRESET_VOICES
|
||||
|
||||
logger.info("项目根目录: %s", PROJECT_ROOT)
|
||||
logger.info("预置音色总数: %d", len(PRESET_VOICES))
|
||||
cosy = _get_cosyvoice_service()
|
||||
bucket, public_base = _get_oss_bucket()
|
||||
|
||||
voice_to_url: dict[str, str] = {}
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="preset_voice_sample_"))
|
||||
|
||||
for voice in PRESET_VOICES:
|
||||
voice_id = voice.voice_id
|
||||
lang = voice.language or "zh-CN"
|
||||
script = VOICE_SAMPLE_SCRIPT_EN if lang.lower().startswith("en") else VOICE_SAMPLE_SCRIPT_ZH
|
||||
oss_key = f"{OSS_PREFIX}{voice_id}_sample.mp3"
|
||||
public_url = f"{public_base}/{oss_key}"
|
||||
|
||||
exists = False
|
||||
try:
|
||||
exists = bucket.object_exists(oss_key)
|
||||
except Exception as e:
|
||||
logger.warning("[%s] 检查OSS存在失败: %s", voice_id, e)
|
||||
|
||||
if exists and not args.force:
|
||||
logger.info("[%s] OSS已有样本,跳过: %s", voice_id, public_url)
|
||||
voice_to_url[voice_id] = public_url
|
||||
continue
|
||||
|
||||
if args.dry_run:
|
||||
logger.info("[DRY][%s] 将合成「%s...」→ oss://%s", voice_id, script[:20], oss_key)
|
||||
voice_to_url[voice_id] = public_url
|
||||
continue
|
||||
|
||||
logger.info("[%s] 合成中...", voice_id)
|
||||
try:
|
||||
result = cosy.synthesize_speech(text=script, voice_id=voice_id, format="mp3")
|
||||
except Exception as e:
|
||||
logger.error("[%s] TTS合成失败: %s", voice_id, e, exc_info=True)
|
||||
continue
|
||||
|
||||
local_mp3 = tmpdir / f"{voice_id}_sample.mp3"
|
||||
try:
|
||||
urllib.request.urlretrieve(result.audio_url, str(local_mp3))
|
||||
size = local_mp3.stat().st_size
|
||||
if size < 4096:
|
||||
logger.error("[%s] 文件太小(%d),跳过", voice_id, size)
|
||||
continue
|
||||
logger.info("[%s] 下载完成: %d bytes", voice_id, size)
|
||||
except Exception as e:
|
||||
logger.error("[%s] 下载合成音频失败: %s", voice_id, e, exc_info=True)
|
||||
continue
|
||||
|
||||
logger.info("[%s] 上传 oss://%s", voice_id, oss_key)
|
||||
try:
|
||||
headers = {"Content-Type": "audio/mpeg"}
|
||||
bucket.put_object_from_file(oss_key, str(local_mp3), headers=headers)
|
||||
if not args.no_public:
|
||||
bucket.put_object_acl(oss_key, oss2.OBJECT_ACL_PUBLIC_READ)
|
||||
logger.info("[%s] 上传完成 → %s", voice_id, public_url)
|
||||
voice_to_url[voice_id] = public_url
|
||||
except Exception as e:
|
||||
logger.error("[%s] OSS上传失败: %s", voice_id, e, exc_info=True)
|
||||
continue
|
||||
|
||||
if not voice_to_url:
|
||||
logger.warning("没有成功生成任何样本")
|
||||
return 1
|
||||
|
||||
if args.dry_run:
|
||||
logger.info("[DRY] 将回填 %d 个 sample_audio_url 到 %s", len(voice_to_url), PRESET_VOICES_PY)
|
||||
for vid, url in voice_to_url.items():
|
||||
logger.info(" %s → %s", vid, url)
|
||||
return 0
|
||||
|
||||
text = PRESET_VOICES_PY.read_text(encoding="utf-8")
|
||||
orig = text
|
||||
for voice_id, url in voice_to_url.items():
|
||||
block_pat = re.compile(
|
||||
r'(PresetVoice\([^)]*?voice_id="' + re.escape(voice_id) + r'"[^)]*?sample_audio_url=)"[^"]*"',
|
||||
re.DOTALL,
|
||||
)
|
||||
new_text, n = block_pat.subn(rf'\1"{url}"', text, count=1)
|
||||
if n == 0:
|
||||
logger.warning("[%s] 回填sample_audio_url失败(正则未匹配),请手动添加", voice_id)
|
||||
continue
|
||||
text = new_text
|
||||
|
||||
if text != orig:
|
||||
PRESET_VOICES_PY.write_text(text, encoding="utf-8")
|
||||
logger.info("✅ 已回填 %d 条 sample_audio_url 到 %s", len(voice_to_url), PRESET_VOICES_PY)
|
||||
logger.info("下一步:git diff packages/domain/preset_voices.py 确认后 commit & push")
|
||||
else:
|
||||
logger.info("preset_voices.py 未变化(可能全部已回填过)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -8,8 +8,11 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from packages.application.ditto_emotion_service import (
|
||||
EMO_ANGER,
|
||||
EMO_HAPPY,
|
||||
EMO_NEUTRAL,
|
||||
EMO_SAD,
|
||||
EMO_SURPRISE,
|
||||
DittoEmotionService,
|
||||
EmotionSegment,
|
||||
_parse_emotion_json,
|
||||
@@ -362,3 +365,112 @@ class TestWarnIfSuspiciousAllNeutral:
|
||||
with caplog.at_level("WARNING", logger="packages.application.ditto_emotion_service"):
|
||||
_warn_if_suspicious_all_neutral("家人们!", [])
|
||||
assert not caplog.records
|
||||
|
||||
|
||||
# ── build_timeline 全中性兜底置空(GPU端表情公式配套修复1)─────────
|
||||
class TestBuildTimelineAllNeutralFallback:
|
||||
"""LLM 兜底特征(全 emo=4 且 intensity<=0.1)→ 不传 emo_timeline,交 GPU 端自行处理."""
|
||||
|
||||
def test_llm_failure_fallback_returns_empty(self):
|
||||
"""LLM 调用失败 → _cached_analyze 兜底全中性0.1 → build_timeline 置空."""
|
||||
import packages.application.ditto_emotion_service as m
|
||||
|
||||
svc = _make_service(enabled=True)
|
||||
m._cached_analyze.cache_clear()
|
||||
try:
|
||||
with patch.object(svc, "_call_llm", return_value=[]):
|
||||
result = svc.build_timeline("全兜底置空检测专用独一无二文案甲。全兜底置空检测专用文案乙。", 5.0)
|
||||
assert result == ""
|
||||
finally:
|
||||
m._cached_analyze.cache_clear()
|
||||
|
||||
def test_all_neutral_low_intensity_returns_empty(self):
|
||||
"""LLM 返回全 emo=4 且 intensity<=0.1(兜底特征)→ 置空."""
|
||||
import packages.application.ditto_emotion_service as m
|
||||
|
||||
svc = _make_service(enabled=True)
|
||||
m._cached_analyze.cache_clear()
|
||||
fake_reply = json.dumps(
|
||||
[
|
||||
{"text": "低强度中性检测专用文案甲", "emo": 4, "intensity": 0.1},
|
||||
{"text": "低强度中性检测专用文案乙", "emo": 4, "intensity": 0.05},
|
||||
]
|
||||
)
|
||||
try:
|
||||
with patch.object(svc, "_call_llm", return_value=_parse_emotion_json(fake_reply)):
|
||||
assert svc.build_timeline("低强度中性检测专用文案甲。低强度中性检测专用文案乙。", 5.0) == ""
|
||||
finally:
|
||||
m._cached_analyze.cache_clear()
|
||||
|
||||
def test_all_neutral_but_higher_intensity_kept(self):
|
||||
"""全中性但 intensity>0.1(LLM 真实输出特征)→ 正常生成 timeline,不误伤."""
|
||||
import packages.application.ditto_emotion_service as m
|
||||
|
||||
svc = _make_service(enabled=True)
|
||||
m._cached_analyze.cache_clear()
|
||||
fake_reply = json.dumps(
|
||||
[
|
||||
{"text": "高强度中性保留检测专用文案甲", "emo": 4, "intensity": 0.3},
|
||||
{"text": "高强度中性保留检测专用文案乙", "emo": 4, "intensity": 0.2},
|
||||
]
|
||||
)
|
||||
try:
|
||||
with patch.object(svc, "_call_llm", return_value=_parse_emotion_json(fake_reply)):
|
||||
result = svc.build_timeline("高强度中性保留检测专用文案甲。高强度中性保留检测专用文案乙。", 4.0)
|
||||
data = json.loads(result)
|
||||
assert len(data) == 2
|
||||
assert all(item["emo"] == 4 for item in data)
|
||||
finally:
|
||||
m._cached_analyze.cache_clear()
|
||||
|
||||
|
||||
# ── timeline entry 强度保底 0.35(GPU端表情公式配套修复3)─────────
|
||||
class TestTimelineIntensityFloor:
|
||||
def test_low_intensity_floored(self):
|
||||
segs = [EmotionSegment("ab", EMO_HAPPY, 0.2)]
|
||||
entries = align_timeline_by_length(segs, 2.0)
|
||||
assert entries[0].intensity == 0.35
|
||||
|
||||
def test_floor_boundary_values(self):
|
||||
segs = [
|
||||
EmotionSegment("a", EMO_HAPPY, 0.1),
|
||||
EmotionSegment("b", EMO_SAD, 0.34),
|
||||
EmotionSegment("c", EMO_ANGER, 0.35),
|
||||
]
|
||||
entries = align_timeline_by_length(segs, 3.0)
|
||||
assert entries[0].intensity == 0.35
|
||||
assert entries[1].intensity == 0.35
|
||||
assert entries[2].intensity == 0.35
|
||||
|
||||
def test_high_intensity_unchanged(self):
|
||||
segs = [EmotionSegment("ab", EMO_HAPPY, 0.6)]
|
||||
entries = align_timeline_by_length(segs, 2.0)
|
||||
assert entries[0].intensity == 0.6
|
||||
|
||||
def test_floor_applies_in_timings_path(self):
|
||||
segs = [EmotionSegment("hello", EMO_SAD, 0.15)]
|
||||
timings = [{"start": 0.0, "end": 2.0}]
|
||||
entries = align_timeline_by_timings(segs, timings, 2.0)
|
||||
assert entries[0].intensity == 0.35
|
||||
|
||||
|
||||
# ── timings 数量不匹配回退字数占比(GPU端表情公式配套修复2,固化行为)───
|
||||
class TestAlignTimingsMismatchCharRatio:
|
||||
def test_mismatch_falls_back_to_char_ratio_with_emo_mapping(self):
|
||||
"""LLM 情绪分句(按逗号)与 TTS 分句(句末标点)数量不一致时,
|
||||
必须回退到按情绪分句字数占比分配,保证 emo 与时间段一一对应."""
|
||||
segs = [
|
||||
EmotionSegment("a" * 6, EMO_HAPPY, 0.5),
|
||||
EmotionSegment("b" * 2, EMO_SAD, 0.5),
|
||||
EmotionSegment("c" * 2, EMO_SURPRISE, 0.5),
|
||||
]
|
||||
timings = [{"start": 0.0, "end": 10.0}] # len 1 != 3
|
||||
entries = align_timeline_by_timings(segs, timings, 10.0)
|
||||
assert len(entries) == 3
|
||||
assert entries[0].start == 0.0
|
||||
assert entries[0].end == 6.0
|
||||
assert entries[1].start == 6.0
|
||||
assert entries[1].end == 8.0
|
||||
assert entries[2].start == 8.0
|
||||
assert entries[2].end == 10.0
|
||||
assert [e.emo for e in entries] == [EMO_HAPPY, EMO_SAD, EMO_SURPRISE]
|
||||
|
||||
@@ -486,6 +486,87 @@ class TestLipsyncServiceUnit:
|
||||
assert call_kwargs.kwargs["audio_url"] == "https://example.com/direct-audio.mp3"
|
||||
assert job.audio_url == "https://example.com/direct-audio.mp3"
|
||||
|
||||
def test_create_job_presynth_backfills_script_text(self, mock_mediakit, mock_cosyvoice):
|
||||
"""预合成模式 script_text 为空时,从 sentence_timings 拼接兜底.
|
||||
|
||||
背景:前端 #1875 起预合成 payload 不带 script_text,导致 job.script_text
|
||||
落库为空,Ditto 任务 script_len=0、情绪分析被跳过。
|
||||
"""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(
|
||||
mock_db,
|
||||
client=mock_mediakit,
|
||||
cosyvoice_service=mock_cosyvoice,
|
||||
voice_clone_repo=MagicMock(),
|
||||
)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
timings = [
|
||||
{"index": 0, "text": "大家好,", "start_time": 0.0, "end_time": 1.0},
|
||||
{"index": 1, "text": "欢迎来到直播间。", "start_time": 1.0, "end_time": 2.5},
|
||||
]
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
audio_duration=2.5,
|
||||
sentence_timings=timings,
|
||||
script_text="", # 前端预合成 payload 不带文案
|
||||
)
|
||||
|
||||
assert job.script_text == "大家好,欢迎来到直播间。"
|
||||
|
||||
def test_create_job_presynth_keeps_supplied_script_text(self, mock_mediakit, mock_cosyvoice):
|
||||
"""预合成模式显式传了 script_text 时,保留原文不做拼接."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(
|
||||
mock_db,
|
||||
client=mock_mediakit,
|
||||
cosyvoice_service=mock_cosyvoice,
|
||||
voice_clone_repo=MagicMock(),
|
||||
)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
timings = [
|
||||
{"index": 0, "text": "分句一", "start_time": 0.0, "end_time": 1.0},
|
||||
]
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
audio_duration=1.0,
|
||||
sentence_timings=timings,
|
||||
script_text="原始文案,含标点。",
|
||||
)
|
||||
|
||||
assert job.script_text == "原始文案,含标点。"
|
||||
|
||||
def test_create_job_presynth_empty_timings_falls_back_to_direct(self, mock_mediakit, mock_cosyvoice):
|
||||
"""sentence_timings 空列表时 is_pre_synth=False,退化为直接音频模式(语义固化)."""
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
|
||||
mock_db = MagicMock()
|
||||
svc = LipsyncService(
|
||||
mock_db,
|
||||
client=mock_mediakit,
|
||||
cosyvoice_service=mock_cosyvoice,
|
||||
voice_clone_repo=MagicMock(),
|
||||
)
|
||||
svc.settings.use_gpu_lipsync = False
|
||||
job = svc.create_job(
|
||||
user_id="user-1",
|
||||
video_url="https://example.com/video.mp4",
|
||||
audio_url="https://example.com/audio.mp3",
|
||||
sentence_timings=[],
|
||||
script_text="",
|
||||
)
|
||||
|
||||
# 直接音频模式:无 timings 可拼,script_text 保持空(不报错)
|
||||
assert job.script_text == ""
|
||||
mock_mediakit.submit_lipsync.assert_called_once()
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""v3: 音色解析与错误码在 service 层处理,路由层做 HTTP 状态码映射."""
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""#2308 parser字段映射单测:
|
||||
1. 新schema XML(<shot>+<visual>+<action_details>)字段正确映射
|
||||
2. 旧schema XML(<visual>混写)能拆分出cam/visual/action
|
||||
3. markdown中文分镜字段正确提取
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_job():
|
||||
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):
|
||||
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. 新schema(v3.1+):独立 <shot>/<visual>/<action_details> ===
|
||||
NEW_SCHEMA_XML = """<script>
|
||||
<copy_display_markdown><![CDATA[来御众堂放松一下]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-4秒</time_range>
|
||||
<voiceover>生完娃,是不是连照镜子都没勇气了?</voiceover>
|
||||
<shot>中近景平视,50mm f/2.0,缓慢推镜</shot>
|
||||
<visual>暖光浴室镜前,妈妈略显疲惫地看向镜中自己,镜灯打在侧脸</visual>
|
||||
<action_details>妈妈手扶洗手台,微微低头再抬眼看镜,眼眶微红</action_details>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>4-10秒</time_range>
|
||||
<voiceover>推开门,暖光和一杯热茶先接住了你。</voiceover>
|
||||
<shot>中景平视,35mm f/2.8,缓慢平移</shot>
|
||||
<visual>门店前台暖光木色装修,茶水冒热气,背景柔和虚化</visual>
|
||||
<action_details>店员微笑双手递上茶杯,妈妈伸手接过,指尖轻触杯沿</action_details>
|
||||
<reference_image_index>1</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>生完娃,是不是连照镜子都没勇气了?推开门,暖光和一杯热茶先接住了你。</voiceover_script>
|
||||
<theme>产后妈妈走进御众堂</theme>
|
||||
</script>"""
|
||||
|
||||
|
||||
def test_new_schema_xml_field_mapping(monkeypatch, mock_job):
|
||||
wv = _import_wv(monkeypatch)
|
||||
r = wv._parse_script(NEW_SCHEMA_XML, mock_job)
|
||||
assert r is not None
|
||||
assert len(r["shots"]) == 2
|
||||
s0 = r["shots"][0]
|
||||
# shot_type 应是纯摄影参数
|
||||
assert "缓慢推镜" in s0["shot_type_angle_movement"]
|
||||
assert "妈妈" not in s0["shot_type_angle_movement"]
|
||||
# scene_and_dialogue 只放对白
|
||||
assert s0["scene_and_dialogue"] == "生完娃,是不是连照镜子都没勇气了?"
|
||||
# action_details 有内容
|
||||
assert "手扶洗手台" in s0["action_details"]
|
||||
# visual 字段有画面
|
||||
assert "暖光" in s0["visual"]
|
||||
assert "推镜" not in s0["visual"]
|
||||
|
||||
|
||||
# === 2. 旧schema(无<shot>/<action_details>标签,<visual>混写):兼容拆分 ===
|
||||
OLD_SCHEMA_XML = """<script>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-4秒</time_range>
|
||||
<voiceover>生完娃,是不是连照镜子都没勇气了?</voiceover>
|
||||
<visual>中近景平视,暖光下妈妈略显疲惫地看向镜中,镜头缓缓推近,妈妈手扶洗手台微微低头</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>生完娃,是不是连照镜子都没勇气了?</voiceover_script>
|
||||
<theme>测试</theme>
|
||||
</script>"""
|
||||
|
||||
|
||||
def test_old_schema_xml_splits_visual(monkeypatch, mock_job):
|
||||
wv = _import_wv(monkeypatch)
|
||||
r = wv._parse_script(OLD_SCHEMA_XML, mock_job)
|
||||
assert r is not None
|
||||
s0 = r["shots"][0]
|
||||
# 摄影参数段应含景别/运镜关键词
|
||||
assert "中近景" in s0["shot_type_angle_movement"] or "推" in s0["shot_type_angle_movement"]
|
||||
# action_details 应含动作动词
|
||||
assert s0["action_details"], "action_details不应为空(旧schema兼容应从<visual>拆出动作)"
|
||||
assert "手扶" in s0["action_details"] or "低头" in s0["action_details"]
|
||||
# scene_and_dialogue 只放对白,不重复画面
|
||||
assert s0["scene_and_dialogue"] == "生完娃,是不是连照镜子都没勇气了?"
|
||||
|
||||
|
||||
# === 3. markdown 中文分镜字段 ===
|
||||
MARKDOWN_INPUT = """## 视频主题:御众堂探店
|
||||
|
||||
### 镜头1
|
||||
- 时间范围:0-4秒
|
||||
- 景别/角度与运镜:中近景平视,缓慢推镜
|
||||
- 画面:暖光下妈妈看向镜中,镜灯打在侧脸
|
||||
- 动作与真人细节:妈妈手扶洗手台,微微低头
|
||||
- 口播:"生完娃,是不是连照镜子都没勇气了?"
|
||||
|
||||
### 镜头2
|
||||
- 时间范围:4-10秒
|
||||
- 景别:中景平视
|
||||
- 运镜:缓慢平移
|
||||
- 画面:门店前台暖光木色装修
|
||||
- 动作:店员递茶,妈妈伸手接过
|
||||
- 口播:"推开门,暖光和一杯热茶先接住了你"
|
||||
"""
|
||||
|
||||
|
||||
def test_markdown_field_extraction(monkeypatch, mock_job):
|
||||
wv = _import_wv(monkeypatch)
|
||||
r = wv._parse_script(MARKDOWN_INPUT, mock_job)
|
||||
assert r is not None
|
||||
assert len(r["shots"]) == 2
|
||||
s0 = r["shots"][0]
|
||||
assert "中近景" in s0["shot_type_angle_movement"]
|
||||
assert "妈妈手扶洗手台" in s0["action_details"]
|
||||
assert s0["scene_and_dialogue"] == "生完娃,是不是连照镜子都没勇气了?"
|
||||
|
||||
|
||||
# === 4. _split_visual_field 单元测试 ===
|
||||
def test_split_visual_field(monkeypatch):
|
||||
wv = _import_wv(monkeypatch)
|
||||
# 典型旧schema混写:"中近景平视,暖光下一位妈妈略显疲惫地看向镜中,镜头缓缓推近,妈妈手扶洗手台微微低头"
|
||||
cam, vis, act = wv._split_visual_field(
|
||||
"中近景平视,暖光下妈妈略显疲惫地看向镜中,镜头缓缓推近,妈妈手扶洗手台微微低头"
|
||||
)
|
||||
assert cam and "中近景" in cam
|
||||
# 动作/表情段应被拆到 act(含"妈妈"主语+动词)
|
||||
assert act and ("手扶" in act or "疲惫" in act or "看向" in act)
|
||||
# 画面描述允许为空(当动作段本身承载了主要画面信息时),或含"暖光"
|
||||
assert (not vis) or ("暖光" in vis) or ("妈妈" in vis)
|
||||
|
||||
|
||||
def test_split_visual_short(monkeypatch):
|
||||
wv = _import_wv(monkeypatch)
|
||||
cam, vis, act = wv._split_visual_field("特写固定镜头")
|
||||
# 切不开时整体归camera/visual,action为空
|
||||
assert "特写" in cam
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ class TestTemplates:
|
||||
duration_constraint_text=build_duration_constraint_text(15),
|
||||
)
|
||||
assert "去油快" in rendered
|
||||
assert "38~60字" in rendered
|
||||
assert "38~75字" in rendered
|
||||
|
||||
|
||||
# ── constants ─────────────────────────────────────────────────────────────
|
||||
@@ -149,7 +149,7 @@ class TestConstants:
|
||||
|
||||
def test_duration_constraint_text_mentions_chars_and_shots(self):
|
||||
text = build_duration_constraint_text(15)
|
||||
assert "38" in text and "60" in text
|
||||
assert "38" in text and "75" in text
|
||||
assert "镜头" in text
|
||||
|
||||
def test_get_expected_shot_count(self):
|
||||
|
||||
@@ -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": "",
|
||||
@@ -264,9 +265,9 @@ class TestBug2VoiceoverWordCount:
|
||||
assert "{duration_constraint_text}" in _STORYBOARD_USER
|
||||
# 15 秒规格 min_chars=38, max_chars=60,约束文本中含具体数字
|
||||
t15 = build_duration_constraint_text(15)
|
||||
assert "38" in t15 and "60" in t15
|
||||
assert "38" in t15 and "75" in t15
|
||||
t30 = build_duration_constraint_text(30)
|
||||
assert "75" in t30 and "120" in t30
|
||||
assert "75" in t30 and "150" in t30
|
||||
# get_duration_spec 兜底:越界 clamp 到 5/30,None/0 默认 15 秒档位
|
||||
assert get_duration_spec(5)["min_chars"] == 12
|
||||
assert get_duration_spec(999)["min_shots"] == 6
|
||||
@@ -1120,3 +1121,401 @@ class TestIssue2249UserParamsFix:
|
||||
|
||||
assert "marketing_purpose" in _STORYBOARD_SYSTEM or "persona" in _STORYBOARD_SYSTEM
|
||||
assert "不能忽略" in _STORYBOARD_SYSTEM or "必须" in _STORYBOARD_SYSTEM
|
||||
|
||||
|
||||
class TestV4ValidationDeadlineFixes:
|
||||
"""v4 编导分镜四叠 bug 回归测试(PR #2294):
|
||||
1. max_chars 放宽(15s 60→75)
|
||||
2. _fallback_script brand==name 去重
|
||||
3. 口播超长自动截断接受
|
||||
4. build_messages few-shot 多轮消息
|
||||
"""
|
||||
|
||||
def test_duration_spec_relaxed(self):
|
||||
"""验证 15s max_chars 已放宽到 75,30s 到 150"""
|
||||
from packages.application.viral_video.constants import get_duration_spec
|
||||
|
||||
assert get_duration_spec(15)["max_chars"] == 75
|
||||
assert get_duration_spec(15)["min_chars"] == 38
|
||||
assert get_duration_spec(10)["max_chars"] == 50
|
||||
assert get_duration_spec(20)["max_chars"] == 100
|
||||
assert get_duration_spec(30)["max_chars"] == 150
|
||||
assert get_duration_spec(5)["max_chars"] == 25
|
||||
|
||||
def test_fallback_script_brand_name_dedup(self):
|
||||
"""_fallback_script: brand==name 时不应拼 'X家的X'"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = MagicMock()
|
||||
job.duration = 15
|
||||
job.video_ratio = "9:16"
|
||||
job.user_copy_text = None
|
||||
job.images = []
|
||||
# 构造 VLM 输出 brand==name
|
||||
job.image_analysis = {"images": [{"type": "product", "name": "御众堂", "brand": "御众堂", "has_person": False}]}
|
||||
job.marketing_purpose = ""
|
||||
job.style_guide = None
|
||||
job.persona_id = ""
|
||||
job.viral_structure = ""
|
||||
job.language = "zh-CN"
|
||||
job.industry = ""
|
||||
job.target_customer = ""
|
||||
job.fusion_level = "ai_polish"
|
||||
|
||||
with patch.object(vv, "normalize_image_analysis", return_value=job.image_analysis):
|
||||
result = vv._fallback_script(job)
|
||||
vo = result["voiceover_script"]
|
||||
# 不应出现"御众堂家的御众堂"重复
|
||||
assert "御众堂家的御众堂" not in vo
|
||||
assert "来看看御众堂" in vo
|
||||
assert len(result["shots"]) == 1
|
||||
|
||||
def test_fallback_script_brand_name_different(self):
|
||||
"""_fallback_script: brand≠name 时仍正常拼接 'X家的Y'"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from apps.worker.worker_app.tasks import viral_video as vv
|
||||
|
||||
job = MagicMock()
|
||||
job.duration = 15
|
||||
job.video_ratio = "9:16"
|
||||
job.user_copy_text = None
|
||||
job.images = []
|
||||
job.image_analysis = {
|
||||
"images": [{"type": "product", "name": "修护精华", "brand": "御众堂", "has_person": False}]
|
||||
}
|
||||
job.marketing_purpose = ""
|
||||
job.style_guide = None
|
||||
job.persona_id = ""
|
||||
job.viral_structure = ""
|
||||
job.language = "zh-CN"
|
||||
job.industry = ""
|
||||
job.target_customer = ""
|
||||
job.fusion_level = "ai_polish"
|
||||
|
||||
with patch.object(vv, "normalize_image_analysis", return_value=job.image_analysis):
|
||||
result = vv._fallback_script(job)
|
||||
vo = result["voiceover_script"]
|
||||
assert "御众堂家的修护精华" in vo
|
||||
|
||||
def test_build_messages_with_example(self):
|
||||
"""build_messages: example_output 非空时生成 4 条消息 few-shot"""
|
||||
from packages.application.viral_video.prompt_loader import (
|
||||
PromptTemplate,
|
||||
build_messages,
|
||||
)
|
||||
|
||||
tpl = PromptTemplate(
|
||||
name="storyboard",
|
||||
prompt_type="storyboard",
|
||||
version=99,
|
||||
system_prompt="系统:你是分镜师",
|
||||
user_prompt_template="请为{product}写脚本",
|
||||
example_output="<script>示例输出</script>",
|
||||
)
|
||||
msgs = build_messages(tpl, product="测试商品")
|
||||
assert len(msgs) == 4
|
||||
assert msgs[0]["role"] == "system"
|
||||
assert msgs[1]["role"] == "user"
|
||||
assert "示例" in msgs[1]["content"]
|
||||
assert msgs[2]["role"] == "assistant"
|
||||
assert "<script>示例输出</script>" in msgs[2]["content"]
|
||||
assert msgs[3]["role"] == "user"
|
||||
assert "测试商品" in msgs[3]["content"]
|
||||
|
||||
def test_build_messages_without_example(self):
|
||||
"""build_messages: example_output 为空时退化为 2 条消息"""
|
||||
from packages.application.viral_video.prompt_loader import (
|
||||
PromptTemplate,
|
||||
build_messages,
|
||||
)
|
||||
|
||||
tpl = PromptTemplate(
|
||||
name="storyboard",
|
||||
prompt_type="storyboard",
|
||||
version=99,
|
||||
system_prompt="系统:你是分镜师",
|
||||
user_prompt_template="请写脚本",
|
||||
example_output="",
|
||||
)
|
||||
msgs = build_messages(tpl)
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0]["role"] == "system"
|
||||
assert msgs[1]["role"] == "user"
|
||||
|
||||
def test_truncate_voiceover_preserves_full_sentences(self):
|
||||
"""_truncate_voiceover: 按句号切句保留完整句,不截到半句"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _truncate_voiceover
|
||||
|
||||
text = "今天给大家推荐一款好产品。它的效果特别棒,用完皮肤水水润润的。大家一定要试试哦。"
|
||||
out = _truncate_voiceover(text, 25)
|
||||
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"]
|
||||
|
||||
|
||||
class TestSafeFormatHardening:
|
||||
"""_safe_format 必须能扛住模板里任意花括号样例,不抛异常、不返回半截内容"""
|
||||
|
||||
def test_missing_key_preserves_placeholder(self):
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("时长{duration}秒,{missing}保留", {"duration": "20"})
|
||||
assert out == "时长20秒,{missing}保留"
|
||||
|
||||
def test_attribute_access_on_string_preserves_placeholder(self):
|
||||
"""复现v5事故:{duration.1格式写全6项参数}访问str的非法属性不应抛异常"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format(
|
||||
"总时长:{duration.1格式写全6项参数,第二个镜头起末尾加转场}秒",
|
||||
{"duration": "20"},
|
||||
)
|
||||
assert "总时长:{duration.1格式写全6项参数,第二个镜头起末尾加转场}秒" == out
|
||||
|
||||
def test_json_braces_in_example_preserved(self):
|
||||
"""模板里给LLM示例JSON时,{"key":"val"}不应被当format_spec解析"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format(
|
||||
'输出JSON示例:{"key": "value", "n": 1},时长{duration}秒',
|
||||
{"duration": "20"},
|
||||
)
|
||||
assert '{"key": "value", "n": 1}' in out
|
||||
assert "时长20秒" in out
|
||||
|
||||
def test_chinese_example_braces_preserved(self):
|
||||
"""中文花括号示例如{示例}原样保留"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("规则:不要输出{示例}这类占位符,时长{duration}", {"duration": "20"})
|
||||
assert "{示例}" in out
|
||||
assert "时长20" in out
|
||||
|
||||
def test_escaped_braces_unescaped(self):
|
||||
"""{{escaped}} 正常输出 {escaped}"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("这是{{escaped}},时长{duration}", {"duration": "20"})
|
||||
assert out == "这是{escaped},时长20"
|
||||
|
||||
def test_legitimate_attribute_access_works(self):
|
||||
"""合法对象属性访问仍正常工作"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
class Obj:
|
||||
brand = "御众堂"
|
||||
|
||||
out = _safe_format("品牌:{obj.brand}", {"obj": Obj()})
|
||||
assert out == "品牌:御众堂"
|
||||
|
||||
def test_invalid_format_spec_preserves_placeholder(self):
|
||||
"""{val:invalid} 这种坏格式符原样保留"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("坏格式{val:invalid_chinese}结束", {"val": "x"})
|
||||
assert "{val:invalid_chinese}" in out
|
||||
|
||||
def test_normal_format_spec_still_works(self):
|
||||
"""合法格式符 {n:>3} 仍正常工作"""
|
||||
from packages.application.viral_video.prompt_loader import _safe_format
|
||||
|
||||
out = _safe_format("数字{n:>3}结束", {"n": 5})
|
||||
assert "5" in out
|
||||
|
||||
Reference in New Issue
Block a user