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>
216 lines
6.8 KiB
Python
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
|