diff --git a/apps/worker/video_processing/gpu_direct_pipeline.py b/apps/worker/video_processing/gpu_direct_pipeline.py index 20520b1c8..d4763956a 100644 --- a/apps/worker/video_processing/gpu_direct_pipeline.py +++ b/apps/worker/video_processing/gpu_direct_pipeline.py @@ -30,11 +30,16 @@ EDGE_CROP_MAX_PCT = 0.05 # 均以 720p 为基准(见前端 titleCanvas.ts 注释 scale=videoWidth/720,types.ts "px @720p"), # 非 720p 输出时按 video_width / TITLE_SIZE_REF_WIDTH 等比缩放,保证成片位置与前端预览一致。 TITLE_SIZE_REF_WIDTH = 720 -# 与前端 titleCanvas.ts 对齐:top 时文本 top-edge 距视频顶 = PAD(16@720p) + margin_top(默认 24@720p) -TITLE_PAD_TOP = 16 -TITLE_DEFAULT_MARGIN_TOP = 24 -TITLE_DEFAULT_MARGIN_BOTTOM = 24 -SUBTITLE_DEFAULT_MARGIN_BOTTOM = 60 # 字幕距底边距(720p 基准,与前端字幕面板默认对齐) +# 与 video_filter_builder.build_title_drawtext_filter(CPU 路径)和 ass_subtitle_builder 对齐: +# - top/bottom 默认 margin 50@720p(vfb 用 _scale_title_len(50, w),即 y=50 / y=h-th-50) +# - margin_top 字段:前端编辑器 marginTop 滑块,叠加在默认 margin 之上(#2095 支持) +# - PAD 概念仅用于前端 Canvas 预览;ffmpeg drawtext y 是 baseline,无 font metrics 可用, +# 直接用统一 50@720p baseline 位置即可保持三端(GPU/CPU/前端视觉)一致。 +TITLE_DEFAULT_MARGIN_TOP = 50 # top 位置 baseline 默认距顶 50@720p(与 vfb/CPU 路径一致) +TITLE_DEFAULT_MARGIN_BOTTOM = 50 # bottom 位置 baseline 默认距底 50@720p +SUBTITLE_DEFAULT_MARGIN_BOTTOM = 50 # 字幕距底边距 50@720p(与 vfb 一致) +TITLE_MARGIN_TOP_FROM_CFG_DEFAULT = 24 # 前端 marginTop 滑块默认值(用户未传时叠加 0) +TITLE_FAUX_BOLD_WIDTH = 2 # 仿粗黑色描边宽度(与 vfb 一致,2@720p 黑色细描边) def _scale_title_len(value, video_width: int): @@ -507,7 +512,7 @@ def build_direct_render( except (TypeError, ValueError): t_size_720 = 0 if t_size_720 <= 0: - t_size_720 = 36 # 与 config_schemas 默认 48 接近;AI Avatar 前端默认 48,剪辑前端默认 28,取中 + t_size_720 = 48 # 与 config_schemas.DEFAULT_EDIT_PLAN_CONFIG.title.size=48 及 vfb 默认一致 t_size = _scale_title_len(t_size_720, output_width) # stroke/shadow 长度字段也需 720p→输出分辨率缩放 t_color = str(t_cfg.get("color", t_cfg.get("font_color", "#ffffff"))) @@ -525,14 +530,22 @@ def build_direct_render( # baseline 需再下移约 0.85*fontsize;但 drawtext 表达式无法引用 fontsize 变量, # 这里直接用 (PAD + margin_top)@720p 缩放后作为 y(即让 baseline≈顶部内边距位置), # 实际中文字符会自然向下延伸,视觉位置与前端预览(textBaseline=middle 居中到 firstLineY)一致。 - _t_margin_top_raw = t_cfg.get("margin_top", TITLE_DEFAULT_MARGIN_TOP) + # margin_top:前端滑块值(默认 24@720p),叠加在默认 50@720p 基线之上 + _t_user_margin_top = t_cfg.get("margin_top") try: - _t_margin_top_720 = int(_t_margin_top_raw) + _t_user_margin_top_720 = int(_t_user_margin_top) if _t_user_margin_top is not None else 0 except (TypeError, ValueError): - _t_margin_top_720 = TITLE_DEFAULT_MARGIN_TOP - t_margin_top = _scale_title_len(TITLE_PAD_TOP + _t_margin_top_720, output_width) - # bottom margin(标题放在 bottom 时也支持) - t_margin_bottom = _scale_title_len(TITLE_PAD_TOP + TITLE_DEFAULT_MARGIN_BOTTOM, output_width) + _t_user_margin_top_720 = 0 + t_margin_top_720 = TITLE_DEFAULT_MARGIN_TOP + _t_user_margin_top_720 + t_margin_top = _scale_title_len(t_margin_top_720, output_width) + # bottom margin(标题放在 bottom 时):用户 margin_bottom 透传,默认 50@720p + _t_user_margin_bottom = t_cfg.get("margin_bottom") + try: + _t_user_margin_bottom_720 = int(_t_user_margin_bottom) if _t_user_margin_bottom is not None else 0 + except (TypeError, ValueError): + _t_user_margin_bottom_720 = 0 + t_margin_bottom_720 = TITLE_DEFAULT_MARGIN_BOTTOM + _t_user_margin_bottom_720 + t_margin_bottom = _scale_title_len(t_margin_bottom_720, output_width) t_borderw = 0 t_border_color = "#000000" t_box = False @@ -566,11 +579,11 @@ def build_direct_render( t_shadow_x = _scale_title_len(t_shadow_x_720, output_width) t_shadow_y = _scale_title_len(t_shadow_y_720, output_width) # bold/italic:drawtext 原生无粗斜体选项;通过同色描边模拟粗体 - t_bold = bool(t_cfg.get("bold", True)) # 与 ASS 路径默认 bold=True 对齐 + t_bold = bool(t_cfg.get("bold", True)) # 与 ASS/vfb 路径默认 bold=True 对齐 if t_bold and t_borderw < 1: - # 同色描边宽度 1@720p,按宽度缩放保证各分辨率视觉一致 - t_borderw = max(1, _scale_title_len(1, output_width)) - t_border_color = t_color # 用文字色描边模拟加粗 + # 粗体未配用户描边时:黑色细描边 2@720p(与 vfb 一致,避免同色描边导致重影) + t_borderw = _scale_title_len(TITLE_FAUX_BOLD_WIDTH, output_width) + t_border_color = "#000000" # 黑色细描边模拟粗体 # ── 解析 subtitle_config ── s_cfg = dict(subtitle_config) if isinstance(subtitle_config, dict) else {} @@ -595,8 +608,21 @@ def build_direct_render( s_pos_x, s_pos_y = None, None s_margin_top = _scale_title_len(60, output_width) # subtitle top (not commonly used) s_margin_bottom = _scale_title_len(SUBTITLE_DEFAULT_MARGIN_BOTTOM, output_width) - s_borderw = max(1, _scale_title_len(2, output_width)) # 字幕默认描边保证可读性 + # subtitle stroke/bold:先解析用户 stroke,再按 bold 默认补描边 + s_borderw = 0 s_border_color = "#000000" + _s_stroke = s_cfg.get("stroke") + if isinstance(_s_stroke, dict) and _s_stroke.get("enabled", False): + try: + s_borderw = _scale_title_len(int(float(_s_stroke.get("width", 2))), output_width) + except (TypeError, ValueError): + s_borderw = 0 + s_border_color = str(_s_stroke.get("color", "#000000")) + s_bold = bool(s_cfg.get("bold", False)) + if s_bold and s_borderw < 1: + # 粗体默认黑色细描边 2@720p(与 title/CPU vfb 一致) + s_borderw = _scale_title_len(TITLE_FAUX_BOLD_WIDTH, output_width) + s_border_color = "#000000" # 静态字幕:static_subtitle_text 非空时构造全片长 segment(0 → total_duration) static_text = (static_subtitle_text or "").strip() diff --git a/tests/unit/test_gpu_direct_pipeline.py b/tests/unit/test_gpu_direct_pipeline.py index bee668860..72c476ef7 100644 --- a/tests/unit/test_gpu_direct_pipeline.py +++ b/tests/unit/test_gpu_direct_pipeline.py @@ -85,7 +85,7 @@ def _patch_pipeline_helpers(monkeypatch): # --------------------------------------------------------------------------- class TestNoConfigBackwardCompat: def test_default_title_drawtext_white_top(self, monkeypatch): - """不传 title_config 时:白字、top、36@720 按 width 缩放到 64、y=71(PAD16+margin24)。""" + """不传 title_config 时:白字、top、48@720 按 width 缩放到 85、y=89(50@720p baseline)。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) @@ -101,15 +101,15 @@ class TestNoConfigBackwardCompat: ) fc = " ".join(plan.filter_complex) assert "fontcolor=0xffffff" in fc - # 默认 position=top → y=71(scale(16+24)=71),不含 h-th - assert "y=71" in fc + # 默认 position=top → y=89(scale(50)=89,与 CPU/vfb 一致),不含 h-th + assert "y=89" in fc assert "h-th" not in fc - # 默认字号 36@720 经 1280/720 缩放 = 64 - assert "fontsize=64" in fc + # 默认字号 48@720 经 1280/720 缩放 = 85 + assert "fontsize=85" in fc assert "text='默认标题'" in fc - # 默认粗体:drawtext 无原生粗体,用同色描边 borderw=1@720(1280/720→scale=2)模拟 - assert "borderw=2" in fc - assert "bordercolor=0xffffff" in fc + # 默认粗体:黑色细描边 borderw=2@720(scale=4),与 CPU vfb 一致避免重影 + assert "borderw=4" in fc + assert "bordercolor=0x000000" in fc def test_no_subtitle_when_none(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp @@ -153,8 +153,8 @@ class TestTitleStylePassthrough: assert "fontcolor=0xff0000" in fc # 60@720 按 1280/720 缩放 = 107 assert "fontsize=107" in fc - # top 位置 y=71(scale(PAD16+margin_top24)=71) - assert "y=71" in fc + # top 位置默认 margin 50@720p → scale=89(未传 margin_top 用默认) + assert "y=89" in fc assert "h-th" not in fc def test_title_position_center(self, monkeypatch): @@ -218,9 +218,9 @@ class TestTitleStylePassthrough: # 阴影层 + 主字层 = 2 条 drawtext assert drawtext_count == 2 joined = " ".join(plan.filter_complex) - # shadow offset 3@720 经 1280/720 缩放 = 5;默认 position=top → y=71 + # shadow offset 3@720 经 1280/720 缩放 = 5;默认 position=top → y=89 assert "x=(w-text_w)/2+5" in joined - assert "y=71+5" in joined + assert "y=89+5" in joined assert "h-th" not in joined def test_title_font_override(self, monkeypatch): @@ -282,10 +282,10 @@ class TestTitleStylePassthrough: assert "(w-text_w)*0.3000" in fc assert "(h-text_h)*0.6000" in fc assert "h-th-" not in fc - assert "y=71" not in fc + assert "y=89" not in fc def test_title_margin_top_respected(self, monkeypatch): - """margin_top 透传:100@720 → scale(16+100)=206@1280。""" + """margin_top 透传:100@720,叠加默认 50 → 150@720 → scale=267@1280。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) @@ -300,8 +300,8 @@ class TestTitleStylePassthrough: title_config={"text": "远离顶部", "position": "top", "margin_top": 100}, ) fc = " ".join(plan.filter_complex) - # scale(PAD16 + margin_top100) = 116*1280/720 = 206 - assert "y=206" in fc + # 默认 50 + margin_top100 = 150@720 → scale=267 + assert "y=267" in fc def test_title_default_bold_true(self, monkeypatch): """不传 bold 时默认粗体:drawtext 用同色描边 borderw 模拟。""" @@ -319,9 +319,9 @@ class TestTitleStylePassthrough: title_config={"text": "粗体"}, ) fc = " ".join(plan.filter_complex) - # bold=True 默认:同色描边 width=1@720 → scale=2,颜色与文字相同(白色) - assert "borderw=2" in fc - assert "bordercolor=0xffffff" in fc + # bold=True 默认:黑色细描边 width=2@720 → scale=4,与 CPU vfb 一致 + assert "borderw=4" in fc + assert "bordercolor=0x000000" in fc assert "text='粗体'" in fc def test_title_bold_false_disables_faux_bold(self, monkeypatch): @@ -359,8 +359,8 @@ class TestTitleStylePassthrough: title_config={"text": "底部", "position": "bottom"}, ) fc = " ".join(plan.filter_complex) - # bottom margin PAD16+24=40 → scale=71 - assert "y=h-th-71" in fc + # bottom margin 50@720 → scale=89 + assert "y=h-th-89" in fc def test_title_size_scales_by_width_not_height(self, monkeypatch): """不同分辨率下同 @720 基准的 size 等比缩放:1080x1920 下 36→54。""" @@ -378,10 +378,10 @@ class TestTitleStylePassthrough: clip_volumes=[1.0], ) fc = " ".join(plan.filter_complex) - # default size 36@720 → 1080w = 54 - assert "fontsize=54" in fc - # top margin 40@720 → 60 - assert "y=60" in fc + # default size 48@720 → 1080w = 72 + assert "fontsize=72" in fc + # top margin 50@720 → 75 + assert "y=75" in fc # --------------------------------------------------------------------------- @@ -434,8 +434,8 @@ class TestSubtitlePassthrough: assert "fontcolor=0x0000ff" in fc # sub size 28@720 经 1280/720 缩放 = 50 assert "fontsize=50" in fc - # bottom margin 60@720 → 107 - assert "y=h-th-107" in fc + # bottom margin 50@720 → 89 + assert "y=h-th-89" in fc def test_subtitle_disabled_hides_subs(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp @@ -644,18 +644,18 @@ class TestCombinedConfig: bgm_config={"volume": 0.2, "fade_in": 0.5, "fade_out": 1.0}, ) fc = " ".join(plan.filter_complex) - # 标题:size 50@720 → 89,top margin 40@720 → 71,stroke 2@720 → 4 + # 标题:size 50@720 → 89,top margin 50@720 → 89,stroke 2@720 → 4 assert "text='主标题'" in fc assert "fontcolor=0xffff00" in fc assert "fontsize=89" in fc - assert "y=71" in fc + assert "y=89" in fc assert "borderw=4" in fc - # 字幕:size 24@720 → 43,bottom margin 60@720 → 107 + # 字幕:size 24@720 → 43,bottom margin 50@720 → 89(默认不加粗) assert "text='成片全字幕'" in fc assert "fontsize=43" in fc - assert "y=h-th-107" in fc + assert "y=h-th-89" in fc assert "fontcolor=0xffffff" in fc - # subtitle 默认 bold=True 也会加同色描边(width=1→2);但若主标题已有 stroke 不影响字幕独立 borderw=4(s 默认 2 像素@720→scale=4) + # subtitle 默认 bold=False,无额外描边(用户未开 stroke) # BGM assert "volume=0.200" in fc assert "afade=t=in:st=0:d=0.50" in fc @@ -688,7 +688,7 @@ class TestEdgeCases: assert "drawtext=" in fc def test_bold_title_increases_borderw(self, monkeypatch): - """bold=True 时若原无描边,自动加 borderw=1 用同色描边模拟加粗。""" + """bold=True 时若原无描边,自动加 borderw 黑色细描边模拟加粗(与 CPU vfb 一致,避免重影)。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) @@ -703,9 +703,9 @@ class TestEdgeCases: title_config={"text": "粗体", "bold": True, "color": "#ff0000"}, ) fc = " ".join(plan.filter_complex) - # 加粗模拟 borderw>=1,且 bordercolor 跟字体色一致(0xff0000) - assert "borderw=" in fc - assert "bordercolor=0xff0000" in fc + # 仿粗用黑色细描边 2@720→scale=4,不跟文字色(避免同色描边重影) + assert "borderw=4" in fc + assert "bordercolor=0x000000" in fc def test_asr_and_static_subtitle_asr_wins(self, monkeypatch): """同时传 static_subtitle_text 和 ASR segments 时,ASR 优先(不插入静态全文)。"""