9f153eec54
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m9s
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 / Frontend Lint (push) Successful in 46s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m4s
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 / Validate - Code Quality (push) Failing after 2m34s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 7m18s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m15s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m21s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 2m22s
CI/CD Pipeline / Unit Tests (push) Failing after 6m18s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 41s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m46s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m50s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
583 lines
21 KiB
Python
Executable File
583 lines
21 KiB
Python
Executable File
"""视频拼接/合并引擎 — 多段视频按顺序拼接成一个成片.
|
||
|
||
基于 FFmpeg 实现两种拼接模式:
|
||
1. **concat demuxer(stream copy)**:最快,所有视频编码参数必须一致
|
||
2. **concat filter(重新编码)**:更灵活,支持不同分辨率/编码/帧率的视频
|
||
|
||
使用场景:
|
||
- 多段素材按顺序合并成一个视频
|
||
- 视频分割后重新拼接
|
||
- 片头 + 正片 + 片尾拼接
|
||
|
||
降级策略:
|
||
- 优先尝试 stream copy(速度快、无质量损失)
|
||
- 参数不一致时自动降级到 concat filter
|
||
- 某段视频失败时跳过,不阻断整体拼接
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
|
||
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, probe_video_info, run_ffmpeg
|
||
from video_processing.path_security import PathSecurityError, is_in_allowed_dirs, safe_resolve_path
|
||
|
||
from packages.domain.video_concat import ( # noqa: F401 向后兼容导出
|
||
ALLOWED_VIDEO_EXTENSIONS,
|
||
CONCAT_DEMUXER_REQUIRED_PARAMS,
|
||
MAX_CONCAT_SEGMENTS,
|
||
ConcatConfig,
|
||
ConcatSegment,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ── 路径安全校验 ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _validate_video_path(video_path: str, work_dir: Path) -> None:
|
||
"""校验视频文件路径安全性.
|
||
|
||
规则:
|
||
- local:// schema → 必须在 work_dir 内
|
||
- 相对路径 → 必须在 work_dir 内
|
||
- 绝对路径 → 必须在允许目录白名单内
|
||
- 扩展名必须是视频格式
|
||
|
||
Raises:
|
||
PathSecurityError: 路径不安全
|
||
"""
|
||
if not video_path or not isinstance(video_path, str):
|
||
raise PathSecurityError("视频路径不能为空")
|
||
|
||
# 本地路径(local:// 或相对路径 / 绝对路径)
|
||
if video_path.startswith("local://") or not video_path.startswith(("http://", "https://", "oss://")):
|
||
is_abs = video_path.startswith("/") and not video_path.startswith("local://")
|
||
resolved_path = safe_resolve_path(
|
||
video_path,
|
||
work_dir,
|
||
allow_outside=is_abs,
|
||
allowed_extensions=ALLOWED_VIDEO_EXTENSIONS,
|
||
)
|
||
# 绝对路径额外检查白名单目录(用realpath规范化后的真实路径比较,防止 ../ 遍历绕过)
|
||
if is_abs:
|
||
resolved_work_dir = work_dir.resolve()
|
||
try:
|
||
resolved_path.relative_to(resolved_work_dir)
|
||
except ValueError as _e:
|
||
if not is_in_allowed_dirs(resolved_path):
|
||
raise PathSecurityError(f"视频路径不在允许目录内: {video_path[:80]}") from _e
|
||
# URL类型路径不做本地路径校验(由下载阶段的SSRF防护负责)
|
||
# 但检查扩展名
|
||
else:
|
||
path_part = video_path.split("?")[0].split("#")[0]
|
||
ext = Path(path_part).suffix.lower()
|
||
if ext and ext not in ALLOWED_VIDEO_EXTENSIONS:
|
||
raise PathSecurityError(f"不允许的视频文件类型: {ext}")
|
||
|
||
|
||
# ── 视频拼接引擎 ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
class ConcatEngine:
|
||
"""视频拼接引擎 — 支持 stream copy 和重新编码两种模式."""
|
||
|
||
def __init__(self, work_dir: Path):
|
||
self.work_dir = work_dir
|
||
self.work_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# ── 主入口 ────────────────────────────────────────────────────────
|
||
|
||
def concat_videos(
|
||
self,
|
||
config: ConcatConfig,
|
||
output_path: Path,
|
||
) -> Path:
|
||
"""拼接多段视频.
|
||
|
||
自动选择最优拼接策略:
|
||
1. 所有片段参数一致 → concat demuxer(stream copy,最快)
|
||
2. 参数不一致或有裁剪 → concat filter(重新编码)
|
||
|
||
Args:
|
||
config: 拼接配置
|
||
output_path: 输出文件路径
|
||
|
||
Returns:
|
||
输出文件路径
|
||
"""
|
||
valid_segments = [s for s in config.segments if s.video_path]
|
||
|
||
if not valid_segments:
|
||
raise ValueError("No valid video segments to concat")
|
||
|
||
# ── 安全校验:段数上限 ──
|
||
if len(valid_segments) > MAX_CONCAT_SEGMENTS:
|
||
raise ValueError(f"Too many concat segments: {len(valid_segments)} > {MAX_CONCAT_SEGMENTS}")
|
||
|
||
# ── 安全校验:所有视频路径白名单校验 ──
|
||
safe_segments = []
|
||
for seg in valid_segments:
|
||
try:
|
||
_validate_video_path(seg.video_path, self.work_dir)
|
||
safe_segments.append(seg)
|
||
except PathSecurityError as e:
|
||
logger.warning("[concat] skip segment: path security check failed: %s", e)
|
||
|
||
if len(safe_segments) != len(valid_segments):
|
||
valid_segments = safe_segments
|
||
config.segments = safe_segments
|
||
logger.info("[concat] %d segments passed security check", len(safe_segments))
|
||
|
||
if not valid_segments:
|
||
raise ValueError("No valid video segments after security check")
|
||
|
||
if len(valid_segments) == 1:
|
||
# 只有一段,直接复制
|
||
import shutil
|
||
|
||
logger.info("[concat] single segment, copy directly")
|
||
shutil.copy2(valid_segments[0].video_path, output_path)
|
||
return output_path
|
||
|
||
# 判断能否用 stream copy
|
||
can_stream_copy = self._can_use_stream_copy(config)
|
||
|
||
if can_stream_copy and not config.force_reencode:
|
||
logger.info("[concat] using concat demuxer (stream copy)")
|
||
try:
|
||
return self._concat_demuxer(config, output_path)
|
||
except Exception as e:
|
||
logger.warning("[concat] demuxer failed, fallback to filter: %s", e)
|
||
|
||
# 降级到 concat filter
|
||
logger.info("[concat] using concat filter (re-encode)")
|
||
return self._concat_filter(config, output_path)
|
||
|
||
# ── 模式判断 ──────────────────────────────────────────────────────
|
||
|
||
def _can_use_stream_copy(self, config: ConcatConfig) -> bool:
|
||
"""判断是否可以使用 concat demuxer(stream copy).
|
||
|
||
条件:
|
||
1. 所有视频编码参数一致(分辨率、帧率、编码、像素格式)
|
||
2. 所有音频参数一致(采样率、声道、编码)
|
||
3. 没有设置 start_time 裁剪(或可以通过 concat demuxer 的 inpoint/outpoint 实现)
|
||
4. 没有强制重新编码
|
||
"""
|
||
if config.force_reencode:
|
||
return False
|
||
|
||
# 如果有转场效果,必须重新编码
|
||
if config.transition != "none":
|
||
return False
|
||
|
||
# 探测所有视频的参数
|
||
video_infos = []
|
||
for seg in config.segments:
|
||
if not seg.video_path:
|
||
continue
|
||
try:
|
||
info = probe_video_info(seg.video_path)
|
||
video_infos.append(info)
|
||
except Exception:
|
||
logger.warning("[concat] probe failed for %s", seg.video_path[-40:])
|
||
return False
|
||
|
||
if len(video_infos) < 2:
|
||
return False
|
||
|
||
# 检查参数一致性
|
||
base_info = video_infos[0]
|
||
for info in video_infos[1:]:
|
||
for param in CONCAT_DEMUXER_REQUIRED_PARAMS:
|
||
base_val = base_info.get(param)
|
||
curr_val = info.get(param)
|
||
if base_val != curr_val:
|
||
logger.debug(
|
||
"[concat] param mismatch: %s (%s vs %s)",
|
||
param,
|
||
base_val,
|
||
curr_val,
|
||
)
|
||
return False
|
||
|
||
# 检查是否有裁剪需求
|
||
# concat demuxer 支持 inpoint/outpoint,所以有裁剪也可以用
|
||
# 但为了简单和稳定性,有裁剪时也用 filter 模式
|
||
# (inpoint/outpoint 不是所有格式都支持得好)
|
||
has_trimming = any(seg.start_time > 0 or seg.duration > 0 for seg in config.segments if seg.video_path)
|
||
if has_trimming:
|
||
return False
|
||
|
||
return True
|
||
|
||
# ── 模式1:concat demuxer(stream copy) ──────────────────────────
|
||
|
||
def _concat_demuxer(self, config: ConcatConfig, output_path: Path) -> Path:
|
||
"""使用 concat demuxer 拼接(stream copy).
|
||
|
||
优点:速度极快,无质量损失
|
||
缺点:要求所有视频参数完全一致
|
||
"""
|
||
# 生成 concat 文件列表
|
||
list_file = self.work_dir / "concat_list.txt"
|
||
lines = []
|
||
for seg in config.segments:
|
||
if not seg.video_path:
|
||
continue
|
||
# 路径转义:单引号替换为 '\''
|
||
safe_path = str(seg.video_path).replace("'", "'\\''")
|
||
lines.append(f"file '{safe_path}'")
|
||
|
||
list_file.write_text("\n".join(lines), encoding="utf-8")
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-f",
|
||
"concat",
|
||
"-safe",
|
||
"0",
|
||
"-i",
|
||
str(list_file),
|
||
"-c",
|
||
"copy",
|
||
"-copyts",
|
||
str(output_path),
|
||
]
|
||
|
||
logger.info("[concat] demuxer: %d segments", config.total_segments)
|
||
run_ffmpeg(command)
|
||
return output_path
|
||
|
||
# ── 模式2:concat filter(重新编码) ──────────────────────────────
|
||
|
||
def _concat_filter(self, config: ConcatConfig, output_path: Path) -> Path:
|
||
"""使用 concat filter 拼接(重新编码).
|
||
|
||
优点:支持不同参数的视频,支持裁剪
|
||
缺点:需要重新编码,较慢
|
||
"""
|
||
valid_segments = [s for s in config.segments if s.video_path]
|
||
num_segments = len(valid_segments)
|
||
|
||
# 构建输入参数
|
||
input_args: list[str] = []
|
||
for seg in valid_segments:
|
||
input_args.extend(["-i", seg.video_path])
|
||
|
||
# 确定输出参数
|
||
output_width, output_height, output_fps = self._get_output_params(config)
|
||
|
||
# 构建 filter_complex
|
||
filter_parts: list[str] = []
|
||
concat_inputs = ""
|
||
|
||
for i, seg in enumerate(valid_segments):
|
||
vid_label = f"v{i}"
|
||
aud_label = f"a{i}"
|
||
|
||
seg_filters: list[str] = []
|
||
|
||
# 1. 裁剪(start_time + duration)
|
||
if seg.start_time > 0 or seg.duration > 0:
|
||
start = seg.start_time
|
||
if seg.duration > 0:
|
||
end = start + seg.duration
|
||
seg_filters.append(f"trim=start={start:.3f}:end={end:.3f}")
|
||
else:
|
||
seg_filters.append(f"trim=start={start:.3f}")
|
||
seg_filters.append("setpts=PTS-STARTPTS")
|
||
|
||
# 音频同步裁剪
|
||
if seg.has_audio:
|
||
if seg.duration > 0:
|
||
filter_parts.append(
|
||
f"[{i}:a]atrim=start={start:.3f}:end={end:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]"
|
||
)
|
||
else:
|
||
filter_parts.append(f"[{i}:a]atrim=start={start:.3f}," f"asetpts=PTS-STARTPTS[{aud_label}]")
|
||
else:
|
||
# 无音频时生成静音轨
|
||
filter_parts.append(
|
||
f"[{i}:v]trim=start={start:.3f}," f"setpts=PTS-STARTPTS, " f"aevalsrc=0:d={0.1}[{aud_label}]"
|
||
)
|
||
else:
|
||
# 无裁剪,直接用原始标签
|
||
if not seg.has_audio:
|
||
# 无音频时需要生成静音
|
||
try:
|
||
dur = probe_duration(seg.video_path)
|
||
except Exception:
|
||
dur = 10.0
|
||
filter_parts.append(f"aevalsrc=0:d={dur:.3f}:s=44100[{aud_label}]")
|
||
|
||
# 2. 缩放/帧率统一
|
||
vf_parts = []
|
||
if not seg_filters:
|
||
vf_parts.append(f"[{i}:v]")
|
||
else:
|
||
vf_parts.append("")
|
||
|
||
# 分辨率统一
|
||
if output_width and output_height:
|
||
vf_parts.append(
|
||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black"
|
||
)
|
||
|
||
# 帧率统一
|
||
if output_fps > 0:
|
||
vf_parts.append(f"fps={output_fps}")
|
||
|
||
# 像素格式统一
|
||
vf_parts.append("format=yuv420p")
|
||
|
||
if len(vf_parts) > 1 or (seg_filters and vf_parts):
|
||
if seg_filters:
|
||
# 先裁剪后缩放
|
||
crop_str = "".join(seg_filters)
|
||
scale_str = "".join(vf_parts[1:]) # 跳过空字符串
|
||
if scale_str:
|
||
filter_parts.append(f"[{i}:v]{crop_str},{scale_str}[{vid_label}]")
|
||
else:
|
||
filter_parts.append(f"[{i}:v]{crop_str}[{vid_label}]")
|
||
else:
|
||
filter_parts.append(f"{vf_parts[0]}{''.join(vf_parts[1:])}[{vid_label}]")
|
||
else:
|
||
if seg_filters:
|
||
filter_parts.append(f"[{i}:v]{''.join(seg_filters)}[{vid_label}]")
|
||
else:
|
||
# 什么都不需要,直接用输入
|
||
pass
|
||
|
||
# 拼接 concat 的输入标签
|
||
if seg_filters or (output_width and output_height) or output_fps > 0:
|
||
concat_inputs += f"[{vid_label}]"
|
||
else:
|
||
concat_inputs += f"[{i}:v]"
|
||
|
||
# 音频标签
|
||
if seg.start_time > 0 or seg.duration > 0:
|
||
# 已经生成了 aud_label
|
||
pass
|
||
elif not seg.has_audio:
|
||
# 已经生成了静音 aud_label
|
||
pass
|
||
else:
|
||
# 使用原始音频
|
||
pass
|
||
|
||
# 简化处理:用更直接的方式构建 filter
|
||
# 重新整理一下,确保所有输入都有对应的 v_i 和 a_i 标签
|
||
filter_parts.clear()
|
||
concat_inputs = "" # 按段交织: [v0][a0][v1][a1]...
|
||
|
||
for i, seg in enumerate(valid_segments):
|
||
v_label = f"v{i}_in"
|
||
a_label = f"a{i}_in"
|
||
|
||
# 视频处理链
|
||
v_steps: list[str] = [f"[{i}:v]"]
|
||
|
||
# 裁剪
|
||
if seg.start_time > 0 or seg.duration > 0:
|
||
start = seg.start_time
|
||
if seg.duration > 0:
|
||
end = start + seg.duration
|
||
v_steps.append(f"trim=start={start:.3f}:end={end:.3f},")
|
||
else:
|
||
v_steps.append(f"trim=start={start:.3f},")
|
||
v_steps.append("setpts=PTS-STARTPTS,")
|
||
|
||
# 缩放
|
||
if output_width and output_height:
|
||
v_steps.append(
|
||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black,"
|
||
)
|
||
|
||
# 帧率
|
||
if output_fps > 0:
|
||
v_steps.append(f"fps={output_fps},")
|
||
|
||
# 像素格式
|
||
v_steps.append("format=yuv420p")
|
||
|
||
v_filter = "".join(v_steps) + f"[{v_label}]"
|
||
filter_parts.append(v_filter)
|
||
|
||
# 音频处理链
|
||
a_steps: list[str] = []
|
||
if seg.has_audio:
|
||
a_steps.append(f"[{i}:a]")
|
||
|
||
if seg.start_time > 0 or seg.duration > 0:
|
||
start = seg.start_time
|
||
if seg.duration > 0:
|
||
end = start + seg.duration
|
||
a_steps.append(f"atrim=start={start:.3f}:end={end:.3f},")
|
||
else:
|
||
a_steps.append(f"atrim=start={start:.3f},")
|
||
a_steps.append("asetpts=PTS-STARTPTS,")
|
||
|
||
a_steps.append("aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo")
|
||
else:
|
||
# 生成静音音频
|
||
try:
|
||
dur = probe_duration(seg.video_path)
|
||
except Exception:
|
||
dur = 10.0
|
||
# 减去裁剪
|
||
if seg.start_time > 0:
|
||
dur = max(0.1, dur - seg.start_time)
|
||
if seg.duration > 0 and seg.duration < dur:
|
||
dur = seg.duration
|
||
a_steps.append(f"aevalsrc=0:d={dur:.3f}:s=44100:c=stereo")
|
||
|
||
a_filter = "".join(a_steps) + f"[{a_label}]"
|
||
filter_parts.append(a_filter)
|
||
|
||
# 按段交织排列(v_i, a_i),这是 FFmpeg concat filter 要求的顺序
|
||
concat_inputs += f"[{v_label}][{a_label}]"
|
||
|
||
# concat filter: 输入按 [v0][a0][v1][a1]... 顺序
|
||
filter_parts.append(f"{concat_inputs}" f"concat=n={num_segments}:v=1:a=1[vout][aout]")
|
||
|
||
filter_complex = ";".join(filter_parts)
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
*input_args,
|
||
"-filter_complex",
|
||
filter_complex,
|
||
"-map",
|
||
"[vout]",
|
||
"-map",
|
||
"[aout]",
|
||
"-c:v",
|
||
"libx264",
|
||
"-preset",
|
||
"fast",
|
||
"-crf",
|
||
"23",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
"-movflags",
|
||
"+faststart",
|
||
str(output_path),
|
||
]
|
||
|
||
logger.info(
|
||
"[concat] filter: %d segments, %dx%d, %.2f fps",
|
||
num_segments,
|
||
output_width,
|
||
output_height,
|
||
output_fps,
|
||
)
|
||
run_ffmpeg(command)
|
||
return output_path
|
||
|
||
# ── 辅助方法 ──────────────────────────────────────────────────────
|
||
|
||
def _get_output_params(self, config: ConcatConfig) -> tuple[int, int, float]:
|
||
"""获取输出参数(宽、高、帧率).
|
||
|
||
优先级:
|
||
1. config 中显式指定的
|
||
2. 第一段视频的参数
|
||
"""
|
||
valid_segments = [s for s in config.segments if s.video_path]
|
||
|
||
width = config.output_width
|
||
height = config.output_height
|
||
fps = config.output_fps
|
||
|
||
# 如果没有显式指定,用第一段的参数
|
||
if (width == 0 or height == 0 or fps == 0) and valid_segments:
|
||
try:
|
||
info = probe_video_info(valid_segments[0].video_path)
|
||
if width == 0:
|
||
width = int(info.get("width", 1080))
|
||
if height == 0:
|
||
height = int(info.get("height", 1920))
|
||
if fps == 0:
|
||
fps_str = info.get("r_frame_rate", "30/1")
|
||
if "/" in str(fps_str):
|
||
num, den = str(fps_str).split("/")
|
||
try:
|
||
fps = float(num) / float(den)
|
||
except (ValueError, ZeroDivisionError):
|
||
fps = 30.0
|
||
else:
|
||
fps = float(fps_str) if fps_str else 30.0
|
||
except Exception:
|
||
# 探测失败,用默认值
|
||
if width == 0:
|
||
width = 1080
|
||
if height == 0:
|
||
height = 1920
|
||
if fps == 0:
|
||
fps = 30.0
|
||
|
||
return width, height, fps
|
||
|
||
|
||
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def concat_video_files(
|
||
video_paths: list[str],
|
||
output_path: Path,
|
||
*,
|
||
work_dir: Path | None = None,
|
||
force_reencode: bool = False,
|
||
) -> Path:
|
||
"""简单拼接多个视频文件.
|
||
|
||
Args:
|
||
video_paths: 视频文件路径列表
|
||
output_path: 输出路径
|
||
work_dir: 工作目录(默认输出文件所在目录)
|
||
force_reencode: 是否强制重新编码
|
||
|
||
Returns:
|
||
输出文件路径
|
||
"""
|
||
if work_dir is None:
|
||
work_dir = output_path.parent
|
||
|
||
segments = [ConcatSegment(video_path=p) for p in video_paths if p]
|
||
config = ConcatConfig(segments=segments, force_reencode=force_reencode)
|
||
|
||
engine = ConcatEngine(work_dir)
|
||
return engine.concat_videos(config, output_path)
|
||
|
||
|
||
def concat_videos_from_config(
|
||
config_dict: dict | None,
|
||
output_path: Path,
|
||
*,
|
||
work_dir: Path,
|
||
) -> Path | None:
|
||
"""从配置字典执行视频拼接.
|
||
|
||
降级策略:配置无效或拼接失败时返回 None.
|
||
"""
|
||
config = ConcatConfig.from_config_dict(config_dict)
|
||
if not config.has_effect:
|
||
return None
|
||
|
||
try:
|
||
engine = ConcatEngine(work_dir)
|
||
return engine.concat_videos(config, output_path)
|
||
except Exception as e:
|
||
logger.error("[concat] concat failed: %s", e)
|
||
return None
|