Files
xiaoxia-saas/tests/unit/test_plan_generator_utils.py
xiaoxia ecbdd49dc1
CI/CD Pipeline / Check if frontend-only change (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 / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 2m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 3m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m55s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m55s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m19s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m21s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 5m1s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m12s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 52s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m6s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m23s
CI/CD Pipeline / Integration Tests (push) Successful in 4m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m20s
CI/CD Pipeline / Unit Tests (push) Failing after 12m24s
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat: 素材片段随机起始时间+去重 + MediaKit智能封面抽帧
## 任务1: 素材片段随机起始时间
- 修改 _calc_random_start_time 增加 used_segments 参数,避开已使用时间段
- 四个分配函数(ONE_TAKE/PIP/VOICE_OVER/VOICE_PIP)各自维护 used_segments
- 同一素材被多个clip使用时,自动记录已用区间,下次随机时避开
- 如果无空间可用,回退到最后已用段之后或缩短时长

## 任务2: MediaKit智能封面抽帧
- 新增 _extract_frames_via_mediakit 函数,上传视频到OSS后调用MediaKit API
- 使用 SceneChange 策略智能选取关键帧
- 失败时自动降级到 ffmpeg 均匀抽帧
- 抽帧后下载帧图、可选叠加标题、上传OSS

## 测试
- 新增 8 个 _calc_random_start_time 单测
- 新增 4 个 distribute_assets 去重逻辑单测
- 新增 4 个 MediaKit 集成单测
- 修复 3 个因字体映射变更的测试断言
2026-08-26 17:22:08 +08:00

780 lines
30 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
_calc_random_start_time,
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个素材:只分配给 mainoverlay 没素材."""
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) + _make_clips(1, "outro") # main
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
# ── 随机 start_time 与去重测试 ─────────────────────────────────────────
class TestCalcRandomStartTime:
"""_calc_random_start_time 函数测试."""
def test_no_durations_returns_none(self):
"""asset_durations 为 None 时返回 None."""
result = _calc_random_start_time("a1", 5.0, None)
assert result is None
def test_asset_not_in_durations_returns_none(self):
"""素材不在 durations 映射中时返回 None."""
result = _calc_random_start_time("a_missing", 5.0, {"a1": 60.0})
assert result is None
def test_zero_duration_returns_none(self):
"""素材时长为 0 时返回 None."""
result = _calc_random_start_time("a1", 5.0, {"a1": 0.0})
assert result is None
def test_clip_longer_than_asset_returns_zero(self):
"""片段时长 >= 素材时长时返回 0.0."""
result = _calc_random_start_time("a1", 60.0, {"a1": 30.0})
assert result == 0.0
def test_returns_value_in_range(self):
"""返回值在 [0, max_start] 范围内."""
for _ in range(50):
result = _calc_random_start_time("a1", 5.0, {"a1": 30.0})
assert result is not None
assert 0.0 <= result <= 25.0
def test_avoids_used_segments(self):
"""生成的 start_time 不与已使用段重叠."""
used = {"a1": [(10.0, 15.0), (20.0, 25.0)]}
for _ in range(100):
result = _calc_random_start_time("a1", 3.0, {"a1": 30.0}, used)
assert result is not None
end = result + 3.0
# 不应与 [10,15) 重叠
assert not (result < 15.0 and 10.0 < end)
# 不应与 [20,25) 重叠
assert not (result < 25.0 and 20.0 < end)
def test_fallback_when_no_space(self):
"""所有空间都被占用时,回退到最后已使用段之后或 0."""
used = {"a1": [(0.0, 28.0)]}
result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}, used)
assert result is not None
# 应该返回 last_used_end (28.0) 但受限于 max_start (25.0),取 min
assert result == 25.0
def test_no_used_segments_for_asset(self):
"""used_segments 中没有当前 asset_id 时正常随机."""
used = {"a2": [(10.0, 15.0)]}
for _ in range(20):
result = _calc_random_start_time("a1", 5.0, {"a1": 30.0}, used)
assert result is not None
assert 0.0 <= result <= 25.0
class TestDistributeAssetsOverlapAvoidance:
"""测试 distribute_assets 中同一素材被多次使用时的去重逻辑."""
def test_same_asset_reused_no_overlap(self):
"""同一素材分配给多个 clip 时,start_time 不重叠."""
# 2 个 clip 共享同一素材,素材时长足够
clips = [
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=1, duration=5.0),
]
asset_ids = ["a1", "a1"] # 同一素材用两次
durations = {"a1": 60.0}
distribute_assets(clips, asset_ids, EditingMode.ONE_TAKE.value, asset_durations=durations)
# 两个 clip 都应有 start_time
assert clips[0].start_time is not None
assert clips[1].start_time is not None
# 两段不应重叠
s1, e1 = clips[0].start_time, clips[0].start_time + 5.0
s2, e2 = clips[1].start_time, clips[1].start_time + 5.0
assert not (s1 < e2 and s2 < e1), f"Segments overlap: [{s1},{e1}) and [{s2},{e2})"
def test_different_assets_independent(self):
"""不同素材各自独立随机,互不影响."""
clips = [
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=1, duration=5.0),
]
asset_ids = ["a1", "a2"]
durations = {"a1": 60.0, "a2": 60.0}
distribute_assets(clips, asset_ids, EditingMode.ONE_TAKE.value, asset_durations=durations)
assert clips[0].asset_id == "a1"
assert clips[1].asset_id == "a2"
assert clips[0].start_time is not None
assert clips[1].start_time is not None
def test_no_durations_still_works(self):
"""不传 asset_durations 时仍正常分配(start_time 为 None."""
clips = [
EditPlanClip.create(plan_id="p1", clip_type=ClipType.MAIN.value, order=0, duration=5.0),
]
distribute_assets(clips, ["a1"], EditingMode.ONE_TAKE.value)
assert clips[0].asset_id == "a1"
# start_time 默认为 0.0(模型默认值)
assert clips[0].start_time == 0.0
def test_voice_pip_overlap_avoidance(self):
"""VOICE_PIP 模式下同一素材多次使用时避免重叠."""
clips = [
EditPlanClip.create(plan_id="p1", clip_type="background", order=0, duration=5.0),
EditPlanClip.create(plan_id="p1", clip_type="b_roll", order=1, duration=5.0),
]
asset_ids = ["a1", "a1"] # 同一素材用两次
durations = {"a1": 60.0}
distribute_assets(clips, asset_ids, EditingMode.VOICE_PIP.value, asset_durations=durations)
assert clips[0].start_time is not None
assert clips[1].start_time is not None
s1, e1 = clips[0].start_time, clips[0].start_time + 5.0
s2, e2 = clips[1].start_time, clips[1].start_time + 5.0
assert not (s1 < e2 and s2 < e1), f"Segments overlap: [{s1},{e1}) and [{s2},{e2})"