Files
CI Bot 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
style: auto-format with black + isort + prettier
2026-07-26 23:21:17 +00:00

311 lines
9.3 KiB
Python
Executable File
Raw Permalink 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.
"""片头片尾引擎 — 视频包装与品牌标识.
支持:
- 片头:视频片段 或 纯文字片头(背景色 + 标题 + 副标题)
- 片尾:视频片段 或 关注引导片尾
- 自动与正片拼接(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