9d31818222
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 21s
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m42s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 1m48s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m57s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 3m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m2s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m48s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 16s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 2m16s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 30m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
721 lines
28 KiB
Python
Executable File
721 lines
28 KiB
Python
Executable File
"""剪辑计划生成 — 纯逻辑工具函数.
|
||
|
||
从 PlanGeneratorService 提取的纯业务逻辑:
|
||
- 素材分配策略(4 种 editing_mode)
|
||
- 默认 clip 结构生成
|
||
- clip_type 按模式映射
|
||
- 从 TemplateClipConfig 创建 EditPlanClip
|
||
|
||
纯函数,无副作用,不依赖 DB/外部服务。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import random
|
||
from typing import Callable, List
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from packages.domain.edit_plan_clip import EditPlanClip
|
||
from packages.domain.editing_mode import EditingMode
|
||
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||
|
||
# ── 默认片段时长(秒) ────────────────────────────────────────────────────────
|
||
DEFAULT_CLIP_DURATION = 5.0
|
||
DEFAULT_INTRO_DURATION = 3.0
|
||
DEFAULT_OUTRO_DURATION = 3.0
|
||
|
||
|
||
# ── SceneChange 镜头段工具 ────────────────────────────────────────────────────
|
||
|
||
|
||
def build_scene_segments(
|
||
scene_changes: list[float],
|
||
asset_duration: float,
|
||
) -> list[tuple[float, float]]:
|
||
"""根据场景切换点构建镜头段列表.
|
||
|
||
Args:
|
||
scene_changes: 场景切换点时间戳列表(已排序,首位为 0.0)
|
||
asset_duration: 素材总时长
|
||
|
||
Returns:
|
||
镜头段列表 [(start, end), ...],仅保留长度 >= 0.5s 的段
|
||
"""
|
||
segments: list[tuple[float, float]] = []
|
||
for i, ts in enumerate(scene_changes):
|
||
end = scene_changes[i + 1] if i + 1 < len(scene_changes) else asset_duration
|
||
# 只保留有效长度的镜头段(至少 0.5 秒)
|
||
if end - ts >= 0.5:
|
||
segments.append((ts, end))
|
||
return segments
|
||
|
||
|
||
def pick_start_in_scene_segment(
|
||
seg_start: float,
|
||
seg_end: float,
|
||
clip_duration: float,
|
||
) -> float | None:
|
||
"""在镜头段内随机选取一个起始时间点.
|
||
|
||
确保 start + clip_duration <= seg_end。
|
||
若镜头段长度不足以容纳片段,返回 None。
|
||
"""
|
||
available = seg_end - seg_start - clip_duration
|
||
if available < 0:
|
||
return None
|
||
max_start = seg_start + available
|
||
return random.uniform(seg_start, max_start)
|
||
|
||
|
||
def _segments_overlap(
|
||
start: float,
|
||
duration: float,
|
||
used: list[tuple[float, float]],
|
||
edge_gap: float = 0.0,
|
||
) -> bool:
|
||
"""候选区间 [start, start+duration] 是否与已用区间冲突(含边缘间隙扩边)。"""
|
||
end = start + duration
|
||
for used_start, used_end in used:
|
||
if start < used_end + edge_gap and end > used_start - edge_gap:
|
||
return True
|
||
return False
|
||
|
||
|
||
def pick_scene_aware_start(
|
||
asset_id: str,
|
||
clip_duration: float,
|
||
asset_durations: dict[str, float],
|
||
asset_scene_points: dict[str, list[float]] | None,
|
||
used_segments: dict[str, list[tuple[float, float]]],
|
||
*,
|
||
edge_gap: float = 0.0,
|
||
) -> float | None:
|
||
"""基于缓存的场景切换点,从随机镜头段中选取不冲突的起始时间.
|
||
|
||
流程:
|
||
1. 读取 asset_scene_points 中该素材的场景切换点缓存 → 构建镜头段
|
||
2. random.shuffle 镜头段(保证同一素材多次生成选不同镜头,而非固定第N段)
|
||
3. 依次尝试:段内随机取点 → 越界检查 → 与 used_segments 冲突检查
|
||
4. 全部冲突/无缓存 → 返回 None,由调用方回退 _calc_random_start_time
|
||
|
||
Args:
|
||
asset_id: 素材 ID
|
||
clip_duration: 片段时长(秒)
|
||
asset_durations: 素材 ID -> 总时长
|
||
asset_scene_points: 素材 ID -> 场景切换点列表(metadata 缓存)
|
||
used_segments: 素材 ID -> 已用区间列表(冲突避让)
|
||
edge_gap: 冲突判定的边缘间隙(秒),已用区间按 [s-gap, e+gap] 扩边
|
||
"""
|
||
asset_total = (asset_durations or {}).get(asset_id)
|
||
if not asset_total or asset_total <= 0:
|
||
return None
|
||
scene_points = (asset_scene_points or {}).get(asset_id)
|
||
if not scene_points:
|
||
return None
|
||
used = used_segments.get(asset_id, []) if used_segments else []
|
||
|
||
scene_segments = build_scene_segments(scene_points, asset_total)
|
||
if not scene_segments:
|
||
return None
|
||
random.shuffle(scene_segments)
|
||
|
||
for seg_start, seg_end in scene_segments:
|
||
candidate = pick_start_in_scene_segment(seg_start, seg_end, clip_duration)
|
||
if candidate is None:
|
||
continue
|
||
# 越界检查(防御:场景点末尾段理论上不越界,metadata 脏数据兜底)
|
||
if candidate + clip_duration > asset_total:
|
||
continue
|
||
# 与已用区间冲突检查
|
||
if _segments_overlap(candidate, clip_duration, used, edge_gap):
|
||
continue
|
||
return candidate
|
||
|
||
return None
|
||
|
||
|
||
def extract_scene_points_from_metadata(metadata: object) -> list[float] | None:
|
||
"""从素材 metadata 中提取并校验场景切换点缓存.
|
||
|
||
合法缓存:list 类型、至少 2 个数值点、单调非负;否则返回 None(按未缓存处理)。
|
||
"""
|
||
if not isinstance(metadata, dict):
|
||
return None
|
||
points = metadata.get("scene_change_points")
|
||
if not isinstance(points, list) or len(points) < 2:
|
||
return None
|
||
try:
|
||
cleaned = [float(p) for p in points]
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if any(p < 0 for p in cleaned):
|
||
return None
|
||
cleaned = sorted(cleaned)
|
||
if cleaned[0] != 0.0:
|
||
cleaned.insert(0, 0.0)
|
||
return cleaned
|
||
|
||
|
||
# ── 素材分配 ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def distribute_assets(
|
||
clips: List[EditPlanClip],
|
||
asset_ids: List[str],
|
||
editing_mode: str,
|
||
*,
|
||
random_selection: bool = False,
|
||
asset_durations: dict[str, float] | None = None,
|
||
asset_scene_points: dict[str, list[float]] | None = None,
|
||
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||
) -> None:
|
||
"""按 editing_mode 将素材分配到 clips(就地修改).
|
||
|
||
分配策略:
|
||
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
|
||
- PIP: 第1个素材→main(全屏背景),其余→overlay clips
|
||
- VOICE_OVER: 素材→main clips (B-roll)
|
||
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||
|
||
start_time 选取:素材 metadata 中有场景切换点缓存时,优先从随机镜头段
|
||
取起点(不同片段来自不同镜头);无缓存或镜头段都冲突时回退随机起点。
|
||
|
||
Args:
|
||
clips: 剪辑片段列表(就地修改 asset_id)
|
||
asset_ids: 素材 ID 列表
|
||
editing_mode: 剪辑模式字符串
|
||
random_selection: 是否随机选择素材(用于预览生成)
|
||
asset_durations: 素材 ID -> 时长(秒)映射,用于设置 start_time
|
||
asset_scene_points: 素材 ID -> 场景切换点列表(metadata 缓存)
|
||
external_used_segments: 跨视频已用区间(来自其他视频的 clips),注入到分配逻辑中避让
|
||
"""
|
||
if not asset_ids or not clips:
|
||
return
|
||
|
||
# 如果需要随机选择,先打乱素材顺序
|
||
if random_selection:
|
||
asset_ids = list(asset_ids) # 复制避免修改原列表
|
||
random.shuffle(asset_ids)
|
||
|
||
if editing_mode == EditingMode.ONE_TAKE.value:
|
||
_distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
|
||
elif editing_mode == EditingMode.PIP.value:
|
||
_distribute_pip(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
|
||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||
_distribute_voice_over(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
|
||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||
_distribute_voice_pip(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
|
||
else:
|
||
# 未知模式,退化为 one_take
|
||
_distribute_one_take(clips, asset_ids, asset_durations, asset_scene_points, external_used_segments)
|
||
|
||
|
||
def _resolve_start_time(
|
||
asset_id: str,
|
||
clip_duration: float,
|
||
asset_durations: dict[str, float] | None,
|
||
used_segments: dict[str, list[tuple[float, float]]],
|
||
asset_scene_points: dict[str, list[float]] | None = None,
|
||
on_exhausted: Callable[[str, float], tuple[float, float] | None] | None = None,
|
||
) -> float | None:
|
||
"""选取片段起点:场景缓存优先(随机镜头段),无缓存/全冲突回退随机起点.
|
||
|
||
场景路径与随机路径共享 used_segments 冲突避让;场景路径返回 None 时
|
||
(无缓存、镜头段全冲突)回退 _calc_random_start_time,其受控复用逻辑
|
||
(on_exhausted)不受影响。
|
||
"""
|
||
if asset_scene_points and asset_scene_points.get(asset_id):
|
||
scene_start = pick_scene_aware_start(
|
||
asset_id,
|
||
clip_duration,
|
||
asset_durations or {},
|
||
asset_scene_points,
|
||
used_segments,
|
||
)
|
||
if scene_start is not None:
|
||
return scene_start
|
||
return _calc_random_start_time(
|
||
asset_id,
|
||
clip_duration,
|
||
asset_durations,
|
||
used_segments,
|
||
on_exhausted=on_exhausted,
|
||
)
|
||
|
||
|
||
def _distribute_one_take(
|
||
clips: List[EditPlanClip],
|
||
asset_ids: List[str],
|
||
asset_durations: dict[str, float] | None = None,
|
||
asset_scene_points: dict[str, list[float]] | None = None,
|
||
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||
) -> None:
|
||
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips."""
|
||
used_segments: dict[str, list[tuple[float, float]]] = (
|
||
{k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {}
|
||
)
|
||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||
for i, clip in enumerate(main_clips):
|
||
if i < len(asset_ids):
|
||
asset_id = asset_ids[i]
|
||
start_time = _resolve_start_time(
|
||
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
|
||
)
|
||
clip.assign_asset(asset_id, start_time=start_time)
|
||
# Record used segment
|
||
if start_time is not None and asset_durations is not None:
|
||
if asset_id not in used_segments:
|
||
used_segments[asset_id] = []
|
||
used_segments[asset_id].append((start_time, start_time + clip.duration))
|
||
|
||
|
||
def _distribute_pip(
|
||
clips: List[EditPlanClip],
|
||
asset_ids: List[str],
|
||
asset_durations: dict[str, float] | None = None,
|
||
asset_scene_points: dict[str, list[float]] | None = None,
|
||
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||
) -> None:
|
||
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips."""
|
||
used_segments: dict[str, list[tuple[float, float]]] = (
|
||
{k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {}
|
||
)
|
||
# 第1个素材 → main clip
|
||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||
if main_clips and asset_ids:
|
||
asset_id = asset_ids[0]
|
||
start_time = _resolve_start_time(
|
||
asset_id, main_clips[0].duration, asset_durations, used_segments, asset_scene_points
|
||
)
|
||
main_clips[0].assign_asset(asset_id, start_time=start_time)
|
||
# Record used segment
|
||
if start_time is not None and asset_durations is not None:
|
||
if asset_id not in used_segments:
|
||
used_segments[asset_id] = []
|
||
used_segments[asset_id].append((start_time, start_time + main_clips[0].duration))
|
||
|
||
# 其余素材 → overlay clips
|
||
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
|
||
remaining = asset_ids[1:]
|
||
for i, clip in enumerate(overlay_clips):
|
||
if i < len(remaining):
|
||
asset_id = remaining[i]
|
||
start_time = _resolve_start_time(
|
||
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
|
||
)
|
||
clip.assign_asset(asset_id, start_time=start_time)
|
||
# Record used segment
|
||
if start_time is not None and asset_durations is not None:
|
||
if asset_id not in used_segments:
|
||
used_segments[asset_id] = []
|
||
used_segments[asset_id].append((start_time, start_time + clip.duration))
|
||
|
||
|
||
def _distribute_voice_over(
|
||
clips: List[EditPlanClip],
|
||
asset_ids: List[str],
|
||
asset_durations: dict[str, float] | None = None,
|
||
asset_scene_points: dict[str, list[float]] | None = None,
|
||
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||
) -> None:
|
||
"""VOICE_OVER: 素材→main clips (B-roll)."""
|
||
used_segments: dict[str, list[tuple[float, float]]] = (
|
||
{k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {}
|
||
)
|
||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||
for i, clip in enumerate(main_clips):
|
||
if i < len(asset_ids):
|
||
asset_id = asset_ids[i]
|
||
start_time = _resolve_start_time(
|
||
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
|
||
)
|
||
clip.assign_asset(asset_id, start_time=start_time)
|
||
# Record used segment
|
||
if start_time is not None and asset_durations is not None:
|
||
if asset_id not in used_segments:
|
||
used_segments[asset_id] = []
|
||
used_segments[asset_id].append((start_time, start_time + clip.duration))
|
||
|
||
|
||
def _distribute_voice_pip(
|
||
clips: List[EditPlanClip],
|
||
asset_ids: List[str],
|
||
asset_durations: dict[str, float] | None = None,
|
||
asset_scene_points: dict[str, list[float]] | None = None,
|
||
external_used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||
) -> None:
|
||
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll."""
|
||
used_segments: dict[str, list[tuple[float, float]]] = (
|
||
{k: list(v) for k, v in external_used_segments.items()} if external_used_segments else {}
|
||
)
|
||
bg_clips = [c for c in clips if c.clip_type == "background"]
|
||
voice_clips = [c for c in clips if c.clip_type == "corner_voice"]
|
||
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
|
||
|
||
idx = 0
|
||
|
||
# 第1个 → background
|
||
if idx < len(asset_ids) and bg_clips:
|
||
asset_id = asset_ids[idx]
|
||
start_time = _resolve_start_time(
|
||
asset_id, bg_clips[0].duration, asset_durations, used_segments, asset_scene_points
|
||
)
|
||
bg_clips[0].assign_asset(asset_id, start_time=start_time)
|
||
# Record used segment
|
||
if start_time is not None and asset_durations is not None:
|
||
if asset_id not in used_segments:
|
||
used_segments[asset_id] = []
|
||
used_segments[asset_id].append((start_time, start_time + bg_clips[0].duration))
|
||
idx += 1
|
||
|
||
# 第2个 → corner_voice
|
||
if idx < len(asset_ids) and voice_clips:
|
||
asset_id = asset_ids[idx]
|
||
start_time = _resolve_start_time(
|
||
asset_id, voice_clips[0].duration, asset_durations, used_segments, asset_scene_points
|
||
)
|
||
voice_clips[0].assign_asset(asset_id, start_time=start_time)
|
||
# Record used segment
|
||
if start_time is not None and asset_durations is not None:
|
||
if asset_id not in used_segments:
|
||
used_segments[asset_id] = []
|
||
used_segments[asset_id].append((start_time, start_time + voice_clips[0].duration))
|
||
idx += 1
|
||
|
||
# 剩余 → b_roll clips
|
||
remaining = asset_ids[idx:]
|
||
for i, clip in enumerate(broll_clips):
|
||
if i < len(remaining):
|
||
asset_id = remaining[i]
|
||
start_time = _resolve_start_time(
|
||
asset_id, clip.duration, asset_durations, used_segments, asset_scene_points
|
||
)
|
||
clip.assign_asset(asset_id, start_time=start_time)
|
||
# Record used segment
|
||
if start_time is not None and asset_durations is not None:
|
||
if asset_id not in used_segments:
|
||
used_segments[asset_id] = []
|
||
used_segments[asset_id].append((start_time, start_time + clip.duration))
|
||
|
||
|
||
# ── 随机 start_time 计算 ────────────────────────────────────────────────────
|
||
|
||
|
||
def _calc_random_start_time(
|
||
asset_id: str,
|
||
clip_duration: float,
|
||
asset_durations: dict[str, float] | None,
|
||
used_segments: dict[str, list[tuple[float, float]]] | None = None,
|
||
on_exhausted: Callable[[str, float], tuple[float, float] | None] | None = None,
|
||
) -> float | None:
|
||
"""计算随机 start_time,避开已使用的时间段.
|
||
|
||
在素材总时长范围内随机取点,确保 clip_duration 不超出素材边界。
|
||
如果 asset_durations 为 None 或素材不在其中,返回 None(使用默认 0.0)。
|
||
如果提供了 used_segments,会避开已使用的时间区间。
|
||
|
||
Args:
|
||
asset_id: 素材 ID
|
||
clip_duration: 片段时长(秒)
|
||
asset_durations: 素材 ID -> 时长映射
|
||
used_segments: {asset_id: [(start1, end1), (start2, end2), ...]} 已使用的时间段
|
||
on_exhausted: 100 次随机都找不到空闲区间时的受控复用回调,入参为
|
||
(asset_id, clip_duration),返回 (start, end) 复用区间或 None。
|
||
历史记录永不自动清空;回调返回 None(全部达上限/复用占比超闸门)时
|
||
本函数返回 None,由调用方轮询下一个素材或报错,不做重叠降级。
|
||
|
||
Returns:
|
||
随机 start_time 或 None
|
||
"""
|
||
if asset_durations is None:
|
||
return None
|
||
|
||
total_duration = asset_durations.get(asset_id)
|
||
if total_duration is None or total_duration <= 0:
|
||
return None
|
||
|
||
# 最大起始点 = 素材总时长 - 片段时长
|
||
max_start = max(0.0, total_duration - clip_duration)
|
||
if max_start <= 0:
|
||
return 0.0
|
||
|
||
# 如果没有已使用段,直接随机
|
||
if not used_segments or asset_id not in used_segments:
|
||
return random.uniform(0.0, max_start)
|
||
|
||
# 尝试找到一个不与已使用段重叠的起始点
|
||
used = sorted(used_segments[asset_id])
|
||
max_attempts = 100
|
||
|
||
for _ in range(max_attempts):
|
||
candidate = random.uniform(0.0, max_start)
|
||
candidate_end = candidate + clip_duration
|
||
|
||
# 检查是否与任何已使用段重叠
|
||
overlap = False
|
||
for seg_start, seg_end in used:
|
||
# 两个区间 [a, b] 和 [c, d] 重叠的条件: a < d and c < b
|
||
if candidate < seg_end and seg_start < candidate_end:
|
||
overlap = True
|
||
break
|
||
|
||
if not overlap:
|
||
return candidate
|
||
|
||
# 100 次都找不到空闲区间:进入受控复用,回调从历史区间中选最久未用且
|
||
# 使用次数未达上限的区间返回(历史记录永不自动清空)
|
||
if on_exhausted is not None:
|
||
try:
|
||
reused = on_exhausted(asset_id, clip_duration)
|
||
except Exception:
|
||
logger.warning(
|
||
"on_exhausted 受控复用回调异常: asset_id=%s",
|
||
asset_id,
|
||
exc_info=True,
|
||
)
|
||
reused = None
|
||
if reused is not None:
|
||
reuse_start, reuse_end = reused
|
||
# 边界保护:不越素材末尾、不为负
|
||
reuse_start = max(0.0, min(float(reuse_start), max_start))
|
||
logger.info(
|
||
"素材可用区间耗尽,受控复用历史区间: asset_id=%s start=%.2f end=%.2f",
|
||
asset_id,
|
||
reuse_start,
|
||
reuse_end,
|
||
)
|
||
return reuse_start
|
||
# 回调存在但拒绝复用(区间全部达 use_count 上限,或复用占比将超 15% 闸门):
|
||
# 返回 None,由调用方轮询下一个素材;绝不能末尾/0.0 降级——那会把片段
|
||
# 放回到已用过的画面,违反区间避让与重复率控制原则
|
||
return None
|
||
|
||
# 未提供 on_exhausted 回调(向后兼容):降级使用素材末尾空闲位置;
|
||
# 末尾也已占满时返回 0.0(旧行为,仅无持久化追踪的调用方会走到这里)
|
||
last_used_end = 0.0
|
||
for _seg_start, seg_end in used:
|
||
last_used_end = max(last_used_end, seg_end)
|
||
|
||
if last_used_end < total_duration:
|
||
# 返回从最后使用点开始的位置
|
||
return min(last_used_end, max_start)
|
||
|
||
return 0.0
|
||
|
||
|
||
# ── clip_type 映射 ────────────────────────────────────────────────────────
|
||
|
||
|
||
def map_clip_types_for_mode(
|
||
clips: List[EditPlanClip],
|
||
editing_mode: str,
|
||
) -> None:
|
||
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
|
||
|
||
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
|
||
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
|
||
(overlay / background / corner_voice / b_roll)。
|
||
|
||
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
|
||
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
|
||
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
|
||
- ONE_TAKE / VOICE_OVER: 保持 main 不变
|
||
|
||
Args:
|
||
clips: 剪辑片段列表(就地修改 clip_type)
|
||
editing_mode: 剪辑模式字符串
|
||
"""
|
||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||
if not main_clips:
|
||
return
|
||
|
||
if editing_mode == EditingMode.PIP.value:
|
||
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
|
||
for i, clip in enumerate(main_clips):
|
||
if i > 0:
|
||
clip.clip_type = "overlay"
|
||
|
||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||
for i, clip in enumerate(main_clips):
|
||
if i == 0:
|
||
clip.clip_type = "background"
|
||
elif i == 1:
|
||
clip.clip_type = "corner_voice"
|
||
else:
|
||
clip.clip_type = "b_roll"
|
||
|
||
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
|
||
|
||
|
||
# ── 默认 clip 生成 ────────────────────────────────────────────────────────
|
||
|
||
|
||
def generate_default_clips(
|
||
plan_id: str,
|
||
editing_mode: str,
|
||
asset_count: int,
|
||
) -> List[EditPlanClip]:
|
||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
|
||
|
||
- ONE_TAKE: N 个 main clips(N = asset_count,至少1个)
|
||
- PIP: 1 个 main + (N-1) 个 overlay(N = asset_count)
|
||
- VOICE_OVER: N 个 main clips + 标记需要配音
|
||
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
|
||
|
||
Args:
|
||
plan_id: 剪辑计划 ID
|
||
editing_mode: 剪辑模式字符串
|
||
asset_count: 素材数量
|
||
|
||
Returns:
|
||
List[EditPlanClip]: 生成的默认剪辑片段列表
|
||
"""
|
||
n = max(asset_count, 1)
|
||
clips: List[EditPlanClip] = []
|
||
order = 0
|
||
|
||
if editing_mode == EditingMode.PIP.value:
|
||
# 1 个 main(全屏背景)
|
||
clips.append(
|
||
EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type=ClipType.MAIN.value,
|
||
order=order,
|
||
duration=DEFAULT_CLIP_DURATION,
|
||
)
|
||
)
|
||
order += 1
|
||
# 剩余为 overlay
|
||
for _ in range(1, n):
|
||
clips.append(
|
||
EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type="overlay",
|
||
order=order,
|
||
duration=DEFAULT_CLIP_DURATION,
|
||
)
|
||
)
|
||
order += 1
|
||
|
||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||
# N 个 main clips(B-roll)
|
||
for _ in range(n):
|
||
clips.append(
|
||
EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type=ClipType.MAIN.value,
|
||
order=order,
|
||
duration=DEFAULT_CLIP_DURATION,
|
||
config={"role": "b_roll"},
|
||
)
|
||
)
|
||
order += 1
|
||
|
||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||
# 1 个 background
|
||
clips.append(
|
||
EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type="background",
|
||
order=order,
|
||
duration=DEFAULT_CLIP_DURATION,
|
||
)
|
||
)
|
||
order += 1
|
||
# 1 个 corner_voice(至少有1个素材就有)
|
||
if n >= 2:
|
||
clips.append(
|
||
EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type="corner_voice",
|
||
order=order,
|
||
duration=DEFAULT_CLIP_DURATION,
|
||
)
|
||
)
|
||
order += 1
|
||
# 剩余为 b_roll
|
||
for _ in range(2, n):
|
||
clips.append(
|
||
EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type="b_roll",
|
||
order=order,
|
||
duration=DEFAULT_CLIP_DURATION,
|
||
)
|
||
)
|
||
order += 1
|
||
|
||
else:
|
||
# ONE_TAKE / 未知模式:N 个 main clips
|
||
for _ in range(n):
|
||
clips.append(
|
||
EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type=ClipType.MAIN.value,
|
||
order=order,
|
||
duration=DEFAULT_CLIP_DURATION,
|
||
)
|
||
)
|
||
order += 1
|
||
|
||
return clips
|
||
|
||
|
||
# ── 从配置创建 clips ────────────────────────────────────────────────────────
|
||
|
||
|
||
def create_clips_from_configs(
|
||
plan_id: str,
|
||
clip_configs: List[TemplateClipConfig],
|
||
) -> List[EditPlanClip]:
|
||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表.
|
||
|
||
Args:
|
||
plan_id: 剪辑计划 ID
|
||
clip_configs: 模板片段配置列表
|
||
|
||
Returns:
|
||
List[EditPlanClip]: 创建的剪辑片段列表(按 order 排序)
|
||
"""
|
||
clips: List[EditPlanClip] = []
|
||
# 按 order 排序
|
||
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
|
||
|
||
for cfg in sorted_configs:
|
||
# 计算时长:取 min_duration 和 max_duration 的中间值
|
||
if cfg.min_duration > 0 and cfg.max_duration > 0:
|
||
duration = (cfg.min_duration + cfg.max_duration) / 2
|
||
elif cfg.min_duration > 0:
|
||
duration = cfg.min_duration
|
||
elif cfg.max_duration > 0:
|
||
duration = cfg.max_duration
|
||
else:
|
||
duration = DEFAULT_CLIP_DURATION
|
||
|
||
# clip_type 可能是枚举或字符串
|
||
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
|
||
|
||
# transition_effect 可能是枚举或字符串
|
||
transition = cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
|
||
|
||
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
|
||
clip_cfg = cfg.config or {}
|
||
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
|
||
|
||
clip = EditPlanClip.create(
|
||
plan_id=plan_id,
|
||
clip_type=clip_type,
|
||
order=cfg.order,
|
||
template_clip_config_id=cfg.id,
|
||
text_content=getattr(cfg, "text_template", "") or "",
|
||
duration=duration,
|
||
transition_effect=transition or "cut",
|
||
playback_speed=playback_speed,
|
||
config=clip_cfg,
|
||
)
|
||
clips.append(clip)
|
||
|
||
return clips
|