Compare commits

...

3 Commits

Author SHA1 Message Date
xiaoxia a47d49f13d style: auto-format with black + isort [skip ci-format-check] 2026-10-15 12:26:51 +08:00
xiaoxia 7d42b03a91 fix(viral): #2307 补充——TTS硬限按model细分(Seedance2.0=14.5s/2.5=29s/Wan=14.5s)
- _check_and_fix_tts_duration 新增 model 参数,按官方 reference_audio 硬限细分:
  * dashscope (Wan 3.0): 14.5s(硬限15s)
  * model含'2.5' (Seedance 2.5): 29s(硬限30s)
  * 其他 (Seedance 2.0/Fast/Mini): 14.5s(硬限15s)
- 调用处透传 _vv_model
- 日志增加 provider/model 字段便于排查
2026-10-15 12:26:14 +08:00
xiaoxia 13a193cea3 fix(viral): #2307 TTS音频时长按模型硬限对齐(Wan14.5s/Seedance29s)+atempo兜底+reviewer超时15s+前端文案5-15s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m21s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m33s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 2m27s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 2m44s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m40s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 4m40s
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
- _check_and_fix_tts_duration 新增 provider 参数:dashscope(Wan)卡14.5s/doubao(Seedance)卡29s
- soft_limit 保持 target+2(atempo 1.1-1.2x 听不出差异),不动 max_chars
- 阶段3 TTS 后校验处透传 _vv_provider
- reviewer LLM timeout 25s→15s(超时本就降级放行,不阻塞渲染)
- 前端 StoryboardCard 合规审核提示 '预计10-30秒'→'预计5-15秒'(关thinking后端到端<25s)
2026-10-15 11:45:59 +08:00
3 changed files with 144 additions and 23 deletions
@@ -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>
)
}
+142 -21
View File
@@ -1174,6 +1174,78 @@ def _parse_script(raw: str, job: ViralVideoJob) -> dict | None:
return _parse_script_markdown(raw, job, dur, ratio, default_theme)
# #2308: 从旧schema混写的<visual>字段中拆分出 摄影参数 / 画面描述 / 动作细节
_CAMERA_KEYWORDS = re.compile(
r"(?:景别|景|远景|全景|中景|近景|特写|大特写|俯拍|仰拍|平视|侧拍|"
r"机位|高度|机位高度|鸟瞰|斜拍|跟拍|固定镜头|手持|稳定器|"
r"推镜|拉镜|摇镜|移镜|平移|横移|升降|环绕|旋转|变焦|"
r"缓慢推|快速推|缓推|急推|慢推|匀速|"
r"[\d]+mm|f/[\d.]+|光圈|焦段|焦距|景深|浅景深)"
)
_ACTION_VERBS = re.compile(
r"(?:表情|面带|露出|微笑|皱眉|闭眼|睁眼|眼神|疲惫|放松|紧张|看向|望着|注视|凝视|"
r"伸手|抬手|挥手|举起|放下|拿起|握住|递|接|手扶|轻抚|抚摸|触碰|轻触|触摸|翻看|"
r"转身|转头|回头|点头|摇头|低头|抬头|歪头|扭头|"
r"走|跑|站|坐|躺|靠|倚|弯腰|侧身|前倾|后仰|走进|走出|推门|开门|关门|进入|离开|"
r"说话|开口|张嘴|嘴角上扬|深呼吸|吐气|流泪|"
r"动作|动作细节|操作|演示|接过|递上|展示|做出|比划)"
)
def _split_visual_field(visual: str) -> tuple[str, str, str]:
"""把旧schema混写的<visual>文本拆成 (摄影参数, 画面描述, 动作细节)。
策略:按逗号/顿号/分号切分小片段,含摄影关键词→camera,含动作动词→action,
其余(主体/场景/光线/氛围)→visual。切不动时按第一个逗号前归camera、
最后一句含动词归action、中间归visual 兜底。
"""
text = (visual or "").strip()
if not text:
return "", "", ""
# 去掉首尾的"镜头:"/"画面:"等前缀
text = re.sub(r"^(?:镜头|画面|视觉)\s*[::]\s*", "", text)
cam_parts: list[str] = []
act_parts: list[str] = []
vis_parts: list[str] = []
# 按中英文逗号、顿号、分号切片
segs = [s.strip() for s in re.split(r"[,,、;;]\s*", text) if s.strip()]
if len(segs) <= 1:
return text, text, "" # 切不开,整体当画面+摄影
for seg in segs:
if _CAMERA_KEYWORDS.search(seg) and not _ACTION_VERBS.search(seg):
cam_parts.append(seg)
elif _ACTION_VERBS.search(seg) and not _CAMERA_KEYWORDS.search(seg):
act_parts.append(seg)
else:
vis_parts.append(seg)
# 兜底:如果 camera 为空,取第一段
if not cam_parts and segs:
cam_parts.append(segs[0])
vis_parts = [s for s in vis_parts if s != segs[0]]
# 兜底1:最后一段含动作动词
if not act_parts and segs and _ACTION_VERBS.search(segs[-1]):
act_parts.append(segs[-1])
vis_parts = [s for s in vis_parts if s != segs[-1]]
# 兜底2:从后往前扫,找到第一个含动作动词且不含摄影关键词的段
if not act_parts:
for seg in reversed(segs):
if _ACTION_VERBS.search(seg) and seg not in cam_parts:
act_parts.append(seg)
vis_parts = [s for s in vis_parts if s != seg]
break
# 兜底3:包含人物主语(妈妈/店员/女生/男生/她/他/人物/模特)+动词的段视为动作
if not act_parts:
_person_re = re.compile(
r"(?:妈妈|店员|女生|男生|女士|男士|女孩|男孩|人物|模特|主播|她|他|主角|孩子|宝宝|老人|顾客|客人|顾客)"
)
for seg in reversed(segs):
if _person_re.search(seg) and seg not in cam_parts and not _CAMERA_KEYWORDS.search(seg):
act_parts.append(seg)
vis_parts = [s for s in vis_parts if s != seg]
break
return ",".join(cam_parts), ",".join(vis_parts), ",".join(act_parts)
def _parse_script_xml(
xml_body: str,
raw_full: str,
@@ -1199,7 +1271,22 @@ def _parse_script_xml(
a = c.get("attrs", {})
body = c.get("text", "") or ""
voice = xp.text_of(body, "voiceover") or xp.text_of(body, "voice_text")
visual = xp.text_of(body, "visual") or xp.text_of(body, "shot_type_angle_movement")
# #2308: 优先读新 <shot>(纯6项摄影参数),fallback 到 <visual>(旧schema混写)
shot_meta = xp.text_of(body, "shot") or ""
visual_full = xp.text_of(body, "visual") or xp.text_of(body, "shot_type_angle_movement") or ""
action_raw = xp.text_of(body, "action_details") or ""
if shot_meta:
# 新schema:<shot>是摄影参数,<visual>是纯画面描述,<action_details>是动作细节
cam_text = shot_meta.strip()
visual_text = visual_full.strip()
action_text = (action_raw or "").strip()
if action_text in ("-", "—", "无", "无。"):
action_text = ""
else:
# 旧schema兼容:<visual> 混写了"景别/运镜+画面+动作",用正则拆分
cam_text, visual_text, action_text = _split_visual_field(visual_full)
if not action_text and action_raw:
action_text = action_raw.strip()
ref_raw = xp.text_of(body, "reference_image_index")
ref_idx = xp.attr_int(ref_raw, -1) if ref_raw not in (None, "") else -1
if not isinstance(ref_idx, int) or ref_idx < 0:
@@ -1209,9 +1296,13 @@ def _parse_script_xml(
voice_parts.append(f"[speaker:{speaker}]{voice}")
shot = {
"time_range": a.get("time_range") or xp.text_of(body, "time_range") or f"{i * 3}-{(i + 1) * 3}秒",
"shot_type_angle_movement": visual or "中景平视,固定镜头",
"scene_and_dialogue": (voice or "") if not visual else f"{visual}|{voice or ''}",
"action_details": xp.text_of(body, "action_details") or "",
# shot_type_angle_movement:纯6项摄影参数,不塞画面动作
"shot_type_angle_movement": cam_text or "中景平视,固定镜头",
# scene_and_dialogue:纯口播对白(不再拼接visual,避免重复)
"scene_and_dialogue": voice or "",
# 画面描述(拆出来的纯画面)放到 shot 里供视频prompt使用
"visual": visual_text,
"action_details": action_text,
"audio_bgm": xp.text_of(body, "audio_bgm") or "轻快BGM",
"transition": xp.text_of(body, "transition") or ("硬切" if i < len(clips) - 1 else "结束"),
"reference_image_index": ref_idx,
@@ -1245,11 +1336,13 @@ _MD_TIME_SPLIT_RE = re.compile(
)
_MD_TIME_RE = re.compile(r"(\d+)\s*[-~到至]\s*(\d+)\s*秒?")
_MD_FIELD_RE = {
"visual": re.compile(r"(?:画面|视觉|镜头画面|shot_type_angle_movement)\s*[::]\s*([^\n]+)"),
"camera": re.compile(r"(?:景别|运镜|镜头运动)\s*[::]\s*([^\n]+)"),
"visual": re.compile(
r"(?:画面(?:内容|描述)?|视觉|镜头画面|场景(?:描述)?|shot_type_angle_movement)\s*[::]\s*([^\n]+)"
),
"camera": re.compile(r"(?:景别(?:/角度)?(?:与运镜)?|运镜|镜头运动|镜头|shot|camera)\s*[::]\s*([^\n]+)"),
"voiceover": re.compile(r"""(?:口播|对白|台词|配音|旁白|voiceover)\s*[::]\s*""" r"""(?:"([^"]+)"|'([^']+)'|(\S[^
]*?)(?=\n\s*(?:镜头|#|$)|\s*$))"""),
"action": re.compile(r"(?:动作|动作细节|action)\s*[::]\s*([^\n]+)"),
"action": re.compile(r"(?:动作(?:与真人细节)?|动作细节|人物动作|action)\s*[::]\s*([^\n]+)"),
"audio_bgm": re.compile(r"(?:音效|BGM|背景音乐|audio)\s*[::]\s*([^\n]+)"),
"transition": re.compile(r"(?:转场|过渡)\s*[::]\s*([^\n]+)"),
"reference": re.compile(r"参考@图片(\d+)|参考图[::]?\s*(\d+)"),
@@ -1372,14 +1465,15 @@ def _parse_script_markdown(
if voice:
voice_parts.append(voice)
# scene_and_dialogue 保持原有拼接格式
visual_desc = visual or "中景平视,固定镜头"
scene_and_dialogue = (voice or "") if not visual_desc else f"{visual_desc}|{voice or ''}"
# #2308: scene_and_dialogue 只放口播对白,画面/景别已在独立字段
_ = visual or camera # 兼容保留
scene_and_dialogue = voice or ""
shot = {
"time_range": time_range,
"shot_type_angle_movement": shot_type,
"scene_and_dialogue": scene_and_dialogue,
"visual": visual or "",
"action_details": action,
"audio_bgm": audio or "轻快BGM",
"transition": trans or ("硬切" if i < len(shot_blocks) - 1 else "结束"),
@@ -1829,11 +1923,14 @@ def _upload_tts_to_oss(job: ViralVideoJob, tts_path) -> str | None:
return None
def _check_and_fix_tts_duration(tts_path, target_duration: int):
"""Bug3 fix: TTS 合成后校验音频时长,超限则加速/截断。
def _check_and_fix_tts_duration(tts_path, target_duration: int, provider: str = "doubao", model: str = ""):
"""#2307: TTS 合成后校验音频时长,超限则加速/截断兜底。
- 音频时长 > max(target+2, 30) → 加速到目标时长
- 音频时长 > 30s(Seedance 硬限制)→ 必须加速/截断到 30s 以内
按模型reference_audio硬限动态调整(留0.5s余量):
- Wan 3.0 (dashscope): reference_audio 单段 ≤15s → 卡14.5s
- Seedance 2.5 (doubao, model含"2.5"): reference_audio ≤30s → 卡29s
- Seedance 2.0/Fast/Mini (doubao): reference_audio ≤15s → 卡14.5s
soft_limit=target+2(atempo 1.1-1.2x 用户听不出差异),不收紧max_chars。
返回处理后的路径(可能覆盖原文件),失败返回原路径不阻塞。
"""
if tts_path is None:
@@ -1863,15 +1960,33 @@ def _check_and_fix_tts_duration(tts_path, target_duration: int):
return tts_path
audio_dur = float(result.stdout.strip())
hard_limit = 30.0 # Seedance 硬限制
# 按provider+model取reference_audio硬限(留0.5s余量)
_prov = (provider or "doubao").lower()
_model_lc = (model or "").lower()
if _prov == "dashscope":
hard_limit = 14.5 # Wan 3.0 reference_audio硬限15s
elif "2.5" in _model_lc:
hard_limit = 29.0 # Seedance 2.5 reference_audio硬限30s
else:
hard_limit = 14.5 # Seedance 2.0/Fast/Mini reference_audio硬限15s
soft_limit = float(target_duration) + 2.0
effective_limit = min(soft_limit, hard_limit)
if float(target_duration) > hard_limit + 1.0:
logger.warning(
"[爆款视频] 目标时长%ds超过模型%s/%s音频硬限%.1fs,TTS将压缩到硬限",
target_duration,
_prov,
_model_lc or "default",
hard_limit,
)
logger.info(
"[爆款视频] TTS 音频时长检查: audio=%.1fs target=%ds limit=%.1fs",
"[爆款视频] TTS 音频时长检查: audio=%.1fs target=%ds limit=%.1fs provider=%s model=%s",
audio_dur,
target_duration,
effective_limit,
_prov,
_model_lc or "default",
)
if audio_dur <= effective_limit:
@@ -1879,9 +1994,9 @@ def _check_and_fix_tts_duration(tts_path, target_duration: int):
# 需要处理:优先用 ffmpeg 加速(atempo)保持内容完整
if audio_dur > hard_limit:
target_sec = 29.0 # 必须压缩到 30s 以内
target_sec = hard_limit
else:
target_sec = float(target_duration)
target_sec = min(float(target_duration), hard_limit)
speed_factor = audio_dur / target_sec
if speed_factor > 2.0:
@@ -2067,8 +2182,14 @@ def _truncate_voiceover(text: str, max_chars: int) -> str:
def _shot_visual(s: dict) -> str:
"""合并镜头画面与动作描述。"""
visual = str(s.get("scene_and_dialogue", "") or s.get("visual", "")).strip()
"""合并镜头画面与动作描述(#2308: scene_and_dialogue现在只放对白,画面应读visual字段)。"""
visual = str(s.get("visual", "") or "").strip()
# 兜底:旧数据/兜底脚本可能没填visual,则从scene_and_dialogue取
if not visual:
sd = str(s.get("scene_and_dialogue", "") or "").strip()
# scene_and_dialogue现在是纯对白(用|分隔是旧格式),只有不含对白痕迹时才当画面
if "|" not in sd and not any(c in sd for c in ",。?!") or len(sd) < 20:
visual = sd
act = str(s.get("action_details", "") or "").strip()
if act and act not in visual:
visual = f"{visual},{act}" if visual else act
@@ -3055,7 +3176,7 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
# Bug3 fix: TTS 音频时长校验与修正
if tts_path is not None:
_dur_limit = max(5, min(30, int(getattr(job, "duration", 15) or 15)))
tts_path = _check_and_fix_tts_duration(tts_path, _dur_limit)
tts_path = _check_and_fix_tts_duration(tts_path, _dur_limit, provider=_vv_provider, model=_vv_model or "")
tts_url = _upload_tts_to_oss(job, tts_path)
_emit_progress(job_id, ViralVideoStage.TTS, 78.0, "配音完成", {"has_tts": tts_url is not None})
+1 -1
View File
@@ -123,7 +123,7 @@ class Reviewer:
messages,
temperature=0.2,
max_tokens=1024,
timeout=25,
timeout=15, # #2307: 25s过长,超时降级放行
)
if not raw:
return None