From 673d18aa837c69f66e59e1266048afb5102e699b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 18:15:38 +0800 Subject: [PATCH] test(wave90): add 52 unit tests for plan_generator_utils (#940) --- .../app/services/plan_generator_service.py | 268 +------ packages/domain/plan_generator_utils.py | 347 +++++++++ tests/unit/test_plan_generator_utils.py | 670 ++++++++++++++++++ 3 files changed, 1037 insertions(+), 248 deletions(-) create mode 100755 packages/domain/plan_generator_utils.py create mode 100755 tests/unit/test_plan_generator_utils.py diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index 57d986f7d..0bf79f168 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -26,6 +26,13 @@ from packages.domain.edit_plan import EditPlan from packages.domain.edit_plan_clip import EditPlanClip from packages.domain.edit_template import EditTemplate from packages.domain.editing_mode import EditingMode +from packages.domain.plan_generator_utils import ( + DEFAULT_CLIP_DURATION, + create_clips_from_configs, + distribute_assets, + generate_default_clips, + map_clip_types_for_mode, +) from packages.domain.template_clip_config import ClipType, TemplateClipConfig logger = logging.getLogger(__name__) @@ -163,83 +170,18 @@ class PlanGeneratorService: plan_id: str, clip_configs: List[TemplateClipConfig], ) -> List[EditPlanClip]: - """从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)""" - clips: List[EditPlanClip] = [] - # 按 order 排序 - sorted_configs = sorted(clip_configs, key=lambda c: c.order) + """从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化). - for cfg in sorted_configs: - # 计算时长:取 min_duration 和 max_duration 的中间值 - if cfg.min_duration > 0 and cfg.max_duration > 0: - duration = (cfg.min_duration + cfg.max_duration) / 2 - elif cfg.min_duration > 0: - duration = cfg.min_duration - elif cfg.max_duration > 0: - duration = cfg.max_duration - else: - duration = _DEFAULT_CLIP_DURATION - - # clip_type 可能是枚举或字符串 - clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type - - # transition_effect 可能是枚举或字符串 - transition = ( - cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect - ) - - # 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名) - clip_cfg = cfg.config or {} - playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0 - - clip = EditPlanClip.create( - plan_id=plan_id, - clip_type=clip_type, - order=cfg.order, - template_clip_config_id=cfg.id, - text_content=getattr(cfg, "text_template", "") or "", - duration=duration, - transition_effect=transition or "cut", - playback_speed=playback_speed, - config=clip_cfg, - ) - clips.append(clip) - - return clips + 委托给 plan_generator_utils.create_clips_from_configs 纯函数。 + """ + return create_clips_from_configs(plan_id, clip_configs) def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None: - """将模板 clip_config 生成的 MAIN 类型片段,按 editing_mode 映射为对应角色类型。 + """将 MAIN 类型片段按 editing_mode 映射为对应角色类型. - 模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等), - 但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名 - (overlay / background / corner_voice / b_roll)。 - - 映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型): - - PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画) - - VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll - - ONE_TAKE / VOICE_OVER: 保持 main 不变 + 委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。 """ - from packages.domain.template_clip_config import ClipType - - main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] - if not main_clips: - return - - if editing_mode == EditingMode.PIP.value: - # 第1个 main 保持(背景层),其余改为 overlay(画中画层) - for i, clip in enumerate(main_clips): - if i > 0: - clip.clip_type = "overlay" - - elif editing_mode == EditingMode.VOICE_PIP.value: - for i, clip in enumerate(main_clips): - if i == 0: - clip.clip_type = "background" - elif i == 1: - clip.clip_type = "corner_voice" - else: - clip.clip_type = "b_roll" - - # ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理 + map_clip_types_for_mode(clips, editing_mode) def _generate_default_clips( self, @@ -247,101 +189,11 @@ class PlanGeneratorService: editing_mode: str, asset_count: int, ) -> List[EditPlanClip]: - """无 clip_configs 时,根据 editing_mode 生成默认 clip 结构 + """无 clip_configs 时,根据 editing_mode 生成默认 clip 结构. - - ONE_TAKE: N 个 main clips(N = asset_count,至少1个) - - PIP: 1 个 main + (N-1) 个 overlay(N = asset_count) - - VOICE_OVER: N 个 main clips + 标记需要配音 - - VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll + 委托给 plan_generator_utils.generate_default_clips 纯函数。 """ - n = max(asset_count, 1) - clips: List[EditPlanClip] = [] - order = 0 - - if editing_mode == EditingMode.PIP.value: - # 1 个 main(全屏背景) - clips.append( - EditPlanClip.create( - plan_id=plan_id, - clip_type=ClipType.MAIN.value, - order=order, - duration=_DEFAULT_CLIP_DURATION, - ) - ) - order += 1 - # 剩余为 overlay - for _ in range(1, n): - clips.append( - EditPlanClip.create( - plan_id=plan_id, - clip_type="overlay", - order=order, - duration=_DEFAULT_CLIP_DURATION, - ) - ) - order += 1 - - elif editing_mode == EditingMode.VOICE_OVER.value: - # N 个 main clips(B-roll) - for _ in range(n): - clips.append( - EditPlanClip.create( - plan_id=plan_id, - clip_type=ClipType.MAIN.value, - order=order, - duration=_DEFAULT_CLIP_DURATION, - config={"role": "b_roll"}, - ) - ) - order += 1 - - elif editing_mode == EditingMode.VOICE_PIP.value: - # 1 个 background - clips.append( - EditPlanClip.create( - plan_id=plan_id, - clip_type="background", - order=order, - duration=_DEFAULT_CLIP_DURATION, - ) - ) - order += 1 - # 1 个 corner_voice - clips.append( - EditPlanClip.create( - plan_id=plan_id, - clip_type="corner_voice", - order=order, - duration=_DEFAULT_CLIP_DURATION, - ) - ) - order += 1 - # 剩余为 b_roll - for _ in range(2, n): - clips.append( - EditPlanClip.create( - plan_id=plan_id, - clip_type="b_roll", - order=order, - duration=_DEFAULT_CLIP_DURATION, - ) - ) - order += 1 - - else: - # ONE_TAKE: N 个 main clips - for _ in range(n): - clips.append( - EditPlanClip.create( - plan_id=plan_id, - clip_type=ClipType.MAIN.value, - order=order, - duration=_DEFAULT_CLIP_DURATION, - ) - ) - order += 1 - - return clips + return generate_default_clips(plan_id, editing_mode, asset_count) def _distribute_assets( self, @@ -349,89 +201,9 @@ class PlanGeneratorService: asset_ids: List[str], editing_mode: str, ) -> None: - """按 editing_mode 将素材分配到 clips(就地修改,未持久化) + """按 editing_mode 将素材分配到 clips(就地修改,未持久化). - 分配策略: - - ONE_TAKE: 素材按顺序依次分配给 main 类型 clips - - PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips - - VOICE_OVER: 素材→main clips (B-roll) - - VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll + 委托给 plan_generator_utils.distribute_assets 纯函数。 """ - if not asset_ids or not clips: - return + distribute_assets(clips, asset_ids, editing_mode) - if editing_mode == EditingMode.ONE_TAKE.value: - self._distribute_one_take(clips, asset_ids) - elif editing_mode == EditingMode.PIP.value: - self._distribute_pip(clips, asset_ids) - elif editing_mode == EditingMode.VOICE_OVER.value: - self._distribute_voice_over(clips, asset_ids) - elif editing_mode == EditingMode.VOICE_PIP.value: - self._distribute_voice_pip(clips, asset_ids) - else: - # 未知模式,退化为 one_take - self._distribute_one_take(clips, asset_ids) - - def _distribute_one_take( - self, - clips: List[EditPlanClip], - asset_ids: List[str], - ) -> None: - """ONE_TAKE: 素材按顺序依次分配给 main 类型 clips""" - main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] - for i, clip in enumerate(main_clips): - if i < len(asset_ids): - clip.assign_asset(asset_ids[i]) - - def _distribute_pip( - self, - clips: List[EditPlanClip], - asset_ids: List[str], - ) -> None: - """PIP: 第1个素材→main(全屏背景),其余→overlay clips""" - # 第1个素材 → main clip - main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] - if main_clips and asset_ids: - main_clips[0].assign_asset(asset_ids[0]) - - # 其余素材 → overlay clips - overlay_clips = [c for c in clips if c.clip_type == "overlay"] - remaining = asset_ids[1:] - for i, clip in enumerate(overlay_clips): - if i < len(remaining): - clip.assign_asset(remaining[i]) - - def _distribute_voice_over( - self, - clips: List[EditPlanClip], - asset_ids: List[str], - ) -> None: - """VOICE_OVER: 素材→main clips (B-roll)""" - main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] - for i, clip in enumerate(main_clips): - if i < len(asset_ids): - clip.assign_asset(asset_ids[i]) - - def _distribute_voice_pip( - self, - clips: List[EditPlanClip], - asset_ids: List[str], - ) -> None: - """VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll""" - bg_clips = [c for c in clips if c.clip_type == "background"] - corner_clips = [c for c in clips if c.clip_type == "corner_voice"] - broll_clips = [c for c in clips if c.clip_type == "b_roll"] - - # 第1个素材 → background - if bg_clips and len(asset_ids) > 0: - bg_clips[0].assign_asset(asset_ids[0]) - - # 第2个素材 → corner_voice - if corner_clips and len(asset_ids) > 1: - corner_clips[0].assign_asset(asset_ids[1]) - - # 其余素材 → b_roll - remaining = asset_ids[2:] - for i, clip in enumerate(broll_clips): - if i < len(remaining): - clip.assign_asset(remaining[i]) diff --git a/packages/domain/plan_generator_utils.py b/packages/domain/plan_generator_utils.py new file mode 100755 index 000000000..4bca02417 --- /dev/null +++ b/packages/domain/plan_generator_utils.py @@ -0,0 +1,347 @@ +"""剪辑计划生成 — 纯逻辑工具函数. + +从 PlanGeneratorService 提取的纯业务逻辑: +- 素材分配策略(4 种 editing_mode) +- 默认 clip 结构生成 +- clip_type 按模式映射 +- 从 TemplateClipConfig 创建 EditPlanClip + +纯函数,无副作用,不依赖 DB/外部服务。 +""" + +from __future__ import annotations + +from typing import List + +from packages.domain.edit_plan_clip import EditPlanClip +from packages.domain.editing_mode import EditingMode +from packages.domain.template_clip_config import ClipType, TemplateClipConfig + +# ── 默认片段时长(秒) ──────────────────────────────────────────────────────── +DEFAULT_CLIP_DURATION = 5.0 +DEFAULT_INTRO_DURATION = 3.0 +DEFAULT_OUTRO_DURATION = 3.0 + + +# ── 素材分配 ──────────────────────────────────────────────────────────────── + + +def distribute_assets( + clips: List[EditPlanClip], + asset_ids: List[str], + editing_mode: str, +) -> None: + """按 editing_mode 将素材分配到 clips(就地修改). + + 分配策略: + - ONE_TAKE: 素材按顺序依次分配给 main 类型 clips + - PIP: 第1个素材→main(全屏背景),其余→overlay clips + - VOICE_OVER: 素材→main clips (B-roll) + - VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll + + Args: + clips: 剪辑片段列表(就地修改 asset_id) + asset_ids: 素材 ID 列表 + editing_mode: 剪辑模式字符串 + """ + if not asset_ids or not clips: + return + + if editing_mode == EditingMode.ONE_TAKE.value: + _distribute_one_take(clips, asset_ids) + elif editing_mode == EditingMode.PIP.value: + _distribute_pip(clips, asset_ids) + elif editing_mode == EditingMode.VOICE_OVER.value: + _distribute_voice_over(clips, asset_ids) + elif editing_mode == EditingMode.VOICE_PIP.value: + _distribute_voice_pip(clips, asset_ids) + else: + # 未知模式,退化为 one_take + _distribute_one_take(clips, asset_ids) + + +def _distribute_one_take( + clips: List[EditPlanClip], + asset_ids: List[str], +) -> None: + """ONE_TAKE: 素材按顺序依次分配给 main 类型 clips.""" + main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] + for i, clip in enumerate(main_clips): + if i < len(asset_ids): + clip.assign_asset(asset_ids[i]) + + +def _distribute_pip( + clips: List[EditPlanClip], + asset_ids: List[str], +) -> None: + """PIP: 第1个素材→main(全屏背景),其余→overlay clips.""" + # 第1个素材 → main clip + main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] + if main_clips and asset_ids: + main_clips[0].assign_asset(asset_ids[0]) + + # 其余素材 → overlay clips + overlay_clips = [c for c in clips if c.clip_type == "overlay"] + remaining = asset_ids[1:] + for i, clip in enumerate(overlay_clips): + if i < len(remaining): + clip.assign_asset(remaining[i]) + + +def _distribute_voice_over( + clips: List[EditPlanClip], + asset_ids: List[str], +) -> None: + """VOICE_OVER: 素材→main clips (B-roll).""" + main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] + for i, clip in enumerate(main_clips): + if i < len(asset_ids): + clip.assign_asset(asset_ids[i]) + + +def _distribute_voice_pip( + clips: List[EditPlanClip], + asset_ids: List[str], +) -> None: + """VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll.""" + bg_clips = [c for c in clips if c.clip_type == "background"] + voice_clips = [c for c in clips if c.clip_type == "corner_voice"] + broll_clips = [c for c in clips if c.clip_type == "b_roll"] + + idx = 0 + + # 第1个 → background + if idx < len(asset_ids) and bg_clips: + bg_clips[0].assign_asset(asset_ids[idx]) + idx += 1 + + # 第2个 → corner_voice + if idx < len(asset_ids) and voice_clips: + voice_clips[0].assign_asset(asset_ids[idx]) + idx += 1 + + # 剩余 → b_roll clips + remaining = asset_ids[idx:] + for i, clip in enumerate(broll_clips): + if i < len(remaining): + clip.assign_asset(remaining[i]) + + +# ── clip_type 映射 ──────────────────────────────────────────────────────── + + +def map_clip_types_for_mode( + clips: List[EditPlanClip], + editing_mode: str, +) -> None: + """将 MAIN 类型片段按 editing_mode 映射为对应角色类型. + + 模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等), + 但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名 + (overlay / background / corner_voice / b_roll)。 + + 映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型): + - PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画) + - VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll + - ONE_TAKE / VOICE_OVER: 保持 main 不变 + + Args: + clips: 剪辑片段列表(就地修改 clip_type) + editing_mode: 剪辑模式字符串 + """ + main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value] + if not main_clips: + return + + if editing_mode == EditingMode.PIP.value: + # 第1个 main 保持(背景层),其余改为 overlay(画中画层) + for i, clip in enumerate(main_clips): + if i > 0: + clip.clip_type = "overlay" + + elif editing_mode == EditingMode.VOICE_PIP.value: + for i, clip in enumerate(main_clips): + if i == 0: + clip.clip_type = "background" + elif i == 1: + clip.clip_type = "corner_voice" + else: + clip.clip_type = "b_roll" + + # ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理 + + +# ── 默认 clip 生成 ──────────────────────────────────────────────────────── + + +def generate_default_clips( + plan_id: str, + editing_mode: str, + asset_count: int, +) -> List[EditPlanClip]: + """无 clip_configs 时,根据 editing_mode 生成默认 clip 结构. + + - ONE_TAKE: N 个 main clips(N = asset_count,至少1个) + - PIP: 1 个 main + (N-1) 个 overlay(N = asset_count) + - VOICE_OVER: N 个 main clips + 标记需要配音 + - VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll + + Args: + plan_id: 剪辑计划 ID + editing_mode: 剪辑模式字符串 + asset_count: 素材数量 + + Returns: + List[EditPlanClip]: 生成的默认剪辑片段列表 + """ + n = max(asset_count, 1) + clips: List[EditPlanClip] = [] + order = 0 + + if editing_mode == EditingMode.PIP.value: + # 1 个 main(全屏背景) + clips.append( + EditPlanClip.create( + plan_id=plan_id, + clip_type=ClipType.MAIN.value, + order=order, + duration=DEFAULT_CLIP_DURATION, + ) + ) + order += 1 + # 剩余为 overlay + for _ in range(1, n): + clips.append( + EditPlanClip.create( + plan_id=plan_id, + clip_type="overlay", + order=order, + duration=DEFAULT_CLIP_DURATION, + ) + ) + order += 1 + + elif editing_mode == EditingMode.VOICE_OVER.value: + # N 个 main clips(B-roll) + for _ in range(n): + clips.append( + EditPlanClip.create( + plan_id=plan_id, + clip_type=ClipType.MAIN.value, + order=order, + duration=DEFAULT_CLIP_DURATION, + config={"role": "b_roll"}, + ) + ) + order += 1 + + elif editing_mode == EditingMode.VOICE_PIP.value: + # 1 个 background + clips.append( + EditPlanClip.create( + plan_id=plan_id, + clip_type="background", + order=order, + duration=DEFAULT_CLIP_DURATION, + ) + ) + order += 1 + # 1 个 corner_voice(至少有1个素材就有) + if n >= 2: + clips.append( + EditPlanClip.create( + plan_id=plan_id, + clip_type="corner_voice", + order=order, + duration=DEFAULT_CLIP_DURATION, + ) + ) + order += 1 + # 剩余为 b_roll + for _ in range(2, n): + clips.append( + EditPlanClip.create( + plan_id=plan_id, + clip_type="b_roll", + order=order, + duration=DEFAULT_CLIP_DURATION, + ) + ) + order += 1 + + else: + # ONE_TAKE / 未知模式:N 个 main clips + for _ in range(n): + clips.append( + EditPlanClip.create( + plan_id=plan_id, + clip_type=ClipType.MAIN.value, + order=order, + duration=DEFAULT_CLIP_DURATION, + ) + ) + order += 1 + + return clips + + +# ── 从配置创建 clips ──────────────────────────────────────────────────────── + + +def create_clips_from_configs( + plan_id: str, + clip_configs: List[TemplateClipConfig], +) -> List[EditPlanClip]: + """从 TemplateClipConfig 列表创建 EditPlanClip 列表. + + Args: + plan_id: 剪辑计划 ID + clip_configs: 模板片段配置列表 + + Returns: + List[EditPlanClip]: 创建的剪辑片段列表(按 order 排序) + """ + clips: List[EditPlanClip] = [] + # 按 order 排序 + sorted_configs = sorted(clip_configs, key=lambda c: c.order) + + for cfg in sorted_configs: + # 计算时长:取 min_duration 和 max_duration 的中间值 + if cfg.min_duration > 0 and cfg.max_duration > 0: + duration = (cfg.min_duration + cfg.max_duration) / 2 + elif cfg.min_duration > 0: + duration = cfg.min_duration + elif cfg.max_duration > 0: + duration = cfg.max_duration + else: + duration = DEFAULT_CLIP_DURATION + + # clip_type 可能是枚举或字符串 + clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type + + # transition_effect 可能是枚举或字符串 + transition = ( + cfg.transition_effect.value + if hasattr(cfg.transition_effect, "value") + else cfg.transition_effect + ) + + # 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名) + clip_cfg = cfg.config or {} + playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0 + + clip = EditPlanClip.create( + plan_id=plan_id, + clip_type=clip_type, + order=cfg.order, + template_clip_config_id=cfg.id, + text_content=getattr(cfg, "text_template", "") or "", + duration=duration, + transition_effect=transition or "cut", + playback_speed=playback_speed, + config=clip_cfg, + ) + clips.append(clip) + + return clips diff --git a/tests/unit/test_plan_generator_utils.py b/tests/unit/test_plan_generator_utils.py new file mode 100755 index 000000000..be7a15d27 --- /dev/null +++ b/tests/unit/test_plan_generator_utils.py @@ -0,0 +1,670 @@ +"""plan_generator_utils 纯逻辑单测 — 第90波. + +测试素材分配、clip_type映射、默认clip生成、配置转clip等纯函数。 +不依赖 DB,使用领域对象直接构造。 +""" + +import pytest + +from packages.domain.edit_plan_clip import EditPlanClip +from packages.domain.editing_mode import EditingMode +from packages.domain.plan_generator_utils import ( + DEFAULT_CLIP_DURATION, + create_clips_from_configs, + distribute_assets, + generate_default_clips, + map_clip_types_for_mode, +) +from packages.domain.template_clip_config import ClipType, TemplateClipConfig + + +# ── 辅助函数 ────────────────────────────────────────────────────────── + + +def _make_main_clip(plan_id: str = "plan1", order: int = 0) -> EditPlanClip: + """创建一个 MAIN 类型的 clip.""" + return EditPlanClip.create( + plan_id=plan_id, + clip_type=ClipType.MAIN.value, + order=order, + duration=5.0, + ) + + +def _make_clips(n: int, clip_type: str = "main") -> list[EditPlanClip]: + """创建 n 个指定类型的 clip.""" + return [ + EditPlanClip.create( + plan_id="plan1", + clip_type=clip_type, + order=i, + duration=5.0, + ) + for i in range(n) + ] + + +def _collect_asset_ids(clips: list[EditPlanClip]) -> list[str]: + """按顺序收集 clips 的 asset_id(空的跳过).""" + return [c.asset_id for c in clips if c.asset_id] + + +# ── distribute_assets: ONE_TAKE ───────────────────────────────────── + + +class TestDistributeOneTake: + """ONE_TAKE 模式素材分配.""" + + def test_equal_count(self): + """素材数 == clip 数:一一对应.""" + clips = _make_clips(3) + distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + assert clips[2].asset_id == "a3" + + def test_more_assets_than_clips(self): + """素材多于 clip:多余的不用.""" + clips = _make_clips(2) + distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.ONE_TAKE.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + + def test_fewer_assets_than_clips(self): + """素材少于 clip:后面的 clip 没素材.""" + clips = _make_clips(5) + distribute_assets(clips, ["a1", "a2"], EditingMode.ONE_TAKE.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + assert clips[2].asset_id == "" + assert clips[3].asset_id == "" + assert clips[4].asset_id == "" + + def test_empty_assets(self): + """空素材列表:无分配.""" + clips = _make_clips(3) + distribute_assets(clips, [], EditingMode.ONE_TAKE.value) + for c in clips: + assert c.asset_id == "" + + def test_empty_clips(self): + """空 clip 列表:不报错.""" + distribute_assets([], ["a1"], EditingMode.ONE_TAKE.value) + + def test_only_main_clips_get_assigned(self): + """只分配给 MAIN 类型 clip,其他类型不受影响.""" + clips = _make_clips(2) + _make_clips(2, "intro") + _make_clips(2, "outro") + distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.ONE_TAKE.value) + mains = [c for c in clips if c.clip_type == "main"] + others = [c for c in clips if c.clip_type != "main"] + assert mains[0].asset_id == "a1" + assert mains[1].asset_id == "a2" + for c in others: + assert c.asset_id == "" + + +# ── distribute_assets: PIP ────────────────────────────────────────── + + +class TestDistributePip: + """PIP 模式素材分配.""" + + def test_basic_pip_distribution(self): + """第1个素材给 main,其余给 overlay.""" + clips = _make_clips(1) + _make_clips(3, "overlay") + distribute_assets(clips, ["a1", "a2", "a3", "a4"], EditingMode.PIP.value) + mains = [c for c in clips if c.clip_type == "main"] + overlays = [c for c in clips if c.clip_type == "overlay"] + assert mains[0].asset_id == "a1" + assert overlays[0].asset_id == "a2" + assert overlays[1].asset_id == "a3" + assert overlays[2].asset_id == "a4" + + def test_single_asset_only_main(self): + """只有1个素材:只分配给 main,overlay 没素材.""" + clips = _make_clips(1) + _make_clips(2, "overlay") + distribute_assets(clips, ["a1"], EditingMode.PIP.value) + mains = [c for c in clips if c.clip_type == "main"] + overlays = [c for c in clips if c.clip_type == "overlay"] + assert mains[0].asset_id == "a1" + assert overlays[0].asset_id == "" + assert overlays[1].asset_id == "" + + def test_more_overlays_than_assets(self): + """overlay 多于剩余素材:后面的 overlay 没素材.""" + clips = _make_clips(1) + _make_clips(5, "overlay") + distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value) + overlays = [c for c in clips if c.clip_type == "overlay"] + assert overlays[0].asset_id == "a2" + assert overlays[1].asset_id == "a3" + assert overlays[2].asset_id == "" + assert overlays[3].asset_id == "" + assert overlays[4].asset_id == "" + + def test_no_main_clip(self): + """没有 main clip:第1个素材没人拿,overlay 从第2个素材开始.""" + clips = _make_clips(3, "overlay") + distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.PIP.value) + overlays = [c for c in clips if c.clip_type == "overlay"] + # PIP 逻辑:先给 main 分配第1个素材(没有 main 则跳过), + # 剩余从第2个开始分配给 overlay + assert overlays[0].asset_id == "a2" + assert overlays[1].asset_id == "a3" + assert overlays[2].asset_id == "" + + +# ── distribute_assets: VOICE_OVER ─────────────────────────────────── + + +class TestDistributeVoiceOver: + """VOICE_OVER 模式素材分配.""" + + def test_voice_over_same_as_one_take(self): + """VOICE_OVER 和 ONE_TAKE 分配策略相同:按顺序给 main.""" + clips = _make_clips(3) + assets = ["a1", "a2", "a3"] + distribute_assets(clips, assets, EditingMode.VOICE_OVER.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + assert clips[2].asset_id == "a3" + + def test_voice_over_fewer_assets(self): + """素材不足时,后面的 main clip 没素材.""" + clips = _make_clips(5) + distribute_assets(clips, ["a1"], EditingMode.VOICE_OVER.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "" + + +# ── distribute_assets: VOICE_PIP ──────────────────────────────────── + + +class TestDistributeVoicePip: + """VOICE_PIP 模式素材分配.""" + + def test_three_assets_full_distribution(self): + """3个素材:background + corner_voice + b_roll 各一个.""" + clips = ( + _make_clips(1, "background") + + _make_clips(1, "corner_voice") + + _make_clips(1, "b_roll") + ) + distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value) + bgs = [c for c in clips if c.clip_type == "background"] + voices = [c for c in clips if c.clip_type == "corner_voice"] + brolls = [c for c in clips if c.clip_type == "b_roll"] + assert bgs[0].asset_id == "a1" + assert voices[0].asset_id == "a2" + assert brolls[0].asset_id == "a3" + + def test_single_asset_only_background(self): + """1个素材:只分配给 background.""" + clips = ( + _make_clips(1, "background") + + _make_clips(1, "corner_voice") + + _make_clips(2, "b_roll") + ) + distribute_assets(clips, ["a1"], EditingMode.VOICE_PIP.value) + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "" + assert clips[2].asset_id == "" + assert clips[3].asset_id == "" + + def test_two_assets_bg_and_voice(self): + """2个素材:background + corner_voice.""" + clips = ( + _make_clips(1, "background") + + _make_clips(1, "corner_voice") + + _make_clips(2, "b_roll") + ) + distribute_assets(clips, ["a1", "a2"], EditingMode.VOICE_PIP.value) + bgs = [c for c in clips if c.clip_type == "background"] + voices = [c for c in clips if c.clip_type == "corner_voice"] + brolls = [c for c in clips if c.clip_type == "b_roll"] + assert bgs[0].asset_id == "a1" + assert voices[0].asset_id == "a2" + assert brolls[0].asset_id == "" + + def test_many_broll_clips(self): + """多个 b_roll clip:按顺序分配剩余素材.""" + clips = ( + _make_clips(1, "background") + + _make_clips(1, "corner_voice") + + _make_clips(5, "b_roll") + ) + distribute_assets( + clips, + ["a1", "a2", "a3", "a4", "a5"], + EditingMode.VOICE_PIP.value, + ) + brolls = [c for c in clips if c.clip_type == "b_roll"] + assert brolls[0].asset_id == "a3" + assert brolls[1].asset_id == "a4" + assert brolls[2].asset_id == "a5" + assert brolls[3].asset_id == "" + assert brolls[4].asset_id == "" + + def test_missing_some_layer_clips(self): + """缺少某些层的 clip 不影响其他层.""" + # 没有 corner_voice,素材应该按顺序:bg 拿 a1,b_roll 从 a2 开始 + clips = _make_clips(1, "background") + _make_clips(3, "b_roll") + distribute_assets(clips, ["a1", "a2", "a3"], EditingMode.VOICE_PIP.value) + bgs = [c for c in clips if c.clip_type == "background"] + brolls = [c for c in clips if c.clip_type == "b_roll"] + assert bgs[0].asset_id == "a1" + # 没有 corner_voice,b_roll 从第2个素材开始 + assert brolls[0].asset_id == "a2" + assert brolls[1].asset_id == "a3" + + +# ── distribute_assets: 边缘情况 ───────────────────────────────────── + + +class TestDistributeEdgeCases: + """素材分配边缘情况.""" + + def test_unknown_mode_falls_back_to_one_take(self): + """未知模式退化为 ONE_TAKE.""" + clips = _make_clips(3) + distribute_assets(clips, ["a1", "a2", "a3"], "unknown_mode") + assert clips[0].asset_id == "a1" + assert clips[1].asset_id == "a2" + assert clips[2].asset_id == "a3" + + def test_both_empty(self): + """两边都空:不报错.""" + distribute_assets([], [], EditingMode.ONE_TAKE.value) + + +# ── map_clip_types_for_mode ───────────────────────────────────────── + + +class TestMapClipTypesForMode: + """clip_type 按模式映射.""" + + def test_one_take_unchanged(self): + """ONE_TAKE 模式:main 保持 main.""" + clips = _make_clips(5) + map_clip_types_for_mode(clips, EditingMode.ONE_TAKE.value) + for c in clips: + assert c.clip_type == "main" + + def test_voice_over_unchanged(self): + """VOICE_OVER 模式:main 保持 main.""" + clips = _make_clips(5) + map_clip_types_for_mode(clips, EditingMode.VOICE_OVER.value) + for c in clips: + assert c.clip_type == "main" + + def test_pip_first_main_stays_rest_become_overlay(self): + """PIP 模式:第1个 main 保持,其余变 overlay.""" + clips = _make_clips(5) + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assert clips[0].clip_type == "main" + assert clips[1].clip_type == "overlay" + assert clips[2].clip_type == "overlay" + assert clips[3].clip_type == "overlay" + assert clips[4].clip_type == "overlay" + + def test_pip_single_main_unchanged(self): + """PIP 模式只有1个 main:保持 main.""" + clips = _make_clips(1) + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assert clips[0].clip_type == "main" + + def test_voice_pip_three_types(self): + """VOICE_PIP 模式:background + corner_voice + b_roll.""" + clips = _make_clips(5) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + 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 clips[4].clip_type == "b_roll" + + def test_voice_pip_one_main(self): + """VOICE_PIP 只有1个 main:变成 background.""" + clips = _make_clips(1) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assert clips[0].clip_type == "background" + + def test_voice_pip_two_mains(self): + """VOICE_PIP 2个 main:background + corner_voice.""" + clips = _make_clips(2) + map_clip_types_for_mode(clips, EditingMode.VOICE_PIP.value) + assert clips[0].clip_type == "background" + assert clips[1].clip_type == "corner_voice" + + def test_non_main_clips_unchanged(self): + """非 MAIN 类型 clip 不受影响.""" + clips = ( + _make_clips(1, "intro") + + _make_clips(3) # main + + _make_clips(1, "outro") + ) + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assert clips[0].clip_type == "intro" + assert clips[1].clip_type == "main" # 第1个 main + assert clips[2].clip_type == "overlay" # 第2个 main → overlay + assert clips[3].clip_type == "overlay" # 第3个 main → overlay + assert clips[4].clip_type == "outro" + + def test_no_main_clips_noop(self): + """没有 main clip:什么都不做.""" + clips = _make_clips(3, "intro") + original_types = [c.clip_type for c in clips] + map_clip_types_for_mode(clips, EditingMode.PIP.value) + assert [c.clip_type for c in clips] == original_types + + def test_empty_clips_noop(self): + """空列表:不报错.""" + map_clip_types_for_mode([], EditingMode.PIP.value) + + +# ── generate_default_clips ────────────────────────────────────────── + + +class TestGenerateDefaultClips: + """默认 clip 生成.""" + + def test_one_take_normal(self): + """ONE_TAKE:N 个 main clip.""" + clips = generate_default_clips("plan1", EditingMode.ONE_TAKE.value, 5) + assert len(clips) == 5 + for c in clips: + assert c.clip_type == "main" + assert c.plan_id == "plan1" + assert c.duration == DEFAULT_CLIP_DURATION + # order 递增 + for i in range(5): + assert clips[i].order == i + + def test_pip_structure(self): + """PIP:1个 main + (N-1)个 overlay.""" + clips = generate_default_clips("plan1", EditingMode.PIP.value, 4) + assert len(clips) == 4 + assert clips[0].clip_type == "main" + assert clips[1].clip_type == "overlay" + assert clips[2].clip_type == "overlay" + assert clips[3].clip_type == "overlay" + assert clips[0].order == 0 + assert clips[3].order == 3 + + def test_pip_single_asset(self): + """PIP 只有1个素材:1个 main,没有 overlay.""" + clips = generate_default_clips("plan1", EditingMode.PIP.value, 1) + assert len(clips) == 1 + assert clips[0].clip_type == "main" + + def test_voice_over_structure(self): + """VOICE_OVER:N 个 main clip,带 b_roll 标记.""" + clips = generate_default_clips("plan1", EditingMode.VOICE_OVER.value, 3) + assert len(clips) == 3 + for c in clips: + assert c.clip_type == "main" + assert c.config.get("role") == "b_roll" + + def test_voice_pip_three_layers(self): + """VOICE_PIP:background + corner_voice + b_roll.""" + clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 5) + assert len(clips) == 5 + 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 clips[4].clip_type == "b_roll" + + def test_voice_pip_single_asset(self): + """VOICE_PIP 1个素材:只有 background.""" + clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 1) + assert len(clips) == 1 + assert clips[0].clip_type == "background" + + def test_voice_pip_two_assets(self): + """VOICE_PIP 2个素材:background + corner_voice.""" + clips = generate_default_clips("plan1", EditingMode.VOICE_PIP.value, 2) + assert len(clips) == 2 + assert clips[0].clip_type == "background" + assert clips[1].clip_type == "corner_voice" + + def test_zero_assets_at_least_one(self): + """0 个素材:至少生成 1 个 clip.""" + for mode in [ + EditingMode.ONE_TAKE.value, + EditingMode.PIP.value, + EditingMode.VOICE_OVER.value, + EditingMode.VOICE_PIP.value, + ]: + clips = generate_default_clips("plan1", mode, 0) + assert len(clips) >= 1 + + def test_unknown_mode_falls_back(self): + """未知模式退化为 ONE_TAKE 风格.""" + clips = generate_default_clips("plan1", "unknown", 3) + assert len(clips) == 3 + for c in clips: + assert c.clip_type == "main" + + def test_order_is_sequential(self): + """所有模式下 order 都是从 0 开始连续递增.""" + for mode in [ + EditingMode.ONE_TAKE.value, + EditingMode.PIP.value, + EditingMode.VOICE_OVER.value, + EditingMode.VOICE_PIP.value, + ]: + clips = generate_default_clips("plan1", mode, 5) + for i, c in enumerate(clips): + assert c.order == i + + +# ── create_clips_from_configs ─────────────────────────────────────── + + +class TestCreateClipsFromConfigs: + """从模板配置创建 clips.""" + + def test_basic_creation(self): + """基本创建:按 order 排序,属性正确传递.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=1, + min_duration=3.0, + max_duration=7.0, + transition_effect="fade", + ), + TemplateClipConfig( + id="cfg2", + template_id="tpl1", + clip_type=ClipType.INTRO, + order=0, + min_duration=2.0, + max_duration=4.0, + transition_effect="cut", + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert len(clips) == 2 + # 按 order 排序:intro(order=0) 在前,main(order=1) 在后 + assert clips[0].clip_type == "intro" + assert clips[1].clip_type == "main" + assert clips[0].order == 0 + assert clips[1].order == 1 + assert clips[0].template_clip_config_id == "cfg2" + assert clips[1].template_clip_config_id == "cfg1" + + def test_duration_average_of_min_max(self): + """min_duration 和 max_duration 都有时,取平均值.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=4.0, + max_duration=6.0, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].duration == 5.0 # (4+6)/2 + + def test_duration_only_min(self): + """只有 min_duration 时,用 min_duration.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=3.5, + max_duration=0, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].duration == 3.5 + + def test_duration_only_max(self): + """只有 max_duration 时,用 max_duration.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=0, + max_duration=8.0, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].duration == 8.0 + + def test_duration_default_when_both_zero(self): + """都为 0 时用默认时长.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=0, + max_duration=0, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].duration == DEFAULT_CLIP_DURATION + + def test_empty_configs_returns_empty(self): + """空配置列表返回空列表.""" + clips = create_clips_from_configs("plan1", []) + assert clips == [] + + def test_plan_id_passed_through(self): + """plan_id 正确传递给所有 clip.""" + configs = [ + TemplateClipConfig( + id=f"cfg{i}", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=i, + min_duration=3, + max_duration=5, + ) + for i in range(3) + ] + clips = create_clips_from_configs("my_plan", configs) + for c in clips: + assert c.plan_id == "my_plan" + + def test_playback_speed_from_config(self): + """playback_speed 从 config.playback_speed 读取.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=3, + max_duration=5, + config={"playback_speed": 1.5}, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].playback_speed == 1.5 + + def test_playback_speed_fallback_to_speed_ratio(self): + """playback_speed 不存在时回退到 speed_ratio.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=3, + max_duration=5, + config={"speed_ratio": 0.8}, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].playback_speed == 0.8 + + def test_playback_speed_default_1(self): + """没有 speed 配置时默认为 1.0.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=3, + max_duration=5, + config={}, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].playback_speed == 1.0 + + def test_playback_speed_none_falls_back(self): + """playback_speed 为 None 时回退到 1.0.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=3, + max_duration=5, + config={"playback_speed": None}, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].playback_speed == 1.0 + + def test_transition_default_cut(self): + """transition_effect 为空时默认为 cut.""" + configs = [ + TemplateClipConfig( + id="cfg1", + template_id="tpl1", + clip_type=ClipType.MAIN, + order=0, + min_duration=3, + max_duration=5, + transition_effect=None, + ), + ] + clips = create_clips_from_configs("plan1", configs) + assert clips[0].transition_effect == "cut" + + +# ── 常量导出 ──────────────────────────────────────────────────────── + + +class TestConstants: + """常量导出验证.""" + + def test_default_duration_value(self): + """默认片段时长应为 5 秒.""" + assert DEFAULT_CLIP_DURATION == 5.0