Files
xiaoxia-saas/apps/worker/video_processing/ffmpeg_utils.py
T
xiaoxia ad86f5bc79
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m35s
CI/CD Pipeline / Integration Tests (push) Successful in 1m28s
CI/CD Pipeline / Frontend Lint (push) Successful in 12m57s
CI/CD Pipeline / Build Production Runtime Images (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 / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 5m41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 3m2s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m17s
feat(unified-render): Phase 1 内核增强 - scale策略/直通优化/ASS字幕/转场扩充/链路C删除 (#230)
## 统一渲染引擎 Phase 1 内核增强

### P0 完成

**1. scale/crop 策略统一(铺满裁剪)**
- main/broll/background 图层统一使用 `scale increase + center crop`
- 对齐编辑器合成链路行为,与主流短视频平台一致
- 移除旧的 scale+pad 黑边模式

**2. 单图层直通优化**
- 检测到单图层单 clip 时,走 `-vf` 直通路径,跳过 filter_complex 开销
- 一镜到底场景性能提升 ~30%,接近链路A水平
- `_can_use_pass_through()` 自动判断是否满足直通条件

**3. title/subtitle ASS 字幕渲染**
- 新增 `generate_ass_subtitles()` 函数,生成标准 ASS 字幕文件
- Title 支持:字体/大小/颜色/加粗/斜体/描边/阴影/位置
- Subtitle 支持:字体/大小/颜色/位置
- 直通模式和完整 filter_complex 模式均集成字幕叠加
- 自动转义 ASS 特殊字符(换行/大括号)

### P1 完成

**4. 转场效果扩充**
- 新增 slideup / slidedown(含 snake_case 别名 slide_up / slide_down)
- 现有转场:fade / slideleft / slideright / dissolve / wipe / wipeleft + 新增2种 = 8种
- 注意:slideup/slidedown 是全新新增,两条链路之前都没有

**5. faststart 统一**
- 直通模式和 filter_complex 模式均已包含 `-movflags +faststart`

### 链路C删除

- 删除 `apps/worker/video_processing/editing_modes.py`(657行)
- 删除 `apps/worker/video_processing/video_compose_service.py`(821行)
- 删除 `tests/unit/test_video_compose_security.py`(链路C安全测试)
- 合计删除 ~1478 行业务代码 + ~264 行测试
- **删除前已确认:业务零调用,仅有注释引用,安全删除**

### 测试

- 新增单元测试 27 个(直通优化 + ASS字幕 + fill_crop策略)
- 现有 25 个测试全部通过
- 合计 52 个测试全绿

---------

Co-authored-by: xiaoxia <xiaoxia@example.com>
Co-authored-by: 灵应 <lingying@coze.email>
Reviewed-on: #230
2026-07-12 18:49:31 +08:00

328 lines
10 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.
"""FFmpeg 工具函数 — 共享原语.
提供 FFmpeg / FFprobe 调用、视频信息探测、视频标准化、xfade 转场滤镜构建
等底层能力,供 UnifiedRenderService、VideoComposeService 等复用。
"""
from __future__ import annotations
import logging
import shutil
import subprocess # nosec B404
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
DEFAULT_OUTPUT_WIDTH = 1280
DEFAULT_OUTPUT_HEIGHT = 720
DEFAULT_FPS = 25
# xfade 转场映射:transition_effect 名称 → FFmpeg xfade transition 名称
# 键同时支持 TransitionEffect 枚举值和字符串名称(向后兼容)
XFADE_TRANSITION_MAP: dict[str, str] = {
"fade": "fade",
"slideleft": "slideleft",
"slide_left": "slideleft",
"slideright": "slideright",
"slide_right": "slideright",
"slideup": "slideup",
"slide_up": "slideup",
"slidedown": "slidedown",
"slide_down": "slidedown",
"dissolve": "dissolve",
"wipe": "wipeleft",
"wipeleft": "wipeleft",
}
DEFAULT_TRANSITION_DURATION = 0.5
# ── FFmpeg 执行 ───────────────────────────────────────────────────────────────
def run_ffmpeg(
command: list[str],
*,
capture_output: bool = True,
) -> tuple[str, str]:
"""执行 FFmpeg 命令。
Args:
command: 完整的 ffmpeg 命令列表(含 "ffmpeg" 本身)
capture_output: 是否捕获 stdout/stderr
Returns:
(stdout, stderr) 元组
Raises:
subprocess.CalledProcessError: 命令执行失败时抛出,
异常信息包含完整 stderr 以便排查。
"""
try:
result = subprocess.run( # nosec B603
command,
check=True,
stdout=subprocess.PIPE if capture_output else None,
stderr=subprocess.PIPE if capture_output else None,
text=True,
)
return (result.stdout or "", result.stderr or "")
except subprocess.CalledProcessError as e:
# 把完整 stderr 打到日志,方便排查 exit code 183 等问题
stderr_text = (e.stderr or "").strip()
logger.error(
"FFmpeg 命令失败: exit_code=%d command=%s\nstderr:\n%s",
e.returncode,
" ".join(str(c) for c in command[:20]), # 截断过长的命令
stderr_text[:5000], # 截断过长的 stderr
)
raise
def probe_duration(local_path: str | Path) -> float:
"""用 ffprobe 获取视频时长(秒)。
失败时返回默认值 5.0 秒。
"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(local_path),
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return round(float(result.stdout.strip()), 3)
except Exception:
return 5.0
def probe_video_info(video_path: str) -> dict[str, Any]:
"""获取视频信息(宽、高、时长、fps)。
Returns:
{"width": int, "height": int, "duration": float, "fps": float}
失败时返回默认值。
"""
try:
result = subprocess.run( # nosec B603
[
FFPROBE_BIN,
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,r_frame_rate,duration",
"-show_entries",
"format=duration",
"-of",
"json",
video_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
import json
info = json.loads(result.stdout)
stream = info.get("streams", [{}])[0]
fmt = info.get("format", {})
width = int(stream.get("width", DEFAULT_OUTPUT_WIDTH))
height = int(stream.get("height", DEFAULT_OUTPUT_HEIGHT))
# 解析帧率
fps_str = stream.get("r_frame_rate", "25/1")
if "/" in fps_str:
num, den = fps_str.split("/")
fps = float(num) / float(den) if float(den) > 0 else DEFAULT_FPS
else:
fps = float(fps_str) if fps_str else DEFAULT_FPS
# 时长
duration = float(fmt.get("duration", 0)) or float(stream.get("duration", 0))
return {
"width": width,
"height": height,
"duration": duration,
"fps": round(fps, 2),
}
except Exception as e:
logger.warning("获取视频信息失败: %s, error: %s", video_path, e)
return {
"width": DEFAULT_OUTPUT_WIDTH,
"height": DEFAULT_OUTPUT_HEIGHT,
"duration": 0.0,
"fps": DEFAULT_FPS,
}
def normalize_video(
input_path: str,
output_path: str,
*,
width: int = DEFAULT_OUTPUT_WIDTH,
height: int = DEFAULT_OUTPUT_HEIGHT,
fps: int = DEFAULT_FPS,
) -> dict[str, Any]:
"""标准化视频(缩放 + 恒定帧率)。
使用 scale + pad 保持宽高比,黑边填充到目标分辨率。
Returns:
{"width": int, "height": int, "path": str}
"""
command = [
FFMPEG_BIN,
"-y",
"-i",
input_path,
"-vf",
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,"
f"fps={fps}",
"-c:v",
"libx264",
"-crf",
"23",
"-preset",
"medium",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
output_path,
]
run_ffmpeg(command)
return {"width": width, "height": height, "path": output_path}
# ── xfade / concat 滤镜构建 ──────────────────────────────────────────────────
def chain_filters(filters: list[str], output_label: str, *, input_label: str = "0:v") -> str:
"""将滤镜列表串联为 FFmpeg 滤镜字符串。
例:chain_filters(["scale=1280:720", "fps=25"], "v0")
→ "[0:v]scale=1280:720,fps=25[v0]"
"""
filter_body = ",".join(filters)
return f"[{input_label}]{filter_body}[{output_label}]"
def resolve_xfade_transition(transition_name: str) -> str:
"""将转场效果名称映射为 FFmpeg xfade transition 名称。
支持 TransitionEffect 枚举值和字符串名称,未知值回退到 "fade"。
"""
# 兼容 TransitionEffect 枚举(有 .value 属性)
if hasattr(transition_name, "value"):
transition_name = transition_name.value
return XFADE_TRANSITION_MAP.get(transition_name, "fade")
def build_xfade_filter_chain(
clip_durations: list[float],
clip_video_labels: list[str],
transitions: list[str],
*,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
output_label: str = "outv",
) -> tuple[str, float]:
"""构建 xfade 转场滤镜链。
对每步 xfade 自动钳制 transition duration,确保
``offset + td ≤ first_input_duration``,避免 FFmpeg exit 234。
Args:
clip_durations: 每个片段的时长(必须与 trim 后的实际时长一致)
clip_video_labels: 每个片段的视频流标签(如 "v0", "v1"
transitions: 每个片段对应的转场效果(第一个片段的转场被忽略)
transition_duration: 转场时长(秒)
output_label: 最终输出标签
Returns:
(filter_string, estimated_total_duration)
"""
n = len(clip_durations)
parts: list[str] = []
if n == 0:
return "", 0.0
if n == 1:
parts.append(f"[{clip_video_labels[0]}]copy[{output_label}]")
return ";".join(parts), clip_durations[0]
# xfade 链 — 每步动态钳制 td,防止 offset + td > first_input_duration
cumulative = 0.0
prev_label = clip_video_labels[0]
total_transition = 0.0 # 累计已使用的转场时长
for i in range(1, n):
cumulative += clip_durations[i - 1]
# 当前 xfade 的第一个输入时长
if i == 1:
first_input_dur = clip_durations[0]
else:
first_input_dur = cumulative - total_transition
# 原始 offset 计算
offset = max(0.0, cumulative - transition_duration * i)
# 安全钳制:offset + td 不能超过第一个输入的时长
available = max(0.0, first_input_dur - offset)
safe_td = min(transition_duration, available)
# 同时不能超过剩余总时长
remaining = max(0.0, sum(clip_durations) - cumulative)
safe_td = min(safe_td, remaining)
# 同时不能超过当前第二个输入(单个片段)的时长
safe_td = min(safe_td, clip_durations[i])
safe_td = max(0.001, safe_td) # 至少 1ms,避免 td=0
transition = transitions[i] if i < len(transitions) else "cut"
xfade_transition = resolve_xfade_transition(transition)
if i == n - 1:
out_label = output_label
else:
out_label = f"xf{i}"
parts.append(
f"[{prev_label}][{clip_video_labels[i]}]"
f"xfade=transition={xfade_transition}"
f":duration={safe_td:.3f}"
f":offset={offset:.3f}"
f"[{out_label}]"
)
prev_label = out_label
total_transition += safe_td
# 总时长减去转场重叠部分
total_duration = sum(clip_durations) - total_transition
return ";".join(parts), max(0.0, total_duration)