From 34e8c56584f1b63f95541aeb1b08f7f96c4da62b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 1 Sep 2026 01:25:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(title):=20=E6=A0=87=E9=A2=98=E8=87=AA?= =?UTF-8?q?=E7=94=B1=E4=BD=8D=E7=BD=AE=E6=8B=96=E6=8B=BD=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E2=80=94=E2=80=94pos=5Fx/pos=5Fy=20=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=20\\pos=20=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工单 #1405 方案 B:前端支持用户在预览画面自由拖拽标题位置, 后端烧录 ASS 时按坐标定位。只动 Title,Subtitle 逻辑不变。 - title_config 新增可选字段 pos_x、pos_y(PlayRes 像素整数, PlayResX/Y = video_width/video_height) - 两字段同时存在、为 int(bool/float/字符串拒绝)、且落在 [0,video_width]×[0,video_height] 闭区间内时: - TitleStyle Alignment 固定 5(\an5 中对齐,\pos 锚点为文本块中心) - Dialogue 文本前注入 {\pos(x,y)} override tag - 字段缺失/非法/越界一律回退 position → alignment 三档逻辑, 现有 top/center/bottom 输出一字节不变 - 新增 9 个单测:合法坐标注入、边界值、无坐标回归、越界回退、 非法类型回退(含 bool/float/None/字符串)、单字段回退、 三档对齐不变、pos 覆盖 position、subtitle 不受影响 --- packages/domain/ass_subtitle_builder.py | 51 ++++++++- tests/unit/test_ass_subtitle_builder.py | 145 ++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py index 4e79a52d4..eb3f4bf6d 100755 --- a/packages/domain/ass_subtitle_builder.py +++ b/packages/domain/ass_subtitle_builder.py @@ -256,6 +256,42 @@ def _wrap_title_text( return "\\N".join(wrapped_segments) +def _parse_title_position( + title_config: dict[str, Any], + video_width: int, + video_height: int, +) -> tuple[int, int] | None: + """解析标题自由拖拽坐标 pos_x/pos_y(PlayRes 像素坐标系)。 + + 要求两个字段同时存在、可转 int,且落在 [0, video_width] × [0, video_height] + 闭区间内。任一条件不满足返回 None,调用方回退 position 三档逻辑。 + + Args: + title_config: 标题配置 dict + video_width: PlayResX(视频宽度像素) + video_height: PlayResY(视频高度像素) + + Returns: + (x, y) 整数坐标,或 None 表示不使用自由位置 + """ + if "pos_x" not in title_config or "pos_y" not in title_config: + return None + raw_x = title_config["pos_x"] + raw_y = title_config["pos_y"] + # 坐标必须是 PlayRes 像素整数:bool 是 int 子类(isinstance(True,int)=True) + # 但 True/False 作坐标无意义;float 静默截断会造成拖拽位置偏差,一律按非法回退 + if isinstance(raw_x, bool) or isinstance(raw_y, bool): + return None + if not isinstance(raw_x, int) or not isinstance(raw_y, int): + return None + x, y = raw_x, raw_y + if video_width <= 0 or video_height <= 0: + return None + if not (0 <= x <= video_width and 0 <= y <= video_height): + return None + return (x, y) + + def build_ass_content( *, video_width: int, @@ -343,7 +379,16 @@ def build_ass_content( title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0, ) - title_alignment = position_to_ass_alignment(title_config.get("position", "top")) + # ── 自由位置拖拽(工单 #1405 方案 B)──────────────────────────── + # pos_x/pos_y 为 PlayRes 坐标系像素整数(PlayResX/Y = video_width/height)。 + # 合法时:TitleStyle Alignment 固定 5(\an5 中对齐,使 \pos 锚点为文本块中心), + # Dialogue 文本前注入 {\pos(x,y)}。字段缺失/非法/越界时一律回退 + # position → alignment 三档逻辑,现有输出保持一字节不变。 + title_pos = _parse_title_position(title_config, video_width, video_height) + + title_alignment = 5 if title_pos is not None else position_to_ass_alignment( + title_config.get("position", "top") + ) styles.append( build_ass_style( @@ -370,6 +415,10 @@ def build_ass_content( safe_title_text_raw = escape_ass_text(title_text) safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size) + # 自由位置:在文本前注入 \pos override tag(锚点为文本块中心,配合 \an5) + if title_pos is not None: + safe_title_text = f"{{\\pos({title_pos[0]},{title_pos[1]})}}{safe_title_text}" + events.append( "Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}" ) diff --git a/tests/unit/test_ass_subtitle_builder.py b/tests/unit/test_ass_subtitle_builder.py index 9483e298c..4a4e98f5d 100755 --- a/tests/unit/test_ass_subtitle_builder.py +++ b/tests/unit/test_ass_subtitle_builder.py @@ -602,3 +602,148 @@ class TestFontsizeCompensation: result = build_ass_style("S", font_size=0) parts = result.split(",") assert int(parts[2]) >= 1 + + +# ── 标题自由位置拖拽(工单 #1405 方案 B)────────────────────────────────────── + + +def _title_style_line(content: str) -> str: + return [line for line in content.splitlines() if line.startswith("Style: TitleStyle")][0] + + +def _title_dialogue_line(content: str) -> str: + return [line for line in content.splitlines() if line.startswith("Dialogue:") and "TitleStyle" in line][0] + + +class TestTitleFreePosition: + """pos_x/pos_y 合法时注入 \\pos 且 Alignment=5;非法/缺失时回退原逻辑。""" + + def _base_kwargs(self): + return dict( + video_width=1080, + video_height=1920, + video_duration=8.0, + title_text="测试标题", + ) + + def test_valid_position_injects_pos_tag_and_alignment_5(self): + content = build_ass_content( + **self._base_kwargs(), + title_config={"position": "top", "size": 36, "pos_x": 540, "pos_y": 300}, + ) + # Dialogue 文本前注入 {\pos(540,300)} + dialogue = _title_dialogue_line(content) + assert "{\\pos(540,300)}" in dialogue + # TitleStyle Alignment 固定 5(\an5 中对齐,\pos 锚点为文本块中心) + fields = [f.strip() for f in _title_style_line(content).split(",")] + assert fields[18] == "5" + + def test_boundary_coordinates_zero_and_max_accepted(self): + """边界值 0 和 video_width/video_height 合法(闭区间)。""" + content = build_ass_content( + **self._base_kwargs(), + title_config={"pos_x": 0, "pos_y": 1920}, + ) + assert "{\\pos(0,1920)}" in _title_dialogue_line(content) + + content2 = build_ass_content( + **self._base_kwargs(), + title_config={"pos_x": 1080, "pos_y": 0}, + ) + assert "{\\pos(1080,0)}" in _title_dialogue_line(content2) + + def test_no_coords_output_identical_to_before(self): + """不传坐标 → 输出与现有断言完全一致(回归保护)。""" + content = build_ass_content( + **self._base_kwargs(), + title_config={"position": "top", "size": 36}, + ) + # 无 \pos 注入 + assert "\\pos(" not in content + # Alignment 走 position 映射(top → 8) + fields = [f.strip() for f in _title_style_line(content).split(",")] + assert fields[18] == "8" + + def test_out_of_bounds_falls_back(self): + """越界坐标 → 回退 position 三档逻辑,输出与无坐标一致。""" + base = build_ass_content( + **self._base_kwargs(), + title_config={"position": "top", "size": 36}, + ) + for pos_x, pos_y in [(-1, 300), (540, -1), (1081, 300), (540, 1921), (99999, 99999)]: + content = build_ass_content( + **self._base_kwargs(), + title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y}, + ) + assert "\\pos(" not in content, f"({pos_x},{pos_y}) should be rejected" + assert content == base, f"({pos_x},{pos_y}) output differs from fallback" + + def test_invalid_coords_falls_back(self): + """非法类型坐标 → 回退原逻辑。""" + base = build_ass_content( + **self._base_kwargs(), + title_config={"position": "top", "size": 36}, + ) + for pos_x, pos_y in [("abc", 300), (540, None), (None, None), (True, 300), (540, False), (540.5, 300.9)]: + content = build_ass_content( + **self._base_kwargs(), + title_config={"position": "top", "size": 36, "pos_x": pos_x, "pos_y": pos_y}, + ) + assert "\\pos(" not in content, f"({pos_x!r},{pos_y!r}) should be rejected" + assert content == base, f"({pos_x!r},{pos_y!r}) output differs from fallback" + + def test_only_one_coord_falls_back(self): + """只传 pos_x 或 pos_y → 回退原逻辑。""" + base = build_ass_content( + **self._base_kwargs(), + title_config={"position": "center", "size": 36}, + ) + content_x = build_ass_content( + **self._base_kwargs(), + title_config={"position": "center", "size": 36, "pos_x": 540}, + ) + content_y = build_ass_content( + **self._base_kwargs(), + title_config={"position": "center", "size": 36, "pos_y": 300}, + ) + assert content_x == base + assert content_y == base + assert "\\pos(" not in content_x + assert "\\pos(" not in content_y + + def test_position_three_levels_unchanged_without_coords(self): + """无坐标时 top/center/bottom 三档 Alignment 输出不变。""" + for position, expected_align in [("top", "8"), ("center", "5"), ("bottom", "2")]: + content = build_ass_content( + **self._base_kwargs(), + title_config={"position": position, "size": 36}, + ) + fields = [f.strip() for f in _title_style_line(content).split(",")] + assert fields[18] == expected_align + + def test_pos_overrides_position_alignment(self): + """有合法坐标时,无论 position 是什么,Alignment 都固定为 5。""" + for position in ["top", "center", "bottom"]: + content = build_ass_content( + **self._base_kwargs(), + title_config={"position": position, "size": 36, "pos_x": 100, "pos_y": 200}, + ) + fields = [f.strip() for f in _title_style_line(content).split(",")] + assert fields[18] == "5" + assert "{\\pos(100,200)}" in _title_dialogue_line(content) + + def test_subtitle_not_affected_by_pos(self): + """pos_x/pos_y 只影响 Title,Subtitle 输出不变。""" + content = build_ass_content( + **self._base_kwargs(), + title_config={"pos_x": 540, "pos_y": 300}, + subtitle_text="配音字幕", + subtitle_config={"position": "bottom", "size": 24}, + ) + sub_style = [line for line in content.splitlines() if line.startswith("Style: SubtitleStyle")][0] + sub_fields = [f.strip() for f in sub_style.split(",")] + assert sub_fields[18] == "2" # bottom + sub_dialogue = [ + line for line in content.splitlines() if line.startswith("Dialogue:") and "SubtitleStyle" in line + ][0] + assert "\\pos(" not in sub_dialogue