Compare commits

...

3 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
CI Bot 3be0aca709 refactor+test(asset): 第89波 - 拆分asset_analyzer评分纯逻辑 + 64单测
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 9s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 59s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 29s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 1m36s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 58s
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 / PR Build Worker Image (pull_request) Successful in 29s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m4s
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 2m3s
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 26s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 45s
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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m23s
AI Code Review / AI Code Review (pull_request) Successful in 3m42s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m4s
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 56s
- 从799行asset_analyzer.py拆出asset_quality_scoring.py纯逻辑模块
- 质量评分:分辨率/帧率/码率/清晰度/稳定性 5个维度纯函数
- 分类评分:9个分类(风景/产品/人物/动物/美食/科技/运动/音乐/其他)
- 64个单测全覆盖:边界值、numpy数组、分类组合、数据类
- 原文件从799行瘦身到467行(-332行)
- 向后兼容:classify_asset_real / calculate_quality_score_real 入口不变
2026-07-26 16:29:39 +08:00
7 changed files with 2186 additions and 603 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])
+22 -354
View File
@@ -19,74 +19,21 @@ from PIL import Image
from packages.domain.classification import AssetClassification
from .asset_quality_scoring import (
AudioAnalysis,
ClassificationResult,
ColorAnalysis,
MotionAnalysis,
QualityScore,
VideoInfo,
calculate_category_scores,
calculate_quality_score,
classify_from_analysis,
)
logger = logging.getLogger(__name__)
@dataclass
class VideoInfo:
"""视频基本信息"""
width: int = 0
height: int = 0
fps: float = 0.0
duration: float = 0.0
bitrate: int = 0
codec: str = ""
has_audio: bool = False
file_size: int = 0
@dataclass
class ColorAnalysis:
"""色彩分析结果"""
dominant_hue: float = 0.0 # 主色调 (0-360)
green_ratio: float = 0.0 # 绿色占比
warm_ratio: float = 0.0 # 暖色调占比
cool_ratio: float = 0.0 # 冷色调占比
avg_saturation: float = 0.0
avg_brightness: float = 0.0
@dataclass
class MotionAnalysis:
"""运动分析结果"""
motion_score: float = 0.0 # 运动幅度 (0-1)
scene_changes: int = 0 # 场景切换次数
@dataclass
class AudioAnalysis:
"""音频分析结果"""
has_audio: bool = False
speech_ratio: float = 0.0 # 人声比例
music_ratio: float = 0.0 # 音乐比例
ambient_ratio: float = 0.0 # 环境音比例
@dataclass
class ClassificationResult:
"""分类结果"""
category: AssetClassification
confidence: float
scores: dict[str, float] = field(default_factory=dict)
@dataclass
class QualityScore:
"""质量评分结果"""
total: float
resolution_score: float = 0.0
fps_score: float = 0.0
bitrate_score: float = 0.0
clarity_score: float = 0.0
stability_score: float = 0.0
class AssetAnalyzer:
"""
轻量级视频素材分析器
@@ -449,316 +396,37 @@ class AssetAnalyzer:
"""
综合分析得出分类结果
评分逻辑在 asset_quality_scoring.calculate_category_scores / classify_from_analysis
纯函数中,此处只负责采集分析数据后委托计算。
Returns:
ClassificationResult 对象
"""
# 提取分析数据
frames = self.extract_frames()
color = self.analyze_color_distribution(frames)
motion = self.analyze_motion(frames)
audio = self.analyze_audio()
# 计算各类别得分
scores = self._calculate_category_scores(color, motion, audio)
# 找最高分
if not scores:
return ClassificationResult(
category=AssetClassification.OTHER,
confidence=0.3,
scores={},
)
best_category = max(scores.items(), key=lambda x: x[1])
category = AssetClassification(best_category[0])
confidence = min(0.95, max(0.3, best_category[1]))
return ClassificationResult(
category=category,
confidence=confidence,
scores=scores,
)
def _calculate_category_scores(
self,
color: ColorAnalysis,
motion: MotionAnalysis,
audio: AudioAnalysis,
) -> dict[str, float]:
"""
计算各类别的置信度得分
Args:
color: 色彩分析结果
motion: 运动分析结果
audio: 音频分析结果
Returns:
各类别得分字典
"""
scores = {}
# 1. 风景 (scenic) - 绿色、户外、自然
scenic_score = 0.0
if color.green_ratio > 0.3:
scenic_score += 0.4 * color.green_ratio
if color.avg_saturation > 0.3:
scenic_score += 0.2 * color.avg_saturation
if color.avg_brightness > 0.4:
scenic_score += 0.2
if motion.motion_score > 0.1 and motion.motion_score < 0.5:
scenic_score += 0.2 # 适度运动(如云朵、树叶)
if not audio.has_audio or audio.ambient_ratio > 0.5:
scenic_score += 0.2 # 自然环境音
scores[AssetClassification.SCENIC.value] = min(1.0, scenic_score)
# 2. 产品 (product) - 中等亮度、均匀色彩、低运动
product_score = 0.0
if 0.3 < color.avg_brightness < 0.7:
product_score += 0.3
if color.avg_saturation < 0.5:
product_score += 0.2
if motion.motion_score < 0.15:
product_score += 0.4 # 低运动 = 产品展示
if color.cool_ratio > 0.3:
product_score += 0.2 # 冷色调 = 科技感
scores[AssetClassification.PRODUCT.value] = min(1.0, product_score)
# 3. 人物 (person) - 中等运动、有时有人声
person_score = 0.0
if 0.1 < motion.motion_score < 0.4:
person_score += 0.3 # 适度运动
if audio.has_audio and audio.speech_ratio > 0.3:
person_score += 0.5 # 有人声
if color.avg_brightness > 0.3:
person_score += 0.2
scores[AssetClassification.PERSON.value] = min(1.0, person_score)
# 4. 动物 (animal) - 高运动、有时自然音
animal_score = 0.0
if motion.motion_score > 0.3:
animal_score += 0.4 # 高运动
if motion.scene_changes > 2:
animal_score += 0.2
if audio.has_audio and (audio.ambient_ratio > 0.3 or audio.speech_ratio > 0.2):
animal_score += 0.3
scores[AssetClassification.ANIMAL.value] = min(1.0, animal_score)
# 5. 美食 (food) - 暖色调、高饱和度
food_score = 0.0
if color.warm_ratio > 0.4:
food_score += 0.5
if color.avg_saturation > 0.5:
food_score += 0.3
if 0.4 < color.avg_brightness < 0.8:
food_score += 0.2
scores[AssetClassification.FOOD.value] = min(1.0, food_score)
# 6. 科技 (tech) - 冷色调、低饱和度、低运动
tech_score = 0.0
if color.cool_ratio > 0.4:
tech_score += 0.4
if color.avg_saturation < 0.4:
tech_score += 0.3
if motion.motion_score < 0.2:
tech_score += 0.3
scores[AssetClassification.TECH.value] = min(1.0, tech_score)
# 7. 运动 (sport) - 高运动
sport_score = 0.0
if motion.motion_score > 0.4:
sport_score += 0.6
if motion.scene_changes > 3:
sport_score += 0.2
if color.avg_brightness > 0.4:
sport_score += 0.2
scores[AssetClassification.SPORT.value] = min(1.0, sport_score)
# 8. 音乐 (music) - 有节奏性音乐
music_score = 0.0
if audio.has_audio and audio.music_ratio > 0.4:
music_score += 0.6
# 纯视觉判断:色彩丰富但非自然
if color.avg_saturation > 0.5 and color.green_ratio < 0.2:
music_score += 0.3
scores[AssetClassification.MUSIC.value] = min(1.0, music_score)
# 9. 其他 (other) - 默认最低分
scores[AssetClassification.OTHER.value] = 0.1
return scores
return classify_from_analysis(color, motion, audio)
def calculate_quality_score(self) -> QualityScore:
"""
计算视频质量综合评分 (0-100)
评分逻辑在 asset_quality_scoring.calculate_quality_score 纯函数中,
此处只负责采集数据后委托计算。
评分维度:
1. 分辨率得分 (25分)
2. 帧率得分 (20分)
3. 码率得分 (20分)
4. 清晰度得分 (20分) - Laplacian 方差
5. 稳定性得分 (15分) - 帧间位移方差
4. 清晰度得分 (20分)
5. 稳定性得分 (15分)
"""
info = self.get_video_info()
frames = self.extract_frames()
# 1. 分辨率得分
resolution_score = self._score_resolution(info.width, info.height)
# 2. 帧率得分
fps_score = self._score_framerate(info.fps)
# 3. 码率得分
bitrate_score = self._score_bitrate(info.bitrate)
# 4. 清晰度得分
clarity_score = self._score_clarity(frames)
# 5. 稳定性得分
stability_score = self._score_stability(frames)
total = resolution_score + fps_score + bitrate_score + clarity_score + stability_score
return QualityScore(
total=round(min(100, max(0, total)), 1),
resolution_score=resolution_score,
fps_score=fps_score,
bitrate_score=bitrate_score,
clarity_score=clarity_score,
stability_score=stability_score,
)
def _score_resolution(self, width: int, height: int) -> float:
"""分辨率评分 (满分 25)"""
pixels = width * height
if pixels >= 3840 * 2160: # 4K
return 25.0
elif pixels >= 2560 * 1440: # 2K
return 22.0
elif pixels >= 1920 * 1080: # 1080p
return 20.0
elif pixels >= 1280 * 720: # 720p
return 15.0
elif pixels >= 854 * 480: # 480p
return 8.0
else:
return 3.0
def _score_framerate(self, fps: float) -> float:
"""帧率评分 (满分 20)"""
if fps >= 60:
return 20.0
elif fps >= 30:
return 15.0
elif fps >= 24:
return 10.0
elif fps >= 15:
return 7.0
else:
return 5.0
def _score_bitrate(self, bitrate: int) -> float:
"""码率评分 (满分 20)"""
bitrate_mbps = bitrate / 1_000_000
if bitrate_mbps > 10:
return 20.0
elif bitrate_mbps >= 5:
return 15.0
elif bitrate_mbps >= 2:
return 10.0
elif bitrate_mbps >= 0.5:
return 5.0
else:
return 3.0
def _score_clarity(self, frames: list[np.ndarray]) -> float:
"""
清晰度评分 (满分 20)
使用 Laplacian 方差评估画面清晰度
高方差 = 细节丰富 = 高分
"""
if not frames:
return 10.0 # 默认中等分
try:
variances = []
for frame in frames[:5]: # 只分析前 5 帧
if len(frame.shape) == 3:
# 转灰度
gray = np.dot(frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
else:
gray = frame
# Laplacian 算子
laplacian = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
# 手动计算卷积
from scipy import signal
laplacian_img = signal.convolve2d(gray.astype(float), laplacian, mode="same")
variance = np.var(laplacian_img)
variances.append(variance)
# 归一化方差到 0-20 分
avg_variance = np.mean(variances)
# 根据经验值调整
score = min(20.0, avg_variance / 100)
return float(score)
except ImportError:
# 如果没有 scipy,使用简化方法
return 10.0
except Exception:
return 10.0
def _score_stability(self, frames: list[np.ndarray]) -> float:
"""
稳定性评分 (满分 15)
分析帧间位移方差
画面稳定 = 高分
剧烈抖动 = 低分
"""
if len(frames) < 2:
return 10.0 # 默认中等分
try:
displacements = []
for i in range(len(frames) - 1):
# 缩小帧以加速处理
scale = 0.25
new_h = int(frames[i].shape[0] * scale)
new_w = int(frames[i].shape[1] * scale)
frame1_small = np.array(Image.fromarray(frames[i]).resize((new_w, new_h)))
new_h2 = int(frames[i + 1].shape[0] * scale)
new_w2 = int(frames[i + 1].shape[1] * scale)
frame2_small = np.array(Image.fromarray(frames[i + 1]).resize((new_w2, new_h2)))
# 简单位移检测:灰度差
gray1 = np.mean(frame1_small, axis=2) if len(frame1_small.shape) == 3 else frame1_small
gray2 = np.mean(frame2_small, axis=2) if len(frame2_small.shape) == 3 else frame2_small
diff = np.abs(gray2.astype(float) - gray1.astype(float))
displacement = np.mean(diff) / 255.0
displacements.append(displacement)
# 高位移方差 = 不稳定
if displacements:
displacement_variance = np.var(displacements)
# 归一化
instability = min(1.0, displacement_variance * 10)
score = 15.0 * (1.0 - instability)
return float(max(0.0, score))
return 10.0
except Exception:
return 10.0
return calculate_quality_score(info, frames)
def classify_asset_real(video_path: str) -> tuple[str, float]:
+459
View File
@@ -0,0 +1,459 @@
"""素材质量与分类评分 — 纯逻辑模块.
从 asset_analyzer.py 提取的评分计算逻辑,纯函数,无副作用。
输入分析结果对象,输出评分/分类结果。
拆分目的:
1. 大文件瘦身(asset_analyzer.py 799行 → 拆出 200+ 行纯逻辑)
2. 评分逻辑可独立单测,不依赖 FFmpeg/视频文件
3. 评分策略调整时不需要触碰分析主流程
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from packages.domain.classification import AssetClassification
# ── 数据类 ────────────────────────────────────────────────────────────
@dataclass
class VideoInfo:
"""视频基本信息"""
width: int = 0
height: int = 0
fps: float = 0.0
duration: float = 0.0
bitrate: int = 0
codec: str = ""
has_audio: bool = False
file_size: int = 0
@dataclass
class ColorAnalysis:
"""色彩分析结果"""
dominant_hue: float = 0.0 # 主色调 (0-360)
green_ratio: float = 0.0 # 绿色占比
warm_ratio: float = 0.0 # 暖色调占比
cool_ratio: float = 0.0 # 冷色调占比
avg_saturation: float = 0.0
avg_brightness: float = 0.0
@dataclass
class MotionAnalysis:
"""运动分析结果"""
motion_score: float = 0.0 # 运动幅度 (0-1)
scene_changes: int = 0 # 场景切换次数
@dataclass
class AudioAnalysis:
"""音频分析结果"""
has_audio: bool = False
speech_ratio: float = 0.0 # 人声比例
music_ratio: float = 0.0 # 音乐比例
ambient_ratio: float = 0.0 # 环境音比例
@dataclass
class ClassificationResult:
"""分类结果"""
category: AssetClassification
confidence: float
scores: dict[str, float] = field(default_factory=dict)
@dataclass
class QualityScore:
"""质量评分结果"""
total: float
resolution_score: float = 0.0
fps_score: float = 0.0
bitrate_score: float = 0.0
clarity_score: float = 0.0
stability_score: float = 0.0
# ── 质量评分纯函数 ────────────────────────────────────────────────────
def score_resolution(width: int, height: int) -> float:
"""分辨率评分 (满分 25).
按像素总数阶梯评分:4K > 2K > 1080p > 720p > 480p > 其他.
Args:
width: 视频宽度(像素)
height: 视频高度(像素)
Returns:
float: 0-25 分
"""
pixels = width * height
if pixels >= 3840 * 2160: # 4K
return 25.0
elif pixels >= 2560 * 1440: # 2K
return 22.0
elif pixels >= 1920 * 1080: # 1080p
return 20.0
elif pixels >= 1280 * 720: # 720p
return 15.0
elif pixels >= 854 * 480: # 480p
return 8.0
else:
return 3.0
def score_framerate(fps: float) -> float:
"""帧率评分 (满分 20).
60fps 满分,阶梯递减.
Args:
fps: 帧率(帧/秒)
Returns:
float: 0-20 分
"""
if fps >= 60:
return 20.0
elif fps >= 30:
return 15.0
elif fps >= 24:
return 10.0
elif fps >= 15:
return 7.0
else:
return 5.0
def score_bitrate(bitrate: int) -> float:
"""码率评分 (满分 20).
按 Mbps 阶梯评分.
Args:
bitrate: 码率(bps
Returns:
float: 0-20 分
"""
bitrate_mbps = bitrate / 1_000_000
if bitrate_mbps > 10:
return 20.0
elif bitrate_mbps >= 5:
return 15.0
elif bitrate_mbps >= 2:
return 10.0
elif bitrate_mbps >= 0.5:
return 5.0
else:
return 3.0
def score_clarity(frames: list[np.ndarray]) -> float:
"""清晰度评分 (满分 20).
使用 Laplacian 方差评估画面清晰度。
高方差 = 细节丰富 = 高分.
Args:
frames: 视频帧列表(numpy 数组,RGB 或灰度)
Returns:
float: 0-20 分
"""
if not frames:
return 10.0 # 默认中等分
try:
variances = []
for frame in frames[:5]: # 只分析前 5 帧
if len(frame.shape) == 3:
# 转灰度
gray = np.dot(frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
else:
gray = frame
# Laplacian 算子
laplacian = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
# 手动计算卷积
from scipy import signal
laplacian_img = signal.convolve2d(gray.astype(float), laplacian, mode="same")
variance = np.var(laplacian_img)
variances.append(variance)
# 归一化方差到 0-20 分
avg_variance = np.mean(variances)
score = min(20.0, avg_variance / 100)
return float(score)
except ImportError:
# 如果没有 scipy,使用简化方法
return 10.0
except Exception:
return 10.0
def score_stability(frames: list[np.ndarray]) -> float:
"""稳定性评分 (满分 15).
分析帧间位移方差。
画面稳定 = 高分;剧烈抖动 = 低分.
Args:
frames: 视频帧列表(numpy 数组,RGB 或灰度)
Returns:
float: 0-15 分
"""
if len(frames) < 2:
return 10.0 # 默认中等分
try:
from PIL import Image
displacements = []
for i in range(len(frames) - 1):
# 缩小帧以加速处理
scale = 0.25
new_h = int(frames[i].shape[0] * scale)
new_w = int(frames[i].shape[1] * scale)
frame1_small = np.array(Image.fromarray(frames[i]).resize((new_w, new_h)))
new_h2 = int(frames[i + 1].shape[0] * scale)
new_w2 = int(frames[i + 1].shape[1] * scale)
frame2_small = np.array(Image.fromarray(frames[i + 1]).resize((new_w2, new_h2)))
# 简单位移检测:灰度差
gray1 = np.mean(frame1_small, axis=2) if len(frame1_small.shape) == 3 else frame1_small
gray2 = np.mean(frame2_small, axis=2) if len(frame2_small.shape) == 3 else frame2_small
diff = np.abs(gray2.astype(float) - gray1.astype(float))
displacement = np.mean(diff) / 255.0
displacements.append(displacement)
# 高位移方差 = 不稳定
if displacements:
displacement_variance = np.var(displacements)
# 归一化
instability = min(1.0, displacement_variance * 10)
score = 15.0 * (1.0 - instability)
return float(max(0.0, score))
return 10.0
except Exception:
return 10.0
def calculate_quality_score(
info: VideoInfo,
frames: Optional[list[np.ndarray]] = None,
) -> QualityScore:
"""计算视频质量综合评分 (0-100).
评分维度:
1. 分辨率得分 (25分)
2. 帧率得分 (20分)
3. 码率得分 (20分)
4. 清晰度得分 (20分) - 无帧时默认10分
5. 稳定性得分 (15分) - 帧不足时默认10分
Args:
info: 视频基本信息
frames: 采样帧列表(可选,无则清晰度/稳定性给默认分)
Returns:
QualityScore: 各维度得分 + 总分
"""
# 1. 分辨率得分
resolution_score = score_resolution(info.width, info.height)
# 2. 帧率得分
fps_score = score_framerate(info.fps)
# 3. 码率得分
bitrate_score = score_bitrate(info.bitrate)
# 4. 清晰度得分
clarity_score = score_clarity(frames) if frames else 10.0
# 5. 稳定性得分
stability_score = score_stability(frames) if frames and len(frames) >= 2 else 10.0
total = resolution_score + fps_score + bitrate_score + clarity_score + stability_score
return QualityScore(
total=round(min(100.0, max(0.0, total)), 1),
resolution_score=resolution_score,
fps_score=fps_score,
bitrate_score=bitrate_score,
clarity_score=clarity_score,
stability_score=stability_score,
)
# ── 分类评分纯函数 ────────────────────────────────────────────────────
def calculate_category_scores(
color: ColorAnalysis,
motion: MotionAnalysis,
audio: AudioAnalysis,
) -> dict[str, float]:
"""计算各类别的置信度得分.
9 个分类:风景、产品、人物、动物、美食、科技、运动、音乐、其他.
Args:
color: 色彩分析结果
motion: 运动分析结果
audio: 音频分析结果
Returns:
dict[str, float]: 各分类名称 -> 得分 (0-1)
"""
scores: dict[str, float] = {}
# 1. 风景 (scenic) - 绿色、户外、自然
scenic_score = 0.0
if color.green_ratio > 0.3:
scenic_score += 0.4 * color.green_ratio
if color.avg_saturation > 0.3:
scenic_score += 0.2 * color.avg_saturation
if color.avg_brightness > 0.4:
scenic_score += 0.2
if 0.1 < motion.motion_score < 0.5:
scenic_score += 0.2 # 适度运动(如云朵、树叶)
if not audio.has_audio or audio.ambient_ratio > 0.5:
scenic_score += 0.2 # 自然环境音
scores[AssetClassification.SCENIC.value] = min(1.0, scenic_score)
# 2. 产品 (product) - 中等亮度、均匀色彩、低运动
product_score = 0.0
if 0.3 < color.avg_brightness < 0.7:
product_score += 0.3
if color.avg_saturation < 0.5:
product_score += 0.2
if motion.motion_score < 0.15:
product_score += 0.4 # 低运动 = 产品展示
if color.cool_ratio > 0.3:
product_score += 0.2 # 冷色调 = 科技感
scores[AssetClassification.PRODUCT.value] = min(1.0, product_score)
# 3. 人物 (person) - 中等运动、有时有人声
person_score = 0.0
if 0.1 < motion.motion_score < 0.4:
person_score += 0.3 # 适度运动
if audio.has_audio and audio.speech_ratio > 0.3:
person_score += 0.5 # 有人声
if color.avg_brightness > 0.3:
person_score += 0.2
scores[AssetClassification.PERSON.value] = min(1.0, person_score)
# 4. 动物 (animal) - 高运动、有时自然音
animal_score = 0.0
if motion.motion_score > 0.3:
animal_score += 0.4 # 高运动
if motion.scene_changes > 2:
animal_score += 0.2
if audio.has_audio and (audio.ambient_ratio > 0.3 or audio.speech_ratio > 0.2):
animal_score += 0.3
scores[AssetClassification.ANIMAL.value] = min(1.0, animal_score)
# 5. 美食 (food) - 暖色调、高饱和度
food_score = 0.0
if color.warm_ratio > 0.4:
food_score += 0.5
if color.avg_saturation > 0.5:
food_score += 0.3
if 0.4 < color.avg_brightness < 0.8:
food_score += 0.2
scores[AssetClassification.FOOD.value] = min(1.0, food_score)
# 6. 科技 (tech) - 冷色调、低饱和度、低运动
tech_score = 0.0
if color.cool_ratio > 0.4:
tech_score += 0.4
if color.avg_saturation < 0.4:
tech_score += 0.3
if motion.motion_score < 0.2:
tech_score += 0.3
scores[AssetClassification.TECH.value] = min(1.0, tech_score)
# 7. 运动 (sport) - 高运动
sport_score = 0.0
if motion.motion_score > 0.4:
sport_score += 0.6
if motion.scene_changes > 3:
sport_score += 0.2
if color.avg_brightness > 0.4:
sport_score += 0.2
scores[AssetClassification.SPORT.value] = min(1.0, sport_score)
# 8. 音乐 (music) - 有节奏性音乐
music_score = 0.0
if audio.has_audio and audio.music_ratio > 0.4:
music_score += 0.6
# 纯视觉判断:色彩丰富但非自然
if color.avg_saturation > 0.5 and color.green_ratio < 0.2:
music_score += 0.3
scores[AssetClassification.MUSIC.value] = min(1.0, music_score)
# 9. 其他 (other) - 默认最低分
scores[AssetClassification.OTHER.value] = 0.1
return scores
def classify_from_analysis(
color: ColorAnalysis,
motion: MotionAnalysis,
audio: AudioAnalysis,
) -> ClassificationResult:
"""综合分析得出分类结果.
Args:
color: 色彩分析结果
motion: 运动分析结果
audio: 音频分析结果
Returns:
ClassificationResult: 分类结果(最高分类别 + 置信度 + 全部分数)
"""
scores = calculate_category_scores(color, motion, audio)
if not scores:
return ClassificationResult(
category=AssetClassification.OTHER,
confidence=0.3,
scores={},
)
best_category = max(scores.items(), key=lambda x: x[1])
category = AssetClassification(best_category[0])
confidence = min(0.95, max(0.3, best_category[1]))
return ClassificationResult(
category=category,
confidence=confidence,
scores=scores,
)
+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",
+667
View File
@@ -0,0 +1,667 @@
"""asset_quality_scoring 纯逻辑单测 — 第89波.
测试评分纯函数,不依赖 FFmpeg/视频文件。
覆盖:分辨率评分、帧率评分、码率评分、清晰度评分、稳定性评分、
质量总评分、9分类评分、分类结果计算。
"""
import numpy as np
import pytest
from apps.worker.worker_app.tasks.asset_quality_scoring import (
AudioAnalysis,
ClassificationResult,
ColorAnalysis,
MotionAnalysis,
QualityScore,
VideoInfo,
calculate_category_scores,
calculate_quality_score,
classify_from_analysis,
score_bitrate,
score_clarity,
score_framerate,
score_resolution,
score_stability,
)
from packages.domain.classification import AssetClassification
# ── 分辨率评分 ────────────────────────────────────────────────────────
class TestScoreResolution:
"""分辨率评分边界测试."""
def test_4k_full_score(self):
"""4K 及以上满分 25."""
assert score_resolution(3840, 2160) == 25.0
assert score_resolution(4096, 2160) == 25.0
assert score_resolution(7680, 4320) == 25.0 # 8K
def test_2k_score(self):
"""2K 档 22 分."""
assert score_resolution(2560, 1440) == 22.0
assert score_resolution(3000, 1600) == 22.0
# 刚好低于 4K
assert score_resolution(3839, 2159) == 22.0
def test_1080p_score(self):
"""1080p 档 20 分."""
assert score_resolution(1920, 1080) == 20.0
assert score_resolution(2000, 1080) == 20.0
# 刚好低于 2K
assert score_resolution(2559, 1439) == 20.0
def test_720p_score(self):
"""720p 档 15 分."""
assert score_resolution(1280, 720) == 15.0
assert score_resolution(1280, 720) == 15.0
# 刚好低于 1080p
assert score_resolution(1919, 1079) == 15.0
# 1080x720 像素数 < 1280x720,掉到 480p 档
assert score_resolution(1080, 720) == 8.0
def test_480p_score(self):
"""480p 档 8 分."""
assert score_resolution(854, 480) == 8.0
assert score_resolution(854, 480) == 8.0
# 刚好低于 720p
assert score_resolution(1279, 719) == 8.0
# 720x480 像素数 < 854x480,掉到最低档
assert score_resolution(720, 480) == 3.0
def test_low_resolution_score(self):
"""低于 480p 给 3 分."""
assert score_resolution(640, 360) == 3.0
assert score_resolution(320, 240) == 3.0
assert score_resolution(0, 0) == 3.0
def test_non_standard_aspect_ratio(self):
"""非标准宽高比按像素总数计算."""
# 竖屏 1080x1920 像素数 = 1080p
assert score_resolution(1080, 1920) == 20.0
# 超宽屏
assert score_resolution(2560, 1080) == 20.0 # 像素≈2.7M < 2K(3.6M)
# 1x1 极低分辨率
assert score_resolution(1, 1) == 3.0
def test_negative_values(self):
"""负尺寸:负负得正按像素数算,一正一负 = 负数像素 = 最低档."""
# 一正一负 → 负像素总数 → < 480p → 3分
assert score_resolution(1920, -1080) == 3.0
assert score_resolution(-1920, 1080) == 3.0
# 都是 0 → 3分
assert score_resolution(0, 0) == 3.0
# ── 帧率评分 ──────────────────────────────────────────────────────────
class TestScoreFramerate:
"""帧率评分边界测试."""
def test_60fps_full_score(self):
"""60fps 及以上满分 20."""
assert score_framerate(60) == 20.0
assert score_framerate(120) == 20.0
assert score_framerate(240) == 20.0
def test_30fps_score(self):
"""30-59fps 给 15 分."""
assert score_framerate(30) == 15.0
assert score_framerate(59.9) == 15.0
assert score_framerate(59) == 15.0
def test_24fps_score(self):
"""24-29fps 给 10 分."""
assert score_framerate(24) == 10.0
assert score_framerate(29.97) == 10.0
assert score_framerate(25) == 10.0
def test_15fps_score(self):
"""15-23fps 给 7 分."""
assert score_framerate(15) == 7.0
assert score_framerate(23.9) == 7.0
assert score_framerate(20) == 7.0
def test_low_fps_score(self):
"""低于 15fps 给 5 分."""
assert score_framerate(10) == 5.0
assert score_framerate(1) == 5.0
assert score_framerate(0) == 5.0
def test_negative_fps(self):
"""负帧率按最低档."""
assert score_framerate(-30) == 5.0
def test_float_fps(self):
"""浮点帧率正确判断边界."""
# 29.97 (NTSC) < 30 → 24fps 档
assert score_framerate(29.97) == 10.0
assert score_framerate(23.976) == 7.0
assert score_framerate(59.94) == 15.0 # 59.94 < 60 → 30fps 档
assert score_framerate(30.0) == 15.0
# ── 码率评分 ──────────────────────────────────────────────────────────
class TestScoreBitrate:
"""码率评分边界测试."""
def test_high_bitrate_full_score(self):
"""10Mbps 以上满分 20."""
assert score_bitrate(10_000_001) == 20.0
assert score_bitrate(50_000_000) == 20.0
assert score_bitrate(100_000_000) == 20.0
def test_5mbps_score(self):
"""5-10Mbps 给 15 分."""
assert score_bitrate(5_000_000) == 15.0
assert score_bitrate(8_000_000) == 15.0
assert score_bitrate(10_000_000) == 15.0 # 刚好 10Mbps = 不 > 10
def test_2mbps_score(self):
"""2-5Mbps 给 10 分."""
assert score_bitrate(2_000_000) == 10.0
assert score_bitrate(3_000_000) == 10.0
assert score_bitrate(4_999_999) == 10.0
def test_05mbps_score(self):
"""0.5-2Mbps 给 5 分."""
assert score_bitrate(500_000) == 5.0
assert score_bitrate(1_000_000) == 5.0
assert score_bitrate(1_999_999) == 5.0
def test_low_bitrate_score(self):
"""低于 0.5Mbps 给 3 分."""
assert score_bitrate(499_999) == 3.0
assert score_bitrate(100_000) == 3.0
assert score_bitrate(0) == 3.0
def test_negative_bitrate(self):
"""负码率按最低档."""
assert score_bitrate(-5_000_000) == 3.0
def test_zero_bitrate(self):
"""0 码率 = 最低档."""
assert score_bitrate(0) == 3.0
# ── 清晰度评分 ────────────────────────────────────────────────────────
class TestScoreClarity:
"""清晰度评分测试."""
def test_empty_frames_default_score(self):
"""空帧列表给默认 10 分."""
assert score_clarity([]) == 10.0
def test_constant_image_low_clarity(self):
"""纯色图像比高细节图像清晰度低很多."""
# 纯灰色图像
gray_frame = np.full((100, 100), 128, dtype=np.uint8)
score_constant = score_clarity([gray_frame])
# 高细节随机图像
detail_frame = np.random.randint(0, 256, (100, 100), dtype=np.uint8)
score_detail = score_clarity([detail_frame])
# 纯色图应该显著低于高细节图
assert score_constant < score_detail
assert 0.0 <= score_constant <= 20.0
def test_edge_rich_image_high_clarity(self):
"""高频边缘图像有较高清晰度得分."""
# 棋盘格图案,边缘丰富
frame = np.zeros((100, 100), dtype=np.uint8)
for i in range(0, 100, 10):
for j in range(0, 100, 10):
if (i // 10 + j // 10) % 2 == 0:
frame[i : i + 10, j : j + 10] = 255
score = score_clarity([frame])
assert score > 1.0 # 应有一定清晰度
assert 0.0 <= score <= 20.0
def test_rgb_frame_converts_to_gray(self):
"""RGB 帧会被转灰度后计算."""
rgb_frame = np.random.randint(0, 256, (50, 50, 3), dtype=np.uint8)
score_rgb = score_clarity([rgb_frame])
# 对应灰度图
gray = np.dot(rgb_frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
score_gray = score_clarity([gray])
# 两者应近似相等
assert abs(score_rgb - score_gray) < 0.01
def test_only_first_five_frames_analyzed(self):
"""只分析前 5 帧."""
# 10 帧:前 5 帧纯色,后 5 帧高对比度
frames = []
for _ in range(5):
frames.append(np.full((50, 50), 128, dtype=np.uint8))
for _ in range(5):
high_freq = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
frames.append(high_freq)
score_10 = score_clarity(frames)
score_5 = score_clarity(frames[:5])
# 前 5 帧相同,得分应相同
assert abs(score_10 - score_5) < 0.01
def test_score_within_bounds(self):
"""得分始终在 0-20 范围内."""
for _ in range(10):
frame = np.random.randint(0, 256, (30, 30, 3), dtype=np.uint8)
score = score_clarity([frame])
assert 0.0 <= score <= 20.0
def test_multiple_frames_averaged(self):
"""多帧取平均方差."""
# 第 1 帧低细节,第 2 帧高细节
low_detail = np.full((50, 50), 100, dtype=np.uint8)
high_detail = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
score_low = score_clarity([low_detail])
score_high = score_clarity([high_detail])
score_both = score_clarity([low_detail, high_detail])
# 混合得分应在两者之间
assert score_low <= score_both <= score_high
# ── 稳定性评分 ────────────────────────────────────────────────────────
class TestScoreStability:
"""稳定性评分测试."""
def test_single_frame_default_score(self):
"""不足 2 帧给默认 10 分."""
assert score_stability([]) == 10.0
assert score_stability([np.zeros((10, 10, 3), dtype=np.uint8)]) == 10.0
def test_identical_frames_max_stability(self):
"""完全相同的帧 = 高稳定性."""
frame = np.random.randint(50, 200, (100, 100, 3), dtype=np.uint8)
score = score_stability([frame, frame.copy()])
assert score > 10.0 # 应该接近满分 15
def test_very_different_frames_low_stability(self):
"""位移方差大的多帧序列 = 低稳定性."""
# 构造 4 帧:帧间位移差异大(有的帧相似、有的帧完全不同)
# 位移方差大 → 不稳定 → 低分
base = np.random.randint(100, 150, (100, 100, 3), dtype=np.uint8)
frames = [
base, # 帧0
base, # 帧1 (完全相同 → 位移0)
np.full_like(base, 255), # 帧2 (纯白 → 位移大)
base, # 帧3 (回到基准 → 位移又大)
]
score = score_stability(frames)
# 位移差异大 → 方差大 → 稳定性低
assert score < 10.0
assert 0.0 <= score <= 15.0
def test_score_within_bounds(self):
"""得分始终在 0-15 范围内."""
for _ in range(10):
f1 = np.random.randint(0, 256, (40, 40, 3), dtype=np.uint8)
f2 = np.random.randint(0, 256, (40, 40, 3), dtype=np.uint8)
score = score_stability([f1, f2])
assert 0.0 <= score <= 15.0
def test_gray_frames_also_work(self):
"""灰度帧也能计算."""
f1 = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
f2 = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
score = score_stability([f1, f2])
assert 0.0 <= score <= 15.0
def test_multiple_frame_pairs(self):
"""多对帧取方差."""
base = np.random.randint(100, 150, (60, 60, 3), dtype=np.uint8)
# 5 帧相似的
frames = []
for i in range(5):
f = base.copy()
# 轻微变化
f = np.clip(f.astype(int) + np.random.randint(-5, 6, f.shape), 0, 255).astype(np.uint8)
frames.append(f)
score = score_stability(frames)
assert score > 5.0 # 相似帧应该有一定稳定性
# ── 质量总评分 ────────────────────────────────────────────────────────
class TestCalculateQualityScore:
"""质量综合评分测试."""
def test_perfect_video_near_100(self):
"""完美参数的视频接近 100 分."""
info = VideoInfo(
width=3840,
height=2160,
fps=60,
bitrate=20_000_000,
)
# 用高细节帧提升清晰度分
frame = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)
result = calculate_quality_score(info, [frame])
assert isinstance(result, QualityScore)
assert result.resolution_score == 25.0
assert result.fps_score == 20.0
assert result.bitrate_score == 20.0
assert 50.0 <= result.total <= 100.0
def test_low_quality_video(self):
"""低质量视频得分低."""
info = VideoInfo(
width=320,
height=240,
fps=10,
bitrate=100_000,
)
result = calculate_quality_score(info, [])
assert isinstance(result, QualityScore)
assert result.resolution_score == 3.0
assert result.fps_score == 5.0
assert result.bitrate_score == 3.0
# 无帧时清晰度和稳定性各给 10 分默认
assert result.clarity_score == 10.0
assert result.stability_score == 10.0
assert result.total == 31.0 # 3+5+3+10+10
def test_no_frames_uses_defaults(self):
"""不传 frames 时清晰度/稳定性给默认分."""
info = VideoInfo(width=1920, height=1080, fps=30, bitrate=5_000_000)
result = calculate_quality_score(info)
assert result.clarity_score == 10.0
assert result.stability_score == 10.0
assert result.resolution_score == 20.0
assert result.fps_score == 15.0
assert result.bitrate_score == 15.0
assert result.total == 70.0
def test_total_capped_at_100(self):
"""总分不超过 100."""
info = VideoInfo(
width=7680,
height=4320,
fps=240,
bitrate=100_000_000,
)
# 即使所有维度都满,总分不超 100
result = calculate_quality_score(info, [])
assert result.total <= 100.0
def test_total_minimum_zero(self):
"""总分不低于 0."""
info = VideoInfo(width=0, height=0, fps=0, bitrate=0)
result = calculate_quality_score(info, [])
assert result.total >= 0.0
def test_total_is_rounded(self):
"""总分保留 1 位小数."""
info = VideoInfo(width=1920, height=1080, fps=30, bitrate=5_000_000)
result = calculate_quality_score(info, [])
# 检查是 1 位小数
assert round(result.total, 1) == result.total
# ── 分类评分 ──────────────────────────────────────────────────────────
class TestCalculateCategoryScores:
"""分类评分计算测试."""
def test_scenic_high_green_and_motion(self):
"""绿色+适度运动+自然音 → 风景高分."""
color = ColorAnalysis(
green_ratio=0.5,
avg_saturation=0.5,
avg_brightness=0.6,
warm_ratio=0.2,
cool_ratio=0.3,
)
motion = MotionAnalysis(motion_score=0.3, scene_changes=1)
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.7)
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.SCENIC.value] > 0.5
assert scores[AssetClassification.SCENIC.value] <= 1.0
def test_product_low_motion_cool_tone(self):
"""低运动+冷色调 → 产品高分."""
color = ColorAnalysis(
avg_brightness=0.5,
avg_saturation=0.3,
cool_ratio=0.5,
green_ratio=0.1,
warm_ratio=0.2,
)
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
audio = AudioAnalysis(has_audio=False)
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.PRODUCT.value] > 0.5
def test_person_with_speech(self):
"""有人声+适度运动 → 人物高分."""
color = ColorAnalysis(avg_brightness=0.5)
motion = MotionAnalysis(motion_score=0.25, scene_changes=1)
audio = AudioAnalysis(has_audio=True, speech_ratio=0.6)
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.PERSON.value] > 0.5
def test_animal_high_motion(self):
"""高运动+多场景切换 → 动物高分."""
color = ColorAnalysis()
motion = MotionAnalysis(motion_score=0.6, scene_changes=5)
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.5)
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.ANIMAL.value] > 0.5
def test_food_warm_saturated(self):
"""暖色调+高饱和 → 美食高分."""
color = ColorAnalysis(
warm_ratio=0.6,
avg_saturation=0.7,
avg_brightness=0.6,
green_ratio=0.1,
cool_ratio=0.2,
)
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
audio = AudioAnalysis(has_audio=False)
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.FOOD.value] > 0.5
def test_tech_cool_low_saturation(self):
"""冷色调+低饱和+低运动 → 科技高分."""
color = ColorAnalysis(
cool_ratio=0.6,
avg_saturation=0.3,
avg_brightness=0.5,
green_ratio=0.1,
warm_ratio=0.2,
)
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
audio = AudioAnalysis(has_audio=False)
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.TECH.value] > 0.5
def test_sport_high_motion(self):
"""高运动+多场景 → 运动高分."""
color = ColorAnalysis(avg_brightness=0.6)
motion = MotionAnalysis(motion_score=0.7, scene_changes=5)
audio = AudioAnalysis(has_audio=False)
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.SPORT.value] > 0.5
def test_music_high_music_ratio(self):
"""高音乐比例 → 音乐高分."""
color = ColorAnalysis(avg_saturation=0.6, green_ratio=0.1)
motion = MotionAnalysis(motion_score=0.2)
audio = AudioAnalysis(has_audio=True, music_ratio=0.7)
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.MUSIC.value] > 0.5
def test_other_has_base_score(self):
"""其他分类有基础分 0.1."""
color = ColorAnalysis()
motion = MotionAnalysis()
audio = AudioAnalysis()
scores = calculate_category_scores(color, motion, audio)
assert scores[AssetClassification.OTHER.value] == 0.1
def test_all_scores_within_bounds(self):
"""所有分类得分都在 0-1 范围内."""
color = ColorAnalysis(
green_ratio=0.9,
warm_ratio=0.9,
cool_ratio=0.9,
avg_saturation=0.99,
avg_brightness=0.99,
)
motion = MotionAnalysis(motion_score=0.9, scene_changes=100)
audio = AudioAnalysis(
has_audio=True,
speech_ratio=0.99,
music_ratio=0.99,
ambient_ratio=0.99,
)
scores = calculate_category_scores(color, motion, audio)
for cat, score in scores.items():
assert 0.0 <= score <= 1.0, f"{cat} score {score} out of bounds"
def test_all_nine_categories_present(self):
"""返回 9 个分类的得分."""
color = ColorAnalysis()
motion = MotionAnalysis()
audio = AudioAnalysis()
scores = calculate_category_scores(color, motion, audio)
assert len(scores) == 9
# ── 分类结果计算 ──────────────────────────────────────────────────────
class TestClassifyFromAnalysis:
"""分类结果计算测试."""
def test_returns_classification_result(self):
"""返回 ClassificationResult 对象."""
color = ColorAnalysis()
motion = MotionAnalysis()
audio = AudioAnalysis()
result = classify_from_analysis(color, motion, audio)
assert isinstance(result, ClassificationResult)
assert isinstance(result.category, AssetClassification)
assert isinstance(result.confidence, float)
assert isinstance(result.scores, dict)
def test_highest_score_wins(self):
"""得分最高的分类胜出."""
# 构造明显偏向风景的特征
color = ColorAnalysis(
green_ratio=0.8,
avg_saturation=0.6,
avg_brightness=0.7,
)
motion = MotionAnalysis(motion_score=0.3)
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.8)
result = classify_from_analysis(color, motion, audio)
assert result.category == AssetClassification.SCENIC
def test_confidence_within_bounds(self):
"""置信度在 0.3-0.95 范围内."""
color = ColorAnalysis()
motion = MotionAnalysis()
audio = AudioAnalysis()
result = classify_from_analysis(color, motion, audio)
assert 0.3 <= result.confidence <= 0.95
def test_confidence_capped_at_095(self):
"""极高得分也被限制在 0.95."""
color = ColorAnalysis(
green_ratio=0.9,
avg_saturation=0.9,
avg_brightness=0.9,
warm_ratio=0.9,
)
motion = MotionAnalysis(motion_score=0.9, scene_changes=10)
audio = AudioAnalysis(
has_audio=True,
speech_ratio=0.9,
music_ratio=0.9,
ambient_ratio=0.9,
)
result = classify_from_analysis(color, motion, audio)
assert result.confidence <= 0.95
def test_confidence_floored_at_03(self):
"""极低得分也有 0.3 最低置信度."""
color = ColorAnalysis()
motion = MotionAnalysis()
audio = AudioAnalysis()
result = classify_from_analysis(color, motion, audio)
assert result.confidence >= 0.3
def test_scores_dict_included(self):
"""结果中包含完整分数字典."""
color = ColorAnalysis()
motion = MotionAnalysis()
audio = AudioAnalysis()
result = classify_from_analysis(color, motion, audio)
assert len(result.scores) == 9
assert AssetClassification.OTHER.value in result.scores
def test_food_category_wins_on_warm_colors(self):
"""暖色调+高饱和 → 美食分类胜出."""
color = ColorAnalysis(
warm_ratio=0.7,
avg_saturation=0.8,
avg_brightness=0.6,
green_ratio=0.05,
)
motion = MotionAnalysis(motion_score=0.05)
audio = AudioAnalysis(has_audio=False)
result = classify_from_analysis(color, motion, audio)
assert result.category == AssetClassification.FOOD
# ── 数据类默认值 ──────────────────────────────────────────────────────
class TestDataclassDefaults:
"""数据类默认值测试."""
def test_video_info_defaults(self):
info = VideoInfo()
assert info.width == 0
assert info.height == 0
assert info.fps == 0.0
assert info.bitrate == 0
assert info.has_audio is False
def test_color_analysis_defaults(self):
color = ColorAnalysis()
assert color.green_ratio == 0.0
assert color.avg_brightness == 0.0
assert color.dominant_hue == 0.0
def test_motion_analysis_defaults(self):
motion = MotionAnalysis()
assert motion.motion_score == 0.0
assert motion.scene_changes == 0
def test_audio_analysis_defaults(self):
audio = AudioAnalysis()
assert audio.has_audio is False
assert audio.speech_ratio == 0.0
assert audio.music_ratio == 0.0
def test_quality_score_requires_total(self):
with pytest.raises(TypeError):
QualityScore()
qs = QualityScore(total=50.0)
assert qs.total == 50.0
assert qs.resolution_score == 0.0
+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