"""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.plan_generator_utils import ( DEFAULT_CLIP_DURATION, create_clips_from_configs, distribute_assets, generate_default_clips, map_clip_types_for_mode, ) 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) # 模板 clip_config 的 clip_type 是 ClipType 枚举(main/intro/outro 等), # 但 PIP / VOICE_PIP 模式需要特定的 clip_type(overlay/background/corner_voice/b_roll) # 才能让素材分配和渲染分层正确工作。 # 这里将 MAIN 类型的片段按顺序映射为对应模式的角色类型。 self._map_clip_types_for_mode(clips, editing_mode) 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/export/filter 配置 for key in ("cover", "title", "subtitle", "bgm", "export", "filter"): 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 列表(未持久化). 委托给 plan_generator_utils.create_clips_from_configs 纯函数。 """ return create_clips_from_configs(plan_id, clip_configs) def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None: """将 MAIN 类型片段按 editing_mode 映射为对应角色类型. 委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。 """ map_clip_types_for_mode(clips, editing_mode) def _generate_default_clips( self, plan_id: str, editing_mode: str, asset_count: int, ) -> List[EditPlanClip]: """无 clip_configs 时,根据 editing_mode 生成默认 clip 结构. 委托给 plan_generator_utils.generate_default_clips 纯函数。 """ return generate_default_clips(plan_id, editing_mode, asset_count) def _distribute_assets( self, clips: List[EditPlanClip], asset_ids: List[str], editing_mode: str, ) -> None: """按 editing_mode 将素材分配到 clips(就地修改,未持久化). 委托给 plan_generator_utils.distribute_assets 纯函数。 """ distribute_assets(clips, asset_ids, editing_mode)