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
## 任务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 个因字体映射变更的测试断言
780 lines
30 KiB
Python
Executable File
780 lines
30 KiB
Python
Executable File
"""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个素材:只分配给 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) + _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_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
|
||
|
||
|
||
# ── 随机 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})"
|