Files
xiaoxia-saas/apps/worker/video_processing/unified_render_service.py
T
xiaoxia 1b2bccee6f
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 1m57s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m37s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (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 / Integration Tests (push) Successful in 1m41s
feat(worker): 直通渲染stream copy优化 - 无重编码性能提升10倍+ (#244)
2026-07-13 11:09:27 +08:00

1511 lines
55 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.
"""统一渲染引擎 — 输入 EditPlan + EditPlanClips,按时间线+图层渲染视频.
核心原则(灵应):渲染引擎是统一的,不判断模式,只按 clip_type/config.role
分组为图层再合成。
图层分组:
main (无 config.role) → main (z=0)
main + config.role=b_roll → broll (z=0,与 main 同层替换)
overlay → overlay (z=1,画中画叠加)
background → background (z=0,全屏底图)
corner_voice → corner_voice (z=1,右上角小窗)
b_roll → broll (z=0)
intro / outro → main (z=0,按 order 排在首/尾)
合成流程:
1. 每个 clip 先 trim + scale + setpts 预处理
2. 同层 clips 按 order 用 xfade 串联
3. overlay/corner_voice 层 overlay 到主层
4. 如有独立音频轨,amix 混入
"""
from __future__ import annotations
import logging
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.ffmpeg_utils import (
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
DEFAULT_OUTPUT_WIDTH,
DEFAULT_TRANSITION_DURATION,
FFMPEG_BIN,
build_xfade_filter_chain,
probe_duration,
probe_video_info,
run_ffmpeg,
)
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
# Title/Subtitle 默认边距(像素)
TITLE_MARGIN_TOP = 60
TITLE_MARGIN_BOTTOM = 60
TITLE_MARGIN_SIDE = 40
# ── 数据结构 ──────────────────────────────────────────────────────────────────
@dataclass
class ResolvedClip:
"""已解析到本地路径的片段。"""
clip_id: str
asset_id: str
local_path: Path
clip_type: str
order: int
start_time: float = 0.0
duration: float = 0.0 # 0 表示使用素材完整时长
transition_effect: str = "cut"
config: dict[str, Any] = field(default_factory=dict)
# 运行时填充
actual_duration: float = 0.0 # 素材实际时长(probe 后填充)
@dataclass
class RenderLayer:
"""渲染图层。"""
role: str # "main" | "overlay" | "pip" | "background" | "corner_voice" | "broll" | "audio"
clips: list[ResolvedClip] = field(default_factory=list)
z_index: int = 0
opacity: float = 1.0
position: tuple[int, int] | None = None # (x, y) 偏移,None 表示全屏
@dataclass
class RenderResult:
"""渲染结果。"""
output_path: Path
duration: float
file_size: int
width: int
height: int
# ── clip_type → layer role 映射 ──────────────────────────────────────────────
# ── ASS 字幕工具 ─────────────────────────────────────────────────────────────
def _hex_to_ass_color(hex_color: str) -> str:
"""将 HEX 颜色(#RRGGBB)转换为 ASS &HBBGGRR 格式。"""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return "&H000000"
r, g, b = hex_color[0:2], hex_color[2:4], hex_color[4:6]
return f"&H{b.upper()}{g.upper()}{r.upper()}"
def _position_to_ass_alignment(position: str) -> int:
"""将文字位置映射为 ASS \an 对齐编号。
ASS 对齐编号(数字小键盘布局):
7 8 9
4 5 6
1 2 3
"""
mapping = {
"top": 8, # 顶部居中
"center": 5, # 居中
"bottom": 2, # 底部居中
}
return mapping.get(position, 8)
def _build_ass_style(
style_name: str,
*,
font_name: str = "思源黑体",
font_size: int = 48,
primary_color: str = "&H00FFFFFF",
outline_color: str = "&H00000000",
outline_width: float = 1.0,
shadow_blur: float = 0.0,
shadow_offset: tuple[int, int] = (0, 0),
bold: bool = False,
italic: bool = False,
alignment: int = 8,
margin_v: int = 60,
margin_l: int = 40,
margin_r: int = 40,
) -> str:
"""构建 ASS Style 行。
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
"""
bold_val = -1 if bold else 0
italic_val = -1 if italic else 0
# BackColour 用于阴影(BorderStyle=1 时 outline + shadow
back_color = primary_color # 阴影颜色默认同文字色(带透明度由阴影模糊控制)
# Shadow 值:ASS 中 Shadow 字段是阴影偏移距离(像素),
# 我们用 shadow_offset[1] 作为纵向偏移,模糊由 BorderStyle=3 实现
# 简化:BorderStyle=1outline + drop shadow),Shadow 字段表示阴影深度
shadow_depth = shadow_offset[1] if shadow_blur > 0 else 0
return (
f"Style: {style_name},{font_name},{font_size},{primary_color},"
f"&H000000FF,{outline_color},{back_color},"
f"{bold_val},{italic_val},0,0,100,100,0,0,"
f"1,{outline_width},{shadow_depth},{alignment},"
f"{margin_l},{margin_r},{margin_v},1"
)
def generate_ass_subtitles(
output_path: Path,
*,
video_width: int,
video_height: int,
video_duration: float,
title_text: str = "",
title_config: dict[str, Any] | None = None,
subtitle_text: str = "",
subtitle_config: dict[str, Any] | None = None,
) -> Path:
"""生成 ASS 字幕文件。
支持 Title(标题)和 Subtitle(字幕)两种字幕类型,
各自可独立配置样式、位置和内容。
Args:
output_path: 输出 ASS 文件路径
video_width: 视频宽度(用于 ASS PlayResX
video_height: 视频高度(用于 ASS PlayResY
video_duration: 视频总时长(秒),字幕显示整个时长
title_text: 标题文本
title_config: 标题样式配置(TitleConfig dict
subtitle_text: 字幕文本
subtitle_config: 字幕样式配置(SubtitleConfig dict
Returns:
生成的 ASS 文件路径
"""
title_config = title_config or {}
subtitle_config = subtitle_config or {}
title_enabled = title_config.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_config.get("enabled", True) and bool(subtitle_text.strip())
if not title_enabled and not subtitle_enabled:
# 没有字幕,生成空文件(仍返回路径,调用方自行判断是否使用)
output_path.write_text("", encoding="utf-8")
return output_path
styles: list[str] = []
events: list[str] = []
# ── Title 样式与事件 ──────────────────────────────────────────────────
if title_enabled:
title_color = _hex_to_ass_color(title_config.get("color", "#ffffff"))
title_stroke = title_config.get("stroke", {}) or {}
title_shadow = title_config.get("shadow", {}) or {}
stroke_color = _hex_to_ass_color(title_stroke.get("color", "#000000"))
stroke_width = float(title_stroke.get("width", 1)) if title_stroke.get("enabled", False) else 0.0
shadow_blur = float(title_shadow.get("blur", 4)) if title_shadow.get("enabled", False) else 0.0
shadow_offset = (
title_shadow.get("offset_x", 2) if title_shadow.get("enabled", False) else 0,
title_shadow.get("offset_y", 2) if title_shadow.get("enabled", False) else 0,
)
title_alignment = _position_to_ass_alignment(title_config.get("position", "top"))
styles.append(
_build_ass_style(
"TitleStyle",
font_name=title_config.get("font", "思源黑体"),
font_size=int(title_config.get("size", 48)),
primary_color=title_color,
outline_color=stroke_color,
outline_width=stroke_width,
shadow_blur=shadow_blur,
shadow_offset=shadow_offset,
bold=bool(title_config.get("bold", True)),
italic=bool(title_config.get("italic", False)),
alignment=title_alignment,
margin_v=TITLE_MARGIN_TOP,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
)
# 转义 ASS 特殊字符
safe_title_text = _escape_ass_text(title_text)
events.append(
"Dialogue: 0,0:00:00.00," f"{_format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
)
# ── Subtitle 样式与事件 ───────────────────────────────────────────────
if subtitle_enabled:
sub_color = _hex_to_ass_color(subtitle_config.get("color", "#ffffff"))
sub_alignment = _position_to_ass_alignment(subtitle_config.get("position", "bottom"))
styles.append(
_build_ass_style(
"SubtitleStyle",
font_name=subtitle_config.get("font", "思源黑体"),
font_size=int(subtitle_config.get("size", 24)),
primary_color=sub_color,
outline_color="&H00000000",
outline_width=1.0,
shadow_blur=0.0,
shadow_offset=(0, 0),
bold=False,
italic=False,
alignment=sub_alignment,
margin_v=TITLE_MARGIN_BOTTOM,
margin_l=TITLE_MARGIN_SIDE,
margin_r=TITLE_MARGIN_SIDE,
)
)
safe_subtitle_text = _escape_ass_text(subtitle_text)
events.append(
"Dialogue: 0,0:00:00.00,"
f"{_format_ass_time(video_duration)},"
"SubtitleStyle,,0,0,0,,"
f"{safe_subtitle_text}"
)
# ── 组装 ASS 文件 ─────────────────────────────────────────────────────
ass_content = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {video_width}
PlayResY: {video_height}
ScaledBorderAndShadow: yes
WrapStyle: 2
Encoding: UTF-8
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding # noqa: E501
{chr(10).join(styles)}
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
{chr(10).join(events)}
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(ass_content, encoding="utf-8")
return output_path
def _escape_ass_text(text: str) -> str:
r"""转义 ASS 文本中的特殊字符。
ASS 中换行用 \N(硬换行)或 \n(软换行),
大括号 {} 用于覆盖样式,需要转义。
"""
# 将实际换行转为 ASS 硬换行
text = text.replace("\r\n", "\\N").replace("\n", "\\N").replace("\r", "\\N")
# 转义大括号(ASS 用它做样式覆盖标签)
text = text.replace("{", "(").replace("}", ")")
return text
def _format_ass_time(seconds: float) -> str:
"""将秒数格式化为 ASS 时间格式 H:MM:SS.cc。"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def _resolve_layer_role(clip_type: str, config: dict[str, Any]) -> str:
"""根据 clip_type 和 config.role 确定图层角色。
映射规则:
intro / outro → "main"(按 order 排在首/尾)
overlay → "overlay"(画中画叠加,z=1
corner_voice → "corner_voice"(右上角小窗,z=1
background → "background"(全屏底图,z=0
b_roll → "broll"z=0
main + config.role=b_roll → "broll"
main (default) → "main"
"""
role = config.get("role", "")
if clip_type in ("intro", "outro"):
return "main"
if clip_type == "overlay":
return "overlay"
if clip_type == "corner_voice":
return "corner_voice"
if clip_type == "background":
return "background"
if clip_type == "b_roll":
return "broll"
# main type
if role == "b_roll":
return "broll"
if role == "audio":
return "audio"
return "main"
# ── 图层默认 z_index ─────────────────────────────────────────────────────────
_LAYER_Z_INDEX: dict[str, int] = {
"background": -1,
"broll": 0,
"main": 0,
"overlay": 1,
"corner_voice": 1,
"audio": 2,
}
# 图层默认 PiP 位置(相对输出画布的偏移)
_PIP_SCALE = 0.25 # PiP 占主画面的比例
# ── 统一渲染引擎 ─────────────────────────────────────────────────────────────
class UnifiedRenderService:
"""统一渲染引擎。
输入 EditPlan + EditPlanClips + 素材路径映射,按时间线+图层执行渲染。
"""
def __init__(
self,
plan: Any, # EditPlan
clips: list[Any], # list[EditPlanClip]
asset_path_map: dict[str, Path], # asset_id → local_path
work_dir: Path,
*,
output_width: int = DEFAULT_OUTPUT_WIDTH,
output_height: int = DEFAULT_OUTPUT_HEIGHT,
output_fps: int = DEFAULT_FPS,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
):
self.plan = plan
self.clips = clips
self.asset_path_map = asset_path_map
self.work_dir = work_dir
self.output_width = output_width
self.output_height = output_height
self.output_fps = output_fps
self.transition_duration = transition_duration
def render(self) -> RenderResult:
"""执行渲染,返回 RenderResult.
优化路径:
- 单图层单 clip → 直通模式(-vf),性能最优
- 其他情况 → 完整 filter_complex 渲染
字幕渲染流程:
1. 视频主渲染(直通或完整链路)
2. 如有 title/subtitle,叠加 ASS 字幕
音频后处理:
1. 主图层音频 concat 拼接
2. 独立音频轨 amix 混入
3. 合并到输出视频
Raises:
ValueError: 没有可渲染的片段时抛出
"""
t_start = time.time()
# 1. 解析 clips → ResolvedClips(跳过无素材的 clip
resolved = self._resolve_clips()
if not resolved:
raise ValueError("没有可渲染的片段(所有片段素材缺失或下载失败)")
# 2. 分组为 RenderLayers
layers = self._group_clips_into_layers(resolved)
# 3. 计算视频总时长(用于字幕显示时长)
video_duration = self._estimate_total_duration(layers)
# 4. 生成 ASS 字幕文件(如果有 title/subtitle 配置)
ass_path = self._maybe_generate_ass(video_duration)
# 灰度埋点:开始渲染
layer_roles = [layer.role for layer in layers]
clip_counts = {layer.role: len(layer.clips) for layer in layers}
logger.info(
"[unified-render] start render: plan_id=%s clip_count=%d layers=%s clip_counts=%s",
self.plan.id,
len(resolved),
layer_roles,
clip_counts,
)
# 5. 视频主渲染
t_video_start = time.time()
video_only_path = self.work_dir / f"rendered_{self.plan.id}_video.mp4"
output_path = self.work_dir / f"rendered_{self.plan.id}.mp4"
is_pass_through = self._can_use_pass_through(layers)
pass_through_has_audio = False
used_stream_copy = False
if is_pass_through:
# 先尝试 stream copy 优化(无重编码,性能提升 10 倍+)
# 条件不满足或失败时回退到带滤镜的直通渲染
stream_copy_ok = self._try_render_stream_copy(
layers, output_path, ass_path=ass_path, video_duration=video_duration
)
if stream_copy_ok:
used_stream_copy = True
# stream copy 模式下,直接探测输出是否有音频
clip = layers[0].clips[0]
info = probe_video_info(str(clip.local_path))
pass_through_has_audio = info.get("has_audio", True)
else:
# 回退到带滤镜的直通渲染
pass_through_has_audio = self._render_pass_through(
layers, output_path, ass_path=ass_path, video_duration=video_duration
)
else:
filter_complex, input_args = self._build_filter_complex(layers, ass_path=ass_path)
self._execute_ffmpeg(filter_complex, input_args, video_only_path)
t_video_end = time.time()
video_render_ms = int((t_video_end - t_video_start) * 1000)
logger.info(
"[unified-render] video render done: plan_id=%s duration_ms=%d pass_through=%s stream_copy=%s",
self.plan.id,
video_render_ms,
is_pass_through,
used_stream_copy,
)
# 6. 音频后处理混音(直通场景已合并处理,跳过)
t_audio_start = time.time()
audio_mix_ms = 0
has_audio = False
if is_pass_through:
# 直通场景已在一次调用中完成视频+音频
has_audio = pass_through_has_audio
else:
audio_path = self._mix_audio(layers, video_duration)
t_audio_end = time.time()
audio_mix_ms = int((t_audio_end - t_audio_start) * 1000)
has_audio = audio_path is not None
if has_audio:
logger.info(
"[unified-render] audio mix done: plan_id=%s duration_ms=%d",
self.plan.id,
audio_mix_ms,
)
# 7. 合并音视频
self._merge_audio_video(video_only_path, audio_path, output_path)
else:
# 无音频,直接用无声视频
import shutil
shutil.copy2(video_only_path, output_path)
# 8. 探测输出
duration, file_size, width, height = self._probe_output(output_path)
t_total = int((time.time() - t_start) * 1000)
logger.info(
"[unified-render] render done: plan_id=%s total_ms=%d video_ms=%d audio_ms=%d "
"output_duration=%.2fs output_size=%d resolution=%dx%d has_audio=%s",
self.plan.id,
t_total,
video_render_ms,
audio_mix_ms if has_audio else 0,
duration,
file_size,
width,
height,
has_audio,
)
return RenderResult(
output_path=output_path,
duration=duration,
file_size=file_size,
width=width,
height=height,
)
def _estimate_total_duration(self, layers: list[RenderLayer]) -> float:
"""估算视频总时长(用于字幕等需要)。
取主图层(main/broll/background)的总时长,转场重叠按 transition_duration 估算。
"""
# 找主图层(第一个有视频内容的图层)
main_layer = None
for role in ("main", "broll", "background"):
for layer in layers:
if layer.role == role:
main_layer = layer
break
if main_layer:
break
if not main_layer or not main_layer.clips:
return 0.0
total = sum(UnifiedRenderService._clip_effective_duration(c) for c in main_layer.clips)
# 减去转场重叠时间(粗略估算)
n_clips = len(main_layer.clips)
if n_clips > 1:
total -= (n_clips - 1) * self.transition_duration
return max(0.1, total)
def _maybe_generate_ass(self, video_duration: float) -> Path | None:
"""根据 plan.config 生成 ASS 字幕文件。
Returns:
ASS 文件路径,没有字幕时返回 None
"""
config = self.plan.config or {}
title_cfg = config.get("title", {}) or {}
subtitle_cfg = config.get("subtitle", {}) or {}
title_enabled = title_cfg.get("enabled", True)
subtitle_enabled = subtitle_cfg.get("enabled", True)
title_text = title_cfg.get("text", "") or ""
subtitle_text = subtitle_cfg.get("text", "") or ""
has_title = title_enabled and bool(title_text.strip())
has_subtitle = subtitle_enabled and bool(subtitle_text.strip())
if not has_title and not has_subtitle:
return None
ass_path = self.work_dir / f"subtitles_{self.plan.id}.ass"
generate_ass_subtitles(
ass_path,
video_width=self.output_width,
video_height=self.output_height,
video_duration=video_duration,
title_text=title_text,
title_config=title_cfg,
subtitle_text=subtitle_text,
subtitle_config=subtitle_cfg,
)
logger.info(
"生成字幕: plan_id=%s title=%s subtitle=%s ass=%s",
self.plan.id,
has_title,
has_subtitle,
ass_path,
)
return ass_path
def _can_use_pass_through(self, layers: list[RenderLayer]) -> bool:
"""判断是否可以走直通优化路径。
条件:
1. 只有 1 个图层
2. 该图层是视频图层(main/broll/background),不是 overlay/corner_voice/audio
3. 该图层只有 1 个 clip(无转场需求)
"""
if len(layers) != 1:
return False
layer = layers[0]
if layer.role not in ("main", "broll", "background"):
return False
if len(layer.clips) != 1:
return False
return True
def _can_use_stream_copy(
self,
clip: ResolvedClip,
*,
ass_path: Path | None = None,
video_duration: float = 0.0,
) -> tuple[bool, str]:
"""判断是否可以走 stream copy(流拷贝,不重编码)。
性能提升:10 倍以上(典型场景从 20s → 1-2s)。
条件:
1. 视频编码为 h264(输出目标也是 h264)
2. 像素格式为 yuv420p
3. 分辨率与输出一致(不需要 scale/crop
4. 帧率与输出一致(误差 < 0.1fps
5. 无字幕叠加(字幕需要滤镜)
6. 无 trim 需求(或 trim 后恰好等于原时长)
7. 无转场、无特效(单 clip 直通已保证)
Returns:
(是否可以 copy, 原因说明)
"""
# 有字幕 → 需要滤镜 → 不能 copy
if ass_path is not None:
return False, "有字幕叠加"
# 探测输入视频参数
info = probe_video_info(str(clip.local_path))
# 编码必须是 h264
if info.get("video_codec", "") != "h264":
return False, f"视频编码不是h264: {info.get('video_codec', 'unknown')}"
# 像素格式必须是 yuv420p
if info.get("pix_fmt", "") != "yuv420p":
return False, f"像素格式不是yuv420p: {info.get('pix_fmt', 'unknown')}"
# 分辨率必须一致
if info.get("width", 0) != self.output_width or info.get("height", 0) != self.output_height:
return False, (
f"分辨率不匹配: "
f"{info.get('width', 0)}x{info.get('height', 0)} "
f"vs {self.output_width}x{self.output_height}"
)
# 帧率必须一致(误差 < 0.1fps
fps_diff = abs(info.get("fps", 0) - self.output_fps)
if fps_diff > 0.1:
return False, f"帧率不匹配: {info.get('fps', 0)} vs {self.output_fps}"
# 检查是否需要 trim
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
if effective_duration > 0:
# 有 trim 需求但视频时长足够,可用 -ss/-t 实现 copy trim
input_duration = info.get("duration", 0)
if input_duration <= 0:
return False, "无法探测输入时长"
# trim 起始点 + 目标时长 <= 输入时长
start_time = getattr(clip, "start_time", 0) or 0
if start_time + effective_duration > input_duration + 0.1:
return False, "trim 超出输入时长"
# video_duration 截断
if video_duration > 0 and effective_duration > 0:
final_duration = min(effective_duration, video_duration)
if final_duration != effective_duration:
# 也需要截断,但 -t 可以 copy 模式下用
pass
return True, "所有条件满足"
def _try_render_stream_copy(
self,
layers: list[RenderLayer],
output_path: Path,
*,
ass_path: Path | None = None,
video_duration: float = 0.0,
) -> bool:
"""尝试 stream copy 渲染,成功返回 True,失败返回 False(调用方回退到重编码)。
stream copy 模式:不重编码,直接拷贝视频/音频流,性能提升 10 倍+。
仅用于单 clip 直通场景且满足 copy 条件。
"""
clip = layers[0].clips[0]
role = layers[0].role
# 判断是否满足 copy 条件
can_copy, reason = self._can_use_stream_copy(clip, ass_path=ass_path, video_duration=video_duration)
if not can_copy:
logger.info(
"[unified-render] stream_copy 跳过: plan_id=%s reason=%s",
self.plan.id,
reason,
)
return False
# 构建 copy 命令
command = [
FFMPEG_BIN,
"-y",
]
# trim 支持(-ss 放在 -i 前 = input seeking,速度更快但精度稍差;
# 放在 -i 后 = output seeking,精度高但慢)
# 这里用 output seeking 保证精度,反正 copy 模式已经很快了
start_time = getattr(clip, "start_time", 0) or 0
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
command.extend(["-i", str(clip.local_path)])
if start_time > 0:
command.extend(["-ss", f"{start_time:.3f}"])
# 计算最终时长
final_duration = effective_duration
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
# 流拷贝
command.extend(
[
"-c:v",
"copy",
"-c:a",
"copy",
"-movflags",
"+faststart",
str(output_path),
]
)
logger.info(
"[unified-render] stream_copy 渲染: plan_id=%s clip=%s role=%s duration=%.2fs",
self.plan.id,
clip.clip_id,
role,
final_duration,
)
try:
run_ffmpeg(command)
# 验证输出文件存在且有大小
if output_path.exists() and output_path.stat().st_size > 0:
logger.info(
"[unified-render] stream_copy 成功: plan_id=%s size=%d",
self.plan.id,
output_path.stat().st_size,
)
return True
else:
logger.warning("[unified-render] stream_copy 输出为空: plan_id=%s", self.plan.id)
return False
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
logger.warning(
"[unified-render] stream_copy 失败,回退到重编码: plan_id=%s error=%s",
self.plan.id,
str(e)[:200],
)
# 清理可能的损坏输出文件
if output_path.exists():
try:
output_path.unlink()
except OSError:
pass
return False
def _render_pass_through(
self,
layers: list[RenderLayer],
output_path: Path,
*,
ass_path: Path | None = None,
video_duration: float = 0.0,
) -> bool:
"""单图层单 clip 直通渲染(使用 -vf 而非 -filter_complex),一次性输出带音频的最终视频。
性能优化:
- 避免 filter_complex 的解析和调度开销,单clip场景性能提升 ~30%
- 视频+音频一次FFmpeg调用完成,省去后续音频提取+音视频合并两次调用
Args:
layers: 图层列表(只有1个图层1个clip)
output_path: 输出文件路径
ass_path: ASS 字幕文件路径,有则叠加字幕
video_duration: 视频总时长(用于截断音频,0表示不额外截断)
Returns:
True 表示输出包含音频(近似判断,实际以输出文件为准)
"""
clip = layers[0].clips[0]
role = layers[0].role
# 构建视频滤镜链(与 _build_filter_complex 中预处理逻辑一致)
filters: list[str] = []
# trim
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
if effective_duration > 0:
filters.append(f"trim=duration={effective_duration}")
filters.append("setpts=PTS-STARTPTS")
# scale + crop(铺满裁剪)
if role in ("overlay", "corner_voice"):
pip_w = int(self.output_width * _PIP_SCALE)
pip_h = int(self.output_height * _PIP_SCALE)
filters.append(f"scale={pip_w}:{pip_h}")
else:
# main / broll / background: 铺满裁剪
filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase")
filters.append(f"crop={self.output_width}:{self.output_height}")
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
filters.append("format=yuv420p")
# 字幕叠加
if ass_path is not None:
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", "\\:")
filters.append(f"subtitles='{ass_filter_path}'")
vf_str = ",".join(filters)
# 最终输出时长:取 clip 有效时长和 video_duration 的较小值
final_duration = effective_duration
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vf",
vf_str,
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
]
# 音频处理:background 通常是图片无音频,跳过;其他编码为 aac
# background 以外的视频素材,默认带音频
has_audio = role != "background"
if has_audio:
command.extend(["-c:a", "aac", "-b:a", "128k"])
# 统一截断时长(同时作用于视频和音频)
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
logger.info(
"直通渲染: plan_id=%s clip=%s role=%s duration=%.2fs has_audio=%s",
self.plan.id,
clip.clip_id,
role,
effective_duration,
has_audio,
)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
logger.error(
"直通渲染失败: plan_id=%s clip=%s exit_code=%d\nvf=%s",
self.plan.id,
clip.clip_id,
e.returncode,
vf_str[:2000],
)
raise
return has_audio
# ── 内部方法 ──────────────────────────────────────────────────────────────
def _resolve_clips(self) -> list[ResolvedClip]:
"""将 EditPlanClip 列表解析为 ResolvedClip 列表。
跳过 asset_id 为空或在 asset_path_map 中找不到的片段。
"""
resolved: list[ResolvedClip] = []
for clip in self.clips:
asset_id = clip.asset_id
if not asset_id:
logger.warning("片段无素材: clip_id=%s", clip.id)
continue
local_path = self.asset_path_map.get(asset_id)
if local_path is None or not local_path.exists():
logger.warning("素材不存在: clip_id=%s asset_id=%s", clip.id, asset_id)
continue
# 探测实际时长
try:
actual_duration = probe_duration(local_path)
except Exception:
actual_duration = clip.duration or 5.0
rc = ResolvedClip(
clip_id=clip.id,
asset_id=asset_id,
local_path=local_path,
clip_type=clip.clip_type,
order=clip.order,
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect or "cut",
config=clip.config or {},
actual_duration=actual_duration,
)
resolved.append(rc)
# 按 order 排序
resolved.sort(key=lambda c: c.order)
return resolved
def _group_clips_into_layers(self, resolved_clips: list[ResolvedClip]) -> list[RenderLayer]:
"""将 ResolvedClips 分组为 RenderLayers。
分组规则见 _resolve_layer_role 函数文档。
"""
layer_map: dict[str, RenderLayer] = {}
for clip in resolved_clips:
role = _resolve_layer_role(clip.clip_type, clip.config)
if role not in layer_map:
z = _LAYER_Z_INDEX.get(role, 0)
layer_map[role] = RenderLayer(role=role, z_index=z)
layer_map[role].clips.append(clip)
# 每个 layer 内的 clips 按 order 排序
for layer in layer_map.values():
layer.clips.sort(key=lambda c: c.order)
# 计算 PiP 位置
pip_width = int(self.output_width * _PIP_SCALE)
margin = 20 # 边距
if "overlay" in layer_map:
layer_map["overlay"].position = (
self.output_width - pip_width - margin,
margin,
)
if "corner_voice" in layer_map:
layer_map["corner_voice"].position = (
self.output_width - pip_width - margin,
margin,
)
# 按 z_index 排序返回
layers = sorted(layer_map.values(), key=lambda lyr: lyr.z_index)
return layers
def _build_filter_complex(
self, layers: list[RenderLayer], *, ass_path: Path | None = None
) -> tuple[str, list[str]]:
"""构建 FFmpeg filter_complex 字符串和输入参数列表。
Args:
layers: 图层列表
ass_path: ASS 字幕文件路径,有则在最后叠加字幕
Returns:
(filter_complex_str, input_args_list)
input_args_list 是 ["-i", path1, "-i", path2, ...] 格式
"""
if not layers:
raise ValueError("没有可渲染的图层")
# 收集所有 clips(按图层顺序,同层按 order)
all_clips: list[ResolvedClip] = []
for layer in layers:
all_clips.extend(layer.clips)
# 构建输入参数
input_args: list[str] = []
clip_to_input_idx: dict[str, int] = {}
for i, clip in enumerate(all_clips):
input_args.extend(["-i", str(clip.local_path)])
clip_to_input_idx[clip.clip_id] = i
filter_parts: list[str] = []
# Step 1: 预处理每个 clip — scale + setpts
# 为每个 clip 生成预处理后的标签 [v0], [v1], ...
preprocessed_labels: list[str] = []
for i, clip in enumerate(all_clips):
label = f"v{i}"
role = _resolve_layer_role(clip.clip_type, clip.config)
filters: list[str] = []
# trim — 始终将输出截断到有效时长,防止 xfade offset 与实际时长不匹配
effective_duration = UnifiedRenderService._clip_effective_duration(clip)
if effective_duration > 0:
filters.append(f"trim=duration={effective_duration}")
filters.append("setpts=PTS-STARTPTS")
# scale
if role in ("overlay", "corner_voice"):
pip_w = int(self.output_width * _PIP_SCALE)
pip_h = int(self.output_height * _PIP_SCALE)
filters.append(f"scale={pip_w}:{pip_h}")
elif role == "background":
filters.append(
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
)
filters.append(f"crop={self.output_width}:{self.output_height}")
else:
# main / broll: 铺满裁剪(scale to cover + center crop
# 对齐链路A编辑器合成行为,与主流短视频平台一致
filters.append(
f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase"
)
filters.append(f"crop={self.output_width}:{self.output_height}")
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
filter_str = f"[{i}:v]{','.join(filters)}[{label}]"
filter_parts.append(filter_str)
preprocessed_labels.append(label)
# Step 2: 同层 clips 用 xfade 串联
layer_output_labels: dict[str, str] = {}
for layer in layers:
layer_clip_indices = [all_clips.index(c) for c in layer.clips]
layer_labels = [preprocessed_labels[i] for i in layer_clip_indices]
# 使用 trim 后的有效时长,与 Step 1 的 trim=duration 保持一致
layer_durations = [UnifiedRenderService._clip_effective_duration(all_clips[i]) for i in layer_clip_indices]
layer_transitions = [all_clips[i].transition_effect for i in layer_clip_indices]
if len(layer_labels) == 1:
# 单 clip 层,直接使用预处理标签
layer_output_labels[layer.role] = layer_labels[0]
else:
# 多 clip 层,用 xfade 串联
out_label = f"{layer.role}_merged"
xfade_filter, _ = build_xfade_filter_chain(
clip_durations=layer_durations,
clip_video_labels=layer_labels,
transitions=layer_transitions,
transition_duration=self.transition_duration,
output_label=out_label,
)
if xfade_filter:
filter_parts.append(xfade_filter)
layer_output_labels[layer.role] = out_label
# Step 3: 合成各层
# 找到主层 — background 优先作为底图,其次 broll / main
final_video_label = None
if "background" in layer_output_labels:
final_video_label = layer_output_labels["background"]
# b_roll / main 叠加到 background 上
for role in ("broll", "main"):
if role in layer_output_labels:
base_label = layer_output_labels[role]
combined_label = f"combined_{role}"
filter_parts.append(
f"[{final_video_label}][{base_label}]" f"overlay=(W-w)/2:(H-h)/2[{combined_label}]"
)
final_video_label = combined_label
else:
# 无 background 时,取 broll 或 main 作为基础
for role in ("broll", "main"):
if role in layer_output_labels:
final_video_label = layer_output_labels[role]
break
if final_video_label is None:
# 没有任何主层,使用第一个层
final_video_label = layer_output_labels[layers[0].role]
# 叠加 overlay 层
for layer in layers:
if layer.role in ("overlay", "corner_voice"):
if layer.role not in layer_output_labels:
continue
overlay_label = layer_output_labels[layer.role]
x, y = layer.position or (
self.output_width - int(self.output_width * _PIP_SCALE) - 20,
20,
)
combined_label = f"combined_{layer.role}"
filter_parts.append(f"[{final_video_label}][{overlay_label}]" f"overlay={x}:{y}[{combined_label}]")
final_video_label = combined_label
# 叠加字幕(如有)+ 最终像素格式
if ass_path is not None:
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", "\\:")
filter_parts.append(f"[{final_video_label}]subtitles='{ass_filter_path}',format=yuv420p[final_video]")
else:
filter_parts.append(f"[{final_video_label}]format=yuv420p[final_video]")
filter_complex = ";".join(filter_parts)
return filter_complex, input_args
def _execute_ffmpeg(
self,
filter_complex: str,
input_args: list[str],
output_path: Path,
) -> None:
"""执行 FFmpeg 渲染命令。
失败时记录完整 filter_complex 以便排查(如 exit code 183)。
"""
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
"[final_video]",
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(output_path),
]
logger.info(
"执行渲染: plan_id=%s inputs=%d output=%s",
self.plan.id,
input_args.count("-i"),
output_path,
)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
# 额外记录 filter_complex,方便排查滤镜链构建问题
logger.error(
"渲染失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s",
self.plan.id,
e.returncode,
filter_complex[:5000],
)
raise
def _probe_output(self, output_path: Path) -> tuple[float, int, int, int]:
"""探测输出文件的时长、大小、宽高.
Returns:
(duration, file_size, width, height)
"""
info = probe_video_info(str(output_path))
file_size = output_path.stat().st_size if output_path.exists() else 0
return (
info["duration"],
file_size,
info["width"],
info["height"],
)
# ── 音频后处理 ────────────────────────────────────────────────────────
def _mix_audio(self, layers: list[RenderLayer], video_duration: float) -> Path | None:
"""音频后处理混音.
处理逻辑:
1. 主音频源按优先级查找:main > brollbackground 不参与主音频,通常是图片无音轨)
2. 主图层音频按顺序 concat 拼接
3. 独立音频轨(audio role)用 amix 混入
4. 输出时长截断到 video_duration
5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败
Args:
layers: 图层列表
video_duration: 视频总时长(用于截断音频)
Returns:
混音后的音频文件路径,无音频时返回 None
"""
# 按优先级精确查找主音频图层:main > broll
# background 不参与主音频(通常是静态图片,无音轨)
layer_map = {layer.role: layer for layer in layers}
main_layer = None
for role in ("main", "broll"):
if role in layer_map and layer_map[role].clips:
main_layer = layer_map[role]
break
main_clips: list[ResolvedClip] = main_layer.clips if main_layer else []
# 没有主视频图层时兜底:检查 overlay/corner_voice 层是否有带音频的素材
if not main_clips:
for role in ("overlay", "corner_voice"):
if role in layer_map and layer_map[role].clips:
main_clips = layer_map[role].clips
break
# 收集独立音频轨
audio_clips: list[ResolvedClip] = []
if "audio" in layer_map:
audio_clips = layer_map["audio"].clips
# ── 防御:过滤掉无音频流的 clip ──
# 源视频可能没有音频流(如静音视频、纯图片转的视频),直接引用 [i:a] 会导致 FFmpeg 失败
main_clips = [c for c in main_clips if self._clip_has_audio(c)]
audio_clips = [c for c in audio_clips if self._clip_has_audio(c)]
if not main_clips and not audio_clips:
return None
# 构建音频处理命令
output_path = self.work_dir / f"audio_{self.plan.id}.aac"
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
if main_clips and not audio_clips:
self._concat_main_audio(main_clips, output_path, video_duration)
return output_path
# 有独立音频轨 → amix 混音
self._mix_with_independent_audio(main_clips, audio_clips, output_path, video_duration)
return output_path
def _concat_main_audio(self, clips: list[ResolvedClip], output_path: Path, video_duration: float) -> None:
"""主图层音频 concat 拼接(对齐链路A行为).
每个 clip 提取音频 → trim → 按顺序 concat。
"""
if len(clips) == 1:
# 单 clip,直接提取音频,截断到 min(clip有效时长, 视频总时长)
clip = clips[0]
effective_duration = self._clip_effective_duration(clip)
# 最终时长:取 clip 有效时长和视频总时长的较小值
# (视频总时长由主图层决定,但单 clip 场景下两者应该一致,仍做保护)
final_duration = effective_duration
if video_duration > 0 and (final_duration <= 0 or final_duration > video_duration):
final_duration = video_duration
command = [
FFMPEG_BIN,
"-y",
"-i",
str(clip.local_path),
"-vn",
"-acodec",
"aac",
"-b:a",
"128k",
]
if final_duration > 0:
command.extend(["-t", f"{final_duration:.3f}"])
command.append(str(output_path))
run_ffmpeg(command)
return
# 多 clip,用 filter_complex concat
input_args: list[str] = []
filter_parts: list[str] = []
for i, clip in enumerate(clips):
input_args.extend(["-i", str(clip.local_path)])
effective_duration = self._clip_effective_duration(clip)
if effective_duration > 0:
filter_parts.append(f"[{i}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[a{i}]")
else:
filter_parts.append(f"[{i}:a]asetpts=PTS-STARTPTS[a{i}]")
audio_labels = "".join(f"[a{i}]" for i in range(len(clips)))
filter_parts.append(f"{audio_labels}concat=n={len(clips)}:v=0:a=1[outa]")
# 截断到视频总时长
if video_duration > 0:
filter_parts.append(f"[outa]atrim=0:{video_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "outa"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
run_ffmpeg(command)
def _mix_with_independent_audio(
self,
main_clips: list[ResolvedClip],
audio_clips: list[ResolvedClip],
output_path: Path,
video_duration: float,
) -> None:
"""主音频 + 独立音频轨 amix 混音.
Args:
main_clips: 主视频 clips(提取音频后 concat
audio_clips: 独立音频轨 clips
output_path: 输出路径
video_duration: 视频总时长
"""
input_args: list[str] = []
filter_parts: list[str] = []
mix_labels: list[str] = []
input_idx = 0
# 1. 主图层音频 concat
if main_clips:
for clip in main_clips:
input_args.extend(["-i", str(clip.local_path)])
effective_duration = self._clip_effective_duration(clip)
if effective_duration > 0:
filter_parts.append(
f"[{input_idx}:a]atrim=0:{effective_duration:.3f},asetpts=PTS-STARTPTS[ma{input_idx}]"
)
else:
filter_parts.append(f"[{input_idx}:a]asetpts=PTS-STARTPTS[ma{input_idx}]")
input_idx += 1
if len(main_clips) == 1:
mix_labels.append("ma0")
else:
main_labels = "".join(f"[ma{i}]" for i in range(len(main_clips)))
filter_parts.append(f"{main_labels}concat=n={len(main_clips)}:v=0:a=1[main_audio]")
mix_labels.append("main_audio")
# 2. 独立音频轨
for j, clip in enumerate(audio_clips):
input_args.extend(["-i", str(clip.local_path)])
effective_duration = self._clip_effective_duration(clip)
volume = clip.config.get("volume", 1.0) if clip.config else 1.0
label = f"ia{j}"
filters = []
if effective_duration > 0:
filters.append(f"atrim=0:{effective_duration:.3f}")
filters.append("asetpts=PTS-STARTPTS")
if volume != 1.0:
filters.append(f"volume={volume}")
filter_parts.append(f"[{input_idx}:a]{','.join(filters)}[{label}]")
mix_labels.append(label)
input_idx += 1
# 3. amix 混音
mix_inputs = "".join(f"[{label}]" for label in mix_labels)
n_inputs = len(mix_labels)
# normalized=0 保持音量,duration=shortest 取最短
filter_parts.append(f"{mix_inputs}amix=inputs={n_inputs}:duration=longest:normalize=0[mixed_audio]")
# 4. 截断到视频时长
if video_duration > 0:
filter_parts.append(f"[mixed_audio]atrim=0:{video_duration:.3f}[final_audio]")
final_label = "final_audio"
else:
final_label = "mixed_audio"
filter_complex = ";".join(filter_parts)
command = [
FFMPEG_BIN,
"-y",
*input_args,
"-filter_complex",
filter_complex,
"-map",
f"[{final_label}]",
"-acodec",
"aac",
"-b:a",
"128k",
str(output_path),
]
logger.info(
"音频混音: plan_id=%s main_clips=%d audio_clips=%d",
self.plan.id,
len(main_clips),
len(audio_clips),
)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
logger.error(
"音频混音失败: plan_id=%s exit_code=%d\nfilter_complex:\n%s",
self.plan.id,
e.returncode,
filter_complex[:3000],
)
raise
def _merge_audio_video(self, video_path: Path, audio_path: Path, output_path: Path) -> None:
"""将音频合并到视频中(视频流拷贝,音频直接复用).
Args:
video_path: 无声视频路径
audio_path: 音频文件路径
output_path: 输出文件路径
"""
command = [
FFMPEG_BIN,
"-y",
"-i",
str(video_path),
"-i",
str(audio_path),
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-map",
"0:v:0",
"-map",
"1:a:0",
"-shortest",
"-movflags",
"+faststart",
str(output_path),
]
logger.info("合并音视频: plan_id=%s", self.plan.id)
try:
run_ffmpeg(command)
except subprocess.CalledProcessError as e:
logger.error(
"合并音视频失败: plan_id=%s exit_code=%d",
self.plan.id,
e.returncode,
)
raise
@staticmethod
def _clip_effective_duration(clip: ResolvedClip) -> float:
"""计算 clip 的有效时长."""
if clip.duration > 0:
return min(clip.duration, clip.actual_duration) if clip.actual_duration > 0 else clip.duration
return clip.actual_duration if clip.actual_duration > 0 else 0.0
def _clip_has_audio(self, clip: ResolvedClip) -> bool:
"""探测 clip 是否有音频流(带缓存).
避免同一个 clip 被多次 ffprobe 探测。
"""
if not hasattr(self, "_audio_cache"):
self._audio_cache: dict[str, bool] = {}
key = str(clip.local_path)
if key not in self._audio_cache:
from .ffmpeg_utils import probe_has_audio
self._audio_cache[key] = probe_has_audio(clip.local_path)
return self._audio_cache[key]