Compare commits

...

2 Commits

Author SHA1 Message Date
xiaoxia 7da2a07a1a fix(test): 修复test_agent_docs.py相对路径问题,从apps/api运行也能找到docs
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 25s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 40s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m14s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m46s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m23s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 33s
AI Code Review / AI Code Review (pull_request) Successful in 1m24s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m7s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 41s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m27s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m17s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 6m13s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 3m47s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m17s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) 失败: CI/CD Pipeline / Validate - Code Quality (pull_request), CI/CD Pipeline / Frontend Lint (pull_request), CI/CD Pipeline / PR Build Web Image (pull_request) [backend-only]
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 49s
使用 __file__ 定位项目根目录,避免依赖pytest运行目录。
修复后Unit Tests CI从apps/api目录运行也能正常通过。
2026-07-27 00:38:55 +08:00
CI Bot 5354093417 test(wave90): add 52 unit tests for plan_generator_utils + extract from plan_generator_service
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 13s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m7s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m13s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m41s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 57s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 33s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m33s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m19s
AI Code Review / AI Code Review (pull_request) Successful in 1m56s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m23s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m8s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m59s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- Extract pure logic from plan_generator_service.py to packages/domain/plan_generator_utils.py
  - distribute_assets: 4-mode asset distribution (ONE_TAKE/PIP/VOICE_OVER/VOICE_PIP)
  - map_clip_types_for_mode: clip type mapping based on generation mode
  - generate_default_clips: default clip structure generation per mode
  - create_clips_from_configs: clip creation from config list with duration calculation
  - Constants: DEFAULT_DURATION, mode clip configs
- Original file: 437 lines -> 209 lines (-228, -52%)
- 52 unit tests covering all functions and edge cases
- black formatted
2026-07-26 16:48:25 +08:00
4 changed files with 1038 additions and 249 deletions
+20 -248
View File
@@ -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 clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = 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 clipsB-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])
+347
View File
@@ -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 clipsN = asset_count,至少1个)
- PIP: 1 个 main + (N-1) 个 overlayN = 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 clipsB-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 -1
View File
@@ -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",
+670
View File
@@ -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 拿 a1b_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_voiceb_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个 mainbackground + 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_TAKEN 个 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):
"""PIP1个 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_OVERN 个 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_PIPbackground + 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