ed972a230c
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m10s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m34s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 3m40s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m51s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m1s
392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""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, Optional
|
||
|
||
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 i 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 i 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 i 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 i 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])
|