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
373 lines
12 KiB
Python
Executable File
373 lines
12 KiB
Python
Executable File
"""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-100,None表示未知)
|
||
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
|