Files
xiaoxia-saas/apps/api/app/services/video_compose_service.py
T
灵应 5ca3bbab31
Deploy / Staging E2E Tests (push) Has been skipped
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 137h30m1s
CI/CD Pipeline / Frontend Lint (push) Failing after 137h30m2s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 137h30m16s
fix: 修复flake8代码质量检查错误
2026-07-03 19:24:17 +08:00

625 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""VideoComposeService — Phase 8 任务 2.09.
FFmpeg 视频合成编排服务:
1. 根据 EditPlan + EditPlanClips 生成 FFmpeg filter_complex 命令
2. 支持逐片段 scale / crop / trim / setpts 滤镜
3. 支持转场效果(fade / slide / dissolve / wipe
4. 支持音频流合并
5. 提供合成前校验逻辑
设计原则:
- 本服务只负责 **命令生成 + 校验**,不执行 FFmpeg
- Worker 层(Celery task)调用本服务生成命令后执行
- API 层可调用 build_compose_command 做预览 / 调试
"""
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import (
SQLAlchemyEditPlanClipRepository,
)
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
from packages.domain.edit_plan import EditPlan, EditPlanStatus
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
from packages.domain.template_clip_config import TransitionEffect
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
DEFAULT_CODEC = "libx264"
DEFAULT_CRF = 23
DEFAULT_PRESET = "medium"
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
_XFADE_TRANSITION_MAP: dict[str, str] = {
TransitionEffect.FADE: "fade",
TransitionEffect.SLIDE_LEFT: "slideleft",
TransitionEffect.SLIDE_RIGHT: "slideright",
TransitionEffect.DISSOLVE: "dissolve",
TransitionEffect.WIPE: "wipeleft",
}
# 转场默认时长(秒)
DEFAULT_TRANSITION_DURATION = 0.5
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ClipFilterChain:
"""单个片段的滤镜链描述。"""
clip_id: str
input_index: int
video_label: str
audio_label: str | None
filters: list[str]
duration: float
@dataclass(frozen=True)
class ComposeCommand:
"""完整的 FFmpeg 合成命令描述。"""
command: list[str]
"""可直接传给 subprocess.run 的命令列表。"""
filter_complex: str
"""-filter_complex 参数值(方便调试 / 日志)。"""
input_paths: list[str]
"""输入文件路径列表。"""
output_path: str
"""输出文件路径。"""
estimated_duration: float
"""预估输出时长(秒)。"""
clip_chains: list[ClipFilterChain]
"""每个片段的滤镜链描述。"""
@dataclass(frozen=True)
class ComposeValidation:
"""合成前校验结果。"""
valid: bool
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
ready_clip_count: int = 0
total_clip_count: int = 0
# ── 服务主体 ──────────────────────────────────────────────────────────────────
class VideoComposeService:
"""FFmpeg 视频合成编排服务。
职责:
- 根据 EditPlan 及其 Clips 生成 FFmpeg filter_complex 命令
- 校验合成前置条件
- 提供合成状态查询
用法::
svc = VideoComposeService(db)
validation = svc.validate_compose(plan_id)
if validation.valid:
cmd = svc.build_compose_command(plan_id, output_path="/tmp/out.mp4")
subprocess.run(cmd.command, check=True)
"""
def __init__(self, db: Session) -> None:
self._db = db
self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
# ── 公开方法 ──────────────────────────────────────────────────────────
def validate_compose(self, plan_id: str) -> ComposeValidation:
"""校验剪辑计划是否可以合成。
检查项:
1. 计划存在
2. 计划状态为 editing 或 rendering
3. 至少有一个 ready 状态的片段
4. 每个 ready 片段都有 asset_id
5. 每个 ready 片段都有 duration > 0
"""
errors: list[str] = []
warnings: list[str] = []
plan = self._plan_repo.get(plan_id)
if plan is None:
return ComposeValidation(
valid=False,
errors=[f"剪辑计划不存在: {plan_id}"],
)
# 状态检查
if plan.status not in (EditPlanStatus.EDITING, EditPlanStatus.RENDERING):
errors.append(f"计划状态不正确,需要 editing 或 rendering,当前: {plan.status.value}")
# 加载片段
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
errors.append("计划没有任何片段")
return ComposeValidation(
valid=False,
errors=errors,
total_clip_count=0,
)
# 按 order 排序
clips.sort(key=lambda c: c.order)
ready_count = 0
pending_count = 0
no_asset_count = 0
no_duration_count = 0
for clip in clips:
if clip.status == EditPlanClipStatus.READY:
ready_count += 1
if not clip.asset_id:
errors.append(f"片段 {clip.id} (order={clip.order}) 没有分配素材")
no_asset_count += 1
if clip.duration <= 0:
warnings.append(f"片段 {clip.id} (order={clip.order}) 时长为 0,将使用默认时长")
no_duration_count += 1
elif clip.status == EditPlanClipStatus.PENDING:
pending_count += 1
elif clip.status == EditPlanClipStatus.FAILED:
warnings.append(f"片段 {clip.id} (order={clip.order}) 状态为 failed,已跳过")
if ready_count == 0:
errors.append("没有就绪(ready)的片段可以合成")
if pending_count > 0:
warnings.append(f"有 {pending_count} 个片段仍处于 pending 状态")
return ComposeValidation(
valid=len(errors) == 0,
errors=errors,
warnings=warnings,
ready_clip_count=ready_count,
total_clip_count=len(clips),
)
def build_compose_command(
self,
plan_id: str,
output_path: str,
*,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
fps: int = DEFAULT_FPS,
codec: str = DEFAULT_CODEC,
crf: int = DEFAULT_CRF,
preset: str = DEFAULT_PRESET,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
) -> ComposeCommand:
"""构建 FFmpeg 合成命令。
根据 EditPlan 的所有 ready 片段,生成完整的 filter_complex 命令。
滤镜链逻辑:
- 每个片段:scale → crop → setpts → trim → atrim
- 多片段之间:concat 滤镜 或 xfade 转场
- 最终输出:-map '[outv]' -map '[outa]'(如有音频)
"""
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
if not clips:
raise ValueError(f"剪辑计划没有片段: {plan_id}")
# 只处理 ready 且有 asset_id 的片段
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY and c.asset_id]
ready_clips.sort(key=lambda c: c.order)
if not ready_clips:
raise ValueError(f"剪辑计划没有可合成的片段: {plan_id}")
# 构建每个片段的滤镜链
clip_chains: list[ClipFilterChain] = []
input_paths: list[str] = []
for idx, clip in enumerate(ready_clips):
chain = self._build_clip_filter(
clip=clip,
input_index=idx,
output_width=output_width,
output_height=output_height,
fps=fps,
)
clip_chains.append(chain)
input_paths.append(clip.asset_id) # asset_id 存储的是 storage_key / URL
# 构建 filter_complex
filter_complex, estimated_duration = self._build_filter_complex(
clip_chains=clip_chains,
output_width=output_width,
output_height=output_height,
transition_duration=transition_duration,
transitions=[c.transition_effect for c in ready_clips],
)
# 构建完整命令
command: list[str] = ["ffmpeg", "-y"]
# 输入文件
for path in input_paths:
command.extend(["-i", path])
# filter_complex
command.extend(["-filter_complex", filter_complex])
# 映射输出流
command.extend(["-map", "[outv]"])
if self._has_audio(clip_chains):
command.extend(["-map", "[outa]"])
# 编码参数
command.extend(
[
"-c:v",
codec,
"-crf",
str(crf),
"-preset",
preset,
"-c:a",
"aac",
"-b:a",
"192k",
]
)
# 输出
command.append(output_path)
return ComposeCommand(
command=command,
filter_complex=filter_complex,
input_paths=input_paths,
output_path=output_path,
estimated_duration=estimated_duration,
clip_chains=clip_chains,
)
def build_single_clip_command(
self,
clip_id: str,
output_path: str,
*,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
fps: int = DEFAULT_FPS,
) -> ComposeCommand:
"""为单个片段构建 FFmpeg 命令(预览 / 调试用)。"""
clip = self._clip_repo.get(clip_id)
if clip is None:
raise ValueError(f"片段不存在: {clip_id}")
if not clip.asset_id:
raise ValueError(f"片段没有分配素材: {clip_id}")
chain = self._build_clip_filter(
clip=clip,
input_index=0,
output_width=output_width,
output_height=output_height,
fps=fps,
)
# 简单命令:input → filter → output
filter_str = ",".join(chain.filters)
command = [
"ffmpeg",
"-y",
"-i",
clip.asset_id,
"-filter_complex",
f"{filter_str}[outv]",
"-map",
"[outv]",
"-c:v",
DEFAULT_CODEC,
"-crf",
str(DEFAULT_CRF),
"-preset",
DEFAULT_PRESET,
output_path,
]
return ComposeCommand(
command=command,
filter_complex=filter_str,
input_paths=[clip.asset_id],
output_path=output_path,
estimated_duration=clip.duration,
clip_chains=[chain],
)
def get_compose_status(self, plan_id: str) -> dict[str, Any]:
"""获取合成状态摘要。"""
plan = self._plan_repo.get(plan_id)
if plan is None:
raise ValueError(f"剪辑计划不存在: {plan_id}")
clips = self._clip_repo.list_by_plan(plan_id, skip=0, limit=10000)
clips.sort(key=lambda c: c.order)
total_duration = sum(c.duration for c in clips if c.duration > 0)
ready_clips = [c for c in clips if c.status == EditPlanClipStatus.READY]
pending_clips = [c for c in clips if c.status == EditPlanClipStatus.PENDING]
rendered_clips = [c for c in clips if c.status == EditPlanClipStatus.RENDERED]
failed_clips = [c for c in clips if c.status == EditPlanClipStatus.FAILED]
return {
"plan_id": plan_id,
"plan_status": plan.status.value,
"total_clips": len(clips),
"ready_clips": len(ready_clips),
"pending_clips": len(pending_clips),
"rendered_clips": len(rendered_clips),
"failed_clips": len(failed_clips),
"total_duration": total_duration,
"can_compose": len(ready_clips) > 0
and plan.status
in (
EditPlanStatus.EDITING,
EditPlanStatus.RENDERING,
),
"rendered_url": plan.config.get("rendered_url", ""),
}
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _build_clip_filter(
clip: EditPlanClip,
input_index: int,
output_width: int,
output_height: int,
fps: int,
) -> ClipFilterChain:
"""为单个片段构建滤镜链。
滤镜顺序:
1. scale — 等比缩放到目标分辨率(保证覆盖)
2. crop — 居中裁剪到目标分辨率
3. setpts — 重置时间戳 + 偏移
4. trim — 视频时长裁剪
5. atrim — 音频时长裁剪(如有音频流)
"""
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time
filters: list[str] = []
# 1. scale: 等比缩放,保证覆盖目标区域(scale to larger, then crop
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=increase")
# 2. crop: 居中裁剪
filters.append(f"crop={output_width}:{output_height}")
# 3. setpts: 重置时间戳
if start > 0:
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
else:
filters.append("setpts=PTS-STARTPTS")
# 4. trim: 视频时长
filters.append(f"trim=0:{duration}")
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
video_label = f"v{input_index}"
# 5. 音频标签:仅当片段类型可能有音频时才设置
# title/subtitle 是纯文字/图片卡片,没有音频流
clip_type = clip.clip_type.lower() if clip.clip_type else ""
has_audio_stream = clip_type not in ("title", "subtitle")
audio_label = f"a{input_index}" if has_audio_stream else None
return ClipFilterChain(
clip_id=clip.id,
input_index=input_index,
video_label=video_label,
audio_label=audio_label,
filters=filters,
duration=duration,
)
@staticmethod
def _build_filter_complex(
clip_chains: list[ClipFilterChain],
output_width: int,
output_height: int,
transition_duration: float,
transitions: list[str],
) -> tuple[str, float]:
"""构建完整的 filter_complex 字符串。
策略:
- 单片段:直接输出
- 多片段 + 全 cut:使用 concat 滤镜(高效)
- 多片段 + 有转场:使用 xfade 滤镜链
返回 (filter_complex_string, estimated_total_duration)。
"""
n = len(clip_chains)
if n == 0:
return "", 0.0
# ── 单片段 ─────────────────────────────────────────────────────
if n == 1:
chain = clip_chains[0]
filter_str = _chain_filters(chain.filters, chain.video_label)
# 音频
if chain.audio_label:
filter_str += f";[0:a]{chain.audio_label}"
total_duration = chain.duration
return filter_str, total_duration
# ── 检查是否有转场 ─────────────────────────────────────────────
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
if not has_transitions:
return _build_concat_filter(clip_chains)
# ── 有转场:使用 xfade ─────────────────────────────────────────
return _build_xfade_filter(
clip_chains=clip_chains,
transition_duration=transition_duration,
transitions=transitions,
)
@staticmethod
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
"""是否有任何片段包含音频流。"""
return any(c.audio_label is not None for c in clip_chains)
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
def _chain_filters(filters: list[str], output_label: str) -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
filter_body = ",".join(filters)
return f"[0:v]{filter_body}[{output_label}]"
def _build_concat_filter(
clip_chains: list[ClipFilterChain],
) -> tuple[str, float]:
"""构建 concat 滤镜(无转场,高效拼接)。
格式:
[0:v]filters[v0]; [1:v]filters[v1]; ...
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# concat 滤镜
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
parts.append(concat_filter)
# 音频 concat(如果有)
audio_parts: list[str] = []
for idx, chain in enumerate(clip_chains):
if chain.audio_label:
audio_parts.append(f"[{idx}:a]atrim=0:{chain.duration},asetpts=PTS-STARTPTS[{chain.audio_label}]")
if audio_parts:
parts.extend(audio_parts)
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
audio_count = sum(1 for c in clip_chains if c.audio_label)
if audio_count > 0:
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
return ";".join(parts), total_duration
def _build_xfade_filter(
clip_chains: list[ClipFilterChain],
transition_duration: float,
transitions: list[str],
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链。
每两个相邻片段之间插入 xfade 转场。
offset = 前一个片段的累积时长 - 转场时长。
格式(2 片段):
[0:v]filters[v0]; [1:v]filters[v1];
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
格式(3+ 片段):
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
"""
n = len(clip_chains)
parts: list[str] = []
total_duration = 0.0
# 每个片段的滤镜链
for idx, chain in enumerate(clip_chains):
filter_body = ",".join(chain.filters)
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
total_duration += chain.duration
# xfade 链
if n == 1:
# 单片段不需要 xfade
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
return ";".join(parts), total_duration
# 计算每个转场的 offset
cumulative = 0.0
prev_label = clip_chains[0].video_label
for i in range(1, n):
cumulative += clip_chains[i - 1].duration
offset = max(0.0, cumulative - transition_duration * i)
# 获取转场类型
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
if i == n - 1:
# 最后一个转场,输出到 [outv]
out_label = "outv"
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_chains[i].video_label}]"
f"xfade=transition={xfade_transition}"
f":duration={transition_duration}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
# 总时长需要减去转场重叠部分
total_duration -= transition_duration * (n - 1)
# 音频 crossfade(简化处理:使用 adelay + amix
audio_labels = [c.audio_label for c in clip_chains if c.audio_label]
if len(audio_labels) >= 2:
# 简单拼接音频(不做 crossfade)
audio_inputs = "".join(f"[{label}]" for label in audio_labels)
parts.append(f"{audio_inputs}concat=n={len(audio_labels)}:v=0:a=1[outa]")
elif len(audio_labels) == 1:
parts.append(f"[{audio_labels[0]}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration)