9a25eb6642
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
311 lines
9.3 KiB
Python
Executable File
311 lines
9.3 KiB
Python
Executable File
"""片头片尾引擎 — 视频包装与品牌标识.
|
||
|
||
支持:
|
||
- 片头:视频片段 或 纯文字片头(背景色 + 标题 + 副标题)
|
||
- 片尾:视频片段 或 关注引导片尾
|
||
- 自动与正片拼接(xfade 转场)
|
||
- 时长可配置
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||
|
||
from packages.domain.intro_outro_config import ( # noqa: F401 — 向后兼容
|
||
INTRO_OUTRO_TYPE_FOLLOW,
|
||
INTRO_OUTRO_TYPE_NONE,
|
||
INTRO_OUTRO_TYPE_TEXT,
|
||
INTRO_OUTRO_TYPE_VIDEO,
|
||
TRANSITION_FADE,
|
||
IntroOutroConfig,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ── 片头片尾引擎 ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
class IntroOutroEngine:
|
||
"""片头片尾引擎 — 生成片头片尾视频并与正片拼接."""
|
||
|
||
@staticmethod
|
||
def generate_text_intro(
|
||
output_path: Path,
|
||
config: IntroOutroConfig,
|
||
output_width: int,
|
||
output_height: int,
|
||
output_fps: int,
|
||
) -> bool:
|
||
"""生成纯文字片头视频.
|
||
|
||
Args:
|
||
output_path: 输出文件路径
|
||
config: 片头片尾配置
|
||
output_width: 输出宽度
|
||
output_height: 输出高度
|
||
output_fps: 输出帧率
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
duration = config.intro_duration
|
||
|
||
# 转义文字
|
||
title = config.intro_title.replace(":", "\\:").replace("'", "\\'")
|
||
subtitle = config.intro_subtitle.replace(":", "\\:").replace("'", "\\'")
|
||
|
||
# 颜色(FFmpeg 颜色格式)
|
||
title_color = config.intro_title_color
|
||
subtitle_color = config.intro_subtitle_color
|
||
|
||
# 计算位置:标题在中心偏上,副标题在中心偏下
|
||
title_y = f"(h-text_h)/2 - {config.intro_title_size // 2}"
|
||
subtitle_y = f"(h-text_h)/2 + {config.intro_title_size}"
|
||
|
||
# 构建滤镜
|
||
filter_parts = []
|
||
|
||
# 背景
|
||
filter_parts.append(f"color=c={config.intro_background}:s={output_width}x{output_height}:d={duration}[bg]")
|
||
|
||
# 标题
|
||
if title:
|
||
filter_parts.append(
|
||
f"[bg]drawtext="
|
||
f"text='{title}':"
|
||
f"fontsize={config.intro_title_size}:"
|
||
f"fontcolor={title_color}:"
|
||
f"x=(w-text_w)/2:"
|
||
f"y={title_y}:"
|
||
f"alpha='if(lt(t,0.5),t/0.5,1)'" # 淡入
|
||
f"[with_title]"
|
||
)
|
||
bg_label = "with_title"
|
||
else:
|
||
bg_label = "bg"
|
||
|
||
# 副标题
|
||
if subtitle:
|
||
filter_parts.append(
|
||
f"[{bg_label}]drawtext="
|
||
f"text='{subtitle}':"
|
||
f"fontsize={config.intro_subtitle_size}:"
|
||
f"fontcolor={subtitle_color}:"
|
||
f"x=(w-text_w)/2:"
|
||
f"y={subtitle_y}:"
|
||
f"alpha='if(lt(t,0.8),0,if(lt(t,1.2),(t-0.8)/0.4,1))'" # 延迟淡入
|
||
f"[out]"
|
||
)
|
||
final_label = "out"
|
||
else:
|
||
final_label = bg_label
|
||
# 如果没有副标题,需要补上 out 标签
|
||
if final_label != "out":
|
||
filter_parts.append(f"[{bg_label}]copy[out]")
|
||
|
||
filter_complex = ";".join(filter_parts)
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
f"color=c={config.intro_background}:s={output_width}x{output_height}:d={duration}:r={output_fps}",
|
||
"-filter_complex",
|
||
filter_complex,
|
||
"-map",
|
||
"[out]",
|
||
"-c:v",
|
||
"libx264",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-r",
|
||
str(output_fps),
|
||
"-t",
|
||
str(duration),
|
||
"-an", # 无音频
|
||
str(output_path),
|
||
]
|
||
|
||
try:
|
||
run_ffmpeg(command)
|
||
return output_path.exists()
|
||
except subprocess.CalledProcessError as e:
|
||
logger.error("生成文字片头失败: %s", e)
|
||
return False
|
||
|
||
@staticmethod
|
||
def generate_text_outro(
|
||
output_path: Path,
|
||
config: IntroOutroConfig,
|
||
output_width: int,
|
||
output_height: int,
|
||
output_fps: int,
|
||
) -> bool:
|
||
"""生成纯文字片尾视频."""
|
||
duration = config.outro_duration
|
||
|
||
# 转义文字
|
||
title = config.outro_title.replace(":", "\\:").replace("'", "\\'")
|
||
subtitle = config.outro_subtitle.replace(":", "\\:").replace("'", "\\'")
|
||
|
||
title_color = config.outro_title_color
|
||
subtitle_color = config.outro_subtitle_color
|
||
|
||
# 位置
|
||
title_y = f"(h-text_h)/2 - {config.outro_title_size // 2}"
|
||
subtitle_y = f"(h-text_h)/2 + {config.outro_title_size}"
|
||
|
||
filter_parts = []
|
||
|
||
# 背景
|
||
bg_src = f"color=c={config.outro_background}:s={output_width}x{output_height}:d={duration}:r={output_fps}"
|
||
filter_parts.append(f"color=c={config.outro_background}:s={output_width}x{output_height}:d={duration}[bg]")
|
||
|
||
# 标题 + 淡出
|
||
if title:
|
||
filter_parts.append(
|
||
f"[bg]drawtext="
|
||
f"text='{title}':"
|
||
f"fontsize={config.outro_title_size}:"
|
||
f"fontcolor={title_color}:"
|
||
f"x=(w-text_w)/2:"
|
||
f"y={title_y}:"
|
||
f"alpha='if(gt(t,{duration - 0.5}),({duration}-t)/0.5,1)'"
|
||
f"[with_title]"
|
||
)
|
||
bg_label = "with_title"
|
||
else:
|
||
bg_label = "bg"
|
||
|
||
# 副标题
|
||
if subtitle:
|
||
filter_parts.append(
|
||
f"[{bg_label}]drawtext="
|
||
f"text='{subtitle}':"
|
||
f"fontsize={config.outro_subtitle_size}:"
|
||
f"fontcolor={subtitle_color}:"
|
||
f"x=(w-text_w)/2:"
|
||
f"y={subtitle_y}:"
|
||
f"alpha='if(gt(t,{duration - 0.5}),({duration}-t)/0.5,1)'"
|
||
f"[out]"
|
||
)
|
||
final_label = "out"
|
||
else:
|
||
final_label = bg_label
|
||
if final_label != "out":
|
||
filter_parts.append(f"[{bg_label}]copy[out]")
|
||
|
||
filter_complex = ";".join(filter_parts)
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-f",
|
||
"lavfi",
|
||
"-i",
|
||
bg_src,
|
||
"-filter_complex",
|
||
filter_complex,
|
||
"-map",
|
||
"[out]",
|
||
"-c:v",
|
||
"libx264",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-r",
|
||
str(output_fps),
|
||
"-t",
|
||
str(duration),
|
||
"-an",
|
||
str(output_path),
|
||
]
|
||
|
||
try:
|
||
run_ffmpeg(command)
|
||
return output_path.exists()
|
||
except subprocess.CalledProcessError as e:
|
||
logger.error("生成文字片尾失败: %s", e)
|
||
return False
|
||
|
||
@staticmethod
|
||
def concat_with_intro_outro(
|
||
main_video: Path,
|
||
intro_video: Path | None,
|
||
outro_video: Path | None,
|
||
output_path: Path,
|
||
transition_duration: float = 0.5,
|
||
transition_effect: str = "fade",
|
||
) -> bool:
|
||
"""将片头 + 正片 + 片尾用 xfade 拼接.
|
||
|
||
只传了片头或片尾也可以,缺失的自动跳过。
|
||
"""
|
||
# 收集所有片段
|
||
# 简单探测时长(用 ffprobe,这里简化处理:直接用 xfade 的 offset)
|
||
# 先添加到列表
|
||
has_intro = intro_video is not None and intro_video.exists()
|
||
has_outro = outro_video is not None and outro_video.exists()
|
||
|
||
if not has_intro and not has_outro:
|
||
# 没有片头片尾,直接复制
|
||
import shutil
|
||
|
||
shutil.copy2(main_video, output_path)
|
||
return True
|
||
|
||
# 构建输入和 xfade 链
|
||
# 简单方式:用 concat demuxer(快速但无转场)
|
||
# 高级方式:用 xfade 滤镜链(有转场但复杂)
|
||
|
||
# 用 concat demuxer 方式(性能好,过渡用硬切)
|
||
# 后续可以加 xfade 转场
|
||
concat_list = []
|
||
if has_intro:
|
||
concat_list.append(intro_video)
|
||
concat_list.append(main_video)
|
||
if has_outro:
|
||
concat_list.append(outro_video)
|
||
|
||
# 生成 concat 列表文件
|
||
list_file = output_path.parent / f"concat_list_{output_path.stem}.txt"
|
||
with open(list_file, "w") as f:
|
||
for seg in concat_list:
|
||
f.write(f"file '{seg}'\n")
|
||
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-f",
|
||
"concat",
|
||
"-safe",
|
||
"0",
|
||
"-i",
|
||
str(list_file),
|
||
"-c:v",
|
||
"libx264",
|
||
"-c:a",
|
||
"aac",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-movflags",
|
||
"+faststart",
|
||
str(output_path),
|
||
]
|
||
|
||
try:
|
||
run_ffmpeg(command)
|
||
# 清理列表文件
|
||
list_file.unlink(missing_ok=True)
|
||
return output_path.exists()
|
||
except subprocess.CalledProcessError as e:
|
||
logger.error("片头片尾拼接失败: %s", e)
|
||
list_file.unlink(missing_ok=True)
|
||
return False
|