Files
xiaoxia-saas/apps/api/app/services/auto_clip_service.py
T
CI Bot e9d2831850
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m30s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m18s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 16m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 16m20s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m24s
feat(ci): mypy upgrade to hard gate + fix type errors
2026-07-16 07:59:07 +08:00

342 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AutoClipService — 智能选片服务.
根据模板片段配置 (TemplateClipConfig) 的素材需求 (material_requirements)
自动从项目素材库中筛选、评分并分配最佳素材到剪辑计划片段 (EditPlanClip)。
评分规则:
- 质量分 (quality_score):权重 0.5
- 时长匹配度:权重 0.3(越接近目标时长得分越高)
- 分类匹配度:权重 0.2(分类完全匹配得满分,部分匹配按比例得分)
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import (
SQLAlchemyAssetRepository,
SQLAlchemyEditPlanClipRepository,
SQLAlchemyEditPlanRepository,
SQLAlchemyTemplateClipConfigRepository,
)
from packages.domain.asset import AssetType
from packages.domain.classification import AssetClassification
from packages.domain.edit_plan_clip import EditPlanClip
logger = logging.getLogger(__name__)
# ── 评分权重 ──────────────────────────────────────────────────────────────────
_WEIGHT_QUALITY = 0.5
_WEIGHT_DURATION = 0.3
_WEIGHT_CLASSIFICATION = 0.2
@dataclass
class AutoSelectResult:
"""智能选片结果。"""
plan_id: str
total_clips: int
assigned_clips: int
unassigned_clips: int
details: list[ClipAssignDetail]
@dataclass
class ClipAssignDetail:
"""单个片段的分配详情。"""
clip_id: str
clip_type: str
assigned_asset_id: str | None
candidate_count: int
score: float | None
reason: str
class AutoClipService:
"""智能选片服务 — 自动为剪辑计划片段分配最佳素材。"""
def __init__(self, db: Session) -> None:
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
self._config_repo = SQLAlchemyTemplateClipConfigRepository(db)
self._asset_repo = SQLAlchemyAssetRepository(db)
# ── 公开方法 ──────────────────────────────────────────────────────────────
def auto_select_assets(self, plan_id: str, project_id: str) -> AutoSelectResult:
"""为剪辑计划的所有片段自动分配素材。
流程:
1. 获取剪辑计划 → 读取 template_id
2. 获取模板的所有片段配置 (TemplateClipConfig)
3. 获取计划的所有片段 (EditPlanClip)
4. 对每个片段,根据其关联的 config 筛选候选素材并评分
5. 将最佳素材分配给片段,标记为 READY
Args:
plan_id: 剪辑计划 ID
project_id: 项目 ID(素材所属项目)
Returns:
AutoSelectResult 包含分配统计和每个片段的详情
Raises:
ValueError: 计划不存在
"""
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
# 获取模板片段配置(按 order 排序)
configs = self._config_repo.list_by_template(plan.template_id)
config_map = {c.id: c for c in configs}
# 获取计划的所有片段
clips = self._clip_repo.list_by_plan(plan_id)
details: list[ClipAssignDetail] = []
assigned_count = 0
for clip in clips:
detail = self._assign_single_clip(clip, project_id, config_map)
details.append(detail)
if detail.assigned_asset_id is not None:
assigned_count += 1
result = AutoSelectResult(
plan_id=plan_id,
total_clips=len(clips),
assigned_clips=assigned_count,
unassigned_clips=len(clips) - assigned_count,
details=details,
)
logger.info(
"智能选片完成: plan=%s total=%d assigned=%d unassigned=%d",
plan_id,
result.total_clips,
result.assigned_clips,
result.unassigned_clips,
)
return result
def select_for_clip(self, clip_id: str, project_id: str) -> ClipAssignDetail:
"""为单个片段选择并分配最佳素材。
Args:
clip_id: 片段 ID
project_id: 项目 ID(素材所属项目)
Returns:
ClipAssignDetail 分配详情
Raises:
ValueError: 片段不存在或缺少关联配置
"""
clip = self._clip_repo.get(clip_id)
if clip is None:
raise ValueError(f"片段不存在: {clip_id}")
# 获取关联的模板配置
config = None
if clip.template_clip_config_id:
config = self._config_repo.get(clip.template_clip_config_id)
config_map = {config.id: config} if config else {}
return self._assign_single_clip(clip, project_id, config_map)
# ── 内部方法 ──────────────────────────────────────────────────────────────
def _assign_single_clip(
self,
clip: EditPlanClip,
project_id: str,
config_map: Mapping[str, object],
) -> ClipAssignDetail:
"""为单个片段分配素材。"""
config = config_map.get(clip.template_clip_config_id) if clip.template_clip_config_id else None
# 解析素材需求
requirements = self._parse_material_requirements(config)
# 搜索候选素材
candidates = self._asset_repo.search_candidates(
project_id=project_id,
file_type=requirements.get("file_type"),
min_quality_score=requirements.get("min_quality_score"),
min_duration=requirements.get("min_duration"),
max_duration=requirements.get("max_duration"),
classification_category=requirements.get("classification_category"),
tags=requirements.get("tags"),
status="completed",
limit=50,
)
if not candidates:
return ClipAssignDetail(
clip_id=clip.id,
clip_type=requirements.get("clip_type", "unknown"),
assigned_asset_id=None,
candidate_count=0,
score=None,
reason="无符合条件的候选素材",
)
# 评分并选择最佳素材
target_duration = requirements.get("target_duration")
target_category = requirements.get("classification_category")
best_asset = None
best_score = -1.0
for asset in candidates:
score = self._score_candidate(
asset,
target_duration=target_duration,
target_category=target_category,
)
if score > best_score:
best_score = score
best_asset = asset
if best_asset is None:
return ClipAssignDetail(
clip_id=clip.id,
clip_type=requirements.get("clip_type", "unknown"),
assigned_asset_id=None,
candidate_count=len(candidates),
score=None,
reason="候选素材评分均不合格",
)
# 分配素材并标记就绪
clip.assign_asset(best_asset.id)
clip.mark_ready()
self._clip_repo.update(clip)
return ClipAssignDetail(
clip_id=clip.id,
clip_type=requirements.get("clip_type", "unknown"),
assigned_asset_id=best_asset.id,
candidate_count=len(candidates),
score=round(best_score, 4),
reason=f"最佳匹配 (score={best_score:.4f})",
)
@staticmethod
def _score_candidate(
asset: object,
*,
target_duration: float | None = None,
target_category: str | None = None,
) -> float:
"""对候选素材评分 (0.0 ~ 1.0)。
评分维度:
- 质量分 (quality_score):归一化到 0-1,权重 0.5
- 时长匹配度:越接近目标时长得分越高,权重 0.3
- 分类匹配度:完全匹配得 1.0,无分类得 0.0,权重 0.2
"""
# 质量分 (0-100 → 0-1)
quality = getattr(asset, "quality_score", None)
quality_score = (quality / 100.0) if quality is not None else 0.5
# 时长匹配度
duration_score = 0.5 # 无目标时长的默认分
if target_duration is not None and target_duration > 0:
asset_duration = getattr(asset, "duration", None)
if asset_duration is not None and asset_duration > 0:
ratio = asset_duration / target_duration
# 比率越接近 1.0 得分越高,使用高斯衰减
duration_score = max(0.0, 1.0 - abs(1.0 - ratio) * 2)
# 无时长的素材得 0 分
else:
duration_score = 0.0
# 分类匹配度
classification_score = 0.0
if target_category is not None:
metadata = getattr(asset, "metadata", {}) or {}
asset_category = metadata.get("category", "")
if asset_category == target_category:
classification_score = 1.0
elif asset_category:
# 部分匹配(同大类)给 0.5
classification_score = 0.3
else:
# 无分类要求,所有素材得满分
classification_score = 1.0
total = (
_WEIGHT_QUALITY * quality_score
+ _WEIGHT_DURATION * duration_score
+ _WEIGHT_CLASSIFICATION * classification_score
)
return total
@staticmethod
def _parse_material_requirements(config: object | None) -> dict:
"""从 TemplateClipConfig 解析素材筛选条件。
将 material_requirements JSON 和 config 自身的时长/类型字段
统一转换为 search_candidates 可用的筛选参数。
"""
result: dict = {}
if config is None:
return result
# 从 material_requirements 提取筛选条件
requirements = getattr(config, "material_requirements", {}) or {}
# 素材类型: material_requirements 中的 "type" 字段
req_type = requirements.get("type")
if req_type and req_type in (AssetType.VIDEO, AssetType.IMAGE, AssetType.AUDIO):
result["file_type"] = req_type
# 最低质量分
min_quality = requirements.get("min_quality_score") or requirements.get("min_quality")
if min_quality is not None:
try:
result["min_quality_score"] = float(min_quality)
except (TypeError, ValueError) as e:
logger.warning(f"Operation failed in apps/api/app/services/auto_clip_service.py: {e}", exc_info=True)
# 分类筛选
category = requirements.get("category") or requirements.get("classification")
if category:
# 验证是否为有效分类
valid_categories = {c.value for c in AssetClassification}
if category in valid_categories:
result["classification_category"] = category
# 标签筛选
tags = requirements.get("tags")
if isinstance(tags, list) and tags:
result["tags"] = tags
# 时长范围:优先使用 config 的 min/max_duration,其次 material_requirements
min_dur = getattr(config, "min_duration", None) or requirements.get("min_duration")
max_dur = getattr(config, "max_duration", None) or requirements.get("max_duration")
if min_dur is not None and min_dur > 0:
result["min_duration"] = float(min_dur)
if max_dur is not None and max_dur > 0:
result["max_duration"] = float(max_dur)
# 目标时长(用于评分)
if min_dur and max_dur:
result["target_duration"] = (float(min_dur) + float(max_dur)) / 2
elif min_dur:
result["target_duration"] = float(min_dur) * 1.2
elif max_dur:
result["target_duration"] = float(max_dur) * 0.8
# 片段类型(用于日志)
clip_type = getattr(config, "clip_type", None)
if clip_type:
result["clip_type"] = clip_type.value if hasattr(clip_type, "value") else str(clip_type)
return result