Files
xiaoxia-saas/packages/domain/atom_clip_service.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

216 lines
6.8 KiB
Python

"""素材原子切片服务 — #1970 智能剪辑流程重构 P1.
切片规则(见 docs/smart-edit-flow-redesign-20260916.md §1):
- 3~6 秒一个片段,具体时长在此范围内随机(避免固定节奏)
- 切点附近 0.5 秒内有 scdet 镜头切换点时,切点偏移到切换处
(复用素材 metadata 中已缓存的 scene_change_points,不重新计算)
- <6 秒素材整条作为一个片段,不切
- 最后一个片段不足 3 秒的合并到前一个;超过 3 秒独立成段
- 片段是逻辑索引,不物理切割视频文件
片段在内存中计算;持久化由上层调用 repository 完成,保证本模块可单测、无 IO 依赖。
"""
from __future__ import annotations
import random
from packages.domain.asset_atom_clip import AssetAtomClip
# 切片参数(集中常量,便于后续抽配置)
MIN_CLIP_SECONDS = 3.0
MAX_CLIP_SECONDS = 6.0
# 切点与 scdet 切换点的对齐窗口
SCENE_SNAP_WINDOW = 0.5
# 末段最小独立时长:不足则并入前一段
MIN_TAIL_SECONDS = 3.0
# 浮点比较容差
_EPS = 0.05
def _round3(value: float) -> float:
return round(float(value), 3)
def _snap_to_scene(
cut: float,
scene_points: list[float] | None,
lower: float,
upper: float,
) -> tuple[float, float | None]:
"""将切点 ``cut`` 对齐到窗口内最近的 scdet 切换点.
Args:
cut: 原始切点(秒)。
scene_points: 候选切换点(秒,已排序),可为空。
lower: 允许偏移的下界(不早于当前片段起点)。
upper: 允许偏移的上界(不晚于素材总时长)。
Returns:
(对齐后的切点, 命中的切换点);未命中返回 (cut, None)。
"""
if not scene_points:
return cut, None
best: float | None = None
best_dist = SCENE_SNAP_WINDOW
for point in scene_points:
# 切换点必须严格落在片段内部(不能与边界重合),且在窗口内
if point <= lower + _EPS or point >= upper - _EPS:
continue
dist = abs(point - cut)
if dist <= best_dist:
best_dist = dist
best = point
if best is None:
return cut, None
return _round3(best), _round3(best)
def compute_atom_clips(
asset_id: str,
duration: float,
*,
scene_change_points: list[float] | None = None,
tags: list[str] | None = None,
rng: random.Random | None = None,
) -> list[AssetAtomClip]:
"""根据素材时长计算原子片段(纯函数,不落库).
Args:
asset_id: 素材 ID。
duration: 素材总时长(秒)。
scene_change_points: metadata 中缓存的 scdet 切换点(秒)。
tags: 继承自素材的标签。
rng: 可选随机源(测试可注入固定种子)。
Returns:
有序的原子片段列表(clip_index 从 0 开始)。
"""
if duration <= 0:
return []
r = rng or random.Random()
points = _normalize_scene_points(scene_change_points, duration)
# <6 秒素材整条作为一个片段,不切
if duration < MAX_CLIP_SECONDS:
return [
AssetAtomClip.create(
asset_id=asset_id,
start_time=0.0,
end_time=_round3(duration),
clip_index=0,
tags=list(tags or []),
)
]
boundaries: list[float] = [0.0]
scene_hits: dict[int, float] = {}
cursor = 0.0
while duration - cursor > MAX_CLIP_SECONDS + _EPS:
# 在 [3, 6] 内随机决定本段目标时长
target_len = r.uniform(MIN_CLIP_SECONDS, MAX_CLIP_SECONDS)
raw_cut = cursor + target_len
if raw_cut >= duration - _EPS:
break
cut, hit = _snap_to_scene(raw_cut, points, lower=cursor, upper=duration)
# 对齐后若导致本段短于 3 秒(切换点太靠近段首),放弃对齐
if cut - cursor < MIN_CLIP_SECONDS - _EPS:
cut = _round3(raw_cut)
hit = None
boundaries.append(_round3(cut))
if hit is not None:
scene_hits[len(boundaries) - 1] = hit
cursor = cut
boundaries.append(_round3(duration))
# 末段处理:最后一个片段不足 3 秒则合并到前一个
if len(boundaries) >= 3:
tail_start = boundaries[-2]
tail_len = duration - tail_start
if tail_len < MIN_TAIL_SECONDS - _EPS:
boundaries.pop(-2)
clips: list[AssetAtomClip] = []
for index in range(len(boundaries) - 1):
start = boundaries[index]
end = boundaries[index + 1]
if end - start < _EPS:
continue
# 片段尾部对齐的切换点 = 该片段右边界(若它来自 snap)
scene_at = scene_hits.get(index + 1)
clips.append(
AssetAtomClip.create(
asset_id=asset_id,
start_time=start,
end_time=end,
clip_index=index,
tags=list(tags or []),
scene_change_at=scene_at,
)
)
return clips
def compute_fallback_clips(
asset_id: str,
duration: float,
*,
tags: list[str] | None = None,
clip_seconds: float = 4.5,
) -> list[AssetAtomClip]:
"""兜底切片:atom_clips 未就绪时,内存中按固定步长临时均匀切片(不存库).
与 :func:`compute_atom_clips` 的区别:不随机、不对齐切点,
产出的片段标记 ``is_fallback=True``。
"""
if duration <= 0:
return []
step = min(max(clip_seconds, MIN_CLIP_SECONDS), MAX_CLIP_SECONDS)
clips: list[AssetAtomClip] = []
cursor = 0.0
index = 0
while cursor < duration - _EPS:
end = min(cursor + step, duration)
clips.append(
AssetAtomClip.create(
asset_id=asset_id,
start_time=_round3(cursor),
end_time=_round3(end),
clip_index=index,
tags=list(tags or []),
is_fallback=True,
)
)
cursor = end
index += 1
# 末段不足 3 秒合并
if len(clips) >= 2 and clips[-1].duration < MIN_TAIL_SECONDS - _EPS:
last = clips.pop()
prev = clips[-1]
merged = AssetAtomClip.create(
asset_id=asset_id,
start_time=prev.start_time,
end_time=last.end_time,
clip_index=prev.clip_index,
tags=list(tags or []),
is_fallback=True,
)
clips[-1] = merged
return clips
def _normalize_scene_points(points: list[float] | None, duration: float) -> list[float]:
"""清洗切换点:去重、排序、限定在 (0, duration) 内。"""
if not points:
return []
cleaned = sorted({round(float(p), 3) for p in points if 0 < float(p) < duration})
return cleaned