2323logger = setup_logger ("subtitle.rounded" )
2424
2525
26- def _get_video_resolution (video_path : str ) -> Tuple [int , int ]:
27- """获取视频分辨率 """
26+ def _get_video_info (video_path : str ) -> Tuple [int , int , float ]:
27+ """获取视频分辨率和时长 """
2828 result = subprocess .run (
2929 ["ffmpeg" , "-i" , video_path ],
3030 capture_output = True ,
3131 text = True ,
3232 encoding = "utf-8" ,
3333 errors = "replace" ,
34- creationflags = (
35- getattr (subprocess , "CREATE_NO_WINDOW" , 0 ) if os .name == "nt" else 0
36- ),
34+ creationflags = (getattr (subprocess , "CREATE_NO_WINDOW" , 0 ) if os .name == "nt" else 0 ),
3735 )
3836
37+ # 解析分辨率
38+ width , height = 0 , 0
3939 if match := re .search (r"Stream.*Video:.* (\d{2,5})x(\d{2,5})" , result .stderr ):
40- return int (match .group (1 )), int (match .group (2 ))
40+ width , height = int (match .group (1 )), int (match .group (2 ))
41+ else :
42+ raise ValueError (f"无法获取视频分辨率: { video_path } " )
43+
44+ # 解析时长
45+ duration = 0.0
46+ if match := re .search (r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)" , result .stderr ):
47+ h , m , s = match .groups ()
48+ duration = int (h ) * 3600 + int (m ) * 60 + float (s )
4149
42- raise ValueError ( f"无法获取视频分辨率: { video_path } " )
50+ return width , height , duration
4351
4452
4553def render_text_block (
@@ -154,9 +162,7 @@ def render_subtitle_image(
154162 else []
155163 )
156164 secondary_lines = (
157- wrap_text (
158- secondary_text , font , width , style .padding_h , extra_margin = extra_margin
159- )
165+ wrap_text (secondary_text , font , width , style .padding_h , extra_margin = extra_margin )
160166 if secondary_text
161167 else []
162168 )
@@ -169,11 +175,7 @@ def calc_block_height(lines: List[str]) -> float:
169175 return 0
170176 bbox = font .getbbox ("测试Ag" )
171177 line_h = bbox [3 ] - bbox [1 ]
172- return (
173- line_h * len (lines )
174- + style .line_spacing * (len (lines ) - 1 )
175- + style .padding_v * 2
176- )
178+ return line_h * len (lines ) + style .line_spacing * (len (lines ) - 1 ) + style .padding_v * 2
177179
178180 primary_height = calc_block_height (primary_lines )
179181 secondary_height = calc_block_height (secondary_lines )
@@ -254,15 +256,11 @@ def render_preview(
254256 )
255257
256258 # 渲染字幕并叠加
257- subtitle_img = render_subtitle_image (
258- primary_text , secondary_text , width , height , style
259- )
259+ subtitle_img = render_subtitle_image (primary_text , secondary_text , width , height , style )
260260 background .paste (subtitle_img , (0 , 0 ), subtitle_img )
261261
262262 # 保存到临时目录
263- with tempfile .NamedTemporaryFile (
264- mode = "wb" , suffix = ".png" , delete = False
265- ) as tmp_file :
263+ with tempfile .NamedTemporaryFile (mode = "wb" , suffix = ".png" , delete = False ) as tmp_file :
266264 background .save (tmp_file , "PNG" )
267265 return tmp_file .name
268266
@@ -302,8 +300,7 @@ def render_rounded_video(
302300 # 检查布局合理性
303301 if layout == SubtitleLayoutEnum .ONLY_TRANSLATE :
304302 has_translation = any (
305- seg .translated_text and seg .translated_text .strip ()
306- for seg in asr_data .segments
303+ seg .translated_text and seg .translated_text .strip () for seg in asr_data .segments
307304 )
308305 if not has_translation :
309306 layout = SubtitleLayoutEnum .ONLY_ORIGINAL
@@ -312,14 +309,13 @@ def render_rounded_video(
312309 or layout == SubtitleLayoutEnum .ORIGINAL_ON_TOP
313310 ):
314311 has_translation = any (
315- seg .translated_text and seg .translated_text .strip ()
316- for seg in asr_data .segments
312+ seg .translated_text and seg .translated_text .strip () for seg in asr_data .segments
317313 )
318314 if not has_translation :
319315 layout = SubtitleLayoutEnum .ONLY_ORIGINAL
320316
321317 # 获取视频信息
322- width , height = _get_video_resolution (video_path )
318+ width , height , video_duration = _get_video_info (video_path )
323319
324320 # 构建并缩放样式
325321 style_config = rounded_style or {}
@@ -343,9 +339,7 @@ def render_rounded_video(
343339 temp_path = Path (temp_dir )
344340
345341 # 步骤1: 生成所有字幕PNG (0-30%)
346- logger .info (
347- f"生成字幕PNG图片(共{ len (asr_data .segments )} 个,布局:{ layout .value } )"
348- )
342+ logger .info (f"生成字幕PNG图片(共{ len (asr_data .segments )} 个,布局:{ layout .value } )" )
349343 subtitle_frames = []
350344
351345 for i , seg in enumerate (asr_data .segments ):
@@ -372,9 +366,7 @@ def render_rounded_video(
372366 # 进度回调
373367 if progress_callback :
374368 progress = int ((i + 1 ) / len (asr_data .segments ) * 30 )
375- progress_callback (
376- progress , f"生成字幕图片 { i + 1 } /{ len (asr_data .segments )} "
377- )
369+ progress_callback (progress , f"生成字幕图片 { i + 1 } /{ len (asr_data .segments )} " )
378370
379371 if not subtitle_frames :
380372 raise ValueError ("没有生成任何有效的字幕图片" )
@@ -409,14 +401,12 @@ def render_rounded_video(
409401 # 判断是否是最后一批
410402 is_last_batch = batch_idx == total_batches - 1
411403 batch_output = (
412- output_path
413- if is_last_batch
414- else temp_path / f"batch_{ batch_idx :03d} .mp4"
404+ output_path if is_last_batch else temp_path / f"batch_{ batch_idx :03d} .mp4"
415405 )
416406
417- logger .info (
418- f"处理批次 { batch_idx + 1 } / { total_batches } ( { len ( batch_frames ) } 个字幕)"
419- )
407+ logger .info (f"处理批次 { batch_idx + 1 } / { total_batches } ( { len ( batch_frames ) } 个字幕)" )
408+ # 构建 ffmpeg 命令
409+ # -t 参数强制保持原视频时长,防止因 overlay 结束而截断视频
420410 cmd = [
421411 "ffmpeg" ,
422412 "-y" ,
@@ -426,7 +416,9 @@ def render_rounded_video(
426416 "-map" ,
427417 final_output ,
428418 "-map" ,
429- "0:a?" , # 每一批都需要映射音频流
419+ "0:a?" ,
420+ "-t" ,
421+ str (video_duration ), # 强制保持原视频时长
430422 "-c:v" ,
431423 "libx264" ,
432424 "-preset" ,
@@ -436,7 +428,7 @@ def render_rounded_video(
436428 "-pix_fmt" ,
437429 "yuv420p" ,
438430 "-c:a" ,
439- "copy" , # 每一批都复制音频
431+ "copy" ,
440432 str (batch_output ),
441433 ]
442434
@@ -448,6 +440,8 @@ def render_rounded_video(
448440 cmd ,
449441 capture_output = True ,
450442 text = True ,
443+ encoding = "utf-8" ,
444+ errors = "replace" ,
451445 creationflags = (
452446 getattr (subprocess , "CREATE_NO_WINDOW" , 0 ) if os .name == "nt" else 0
453447 ),
0 commit comments