@@ -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 (
" [爆款视频] 目标时长 %d s超过模型 %s / %s 音频硬限 %.1f s, TTS将压缩到硬限 " ,
target_duration ,
_prov ,
_model_lc or " default " ,
hard_limit ,
)
logger . info (
" [爆款视频] TTS 音频时长检查: audio= %.1f s target= %d s limit= %.1f s " ,
" [爆款视频] TTS 音频时长检查: audio= %.1f s target= %d s limit= %.1f s 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 } )