"""剪辑计划生成 — 纯逻辑工具函数. 从 PlanGeneratorService 提取的纯业务逻辑: - 素材分配策略(4 种 editing_mode) - 默认 clip 结构生成 - clip_type 按模式映射 - 从 TemplateClipConfig 创建 EditPlanClip 纯函数,无副作用,不依赖 DB/外部服务。 """ from __future__ import annotations import random from typing import List 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 # ── 素材分配 ──────────────────────────────────────────────────────────────── def distribute_assets( clips: List[EditPlanClip], asset_ids: List[str], editing_mode: str, *, random_selection: bool = False, asset_durations: dict[str, 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 Args: clips: 剪辑片段列表(就地修改 asset_id) asset_ids: 素材 ID 列表 editing_mode: 剪辑模式字符串 random_selection: 是否随机选择素材(用于预览生成) asset_durations: 素材 ID -> 时长(秒)映射,用于设置随机 start_time """ 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) elif editing_mode == EditingMode.PIP.value: _distribute_pip(clips, asset_ids, asset_durations) elif editing_mode == EditingMode.VOICE_OVER.value: _distribute_voice_over(clips, asset_ids, asset_durations) elif editing_mode == EditingMode.VOICE_PIP.value: _distribute_voice_pip(clips, asset_ids, asset_durations) else: # 未知模式,退化为 one_take _distribute_one_take(clips, asset_ids, asset_durations) def _distribute_one_take( clips: List[EditPlanClip], asset_ids: List[str], asset_durations: dict[str, float] | None = None, ) -> None: """ONE_TAKE: 素材按顺序依次分配给 main 类型 clips.""" 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 = _calc_random_start_time(asset_id, clip.duration, asset_durations) clip.assign_asset(asset_id, start_time=start_time) def _distribute_pip( clips: List[EditPlanClip], asset_ids: List[str], asset_durations: dict[str, float] | None = None, ) -> None: """PIP: 第1个素材→main(全屏背景),其余→overlay clips.""" # 第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 = _calc_random_start_time(asset_id, main_clips[0].duration, asset_durations) main_clips[0].assign_asset(asset_id, start_time=start_time) # 其余素材 → 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 = _calc_random_start_time(asset_id, clip.duration, asset_durations) clip.assign_asset(asset_id, start_time=start_time) def _distribute_voice_over( clips: List[EditPlanClip], asset_ids: List[str], asset_durations: dict[str, float] | None = None, ) -> None: """VOICE_OVER: 素材→main clips (B-roll).""" 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 = _calc_random_start_time(asset_id, clip.duration, asset_durations) clip.assign_asset(asset_id, start_time=start_time) def _distribute_voice_pip( clips: List[EditPlanClip], asset_ids: List[str], asset_durations: dict[str, float] | None = None, ) -> None: """VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll.""" 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 = _calc_random_start_time(asset_id, bg_clips[0].duration, asset_durations) bg_clips[0].assign_asset(asset_id, start_time=start_time) idx += 1 # 第2个 → corner_voice if idx < len(asset_ids) and voice_clips: asset_id = asset_ids[idx] start_time = _calc_random_start_time(asset_id, voice_clips[0].duration, asset_durations) voice_clips[0].assign_asset(asset_id, start_time=start_time) 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 = _calc_random_start_time(asset_id, clip.duration, asset_durations) clip.assign_asset(asset_id, start_time=start_time) # ── 随机 start_time 计算 ──────────────────────────────────────────────────── def _calc_random_start_time( asset_id: str, clip_duration: float, asset_durations: dict[str, float] | None, ) -> float | None: """计算随机 start_time. 在素材总时长范围内随机取点,确保 clip_duration 不超出素材边界。 如果 asset_durations 为 None 或素材不在其中,返回 None(使用默认 0.0)。 Args: asset_id: 素材 ID clip_duration: 片段时长(秒) asset_durations: 素材 ID -> 时长映射 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 return random.uniform(0.0, max_start) # ── 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