09d2b12ea8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m7s
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 18m38s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 19s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 8m7s
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) Successful in 2m16s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 4m35s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
417 lines
13 KiB
Python
Executable File
417 lines
13 KiB
Python
Executable File
"""片头片尾引擎 — 视频包装与品牌标识.
|
||
|
||
支持:
|
||
- 片头:视频片段 或 纯文字片头(背景色 + 标题 + 副标题)
|
||
- 片尾:视频片段 或 关注引导片尾
|
||
- 自动与正片拼接(xfade 转场)
|
||
- 时长可配置
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import subprocess
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass
|
||
class IntroOutroConfig:
|
||
"""片头片尾配置.
|
||
|
||
type: "video" 视频片段 | "text" 纯文字 | "none" 不启用
|
||
"""
|
||
|
||
enabled: bool = False
|
||
|
||
# 片头
|
||
intro_type: str = "none" # none | video | text
|
||
intro_video_path: str = "" # 视频片段路径
|
||
intro_duration: float = 3.0 # 片头时长(秒)
|
||
|
||
# 文字片头配置
|
||
intro_background: str = "#000000" # 背景色
|
||
intro_title: str = ""
|
||
intro_subtitle: str = ""
|
||
intro_title_color: str = "white"
|
||
intro_title_size: int = 48
|
||
intro_subtitle_color: str = "gray"
|
||
intro_subtitle_size: int = 24
|
||
|
||
# 片尾
|
||
outro_type: str = "none" # none | video | text | follow
|
||
outro_video_path: str = "" # 视频片段路径
|
||
outro_duration: float = 3.0 # 片尾时长(秒)
|
||
|
||
# 文字片尾配置
|
||
outro_background: str = "#000000"
|
||
outro_title: str = "感谢观看"
|
||
outro_subtitle: str = "点赞关注不迷路"
|
||
outro_title_color: str = "white"
|
||
outro_title_size: int = 48
|
||
outro_subtitle_color: str = "gray"
|
||
outro_subtitle_size: int = 24
|
||
|
||
# 转场
|
||
transition_effect: str = "fade"
|
||
transition_duration: float = 0.5
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: dict[str, Any] | None) -> IntroOutroConfig:
|
||
"""从字典构造."""
|
||
if not data:
|
||
return cls()
|
||
|
||
enabled = data.get("enabled", False)
|
||
if not enabled:
|
||
return cls()
|
||
|
||
intro = data.get("intro", {}) or {}
|
||
outro = data.get("outro", {}) or {}
|
||
|
||
return cls(
|
||
enabled=True,
|
||
# 片头
|
||
intro_type=str(intro.get("type", "none")),
|
||
intro_video_path=str(intro.get("video_path", intro.get("video", "")) or ""),
|
||
intro_duration=float(intro.get("duration", 3.0)),
|
||
intro_background=str(intro.get("background", "#000000")),
|
||
intro_title=str(intro.get("title", "") or ""),
|
||
intro_subtitle=str(intro.get("subtitle", "") or ""),
|
||
intro_title_color=str(intro.get("title_color", "white")),
|
||
intro_title_size=int(intro.get("title_size", 48)),
|
||
intro_subtitle_color=str(intro.get("subtitle_color", "gray")),
|
||
intro_subtitle_size=int(intro.get("subtitle_size", 24)),
|
||
# 片尾
|
||
outro_type=str(outro.get("type", "none")),
|
||
outro_video_path=str(outro.get("video_path", outro.get("video", "")) or ""),
|
||
outro_duration=float(outro.get("duration", 3.0)),
|
||
outro_background=str(outro.get("background", "#000000")),
|
||
outro_title=str(outro.get("title", "感谢观看") or "感谢观看"),
|
||
outro_subtitle=str(outro.get("subtitle", "点赞关注不迷路") or "点赞关注不迷路"),
|
||
outro_title_color=str(outro.get("title_color", "white")),
|
||
outro_title_size=int(outro.get("title_size", 48)),
|
||
outro_subtitle_color=str(outro.get("subtitle_color", "gray")),
|
||
outro_subtitle_size=int(outro.get("subtitle_size", 24)),
|
||
# 转场
|
||
transition_effect=str(data.get("transition", "fade")),
|
||
transition_duration=float(data.get("transition_duration", 0.5)),
|
||
)
|
||
|
||
@property
|
||
def has_intro(self) -> bool:
|
||
"""是否有片头."""
|
||
return self.enabled and self.intro_type in ("video", "text")
|
||
|
||
@property
|
||
def has_outro(self) -> bool:
|
||
"""是否有片尾."""
|
||
return self.enabled and self.outro_type in ("video", "text", "follow")
|
||
|
||
def validate(self) -> tuple[bool, str]:
|
||
"""校验配置."""
|
||
if not self.enabled:
|
||
return True, ""
|
||
|
||
if self.intro_type == "video" and not self.intro_video_path:
|
||
return False, "视频片头缺少 video_path"
|
||
if self.intro_type == "text" and not self.intro_title:
|
||
return False, "文字片头缺少 title"
|
||
|
||
if self.outro_type == "video" and not self.outro_video_path:
|
||
return False, "视频片尾缺少 video_path"
|
||
if self.outro_type in ("text", "follow") and not self.outro_title:
|
||
return False, "文字片尾缺少 title"
|
||
|
||
if self.intro_duration <= 0:
|
||
return False, "片头时长必须大于 0"
|
||
if self.outro_duration <= 0:
|
||
return False, "片尾时长必须大于 0"
|
||
|
||
return True, ""
|
||
|
||
|
||
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
|