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
344 lines
12 KiB
Python
Executable File
344 lines
12 KiB
Python
Executable File
"""剪辑计划生成 — 纯逻辑工具函数.
|
||
|
||
从 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
|