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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
265 lines
9.7 KiB
Python
265 lines
9.7 KiB
Python
"""原子片段级选片核心 — #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
|