7f3c462617
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m1s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 56s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 1m30s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m57s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m51s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m40s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m32s
CI/CD Pipeline / Build Staging API Image (push) Successful in 13m22s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m3s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 1h12m17s
209 lines
7.6 KiB
Python
Executable File
209 lines
7.6 KiB
Python
Executable File
"""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)
|