"""素材原子切片服务 — #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