Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7da2a07a1a | |||
| 5354093417 |
@@ -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])
|
||||
|
||||
Executable
+347
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_DOCS = Path("docs/agents")
|
||||
AGENT_DOCS = Path(__file__).parent.parent.parent / "docs" / "agents"
|
||||
EXPECTED_AGENTS = [
|
||||
"requirement_agent",
|
||||
"arch_agent",
|
||||
|
||||
Executable
+670
@@ -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
|
||||
Reference in New Issue
Block a user