From c6a28ea7cd20b5e7d80aff4007ae53ecccf6513d Mon Sep 17 00:00:00 2001 From: Backend Agent Date: Tue, 4 Aug 2026 17:47:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(render):=20=E7=94=BB=E4=B8=AD=E7=94=BB?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=85=A8=E9=9D=A2=E4=B8=8B=E7=BA=BF=20+=20?= =?UTF-8?q?=E6=A0=87=E9=A2=98ASS=E8=87=AA=E5=8A=A8=E6=8D=A2=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Q1 — 画中画(PiP)下线: - worker: _build_plan_and_clips_from_task 移除pip/voice_pip分支, 入口处强制映射为one_take(顺序拼接) - api preview: _resolve_strategy_id_from_template 返回前做pip→one_take映射 - api generation_tasks: strategy_id中的pip/voice_pip统一映射为one_take - 更新集成测试:pip/voice_pip测试改为验证one_take行为 Q2 — 标题自动换行: - packages/domain/ass_subtitle_builder.py: 新增_wrap_title_text函数 根据视频宽度、字号、边距估算每行最大字符数,CJK全角+英文半角 超出可用宽度时插入\N硬换行 - build_ass_content中title Dialogue生成前调用自动换行 测试:75个ASS单测全绿 + 10个四模式测试全绿 --- apps/api/app/api/routes/generation_preview.py | 8 +++ apps/api/app/api/routes/generation_tasks.py | 8 ++- apps/worker/worker_app/tasks/generation.py | 44 +++---------- packages/domain/ass_subtitle_builder.py | 47 +++++++++++++- tests/integration/test_four_mode_rendering.py | 32 +++++----- tests/unit/test_ass_subtitle_builder.py | 62 +++++++++++++++++++ 6 files changed, 145 insertions(+), 56 deletions(-) diff --git a/apps/api/app/api/routes/generation_preview.py b/apps/api/app/api/routes/generation_preview.py index 746eac25f..2246df9a1 100755 --- a/apps/api/app/api/routes/generation_preview.py +++ b/apps/api/app/api/routes/generation_preview.py @@ -125,6 +125,10 @@ def _resolve_strategy_id_from_template( mode, template_id, ) + # 画中画已下线,pip/voice_pip 统一映射为 one_take + if mode in ("pip", "voice_pip"): + logger.info("[预览生成] %s → one_take (画中画已下线)", mode) + mode = "one_take" return mode except Exception: logger.debug( @@ -146,6 +150,10 @@ def _resolve_strategy_id_from_template( mode, template_id, ) + # 画中画已下线,pip/voice_pip 统一映射为 one_take + if mode in ("pip", "voice_pip"): + logger.info("[预览生成] %s → one_take (画中画已下线)", mode) + mode = "one_take" return mode except Exception: logger.warning( diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 350d20498..795514544 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -271,13 +271,19 @@ def create_generation_task( detail="系统繁忙,请稍后再试", ) from e + # 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take + effective_strategy_id = request.strategy_id + if effective_strategy_id in ("pip", "voice_pip"): + logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id) + effective_strategy_id = "one_take" + try: for _ in range(count): task = use_case.execute( CreateGenerationTaskCommand( project_id=project_id, asset_library_id=asset_library_id, - strategy_id=request.strategy_id, + strategy_id=effective_strategy_id, voice_library_id=request.voice_library_id, template_id=request.template_id, asset_ids=resolved_asset_ids, diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index fb917d7d1..d2c9ffb83 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -235,10 +235,8 @@ def _build_plan_and_clips_from_task( """根据模式和下载的素材路径,构建虚拟 plan + clips + asset_path_map。 模式 → clip_type 映射: - ONE_TAKE: N 个 main clips - PIP: 1 main + N-1 overlay + ONE_TAKE: N 个 main clips(默认,pip/voice_pip 已统一映射为此模式) VOICE_OVER: N 个 main(config.role=b_roll) - VOICE_PIP: 1 background + 1 corner_voice + N-2 b_roll Returns: (virtual_plan, virtual_clips, asset_path_map) @@ -255,23 +253,14 @@ def _build_plan_and_clips_from_task( path_to_asset_id[p] = asset_id path_duration[p] = probe_duration(p) + # 产品已确认全面下线画中画,pip/voice_pip统一走one_take(顺序拼接) + if mode in ("pip", "voice_pip"): + logger.info("画中画模式已下线,%s 强制映射为 one_take", mode) + mode = "one_take" + clips: list[_VirtualClip] = [] - if mode == "pip": - # 1 main + N-1 overlay - for i, p in enumerate(downloaded_paths): - clip_type = "main" if i == 0 else "overlay" - clips.append( - _VirtualClip( - id=f"vc_{i:03d}", - plan_id=task_id, - clip_type=clip_type, - order=i, - asset_id=path_to_asset_id[p], - duration=path_duration[p], - ) - ) - elif mode == "voice_over": + if mode == "voice_over": # N 个 main(config.role=b_roll) for i, p in enumerate(downloaded_paths): clips.append( @@ -285,25 +274,6 @@ def _build_plan_and_clips_from_task( config={"role": "b_roll"}, ) ) - elif mode == "voice_pip": - # 1 background + 1 corner_voice + N-2 b_roll - for i, p in enumerate(downloaded_paths): - if i == 0: - clip_type = "background" - elif i == 1: - clip_type = "corner_voice" - else: - clip_type = "b_roll" - clips.append( - _VirtualClip( - id=f"vc_{i:03d}", - plan_id=task_id, - clip_type=clip_type, - order=i, - asset_id=path_to_asset_id[p], - duration=path_duration[p], - ) - ) else: # ONE_TAKE (default): N 个 main clips for i, p in enumerate(downloaded_paths): diff --git a/packages/domain/ass_subtitle_builder.py b/packages/domain/ass_subtitle_builder.py index 143d56f80..3905cd522 100755 --- a/packages/domain/ass_subtitle_builder.py +++ b/packages/domain/ass_subtitle_builder.py @@ -175,6 +175,48 @@ def format_ass_time(seconds: float) -> str: # ── 完整 ASS 内容生成 ───────────────────────────────────────────────────────── + +def _wrap_title_text( + text: str, + video_width: int, + font_size: int, + margin_l: int = TITLE_MARGIN_SIDE, + margin_r: int = TITLE_MARGIN_SIDE, +) -> str: + """根据视频宽度和字号自动换行标题文本。 + + 中文字符按 font_size 像素宽度估算,英文/数字按半角估算。 + 超过可用宽度时插入 \\N (ASS 硬换行)。 + """ + if not text or video_width <= 0 or font_size <= 0: + return text + + available_width = video_width - margin_l - margin_r + if available_width <= 0: + return text + + lines: list[str] = [] + current_line = "" + current_width = 0.0 + + for ch in text: + # CJK 字符按全角估算,其他按半角 + char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55 + + if current_width + char_width > available_width and current_line: + lines.append(current_line) + current_line = ch + current_width = char_width + else: + current_line += ch + current_width += char_width + + if current_line: + lines.append(current_line) + + return "\\N".join(lines) + + def build_ass_content( *, video_width: int, @@ -248,7 +290,10 @@ def build_ass_content( ) ) - safe_title_text = escape_ass_text(title_text) + # 根据视频宽度和字号自动换行标题,防止超出画面 + title_font_size = int(title_config.get("size", 48)) + wrapped_title = _wrap_title_text(title_text, video_width, title_font_size) + safe_title_text = escape_ass_text(wrapped_title) 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/integration/test_four_mode_rendering.py b/tests/integration/test_four_mode_rendering.py index eaa93506d..581aa0729 100644 --- a/tests/integration/test_four_mode_rendering.py +++ b/tests/integration/test_four_mode_rendering.py @@ -1,7 +1,8 @@ -"""四模式渲染集成测试. +"""剪辑模式渲染集成测试. -验证 4 种剪辑模式(ONE_TAKE / PIP / VOICE_OVER / VOICE_PIP)通过 +验证剪辑模式(ONE_TAKE / VOICE_OVER)通过 _build_plan_and_clips_from_task + UnifiedRenderService 的完整渲染流程。 +注:PIP / VOICE_PIP 已下线,统一映射为 ONE_TAKE。 需要 ffmpeg 可用;CI 无 ffmpeg 时自动跳过。 """ @@ -109,14 +110,13 @@ class TestBuildPlanAndClips: assert all(c.clip_type == "main" for c in clips) assert len(asset_map) == 3 - def test_pip_mode(self): + def test_pip_mode_maps_to_one_take(self): + """PIP 已下线,映射为 one_take → 全部 main clips。""" paths = self._make_paths(3) plan, clips, asset_map = _build_plan_and_clips_from_task("t2", paths, "pip") assert len(clips) == 3 - assert clips[0].clip_type == "main" - assert clips[1].clip_type == "overlay" - assert clips[2].clip_type == "overlay" + assert all(c.clip_type == "main" for c in clips) def test_voice_over_mode(self): paths = self._make_paths(3) @@ -126,15 +126,13 @@ class TestBuildPlanAndClips: assert all(c.clip_type == "main" for c in clips) assert all(c.config.get("role") == "b_roll" for c in clips) - def test_voice_pip_mode(self): + def test_voice_pip_mode_maps_to_one_take(self): + """VOICE_PIP 已下线,映射为 one_take → 全部 main clips。""" paths = self._make_paths(4) plan, clips, asset_map = _build_plan_and_clips_from_task("t4", paths, "voice_pip") assert len(clips) == 4 - assert clips[0].clip_type == "background" - assert clips[1].clip_type == "corner_voice" - assert clips[2].clip_type == "b_roll" - assert clips[3].clip_type == "b_roll" + assert all(c.clip_type == "main" for c in clips) def test_unknown_mode_defaults_to_one_take(self): paths = self._make_paths(2) @@ -166,13 +164,13 @@ class TestFourModeLayerGrouping: roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips} assert roles == {"main"} - def test_pip_layers(self): - """PIP: 1 main + 2 overlay → main + overlay。""" + def test_pip_layers_now_one_take(self): + """PIP 已下线 → one_take: 3 main → 1 main layer。""" paths = [Path(f"/tmp/pip_{i}.mp4") for i in range(3)] _, clips, _ = _build_plan_and_clips_from_task("pip", paths, "pip") roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips} - assert roles == {"main", "overlay"} + assert roles == {"main"} def test_voice_over_layers(self): """VOICE_OVER: 3 main(b_roll) → broll。""" @@ -182,13 +180,13 @@ class TestFourModeLayerGrouping: roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips} assert roles == {"broll"} - def test_voice_pip_layers(self): - """VOICE_PIP: 1 bg + 1 corner_voice + 2 b_roll → 3 个图层。""" + def test_voice_pip_layers_now_one_take(self): + """VOICE_PIP 已下线 → one_take: 4 main → main layer。""" paths = [Path(f"/tmp/vpip_{i}.mp4") for i in range(4)] _, clips, _ = _build_plan_and_clips_from_task("vpip", paths, "voice_pip") roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips} - assert roles == {"background", "corner_voice", "broll"} + assert roles == {"main"} # ── 端到端渲染测试(需要 ffmpeg) ───────────────────────────────────────────── diff --git a/tests/unit/test_ass_subtitle_builder.py b/tests/unit/test_ass_subtitle_builder.py index 971255014..2772e7948 100755 --- a/tests/unit/test_ass_subtitle_builder.py +++ b/tests/unit/test_ass_subtitle_builder.py @@ -8,6 +8,7 @@ from packages.domain.ass_subtitle_builder import ( TITLE_MARGIN_BOTTOM, TITLE_MARGIN_SIDE, TITLE_MARGIN_TOP, + _wrap_title_text, build_ass_content, build_ass_style, escape_ass_text, @@ -446,3 +447,64 @@ class TestConstants: def test_title_margin_side(self): assert TITLE_MARGIN_SIDE == 40 + +# ── 标题自动换行 ────────────────────────────────────────────────────────────── + + +class TestWrapTitleText: + """测试标题自动换行逻辑。""" + + def test_short_title_no_wrap(self): + """短标题不需要换行。""" + result = _wrap_title_text("你好世界", video_width=1080, font_size=48) + assert "\\N" not in result + assert result == "你好世界" + + def test_long_title_wraps(self): + """长标题应该被换行。""" + # 1080p竖屏 480px宽,字号48,边距40*2 + # 可用宽度 = 480 - 40 - 40 = 400 + # 每个中文字符 = 48px,最多约 8 个字符一行 + long_title = "这是一个非常非常长的标题需要换行处理" + result = _wrap_title_text(long_title, video_width=480, font_size=48) + assert "\\N" in result + lines = result.split("\\N") + assert len(lines) >= 2 + + def test_empty_text(self): + """空文本直接返回。""" + assert _wrap_title_text("", 1080, 48) == "" + + def test_english_half_width(self): + """英文字符按半角计算。""" + # 英文宽度 = 48 * 0.55 = 26.4px + # 可用宽度 = 480 - 80 = 400, 约15个字符 + result = _wrap_title_text("a" * 20, video_width=480, font_size=48) + # 20个英文字符 * 26.4 = 528 > 400,应该换行 + assert "\\N" in result + + def test_zero_width(self): + """视频宽度为0时直接返回原文。""" + assert _wrap_title_text("测试", 0, 48) == "测试" + + def test_zero_font_size(self): + """字号为0时直接返回原文。""" + assert _wrap_title_text("测试", 1080, 0) == "测试" + + def test_build_ass_content_integration(self): + """集成测试:build_ass_content 中的标题应该自动换行。""" + long_title = "这是一段非常长的标题文字用于测试自动换行功能是否正常工作" + content = build_ass_content( + video_width=480, + video_height=854, + video_duration=10.0, + title_text=long_title, + title_config={"size": 48, "position": "top"}, + ) + # 检查 Dialogue 行中包含 \N 换行 + for line in content.split("\n"): + if "Dialogue" in line and "TitleStyle" in line: + assert "\\N" in line, f"标题应该包含换行符: {line}" + break + else: + pytest.fail("未找到 TitleStyle Dialogue 行")