"""剪辑计划生成 — 纯逻辑工具函数. 从 PlanGeneratorService 提取的纯业务逻辑: - 素材分配策略(4 种 editing_mode) - 默认 clip 结构生成 - clip_type 按模式映射 - 从 TemplateClipConfig 创建 EditPlanClip 纯函数,无副作用,不依赖 DB/外部服务。 """ from __future__ import annotations 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, ) -> 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: 剪辑模式字符串 """ if not asset_ids or not clips: return if editing_mode == EditingMode.ONE_TAKE.value: _distribute_one_take(clips, asset_ids) elif editing_mode == EditingMode.PIP.value: _distribute_pip(clips, asset_ids) elif editing_mode == EditingMode.VOICE_OVER.value: _distribute_voice_over(clips, asset_ids) elif editing_mode == EditingMode.VOICE_PIP.value: _distribute_voice_pip(clips, asset_ids) else: # 未知模式,退化为 one_take _distribute_one_take(clips, asset_ids) def _distribute_one_take( clips: List[EditPlanClip], asset_ids: List[str], ) -> 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): clip.assign_asset(asset_ids[i]) def _distribute_pip( clips: List[EditPlanClip], asset_ids: List[str], ) -> 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: main_clips[0].assign_asset(asset_ids[0]) # 其余素材 → 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): clip.assign_asset(remaining[i]) def _distribute_voice_over( clips: List[EditPlanClip], asset_ids: List[str], ) -> 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): clip.assign_asset(asset_ids[i]) def _distribute_voice_pip( clips: List[EditPlanClip], asset_ids: List[str], ) -> 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: bg_clips[0].assign_asset(asset_ids[idx]) idx += 1 # 第2个 → corner_voice if idx < len(asset_ids) and voice_clips: voice_clips[0].assign_asset(asset_ids[idx]) idx += 1 # 剩余 → b_roll clips remaining = asset_ids[idx:] for i, clip in enumerate(broll_clips): if i < len(remaining): clip.assign_asset(remaining[i]) # ── 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