"""PlanGeneratorService — 基于模板+素材自动生成剪辑计划. 核心职责: - 根据 EditTemplate 的 editing_mode 和 TemplateClipConfig 列表, 自动生成 EditPlan + EditPlanClip 列表 - 四种模式素材分配策略: - ONE_TAKE: 素材顺序分配给 main 类型 clips - PIP: 第1个素材→main(全屏背景),其余→overlay clips - VOICE_OVER: 素材→main clips (B-roll),标记需要配音叠加 - VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll """ from __future__ import annotations import logging from typing import Any, List from sqlalchemy.orm import Session from packages.adapters.sqlalchemy_impl import ( SQLAlchemyEditPlanClipRepository, SQLAlchemyEditPlanRepository, ) from packages.domain.config_schemas import normalize_plan_config from packages.domain.edit_plan import EditPlan from packages.domain.edit_plan_clip import EditPlanClip from packages.domain.edit_template import EditTemplate from packages.domain.editing_mode import EditingMode from packages.domain.template_clip_config import ClipType, TemplateClipConfig logger = logging.getLogger(__name__) # ── 默认片段时长(秒) ──────────────────────────────────────────────────────── _DEFAULT_CLIP_DURATION = 5.0 _DEFAULT_INTRO_DURATION = 3.0 _DEFAULT_OUTRO_DURATION = 3.0 class PlanGeneratorService: """剪辑计划生成器 基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。 """ def __init__(self, db: Session) -> None: self._plan_repo = SQLAlchemyEditPlanRepository(db) self._clip_repo = SQLAlchemyEditPlanClipRepository(db) # ── 公开接口 ───────────────────────────────────────────────────────────── def generate_from_template( self, template: EditTemplate, clip_configs: List[TemplateClipConfig], asset_ids: List[str], *, project_id: str = "", created_by_user_id: str = "", name: str = "", ) -> dict[str, Any]: """基于模板+素材生成剪辑计划 Args: template: 剪辑模板实体 clip_configs: 模板片段配置列表(可为空,自动生成默认结构) asset_ids: 素材 ID 列表 project_id: 所属项目 ID created_by_user_id: 创建者用户 ID name: 计划名称(为空则自动取模板名) Returns: dict: {"plan": EditPlan, "clips": List[EditPlanClip]} """ editing_mode = template.editing_mode or EditingMode.ONE_TAKE.value plan_name = name.strip() or f"{template.name} - 剪辑计划" # 1. 构建 plan config(继承模板的 title/subtitle/bgm,记录 editing_mode) plan_config = self._build_plan_config(template, editing_mode) # 2. 创建 EditPlan plan = EditPlan.create( template_id=template.id, name=plan_name, config=plan_config, total_duration=0.0, project_id=project_id, created_by_user_id=created_by_user_id, ) plan = self._plan_repo.create(plan) logger.info( "生成剪辑计划: plan_id=%s template=%s mode=%s assets=%d", plan.id, template.id, editing_mode, len(asset_ids), ) # 3. 生成片段列表 if clip_configs: clips = self._create_clips_from_configs(plan.id, clip_configs) else: clips = self._generate_default_clips(plan.id, editing_mode, len(asset_ids)) # 4. 按 editing_mode 分配素材 if asset_ids: self._distribute_assets(clips, asset_ids, editing_mode) # 5. 持久化所有 clips 并计算总时长 created_clips: List[EditPlanClip] = [] total_duration = 0.0 for clip in clips: saved = self._clip_repo.create(clip) created_clips.append(saved) total_duration += saved.duration # 6. 更新 plan 的 total_duration plan.total_duration = total_duration plan = self._plan_repo.update(plan) # 7. 流转到 editing 状态 try: plan.start_editing() plan = self._plan_repo.update(plan) except ValueError as exc: logger.warning("计划状态流转失败: plan_id=%s error=%s", plan.id, exc) logger.info( "剪辑计划生成完成: plan_id=%s clips=%d duration=%.1f", plan.id, len(created_clips), total_duration, ) return {"plan": plan, "clips": created_clips} # ── 内部方法 ───────────────────────────────────────────────────────────── def _build_plan_config( self, template: EditTemplate, editing_mode: str, ) -> dict[str, Any]: """从模板配置构建 plan config""" template_config = template.config or {} plan_config: dict[str, Any] = { "editing_mode": editing_mode, } # 继承模板的 cover/title/subtitle/bgm 配置 for key in ("cover", "title", "subtitle", "bgm"): if key in template_config: plan_config[key] = template_config[key] return normalize_plan_config(plan_config) def _create_clips_from_configs( self, plan_id: str, clip_configs: List[TemplateClipConfig], ) -> List[EditPlanClip]: """从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)""" 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 = 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", ) clips.append(clip) return clips def _generate_default_clips( self, 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 """ 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 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 def _distribute_assets( self, 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 """ if not asset_ids or not clips: return if editing_mode == EditingMode.ONE_TAKE.value: self._distribute_one_take(clips, asset_ids) elif editing_mode == EditingMode.PIP.value: self._distribute_pip(clips, asset_ids) elif editing_mode == EditingMode.VOICE_OVER.value: self._distribute_voice_over(clips, asset_ids) elif editing_mode == EditingMode.VOICE_PIP.value: self._distribute_voice_pip(clips, asset_ids) else: # 未知模式,退化为 one_take self._distribute_one_take(clips, asset_ids) def _distribute_one_take( self, 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( self, 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( self, 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( self, 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"] corner_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"] # 第1个素材 → background if bg_clips and len(asset_ids) > 0: bg_clips[0].assign_asset(asset_ids[0]) # 第2个素材 → corner_voice if corner_clips and len(asset_ids) > 1: corner_clips[0].assign_asset(asset_ids[1]) # 其余素材 → b_roll remaining = asset_ids[2:] for i, clip in enumerate(broll_clips): if i < len(remaining): clip.assign_asset(remaining[i])