Files
xiaoxia-saas/packages/domain/atom_clip_selector.py
T
xiaoxia f1621ace9f
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m17s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m26s
CI/CD Pipeline / Integration Tests (push) Successful in 3m48s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 4m13s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m44s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m19s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 59s
CI/CD Pipeline / Validate - Style (push) Successful in 7m51s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m54s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m44s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m58s
CI/CD Pipeline / Unit Tests (push) Successful in 10m42s
CI/CD Pipeline / Validate - Security (push) Successful in 12m25s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat(#1970): 素材原子化切片 P1 - 数据层/切片逻辑/原子片段级选片 (#1974)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-18 03:57:07 +08:00

265 lines
9.7 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.
"""原子片段级选片核心 — #1970 智能剪辑流程重构 P1.
选片单元从"整条素材 + 随机起点"升级为"原子片段(atom clip)":
- 每个 EditPlanClip 指向一个 atom_clip_id(含 asset_id + start/end);
- 同一素材的不同原子片段可被同一视频多次选用;
- 同一原子片段在一个视频内只用一次;
- 跨变体/跨任务的避让升级为原子片段级(同 asset 的不同片段天然不重叠);
- atom_clips 未就绪(老素材/切片失败)时由调用方走内存兜底切片,
再不行回退到现有的整条素材随机起点逻辑。
本模块是纯函数:原子片段数据由调用方从 repository 读取后注入,不直接碰 DB,
便于单元测试。评分维度与 smart_match 保持一致(质量分、时长适配、新鲜度、
未使用加分),只是评分对象从素材变为原子片段。
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import Any
from packages.domain.asset_atom_clip import AssetAtomClip
@dataclass(slots=True)
class ScoredAtomClip:
"""带评分的候选原子片段。"""
clip: AssetAtomClip
score: float
@property
def atom_clip_id(self) -> str:
return self.clip.id
@property
def asset_id(self) -> str:
return self.clip.asset_id
@property
def start_time(self) -> float:
return self.clip.start_time
@property
def end_time(self) -> float:
return self.clip.end_time
@property
def duration(self) -> float:
return self.clip.duration
# 评分权重(与 smart_match.score_asset 的维度对齐)
W_QUALITY = 0.35
W_DURATION_FIT = 0.30
W_FRESHNESS = 0.15
W_UNUSED_BONUS = 0.10
W_ASSET_BALANCE = 0.10
# 评分随机噪声上限(与 SCORE_RANDOM_NOISE_MAX 同量级,避免反复选同一组合)
SCORE_NOISE_MAX = 0.05
def score_atom_clip(
clip: AssetAtomClip,
*,
target_duration: float,
asset_quality: dict[str, float] | None = None,
asset_freshness: dict[str, float] | None = None,
used_in_video: set[str] | None = None,
asset_usage_counts: dict[str, int] | None = None,
recently_used: set[str] | None = None,
required_count: int = 1,
total_candidates: int = 1,
) -> float:
"""评估单个原子片段对某个目标槽位的适配分(越高越优先).
评分维度:
- 质量分(继承素材质量,缺省中性 0.6);
- 时长适配(片段时长越接近目标越好,覆盖不满显著扣分);
- 新鲜度(缺省中性 0.5);
- 未使用加分(本视频内未用过 +1,已用 0);
- 素材均衡(同一素材在本视频用得越多,其剩余片段扣分越多,鼓励分散到多素材);
- 跨视频/历史使用降权(recently_used 中的片段扣分,不硬禁)。
"""
asset_quality = asset_quality or {}
asset_freshness = asset_freshness or {}
used_in_video = used_in_video or set()
asset_usage_counts = asset_usage_counts or {}
recently_used = recently_used or set()
quality = asset_quality.get(clip.asset_id, 0.6)
if target_duration > 0:
coverage = min(1.0, clip.duration / target_duration)
overshoot = max(0.0, (clip.duration - target_duration) / target_duration)
duration_fit = max(0.0, coverage - 0.15 * overshoot)
else:
duration_fit = 0.5
freshness = asset_freshness.get(clip.asset_id, 0.5)
unused_bonus = 0.0 if clip.id in used_in_video else 1.0
# 素材均衡:该素材已被本视频选用 k 次,其片段逐次扣分
times_used = asset_usage_counts.get(clip.asset_id, 0)
balance = 1.0 / (1.0 + times_used)
# 跨视频/历史使用降权(不硬禁)
history_penalty = 0.35 if clip.id in recently_used else 0.0
score = (
W_QUALITY * quality
+ W_DURATION_FIT * duration_fit
+ W_FRESHNESS * freshness
+ W_UNUSED_BONUS * unused_bonus
+ W_ASSET_BALANCE * balance
- history_penalty
)
return score
def select_atom_clips(
candidates: list[AssetAtomClip],
*,
target_duration: float = 0.0,
used_atom_clip_ids: set[str] | None = None,
asset_usage_counts: dict[str, int] | None = None,
recently_used_atom_ids: set[str] | None = None,
required_count: int = 1,
limit: int = 0,
asset_quality: dict[str, float] | None = None,
asset_freshness: dict[str, float] | None = None,
rng: random.Random | None = None,
) -> list[ScoredAtomClip]:
"""为一个目标槽位从候选原子片段中评分选片(纯函数).
Args:
candidates: 候选原子片段(可跨多素材)。
target_duration: 槽位目标时长(秒)。
used_atom_clip_ids: 本视频已用过的原子片段 ID(硬排除,同片段不重复)。
asset_usage_counts: 本视频各素材已选片段数(均衡评分用)。
recently_used_atom_ids: 跨视频/历史成片用过的片段 ID(降权,不硬禁)。
required_count: 整个视频需要的片段总数(预留,供覆盖策略判断)。
limit: 最多返回条数;<=0 表示返回全部排序结果。
asset_quality / asset_freshness: 评分注入。
rng: 可选随机源(测试注入)。
Returns:
评分降序的 ScoredAtomClip 列表(已排除本视频用过的片段)。
"""
rng = rng or random.Random()
used = used_atom_clip_ids or set()
asset_usage_counts = asset_usage_counts or {}
recently_used = recently_used_atom_ids or set()
available = [c for c in candidates if c.id not in used]
scored: list[ScoredAtomClip] = []
for clip in available:
base = score_atom_clip(
clip,
target_duration=target_duration,
asset_quality=asset_quality,
asset_freshness=asset_freshness,
used_in_video=used,
asset_usage_counts=asset_usage_counts,
recently_used=recently_used,
required_count=required_count,
total_candidates=len(candidates),
)
noise = rng.uniform(0.0, SCORE_NOISE_MAX)
scored.append(ScoredAtomClip(clip=clip, score=base + noise))
scored.sort(key=lambda s: s.score, reverse=True)
if limit and limit > 0:
return scored[:limit]
return scored
def clips_to_segments(clips: list[AssetAtomClip]) -> dict[str, list[tuple[float, float]]]:
"""把选中的原子片段转换为旧的 {asset_id: [(start, end), ...]} 区间结构.
用于与现有跨变体区间避让(variant_plan_selector / metadata.used_segments)对接。
原子片段级天然不重叠,同素材多片段直接形成多段不重叠区间。
"""
segments: dict[str, list[tuple[float, float]]] = {}
for clip in clips:
segments.setdefault(clip.asset_id, []).append((clip.start_time, clip.end_time))
for asset_id in segments:
segments[asset_id].sort()
return segments
def estimate_required_clip_count(
voice_total_duration: float,
average_clip_duration: float = 4.5,
) -> int:
"""配音总时长 / 平均片段时长 ≈ 需要的片段数(至少 1)。"""
if voice_total_duration <= 0 or average_clip_duration <= 0:
return 1
return max(1, round(voice_total_duration / average_clip_duration))
def reselect_clips_from_atoms(
source_clips: list[dict[str, Any]],
candidates: list[AssetAtomClip],
*,
historical_atom_ids: set[str] | None = None,
batch_used_atom_ids: set[str] | None = None,
rng: random.Random | None = None,
) -> list[dict[str, Any]] | None:
"""#1970 变体重选的原子片段级实现.
与 variant_plan_selector.reselect_clips_for_variant 对应:保留源 plan 的
片段骨架(order/clip_type/文案/转场),从候选原子片段中为每个 main 片段
选取一个原子片段;同变体/批次内同一片段不可重复,历史成片用过的片段降权。
Returns:
新 clips_data(dict 列表,含 asset_id/atom_clip_id/start_time/duration),
候选不足(main 片段多于去重后片段数)时返回 None,由调用方回退整条素材路径。
非 main 片段(intro/outro 等)原样保留不分配素材。
"""
if not source_clips or not candidates:
return None
rng = rng or random.Random()
main_indexes = [i for i, c in enumerate(source_clips) if c.get("clip_type", "main") == "main"]
if len(main_indexes) > len({c.id for c in candidates}):
return None
used: set[str] = set(batch_used_atom_ids or ())
result: list[dict[str, Any]] = [dict(c) for c in source_clips]
asset_usage: dict[str, int] = {}
for idx in main_indexes:
skeleton = source_clips[idx]
target_duration = float(skeleton.get("duration") or 0.0)
ranked = select_atom_clips(
candidates,
target_duration=target_duration,
used_atom_clip_ids=used,
asset_usage_counts=asset_usage,
recently_used_atom_ids=historical_atom_ids or set(),
required_count=len(main_indexes),
limit=1,
rng=rng,
)
if not ranked:
return None
picked = ranked[0]
# 段长:片段短于槽位时取片段全长(渲染末帧冻结铺满),长于槽位时按槽位时长 trim
new_duration = picked.duration if target_duration <= 0 else min(target_duration, picked.duration)
result[idx].update(
{
"asset_id": picked.asset_id,
"atom_clip_id": picked.atom_clip_id,
"start_time": round(picked.start_time, 3),
"duration": round(new_duration, 3),
}
)
used.add(picked.atom_clip_id)
asset_usage[picked.asset_id] = asset_usage.get(picked.asset_id, 0) + 1
return result