Files
xiaoxia-saas/packages/domain/asset_scoring.py
T
xiaoxia b896873ece
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
test(wave92): extract asset_scoring domain module + 89 unit tests (#955)
2026-07-26 18:15:59 +08:00

373 lines
12 KiB
Python
Executable File
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.
"""Asset scoring pure logic — multi-dimensional scoring + diverse selection.
从 smart_asset_selector.py 抽出来的纯逻辑模块:
- 评分维度:质量分、分辨率、时长、码率(加权求和,总分 0-1)
- 多样性选择:按时长分桶(短/中/长)保证分布均匀
- 数据类:AssetScoreDetail, SmartSelectResult
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
# ── 评分权重(总和 = 1.0) ────────────────────────────────────────────────────
WEIGHT_QUALITY = 0.5
WEIGHT_RESOLUTION = 0.2
WEIGHT_DURATION = 0.2
WEIGHT_BITRATE = 0.1
# ── 评分参数 ──────────────────────────────────────────────────────────────────
TARGET_WIDTH = 1920 # 目标分辨率宽度基准
TARGET_HEIGHT = 1080 # 目标分辨率高度基准
MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
OPTIMAL_DURATION_MAX = 30.0
# ── 多样性分桶阈值 ───────────────────────────────────────────────────────────
SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
# 长素材:>= 15s
# ── 数据类 ───────────────────────────────────────────────────────────────────
@dataclass
class AssetScoreDetail:
"""单个素材的评分详情."""
asset_id: str
total_score: float
quality_score: float
resolution_score: float
duration_score: float
bitrate_score: float
duration: float | None
@dataclass
class SmartSelectResult:
"""智能选择结果."""
selected_ids: list[str]
total_candidates: int
filtered_out: int # 被质量门槛过滤的数量
avg_score: float
details: list[AssetScoreDetail] = field(default_factory=list)
# ── 评分函数 ─────────────────────────────────────────────────────────────────
def score_resolution(
width: int | None,
height: int | None,
target_width: int = TARGET_WIDTH,
target_height: int = TARGET_HEIGHT,
) -> float:
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重.
Args:
width: 素材宽度(像素)
height: 素材高度(像素)
target_width: 目标宽度
target_height: 目标高度
Returns:
0.0 - 1.0 之间的评分
"""
if width is None or height is None or width <= 0 or height <= 0:
return 0.5 # 未知分辨率给中评分
target_pixels = target_width * target_height
actual_pixels = width * height
# 计算像素数比例
ratio = actual_pixels / target_pixels
if ratio >= 1.0:
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
return 1.0
else:
# 低于目标分辨率:线性衰减,但最低不低于 0.1
score = 0.3 + 0.7 * ratio
return max(0.1, min(1.0, score))
def score_duration(duration: float | None) -> float:
"""时长评分:3-30秒最佳,过短或过长都扣分.
Args:
duration: 时长(秒)
Returns:
0.0 - 1.0 之间的评分
"""
if duration is None or duration <= 0:
return 0.5 # 未知时长给中评分
if OPTIMAL_DURATION_MIN <= duration <= OPTIMAL_DURATION_MAX:
# 最佳区间:满分
return 1.0
if duration < OPTIMAL_DURATION_MIN:
# 太短:线性衰减,趋近于 0.3
ratio = duration / OPTIMAL_DURATION_MIN
return 0.3 + 0.7 * ratio
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
excess = duration - OPTIMAL_DURATION_MAX
penalty = min(0.8, excess / 10.0 * 0.1)
return max(0.2, 1.0 - penalty)
def score_bitrate(file_size: int, duration: float | None) -> float:
"""码率评分:根据文件大小和时长估算码率,适中得分高.
Args:
file_size: 文件大小(字节)
duration: 时长(秒)
Returns:
0.0 - 1.0 之间的评分
"""
if not file_size or not duration or duration <= 0:
return 0.5 # 未知给中评分
# 估算码率(bps
bitrate = (file_size * 8) / duration
# 最佳码率范围:2-8 Mbps
optimal_low = 2_000_000 # 2 Mbps
optimal_high = 8_000_000 # 8 Mbps
if optimal_low <= bitrate <= optimal_high:
return 1.0
if bitrate < optimal_low:
# 码率太低:线性衰减
ratio = bitrate / optimal_low
return 0.3 + 0.7 * ratio
# 码率太高(文件太大):适度扣分,最低 0.5
excess = bitrate / optimal_high - 1.0
penalty = min(0.5, excess * 0.2)
return max(0.5, 1.0 - penalty)
def calculate_total_score(
quality_score: float,
resolution_score: float,
duration_score: float,
bitrate_score: float,
) -> float:
"""计算加权总分.
Args:
quality_score: 质量分(0-1
resolution_score: 分辨率分(0-1
duration_score: 时长分(0-1
bitrate_score: 码率分(0-1
Returns:
加权总分(0-1
"""
total = (
WEIGHT_QUALITY * quality_score
+ WEIGHT_RESOLUTION * resolution_score
+ WEIGHT_DURATION * duration_score
+ WEIGHT_BITRATE * bitrate_score
)
return round(total, 4)
def score_asset_detail(
asset_id: str,
quality: float | None,
width: int | None,
height: int | None,
duration: float | None,
file_size: int,
target_width: int = TARGET_WIDTH,
target_height: int = TARGET_HEIGHT,
) -> AssetScoreDetail:
"""对单个素材进行多维度评分,返回详细评分结果.
Args:
asset_id: 素材ID
quality: 质量分(0-100None表示未知)
width: 宽度
height: 高度
duration: 时长
file_size: 文件大小
target_width: 目标宽度
target_height: 目标高度
Returns:
AssetScoreDetail 评分详情
"""
# 质量分归一化到 0-1
quality_score = (quality / 100.0) if quality is not None else 0.5
resolution_score = score_resolution(width, height, target_width, target_height)
duration_score = score_duration(duration)
bitrate_score = score_bitrate(file_size, duration)
total_score = calculate_total_score(
quality_score,
resolution_score,
duration_score,
bitrate_score,
)
return AssetScoreDetail(
asset_id=asset_id,
total_score=total_score,
quality_score=round(quality_score, 4),
resolution_score=round(resolution_score, 4),
duration_score=round(duration_score, 4),
bitrate_score=round(bitrate_score, 4),
duration=duration,
)
# ── 多样性选择 ───────────────────────────────────────────────────────────────
def _bucket_by_duration(item: AssetScoreDetail) -> str:
"""根据时长判断所属桶.
Returns:
'short' / 'medium' / 'long' / 'unknown'
"""
if item.duration is None:
return "unknown"
if item.duration < SHORT_BUCKET_MAX:
return "short"
if item.duration < MEDIUM_BUCKET_MAX:
return "medium"
return "long"
def diverse_selection(
scored: list[AssetScoreDetail],
count: int,
) -> list[AssetScoreDetail]:
"""多样性选择:按时长分桶,保证每个桶都有素材.
策略:
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>=15s)
2. 每个桶配额 = max(1, count // 3)
3. 先从每桶按配额取最高分的
4. 剩余名额从全局最高分中取(不重复)
5. 如果还不够,加上未知时长的
Args:
scored: 已按总分降序排列的评分列表
count: 需要选取的数量
Returns:
选中的评分列表(不超过 count 个)
"""
if count <= 0 or not scored:
return []
# 分桶
short_bucket = [d for d in scored if _bucket_by_duration(d) == "short"]
medium_bucket = [d for d in scored if _bucket_by_duration(d) == "medium"]
long_bucket = [d for d in scored if _bucket_by_duration(d) == "long"]
unknown_bucket = [d for d in scored if _bucket_by_duration(d) == "unknown"]
buckets = [short_bucket, medium_bucket, long_bucket]
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
base_quota = max(1, count // 3)
selected: list[AssetScoreDetail] = []
selected_ids: set[str] = set()
# 先按配额从每个桶取
for bucket in buckets:
quota = min(base_quota, len(bucket))
if quota <= 0:
continue
# 桶内已经按分数排好序了,直接取前 quota 个
for item in bucket[:quota]:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
return selected
# 剩余名额:从全局(未被选中的)中按分数高低取
remaining_needed = count - len(selected)
if remaining_needed > 0:
for item in scored:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
# 如果还不够(不应该发生),加上未知时长的
if len(selected) < count and unknown_bucket:
for item in unknown_bucket:
if item.asset_id not in selected_ids:
selected.append(item)
selected_ids.add(item.asset_id)
if len(selected) >= count:
break
return selected[:count]
# ── 候选过滤 ─────────────────────────────────────────────────────────────────
def filter_candidates(
assets: list[Any],
min_quality_score: float = MIN_QUALITY_SCORE,
) -> tuple[list[Any], int]:
"""从素材列表中筛选出合格的候选素材.
筛选条件:
- status == 'ready'
- mime_type 以 'video' 开头
- quality_score >= min_quality_score(如果quality不为None
Args:
assets: 素材列表
min_quality_score: 最低质量分门槛
Returns:
(合格素材列表, 被质量门槛过滤的数量)
"""
candidates = []
filtered_out = 0
for asset in assets:
# 状态检查
status = getattr(asset, "status", None)
status_val = status.value if hasattr(status, "value") else str(status)
if status_val != "ready":
continue
# 类型检查
mime_type = getattr(asset, "mime_type", "") or ""
if not mime_type.startswith("video"):
continue
# 质量分门槛
quality = getattr(asset, "quality_score", None)
if quality is not None and quality < min_quality_score:
filtered_out += 1
continue
candidates.append(asset)
return candidates, filtered_out