Files
xiaoxia-saas/apps/api/app/services/video_compose_service.py
T
xiaoxia 7378ef378e
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m17s
CI/CD Pipeline / Unit Tests (push) Successful in 3m33s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 4m22s
CI/CD Pipeline / Integration Tests (push) Successful in 1m33s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 44s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 5m14s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 20s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m34s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m17s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 2m55s
fix(P0): legacy渲染引擎5项normalize补全 + ffmpeg错误日志增强 (#511)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 15:07:58 +08:00

649 lines
23 KiB
Python
Executable File
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
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 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 → fps → 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. fps — 统一输出帧率(concat 要求所有输入帧率一致)
4. setpts — 重置时间戳 + 偏移
5. trim — 视频时长裁剪
6. atrim — 音频时长裁剪(如有音频流)
"""
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
start = clip.start_time
filters: list[str] = []
# 1. scale: 等比缩放(保持比例,不裁剪)
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
# 3. format: 统一像素格式为 yuv420pH.264 标准格式,concat 要求所有输入像素格式一致)
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
filters.append("format=yuv420p")
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
if fps and fps > 0:
filters.append(f"fps={fps}")
# 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(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
audio_parts: list[str] = []
for idx, chain in enumerate(clip_chains):
if chain.audio_label:
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{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)
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
if len(audio_chains_with_label) >= 2:
normalized_audio_labels: list[str] = []
for chain, _ in audio_chains_with_label:
norm_label = f"anorm_{chain.video_label}"
audio_filters = [
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
f"atrim=0:{chain.duration}",
"asetpts=PTS-STARTPTS",
]
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
normalized_audio_labels.append(norm_label)
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
elif len(audio_chains_with_label) == 1:
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
return ";".join(parts), max(0.0, total_duration)