diff --git a/apps/worker/video_processing/gpu_direct_pipeline.py b/apps/worker/video_processing/gpu_direct_pipeline.py index c0fb22e14..20520b1c8 100644 --- a/apps/worker/video_processing/gpu_direct_pipeline.py +++ b/apps/worker/video_processing/gpu_direct_pipeline.py @@ -26,6 +26,33 @@ DEFAULT_DRAWTEXT_FONT = "Noto Sans CJK SC" EDGE_CROP_MIN_PCT = 0.02 EDGE_CROP_MAX_PCT = 0.05 +# 标题/字幕样式基准宽度(px)。前端 TitleSettings 所有长度字段(size/描边/阴影/margin/pos) +# 均以 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 基准,与前端字幕面板默认对齐) + + +def _scale_title_len(value, video_width: int): + """将 720p 基准长度按 video_width 等比缩放(与 packages/domain/ass_subtitle_builder._scale_len 一致)。 + + int 输入 → 返回 int;float 输入 → 返回 float;非法值原样返回。 + """ + if value is None: + return None + try: + v = float(value) + except (TypeError, ValueError): + return value + if not video_width or video_width <= 0: + return int(round(v)) if isinstance(value, int) else v + scaled = v * (video_width / TITLE_SIZE_REF_WIDTH) + return int(round(scaled)) if isinstance(value, int) else scaled + def escape_drawtext_text(text: str) -> str: if not text: @@ -91,22 +118,52 @@ def _hex_to_drawtext_color(hex_color: str, default: str = "white") -> str: return default -def _position_to_drawtext_xy(position: str, *, margin: int = 40) -> tuple[str, str]: - """把 top/center/bottom 位置映射到 drawtext x/y 表达式。 +def _position_to_drawtext_xy( + position: str, + *, + margin_top: int = 0, + margin_bottom: int = 0, + pos_x: Optional[float] = None, + pos_y: Optional[float] = None, +) -> tuple[str, str]: + """把位置映射到 drawtext x/y 表达式,对齐前端 titleCanvas.ts 预览坐标。 - 返回 (x_expr, y_expr)。默认居中对齐。margin 为距离视频边缘的像素。 + position 支持: top / center(middle) / bottom / custom。 + - top: 文本基线放在 margin_top + ascent ≈ 顶部边缘留 PAD+margin_top 距离 + (drawtext y 是基线位置;为让文本 top-edge ≈ margin_top,把 y 设为 margin_top + font_ascent。 + 但 drawtext 运行时不知道 ascent,用经验系数 0.8*fontsize 近似,和前端 PAD+margin_top 对齐)。 + 为简化且精确对齐,这里用 y=margin_top(基线放在 margin_top 处), + 并在调用处把 margin_top 设为 前端的 (PAD+marginTop)+ascent 估算值。 + - center: (h-text_h)/2 垂直居中。 + - bottom: 文本底线距离底边 margin_bottom。 + - custom: pos_x/pos_y 为百分比 0-100(前端拖拽坐标系),文本中心落在 (pct_x*w, pct_y*h)。 + margin_top/margin_bottom 为已按 video_width 缩放过的像素值。 """ - p = (position or "bottom").lower().strip() + p = (position or "top").lower().strip() + + # custom:自由拖拽百分比坐标(0-100)→ 文本中心对齐到 (pct*w, pct*h) + if p == "custom" and pos_x is not None and pos_y is not None: + try: + px = max(0.0, min(100.0, float(pos_x))) / 100.0 + py = max(0.0, min(100.0, float(pos_y))) / 100.0 + return f"(w-text_w)*{px:.4f}", f"(h-text_h)*{py:.4f}" + except (TypeError, ValueError): + pass # fall through to default + x = "(w-text_w)/2" if p in ("top",): - y = f"{margin}" + # drawtext y 是 baseline 位置。中文字符顶边距基线约 0.85*fontsize(ascent), + # 但 drawtext 表达式里无法引用 fontsize 变量;这里让 y=margin_top 作为 baseline, + # 调用方传入的 margin_top 已包含 ascent 补偿,使文本 top-edge 与前端 PAD+marginTop 对齐。 + y = f"{int(margin_top)}" elif p in ("center", "middle"): y = "(h-text_h)/2" elif p in ("bottom",): - y = f"h-th-{margin}" + # h-th-margin_bottom:th ≈ text_h,文本底边距底边 margin_bottom + y = f"h-th-{int(margin_bottom)}" else: - # 未知值回退到底部 - y = f"h-th-{margin}" + # 未知值回退到顶部(与前端默认 position=top 对齐) + y = f"{int(margin_top)}" return x, y @@ -118,8 +175,11 @@ def _build_drawtext_filters( font: str = DEFAULT_DRAWTEXT_FONT, font_size: int = 0, font_color: str = "white", - position: str = "bottom", - margin: int = 40, + position: str = "top", + margin_top: int = 0, + margin_bottom: int = 0, + pos_x: Optional[float] = None, + pos_y: Optional[float] = None, box_enabled: bool = False, box_color: str = "black@0.5", borderw: int = 0, @@ -139,7 +199,13 @@ def _build_drawtext_filters( if not txt: return [] - x_expr, y_expr = _position_to_drawtext_xy(position, margin=margin) + x_expr, y_expr = _position_to_drawtext_xy( + position, + margin_top=margin_top, + margin_bottom=margin_bottom, + pos_x=pos_x, + pos_y=pos_y, + ) fc_color = _hex_to_drawtext_color(font_color, default="white") bd_color = _hex_to_drawtext_color(border_color, default="black") sh_color = _hex_to_drawtext_color(shadow_color, default="black@0.6") @@ -427,20 +493,46 @@ def build_direct_render( # 5. drawtext 字幕(标题 + 静态全文 + ASR 分段) # ── 解析 title_config(兼容字段名 font_size/font_color → size/color) ── + # 所有长度字段(size/stroke/shadow/margin)均为 720p 基准值,按 video_width 等比缩放, + # 对齐前端 titleCanvas.ts(scale=videoWidth/720)与 CPU/ASS 路径 _scale_len 规则, + # 保证成片标题位置/大小与前端预览一致(修复 PR#2093 位置不匹配 bug)。 t_cfg = dict(title_config) if isinstance(title_config, dict) else {} t_enabled = bool(t_cfg.get("enabled", True)) t_text = (t_cfg.get("text", "") or title_text or "").strip() t_font = str(t_cfg.get("font", font) or font) + # size:前端传 px@720p,未配置默认 28(前端 DEFAULT_TITLE_SETTINGS.size=28,对齐 AI Avatar 默认48) t_size_raw = t_cfg.get("size", t_cfg.get("font_size", 0)) try: - t_size = int(t_size_raw) if t_size_raw else 0 + t_size_720 = int(t_size_raw) if t_size_raw else 0 except (TypeError, ValueError): - t_size = 0 - if t_size <= 0: - t_size = max(int(output_height * 0.05), 24) + t_size_720 = 0 + if t_size_720 <= 0: + t_size_720 = 36 # 与 config_schemas 默认 48 接近;AI Avatar 前端默认 48,剪辑前端默认 28,取中 + 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"))) - t_position = str(t_cfg.get("position", "bottom")).lower() - t_margin = max(40, int(output_height * 0.05)) + t_position = str(t_cfg.get("position", "top")).lower().strip() + # 自由拖拽坐标(百分比 0-100),与 video_filter_builder.build_title_drawtext_filter 一致 + t_pos_x = t_cfg.get("pos_x") + t_pos_y = t_cfg.get("pos_y") + try: + t_pos_x = float(t_pos_x) if t_pos_x is not None else None + t_pos_y = float(t_pos_y) if t_pos_y is not None else None + except (TypeError, ValueError): + t_pos_x, t_pos_y = None, None + # margin_top:前端默认 24@720p;整体顶距 = PAD(16@720p) + margin_top + # 因为 drawtext y 是 baseline,中文字符 ascent≈0.85*fontsize,为让文本 top-edge≈(PAD+marginTop), + # 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) + try: + _t_margin_top_720 = int(_t_margin_top_raw) + 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_borderw = 0 t_border_color = "#000000" t_box = False @@ -449,31 +541,35 @@ def build_direct_render( _stroke = t_cfg.get("stroke") if isinstance(_stroke, dict) and _stroke.get("enabled", False): try: - t_borderw = int(float(_stroke.get("width", 2))) + t_borderw_720 = int(float(_stroke.get("width", 2))) except (TypeError, ValueError): - t_borderw = 2 + t_borderw_720 = 2 + t_borderw = max(1, _scale_title_len(t_borderw_720, output_width)) t_border_color = str(_stroke.get("color", "#000000")) elif isinstance(_stroke, bool) and _stroke: - t_borderw = 2 + t_borderw = max(1, _scale_title_len(2, output_width)) # shadow _shadow = t_cfg.get("shadow") t_shadow_enabled = False t_shadow_color = "#000000@0.6" - t_shadow_x, t_shadow_y = 2, 2 + t_shadow_x_720, t_shadow_y_720 = 2, 2 if isinstance(_shadow, dict) and _shadow.get("enabled", False): t_shadow_enabled = True t_shadow_color = str(_shadow.get("color", "#000000@0.6")) try: - t_shadow_x = int(float(_shadow.get("offset_x", 2))) - t_shadow_y = int(float(_shadow.get("offset_y", 2))) + t_shadow_x_720 = int(float(_shadow.get("offset_x", 2))) + t_shadow_y_720 = int(float(_shadow.get("offset_y", 2))) except (TypeError, ValueError): - t_shadow_x, t_shadow_y = 2, 2 + t_shadow_x_720, t_shadow_y_720 = 2, 2 elif isinstance(_shadow, bool) and _shadow: t_shadow_enabled = True - # bold/italic:drawtext 原生无粗斜体选项;通过加大 borderw 模拟粗体 - t_bold = bool(t_cfg.get("bold", False)) + 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 对齐 if t_bold and t_borderw < 1: - t_borderw = 1 + # 同色描边宽度 1@720p,按宽度缩放保证各分辨率视觉一致 + t_borderw = max(1, _scale_title_len(1, output_width)) t_border_color = t_color # 用文字色描边模拟加粗 # ── 解析 subtitle_config ── @@ -482,15 +578,24 @@ def build_direct_render( s_font = str(s_cfg.get("font", font) or font) s_size_raw = s_cfg.get("size", s_cfg.get("font_size", 0)) try: - s_size = int(s_size_raw) if s_size_raw else 0 + s_size_720 = int(s_size_raw) if s_size_raw else 0 except (TypeError, ValueError): - s_size = 0 - if s_size <= 0: - s_size = max(int(output_height * 0.04), 20) + s_size_720 = 0 + if s_size_720 <= 0: + s_size_720 = 24 # 字幕默认 24@720p(对齐 ass_subtitle_builder defaults size=24) + s_size = _scale_title_len(s_size_720, output_width) s_color = str(s_cfg.get("color", s_cfg.get("font_color", "#ffffff"))) - s_position = str(s_cfg.get("position", "bottom")).lower() - s_margin = max(60, int(output_height * 0.06)) - s_borderw = 2 # 字幕默认描边保证可读性 + s_position = str(s_cfg.get("position", "bottom")).lower().strip() + s_pos_x = s_cfg.get("pos_x") + s_pos_y = s_cfg.get("pos_y") + try: + s_pos_x = float(s_pos_x) if s_pos_x is not None else None + s_pos_y = float(s_pos_y) if s_pos_y is not None else None + except (TypeError, ValueError): + 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)) # 字幕默认描边保证可读性 s_border_color = "#000000" # 静态字幕:static_subtitle_text 非空时构造全片长 segment(0 → total_duration) @@ -519,7 +624,10 @@ def build_direct_render( font_size=t_size, font_color=t_color, position=t_position, - margin=t_margin, + margin_top=t_margin_top, + margin_bottom=t_margin_bottom, + pos_x=t_pos_x, + pos_y=t_pos_y, box_enabled=t_box, box_color=t_box_color, borderw=t_borderw, @@ -548,7 +656,10 @@ def build_direct_render( font_size=s_size, font_color=s_color, position=s_position, - margin=s_margin, + margin_top=s_margin_top, + margin_bottom=s_margin_bottom, + pos_x=s_pos_x, + pos_y=s_pos_y, box_enabled=False, borderw=s_borderw, border_color=s_border_color, diff --git a/tests/unit/test_gpu_direct_pipeline.py b/tests/unit/test_gpu_direct_pipeline.py index 12e6f46be..bee668860 100644 --- a/tests/unit/test_gpu_direct_pipeline.py +++ b/tests/unit/test_gpu_direct_pipeline.py @@ -84,7 +84,8 @@ def _patch_pipeline_helpers(monkeypatch): # 基线:不传 config 保持旧默认行为 # --------------------------------------------------------------------------- class TestNoConfigBackwardCompat: - def test_default_title_drawtext_white_bottom(self, monkeypatch): + def test_default_title_drawtext_white_top(self, monkeypatch): + """不传 title_config 时:白字、top、36@720 按 width 缩放到 64、y=71(PAD16+margin24)。""" import video_processing.gpu_direct_pipeline as gdp _patch_pipeline_helpers(monkeypatch) @@ -99,14 +100,16 @@ class TestNoConfigBackwardCompat: clip_volumes=[1.0, 1.0], ) fc = " ".join(plan.filter_complex) - # 默认 fontcolor=0xffffff(白色) assert "fontcolor=0xffffff" in fc - # 默认 position=bottom → y 表达式含 h-th - assert "h-th" in fc - # 默认字号:max(720*0.05,24) = 36 - assert "fontsize=36" in fc - # escape 对中文无影响(只转义 :'\ ),所以中文原样出现 + # 默认 position=top → y=71(scale(16+24)=71),不含 h-th + assert "y=71" in fc + assert "h-th" not in fc + # 默认字号 36@720 经 1280/720 缩放 = 64 + assert "fontsize=64" in fc assert "text='默认标题'" in fc + # 默认粗体:drawtext 无原生粗体,用同色描边 borderw=1@720(1280/720→scale=2)模拟 + assert "borderw=2" in fc + assert "bordercolor=0xffffff" in fc def test_no_subtitle_when_none(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp @@ -148,9 +151,10 @@ class TestTitleStylePassthrough: ) fc = " ".join(plan.filter_complex) assert "fontcolor=0xff0000" in fc - assert "fontsize=60" in fc - # top 位置 y=40 附近(h_th 不出现) - assert "y=40" 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 assert "h-th" not in fc def test_title_position_center(self, monkeypatch): @@ -188,7 +192,8 @@ class TestTitleStylePassthrough: }, ) fc = " ".join(plan.filter_complex) - assert "borderw=4" in fc + # stroke width 4@720 经 1280/720 缩放 = 7 + assert "borderw=7" in fc assert "bordercolor=0x0000ff" in fc def test_title_shadow_produces_two_drawtext_layers(self, monkeypatch): @@ -213,8 +218,10 @@ class TestTitleStylePassthrough: # 阴影层 + 主字层 = 2 条 drawtext assert drawtext_count == 2 joined = " ".join(plan.filter_complex) - assert "x=(w-text_w)/2+3" in joined - assert "y=h-th-" in joined and "+3" in joined + # shadow offset 3@720 经 1280/720 缩放 = 5;默认 position=top → y=71 + assert "x=(w-text_w)/2+5" in joined + assert "y=71+5" in joined + assert "h-th" not in joined def test_title_font_override(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp @@ -251,6 +258,131 @@ class TestTitleStylePassthrough: fc = " ".join(plan.filter_complex) assert "drawtext=" not in fc + def test_title_custom_position_uses_pct_xy(self, monkeypatch): + """position=custom + pos_x/pos_y 百分比 → (w-text_w)*pct, (h-text_h)*pct。""" + import video_processing.gpu_direct_pipeline as gdp + + _patch_pipeline_helpers(monkeypatch) + plan = gdp.build_direct_render( + resolved_clips=_make_clips(1, 2.0), + output_width=1280, + output_height=720, + output_fps=30, + total_duration=2.0, + clip_has_audio=[True], + clip_volumes=[1.0], + title_config={ + "text": "拖拽标题", + "position": "custom", + "pos_x": 30, + "pos_y": 60, + }, + ) + fc = " ".join(plan.filter_complex) + 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 + + def test_title_margin_top_respected(self, monkeypatch): + """margin_top 透传:100@720 → scale(16+100)=206@1280。""" + import video_processing.gpu_direct_pipeline as gdp + + _patch_pipeline_helpers(monkeypatch) + plan = gdp.build_direct_render( + resolved_clips=_make_clips(1, 2.0), + output_width=1280, + output_height=720, + output_fps=30, + total_duration=2.0, + clip_has_audio=[True], + clip_volumes=[1.0], + 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 + + def test_title_default_bold_true(self, monkeypatch): + """不传 bold 时默认粗体:drawtext 用同色描边 borderw 模拟。""" + import video_processing.gpu_direct_pipeline as gdp + + _patch_pipeline_helpers(monkeypatch) + plan = gdp.build_direct_render( + resolved_clips=_make_clips(1, 2.0), + output_width=1280, + output_height=720, + output_fps=30, + total_duration=2.0, + clip_has_audio=[True], + clip_volumes=[1.0], + 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 + assert "text='粗体'" in fc + + def test_title_bold_false_disables_faux_bold(self, monkeypatch): + """显式 bold=False 时不加粗描边。""" + import video_processing.gpu_direct_pipeline as gdp + + _patch_pipeline_helpers(monkeypatch) + plan = gdp.build_direct_render( + resolved_clips=_make_clips(1, 2.0), + output_width=1280, + output_height=720, + output_fps=30, + total_duration=2.0, + clip_has_audio=[True], + clip_volumes=[1.0], + title_config={"text": "细体", "bold": False}, + ) + fc = " ".join(plan.filter_complex) + # 无描边 + assert "borderw=" not in fc + + def test_title_position_bottom(self, monkeypatch): + """position=bottom → y=h-th-{scaled margin}。""" + import video_processing.gpu_direct_pipeline as gdp + + _patch_pipeline_helpers(monkeypatch) + plan = gdp.build_direct_render( + resolved_clips=_make_clips(1, 2.0), + output_width=1280, + output_height=720, + output_fps=30, + total_duration=2.0, + clip_has_audio=[True], + clip_volumes=[1.0], + title_config={"text": "底部", "position": "bottom"}, + ) + fc = " ".join(plan.filter_complex) + # bottom margin PAD16+24=40 → scale=71 + assert "y=h-th-71" in fc + + def test_title_size_scales_by_width_not_height(self, monkeypatch): + """不同分辨率下同 @720 基准的 size 等比缩放:1080x1920 下 36→54。""" + import video_processing.gpu_direct_pipeline as gdp + + _patch_pipeline_helpers(monkeypatch) + plan = gdp.build_direct_render( + resolved_clips=_make_clips(1, 2.0), + output_width=1080, + output_height=1920, + output_fps=30, + title_text="竖屏标题", + total_duration=2.0, + clip_has_audio=[True], + 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 + # --------------------------------------------------------------------------- # 字幕:静态 subtitle_text + ASR segments @@ -300,7 +432,10 @@ class TestSubtitlePassthrough: assert "text='第一句'" in fc assert "text='第二句'" in fc assert "fontcolor=0x0000ff" in fc - assert "fontsize=28" 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 def test_subtitle_disabled_hides_subs(self, monkeypatch): import video_processing.gpu_direct_pipeline as gdp @@ -509,16 +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 assert "text='主标题'" in fc assert "fontcolor=0xffff00" in fc - assert "fontsize=50" in fc - assert "y=40" in fc - assert "borderw=2" in fc - # 字幕 + assert "fontsize=89" in fc + assert "y=71" in fc + assert "borderw=4" in fc + # 字幕:size 24@720 → 43,bottom margin 60@720 → 107 assert "text='成片全字幕'" in fc + assert "fontsize=43" in fc + assert "y=h-th-107" in fc assert "fontcolor=0xffffff" in fc - assert "fontsize=24" in fc + # subtitle 默认 bold=True 也会加同色描边(width=1→2);但若主标题已有 stroke 不影响字幕独立 borderw=4(s 默认 2 像素@720→scale=4) # BGM assert "volume=0.200" in fc assert "afade=t=in:st=0:d=0.50" in fc