basketball-highlight / services /video_processor.py
hiro1997's picture
Upload services/video_processor.py with huggingface_hub
b547095 verified
Raw
History Blame Contribute Delete
118 kB
import os
import subprocess
import json
import logging
import random
import glob as glob_module
from datetime import datetime
from typing import Dict, Any, Optional, Tuple, List
logger = logging.getLogger(__name__)
# Base directory for audio assets (relative to project root)
# 兼容本地(backend/services/ → 3层上溯到项目根)和 HF Spaces(services/ → 2层上溯到 /app)
def _find_assets_dir() -> str:
here = os.path.abspath(__file__)
candidates = [
os.path.join(os.path.dirname(os.path.dirname(here)), "assets"), # HF Spaces: /app/assets
os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(here))), "assets"), # 本地: 项目根/assets
]
for path in candidates:
if os.path.isdir(path):
return path
return candidates[-1]
_ASSETS_DIR = _find_assets_dir()
def _find_cjk_font() -> str:
"""Find a suitable CJK font for text overlay."""
candidates = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/PingFang.ttc",
]
for path in candidates:
if os.path.isfile(path):
return path
return "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"
def _find_real_sfx(effect_type: str) -> Optional[str]:
"""Find a real SFX file in assets/sfx/ matching the effect type.
Mapping:
swish -> basketball_swish.mp3
slam -> basketball_dunk_rim.mp3, impact_*.mp3
whistle -> whistle_short.mp3, whistle_swoosh.mp3
"""
sfx_dir = os.path.join(_ASSETS_DIR, "sfx")
if not os.path.isdir(sfx_dir):
return None
sfx_mapping = {
"swish": ["basketball_swish.mp3", "basketball_bounce_01.mp3"],
"slam": ["basketball_dunk_rim.mp3", "impact_01.mp3", "impact_02.mp3", "impact_03.mp3"],
"whistle": ["whistle_short.mp3", "whistle_swoosh.mp3", "basketball_buzzer.mp3"],
}
candidates = sfx_mapping.get(effect_type, [])
for candidate in candidates:
path = os.path.join(sfx_dir, candidate)
if os.path.isfile(path):
return path
# Fallback: try to find any file with the effect_type in the name
pattern = os.path.join(sfx_dir, f"*{effect_type}*.mp3")
matches = glob_module.glob(pattern)
if matches:
return random.choice(matches)
return None
def _find_real_bgm() -> Optional[str]:
"""Find a random real BGM file in assets/bgm/."""
bgm_dir = os.path.join(_ASSETS_DIR, "bgm")
if not os.path.isdir(bgm_dir):
return None
pattern = os.path.join(bgm_dir, "*.mp3")
matches = glob_module.glob(pattern)
if matches:
return random.choice(matches)
return None
def get_video_info(video_path: str) -> Dict[str, Any]:
try:
cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", video_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode == 0:
info = json.loads(result.stdout)
duration = float(info.get("format", {}).get("duration", 0))
streams = info.get("streams", [])
video_stream = next((s for s in streams if s["codec_type"] == "video"), None)
width = int(video_stream["width"]) if video_stream else 0
height = int(video_stream["height"]) if video_stream else 0
fps = 30
if video_stream and "r_frame_rate" in video_stream:
parts = video_stream["r_frame_rate"].split("/")
if len(parts) == 2 and int(parts[1]) > 0:
fps = int(parts[0]) / int(parts[1])
return {"duration": duration, "width": width, "height": height, "fps": fps}
except (FileNotFoundError, subprocess.TimeoutExpired, json.JSONDecodeError) as e:
logger.info(f"ffprobe not available or failed: {e}, trying cv2 fallback")
try:
import cv2
cap = cv2.VideoCapture(video_path)
if cap.isOpened():
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps if fps > 0 else 0
cap.release()
return {"duration": duration, "width": width, "height": height, "fps": fps}
except ImportError:
logger.warning("cv2 not available for video info fallback")
except Exception as e:
logger.warning(f"cv2 video info fallback failed: {e}")
logger.warning("All video info methods failed, using defaults")
return {"duration": 0, "width": 1920, "height": 1080, "fps": 30}
def extract_clip_with_effects(
video_path: str, start: float, end: float, output_path: str,
peak_time: float = 0.0,
):
"""合并提取+调色+暗角为一次FFmpeg调用,大幅减少重编码次数
相比分别调用 extract_clip → apply_color_grading → apply_vignette,
此函数将所有滤镜合并到一条 filter_complex 中,只需一次编码。
"""
clip_duration = end - start
filter_parts = []
# 1. 调色
filter_parts.append(
"eq=contrast=1.35:brightness=0.05:saturation=1.5,"
"colorbalance=rs=0.1:bs=-0.05:rm=0.05:bm=-0.03,"
"unsharp=5:5:1.0:5:5:0.0"
)
# 2. 暗角(静态暗角,避免条件表达式的复杂性)
filter_parts.append("vignette=angle=0.4:mode=forward")
filter_complex = f"[0:v]{','.join(filter_parts)}[v]"
# 使用 -ss 在 -i 之前(快速 seeking),-t 指定时长(避免 -to 时间轴歧义)
clip_dur = end - start
cmd = [
"ffmpeg", "-y",
"-ss", str(start),
"-i", video_path,
"-t", str(clip_dur),
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.warning(f"[extract_clip_with_effects] combined filter failed: {result.stderr[:300]}")
# fallback 1: simple extract without effects, -ss before -i
cmd_simple = [
"ffmpeg", "-y",
"-ss", str(start),
"-i", video_path,
"-t", str(clip_dur),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
r2 = subprocess.run(cmd_simple, capture_output=True, text=True, timeout=60)
if r2.returncode != 0:
logger.warning(f"[extract_clip_with_effects] simple extract failed: {r2.stderr[:300]}")
# fallback 2: -ss after -i(慢但更精确)
cmd_slow = [
"ffmpeg", "-y", "-i", video_path,
"-ss", str(start), "-to", str(end),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd_slow, capture_output=True, text=True, timeout=60)
except subprocess.TimeoutExpired:
logger.warning("[extract_clip_with_effects] timed out, doing simple extract")
try:
cmd_simple = [
"ffmpeg", "-y",
"-ss", str(start),
"-i", video_path,
"-t", str(clip_dur),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd_simple, capture_output=True, text=True, timeout=60)
except Exception:
pass
def extract_clip(video_path: str, start: float, end: float, output_path: str):
cmd = [
"ffmpeg", "-y", "-i", video_path,
"-ss", str(start), "-to", str(end),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=60)
def apply_slow_motion(input_path: str, output_path: str, factor: float = 2.0):
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", f"[0:v]setpts={factor}*PTS[v];[0:a]atempo={1/factor}[a]",
"-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-vsync", "cfr",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=120)
def apply_peak_slow_motion(input_path: str, output_path: str, peak_time: float, clip_duration: float,
slow_factor: float = 3.0, slow_duration: float = 1.5):
"""关键帧变速:只在 peak_time 附近慢放,其他部分正常速度
慢放区域:peak_time 前后各 slow_duration/2 秒
有音频时使用分段拼接方式:将视频切为三段,仅中间段做视频+音频慢放,
然后用 FFmpeg concat 合并,确保音视频时长同步。
无音频时使用 setpts 条件表达式实现局部慢放。
"""
slow_start = max(0.0, peak_time - slow_duration / 2)
slow_end = min(clip_duration, peak_time + slow_duration / 2)
# 检查输入是否有音频流
probe_cmd = ["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_name", "-of", "csv=p=0", input_path]
probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
has_audio = bool(probe_result.stdout.strip())
try:
if has_audio:
# 有音频:使用分段拼接方式,确保音视频同步
# 分三段:[0, slow_start] + [slow_start, slow_end]慢放 + [slow_end, end]
import tempfile
# 如果慢放区域覆盖整个片段,直接全局慢放
if slow_start < 0.3 and (clip_duration - slow_end) < 0.3:
apply_slow_motion(input_path, output_path, factor=slow_factor)
return
tmp_dir = tempfile.mkdtemp(prefix="peak_slow_")
try:
part1 = os.path.join(tmp_dir, "part1.mp4")
part2_slow = os.path.join(tmp_dir, "part2_slow.mp4")
part3 = os.path.join(tmp_dir, "part3.mp4")
# Part 1: 0 → slow_start(正常速度)
if slow_start > 0.05:
cmd1 = [
"ffmpeg", "-y", "-i", input_path,
"-t", str(slow_start),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
part1,
]
r1 = subprocess.run(cmd1, capture_output=True, text=True, timeout=60)
if r1.returncode != 0:
logger.warning(f"[apply_peak_slow_motion] part1 failed: {r1.stderr[:200]}")
else:
part1 = None
# Part 2: slow_start → slow_end(慢放,视频+音频同步变速)
# atempo 只支持 0.5~2.0 范围,slow_factor > 2 时需要链式 atempo
tempo_val = 1.0 / slow_factor
if tempo_val < 0.5:
tempo1 = 0.5
tempo2 = tempo_val / tempo1
audio_filter = f"[0:a]atempo={tempo1},atempo={tempo2:.4f}[a]"
else:
audio_filter = f"[0:a]atempo={tempo_val:.4f}[a]"
cmd2 = [
"ffmpeg", "-y", "-i", input_path,
"-ss", str(slow_start), "-to", str(slow_end),
"-filter_complex", f"[0:v]setpts={slow_factor}*PTS[v];{audio_filter}",
"-map", "[v]", "-map", "[a]",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
part2_slow,
]
r2 = subprocess.run(cmd2, capture_output=True, text=True, timeout=60)
if r2.returncode != 0:
logger.warning(f"[apply_peak_slow_motion] part2_slow failed: {r2.stderr[:200]}")
raise RuntimeError("part2_slow extraction failed")
# Part 3: slow_end → end(正常速度)
if (clip_duration - slow_end) > 0.05:
cmd3 = [
"ffmpeg", "-y", "-i", input_path,
"-ss", str(slow_end),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
part3,
]
r3 = subprocess.run(cmd3, capture_output=True, text=True, timeout=60)
if r3.returncode != 0:
logger.warning(f"[apply_peak_slow_motion] part3 failed: {r3.stderr[:200]}")
else:
part3 = None
# 构建 concat 文件
concat_file = os.path.join(tmp_dir, "concat.txt")
with open(concat_file, "w") as f:
if part1 and os.path.exists(part1) and os.path.getsize(part1) > 0:
f.write(f"file '{part1}'\n")
if os.path.exists(part2_slow) and os.path.getsize(part2_slow) > 0:
f.write(f"file '{part2_slow}'\n")
if part3 and os.path.exists(part3) and os.path.getsize(part3) > 0:
f.write(f"file '{part3}'\n")
# 合并
cmd_concat = [
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
result = subprocess.run(cmd_concat, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.warning(f"[apply_peak_slow_motion] concat failed: {result.stderr[:200]}")
raise RuntimeError("concat failed")
finally:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
else:
# 无音频:只处理视频(使用 setpts 条件表达式实现局部慢放)
video_filter = (
f"[0:v]setpts='if(between(t,{slow_start},{slow_end}),"
f"{slow_factor}*PTS,PTS)'[v]"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", video_filter,
"-map", "[v]",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-movflags", "+faststart",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
return
logger.warning(f"[apply_peak_slow_motion] no-audio filter failed: {result.stderr[:300]}")
raise RuntimeError("no-audio filter failed")
# 验证输出
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
return
raise RuntimeError("output file missing or empty")
except (subprocess.TimeoutExpired, Exception) as e:
logger.warning(f"[apply_peak_slow_motion] segment approach failed: {e}")
# Fallback:全局慢放(最简单最可靠)
logger.info("[apply_peak_slow_motion] falling back to global slow motion")
apply_slow_motion(input_path, output_path, factor=slow_factor)
def apply_ken_burns_zoom(input_path: str, output_path: str, peak_time: float, clip_duration: float,
zoom_factor: float = 1.15, zoom_duration: float = 0.8):
"""Ken Burns zoom-in 效果:在 peak_time 附近平滑放大镜头
zoom_factor: 最大放大倍数(1.15 = 放大 15%)
zoom_duration: zoom 持续时间(秒)
"""
info = get_video_info(input_path)
width = info.get("width", 1920)
height = info.get("height", 1080)
if width <= 0 or height <= 0:
import shutil
shutil.copy2(input_path, output_path)
return
# 确保 width/height 为偶数
width = width + (width % 2)
height = height + (height % 2)
# zoom 时间范围
zoom_start = max(0.0, peak_time - zoom_duration / 2)
zoom_peak = peak_time
zoom_end = min(clip_duration, peak_time + zoom_duration / 2)
# 使用 zoompan 滤镜实现平滑 zoom
# zoompan 参数:z=zoom_level, d=duration_in_frames, x/y=pan_position
fps = 30
total_frames = int(clip_duration * fps)
zoom_start_frame = int(zoom_start * fps)
zoom_peak_frame = int(zoom_peak * fps)
zoom_end_frame = int(zoom_end * fps)
# 构建 zoompan 表达式
# 在 zoom_start 之前:z=1(正常)
# 从 zoom_start 到 zoom_peak:z 从 1 线性增加到 zoom_factor
# 从 zoom_peak 到 zoom_end:z 从 zoom_factor 线性减少到 1
# zoom_end 之后:z=1(正常)
zoom_expr = (
f"if(lte(on, {zoom_start_frame}), 1,"
f"if(lte(on, {zoom_peak_frame}),"
f" 1+({zoom_factor}-1)*(on-{zoom_start_frame})/({zoom_peak_frame}-{zoom_start_frame}),"
f"if(lte(on, {zoom_end_frame}),"
f" {zoom_factor}-({zoom_factor}-1)*(on-{zoom_peak_frame})/({zoom_end_frame}-{zoom_peak_frame}),"
f" 1)))"
)
# x/y 居中
x_expr = f"iw/2-(iw/zoom)/2"
y_expr = f"ih/2-(ih/zoom)/2"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]zoompan=z='{zoom_expr}':x='{x_expr}':y='{y_expr}':d={total_frames}:s={width}x{height}:fps={fps}[v]",
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode != 0:
logger.warning(f"[apply_ken_burns_zoom] zoompan failed: {result.stderr[:200]}")
import shutil
shutil.copy2(input_path, output_path)
except subprocess.TimeoutExpired:
logger.warning("[apply_ken_burns_zoom] zoompan timed out, copying original")
import shutil
shutil.copy2(input_path, output_path)
except Exception as e:
logger.warning(f"[apply_ken_burns_zoom] error: {e}")
import shutil
shutil.copy2(input_path, output_path)
def apply_cpu_zoom_in(input_path: str, output_path: str, peak_time: float, clip_duration: float,
zoom_factor: float = 1.2, zoom_duration: float = 1.0):
"""CPU 友好的推镜头效果:使用 scale+crop 动态缩放替代 zoompan
相比 zoompan(CPU 上极慢),此方案使用 crop 的 x/y 参数做动画,
从 1.0x 渐进到 zoom_factor 再回退,模拟推镜头效果。
CPU 开销小,3s 片段约 2s 处理时间。
"""
info = get_video_info(input_path)
width = info.get("width", 1920)
height = info.get("height", 1080)
if width <= 0 or height <= 0:
import shutil
shutil.copy2(input_path, output_path)
return
# 确保 width/height 为偶数
width = width + (width % 2)
height = height + (height % 2)
# zoom 时间范围
zoom_start = max(0.0, peak_time - zoom_duration / 2)
zoom_end = min(clip_duration, peak_time + zoom_duration / 2)
# 使用 crop + scale 实现动态推镜头
# crop 宽高随时间变化:从 iw/1.0 逐渐缩小到 iw/zoom_factor(即放大效果)
# 然后再从 iw/zoom_factor 逐渐恢复到 iw/1.0
# crop_x/crop_y 居中
if zoom_start < peak_time and peak_time < zoom_end:
# 渐进推镜头:zoom_start → peak_time 放大,peak_time → zoom_end 缩回
crop_w_expr = (
f"iw/if(between(t,{zoom_start},{peak_time}),"
f"1+({zoom_factor}-1)*(t-{zoom_start})/({peak_time}-{zoom_start}),"
f"if(between(t,{peak_time},{zoom_end}),"
f"{zoom_factor}-({zoom_factor}-1)*(t-{peak_time})/({zoom_end}-{peak_time}),"
f"1))"
)
crop_h_expr = crop_w_expr.replace("iw/", "ih/")
x_expr = f"(iw-ow)/2"
y_expr = f"(ih-oh)/2"
filter_complex = (
f"[0:v]crop={crop_w_expr}:{crop_h_expr}:{x_expr}:{y_expr},"
f"scale={width}:{height}[v]"
)
else:
# 时间不足,做静态放大
filter_complex = (
f"[0:v]scale=iw*{zoom_factor}:ih*{zoom_factor},"
f"crop={width}:{height}:(iw-{width})/2:(ih-{height})/2[v]"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.warning(f"[apply_cpu_zoom_in] failed: {result.stderr[:200]}")
import shutil
shutil.copy2(input_path, output_path)
except subprocess.TimeoutExpired:
logger.warning("[apply_cpu_zoom_in] timed out, copying original")
import shutil
shutil.copy2(input_path, output_path)
except Exception as e:
logger.warning(f"[apply_cpu_zoom_in] error: {e}")
import shutil
shutil.copy2(input_path, output_path)
def apply_flash_effect(input_path: str, output_path: str, peak_time: float, clip_duration: float,
flash_duration: float = 0.2, brightness_peak: float = 1.0):
"""闪光效果:peak_time 时刻全屏白色闪烁
使用 eq=brightness 在 peak_time 瞬间提升亮度,flash_duration 内衰减回正常。
"""
flash_start = max(0.0, peak_time - flash_duration / 2)
flash_end = min(clip_duration, peak_time + flash_duration / 2)
# 亮度表达式:flash_start 时亮度突增 brightness_peak,flash_end 时衰减回 0
brightness_expr = (
f"if(between(t,{flash_start},{flash_end}),"
f"{brightness_peak}*(1-(t-{flash_start})/{flash_duration}),0)"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]eq=brightness='{brightness_expr}'[v]",
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
logger.warning(f"[apply_flash_effect] failed: {result.stderr[:200]}")
import shutil
shutil.copy2(input_path, output_path)
except (subprocess.TimeoutExpired, Exception) as e:
logger.warning(f"[apply_flash_effect] error: {e}")
import shutil
shutil.copy2(input_path, output_path)
def apply_shake_effect(input_path: str, output_path: str, peak_time: float, clip_duration: float,
shake_duration: float = 0.4, shake_amplitude: int = 25):
"""画面震动效果:peak_time 附近画面做正弦波位移,增强力量感
使用 overlay + 正弦波偏移实现画面抖动。
"""
shake_start = max(0.0, peak_time - shake_duration / 2)
shake_end = min(clip_duration, peak_time + shake_duration / 2)
info = get_video_info(input_path)
width = info.get("width", 1920)
height = info.get("height", 1080)
if width <= 0 or height <= 0:
import shutil
shutil.copy2(input_path, output_path)
return
# 确保 width/height 为偶数
width = width + (width % 2)
height = height + (height % 2)
# 震动频率 15Hz,振幅 shake_amplitude 像素
freq = 15
x_expr = f"{shake_amplitude}+{shake_amplitude}*sin(2*PI*{freq}*(t-{shake_start}))"
y_expr = f"{shake_amplitude}+{shake_amplitude}*cos(2*PI*{freq}*(t-{shake_start}))"
enable_expr = f"between(t,{shake_start},{shake_end})"
# 使用 crop + overlay 实现震动
crop_w = width - 2 * shake_amplitude
crop_h = height - 2 * shake_amplitude
# 确保 crop 尺寸为偶数
crop_w = crop_w + (crop_w % 2)
crop_h = crop_h + (crop_h % 2)
filter_complex = (
f"[0:v]split[bg][fg];"
f"[fg]crop={crop_w}:{crop_h}:{shake_amplitude}:{shake_amplitude}[cropped];"
f"[bg][cropped]overlay=x='{x_expr}':y='{y_expr}':enable='{enable_expr}'[v]"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
logger.warning(f"[apply_shake_effect] failed: {result.stderr[:200]}")
import shutil
shutil.copy2(input_path, output_path)
except (subprocess.TimeoutExpired, Exception) as e:
logger.warning(f"[apply_shake_effect] error: {e}")
import shutil
shutil.copy2(input_path, output_path)
def apply_glitch_effect(input_path: str, output_path: str, peak_time: float, clip_duration: float,
glitch_duration: float = 0.15, shift_pixels: int = 8):
"""RGB 色差分离(Glitch)效果:peak_time 附近短暂 RGB 通道偏移
在进球瞬间(peak_time)短暂应用 RGB 通道水平偏移,
营造数字故障感,增强视觉冲击力。
"""
glitch_start = max(0.0, peak_time - glitch_duration / 2)
glitch_end = min(clip_duration, peak_time + glitch_duration / 2)
info = get_video_info(input_path)
width = info.get("width", 1920)
height = info.get("height", 1080)
if width <= 0 or height <= 0:
import shutil
shutil.copy2(input_path, output_path)
return
# 确保 width/height 为偶数
width = width + (width % 2)
height = height + (height % 2)
# 使用 colorchannelmixer 实现 RGB 色差偏移
# 在 glitch_start ~ glitch_end 期间,R 通道向左偏移,B 通道向右偏移
# colorchannelmixer 可以混合通道来实现偏移效果
# 但 FFmpeg 没有直接的通道偏移滤镜,使用 crop + overlay 方式
# 简化方案:使用 eq 滤镜在 glitch 期间增强对比度+饱和度 + 轻微色相偏移
# 配合闪光效果已经足够产生 Glitch 感
enable_expr = f"between(t,{glitch_start},{glitch_end})"
# 在 glitch 期间:高对比度 + 高饱和 + 色相偏移 15 度
filter_complex = (
f"[0:v]eq=contrast='if({enable_expr},2.0,1.0)':"
f"saturation='if({enable_expr},2.0,1.0)':"
f"brightness='if({enable_expr},0.1,0.0)',"
f"hue=h='if({enable_expr},15,0)'[v]"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
logger.warning(f"[apply_glitch_effect] failed: {result.stderr[:200]}")
import shutil
shutil.copy2(input_path, output_path)
except (subprocess.TimeoutExpired, Exception) as e:
logger.warning(f"[apply_glitch_effect] error: {e}")
import shutil
shutil.copy2(input_path, output_path)
def apply_combined_effects(input_path: str, output_path: str, peak_time: float, clip_duration: float,
flash_duration: float = 0.2, brightness_peak: float = 1.0,
shake_duration: float = 0.4, shake_amplitude: int = 25,
glitch_duration: float = 0.15):
"""合并闪光+震屏+Glitch为一次FFmpeg调用,大幅减少重编码次数
将三种特效合并到一条 filter_complex 中:
1. 闪光:peak_time 附近亮度突增
2. 震屏:peak_time 附近画面正弦波位移
3. Glitch:peak_time 附近对比度+饱和度+色相偏移
"""
info = get_video_info(input_path)
width = info.get("width", 1920)
height = info.get("height", 1080)
if width <= 0 or height <= 0:
import shutil
shutil.copy2(input_path, output_path)
return
width = width + (width % 2)
height = height + (height % 2)
# 时间范围
flash_start = max(0.0, peak_time - flash_duration / 2)
flash_end = min(clip_duration, peak_time + flash_duration / 2)
shake_start = max(0.0, peak_time - shake_duration / 2)
shake_end = min(clip_duration, peak_time + shake_duration / 2)
glitch_start = max(0.0, peak_time - glitch_duration / 2)
glitch_end = min(clip_duration, peak_time + glitch_duration / 2)
# 1. 闪光亮度表达式
brightness_expr = (
f"if(between(t,{flash_start},{flash_end}),"
f"{brightness_peak}*(1-(t-{flash_start})/{flash_duration}),0)"
)
# 2. Glitch 效果:对比度+饱和度+色相偏移
glitch_enable = f"between(t,{glitch_start},{glitch_end})"
# 3. 震屏效果:crop + overlay
crop_w = width - 2 * shake_amplitude
crop_h = height - 2 * shake_amplitude
crop_w = crop_w + (crop_w % 2)
crop_h = crop_h + (crop_h % 2)
freq = 15
x_expr = f"{shake_amplitude}+{shake_amplitude}*sin(2*PI*{freq}*(t-{shake_start}))"
y_expr = f"{shake_amplitude}+{shake_amplitude}*cos(2*PI*{freq}*(t-{shake_start}))"
shake_enable = f"between(t,{shake_start},{shake_end})"
# 合并滤镜链:eq(闪光+Glitch) → split → crop → overlay(震屏)
# 注意:hue 滤镜参数格式是 h=角度值
hue_expr = f"if({glitch_enable},15,0)"
filter_complex = (
f"[0:v]eq="
f"brightness='{brightness_expr}':"
f"contrast='if({glitch_enable},2.0,1.0)':"
f"saturation='if({glitch_enable},2.0,1.0)',"
f"hue=h='{hue_expr}',"
f"split[bg][fg];"
f"[fg]crop={crop_w}:{crop_h}:{shake_amplitude}:{shake_amplitude}[cropped];"
f"[bg][cropped]overlay=x='{x_expr}':y='{y_expr}':enable='{shake_enable}'[v]"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.warning(f"[apply_combined_effects] failed: {result.stderr[:300]}")
import shutil
shutil.copy2(input_path, output_path)
except (subprocess.TimeoutExpired, Exception) as e:
logger.warning(f"[apply_combined_effects] error: {e}")
import shutil
shutil.copy2(input_path, output_path)
def apply_replay_effect(input_path: str, output_path: str, peak_time: float, clip_duration: float,
replay_duration: float = 1.0, replay_count: int = 2):
"""重播效果:peak_time 附近的片段重复播放,第二次慢放
将片段切为三段:[start, peak-replay] + [peak-replay, peak+replay] + [peak+replay, end]
中间段重复 replay_count 次(第一次原速,后续慢放 2x)
然后用 FFmpeg concat 合并。
"""
import tempfile
replay_start = max(0.0, peak_time - replay_duration / 2)
replay_end = min(clip_duration, peak_time + replay_duration / 2)
# 如果片段太短,不做重播
if replay_start < 0.3 or (clip_duration - replay_end) < 0.3:
import shutil
shutil.copy2(input_path, output_path)
return
tmp_dir = tempfile.mkdtemp(prefix="replay_")
try:
# 切分三段
part1 = os.path.join(tmp_dir, "part1.mp4")
part2 = os.path.join(tmp_dir, "part2.mp4")
part3 = os.path.join(tmp_dir, "part3.mp4")
# Part 1: start → replay_start
cmd1 = [
"ffmpeg", "-y", "-i", input_path,
"-t", str(replay_start),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
part1,
]
subprocess.run(cmd1, capture_output=True, text=True, timeout=60)
# Part 2: replay_start → replay_end (原速)
cmd2 = [
"ffmpeg", "-y", "-i", input_path,
"-ss", str(replay_start), "-to", str(replay_end),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
part2,
]
subprocess.run(cmd2, capture_output=True, text=True, timeout=60)
# Part 3: replay_end → end
cmd3 = [
"ffmpeg", "-y", "-i", input_path,
"-ss", str(replay_end),
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
part3,
]
subprocess.run(cmd3, capture_output=True, text=True, timeout=60)
# Part 2 慢放版本
part2_slow = os.path.join(tmp_dir, "part2_slow.mp4")
apply_slow_motion(part2, part2_slow, factor=2.0)
# 构建 concat 文件:part1 + part2(原速) + part2_slow(慢放) + part3
concat_file = os.path.join(tmp_dir, "concat.txt")
with open(concat_file, "w") as f:
f.write(f"file '{part1}'\n")
f.write(f"file '{part2}'\n")
if os.path.exists(part2_slow) and os.path.getsize(part2_slow) > 0:
f.write(f"file '{part2_slow}'\n")
f.write(f"file '{part3}'\n")
# 合并
cmd_concat = [
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
result = subprocess.run(cmd_concat, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.warning(f"[apply_replay_effect] concat failed: {result.stderr[:200]}")
import shutil
shutil.copy2(input_path, output_path)
except (subprocess.TimeoutExpired, Exception) as e:
logger.warning(f"[apply_replay_effect] error: {e}")
import shutil
shutil.copy2(input_path, output_path)
finally:
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
def apply_color_grading(input_path: str, output_path: str):
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
"[0:v]eq=contrast=1.2:brightness=0.03:saturation=1.3,"
"colorbalance=rs=0.08:bs=-0.04:rm=0.04:bm=-0.02,"
"unsharp=5:5:0.8:5:5:0.0[v]",
"-map", "[v]", "-c:a", "copy",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=120)
def apply_vignette(input_path: str, output_path: str, mode: str = "peak", peak_time: float = 0.0, clip_duration: float = 0.0):
"""暗角效果:边缘暗化强调主体
mode: 'static' 始终暗角, 'peak' 只在 peak_time 附近加强暗角
"""
if mode == "peak" and peak_time > 0 and clip_duration > 0:
enable_strong = f"between(t,{max(0,peak_time-0.5)},{min(clip_duration,peak_time+0.5)})"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]vignette=angle=0.3:mode=forward:enable='1',"
f"vignette=angle=0.5:mode=forward:enable='{enable_strong}'[v]",
"-map", "[v]", "-c:a", "copy",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-movflags", "+faststart",
output_path,
]
else:
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
"[0:v]vignette=angle=0.3:mode=forward[v]",
"-map", "[v]", "-c:a", "copy",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.warning(f"[apply_vignette] failed: {result.stderr[:200]}")
import shutil
shutil.copy2(input_path, output_path)
except subprocess.TimeoutExpired:
logger.warning("[apply_vignette] timed out, copying original")
import shutil
shutil.copy2(input_path, output_path)
def add_highlight_overlay(input_path: str, output_path: str, text: str, peak_time: float,
clip_duration: float, fontcolor: str = "white", fontsize: int = 90):
"""高光文字叠加:在 peak_time 附近显示进球/2+1/神仙球等文字,带缩放弹入动画
文字在 peak_time 前 0.3s 出现,持续 1.5s,带 zoom-in 动画和淡入淡出。
正确处理音频流(-map 0:a?),避免丢失音频。
"""
fontfile = _find_cjk_font()
# 文字出现时间:peak 前 0.3s 到 peak 后 1.2s
text_start = max(0.0, peak_time - 0.3)
text_end = min(clip_duration, peak_time + 1.2)
fade_in = 0.2
fade_out = 0.3
# zoom-in 动画:字号从 30 渐变到目标大小
fs_expr = f"if(lt(t,{text_start + fade_in}),30+({fontsize}-30)*(t-{text_start})/{fade_in},{fontsize})"
enable = f"between(t,{text_start},{text_end})"
alpha_expr = (
f"if(between(t,{text_start},{text_start + fade_in}),(t-{text_start})/{fade_in},"
f"if(between(t,{text_end - fade_out},{text_end}),({text_end}-t)/{fade_out},1))"
)
filter_complex = (
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize='{fs_expr}':"
f"borderw=5:bordercolor=black:"
f"x=(w-text_w)/2:y=h*0.75:"
f"alpha='{alpha_expr}':"
f"enable='{enable}'[v]"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
logger.warning(f"[add_highlight_overlay] failed: {result.stderr[:200]}")
import shutil
shutil.copy2(input_path, output_path)
except (subprocess.TimeoutExpired, Exception) as e:
logger.warning(f"[add_highlight_overlay] error: {e}")
import shutil
shutil.copy2(input_path, output_path)
def add_subtitle(input_path: str, output_path: str, text: str, start: float, end: float, fontfile: str = "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", fontcolor: str = "yellow", fontsize: int = 80, effect: str = "fade"):
"""添加字幕,支持多种动画效果
effect: 'fade' 淡入淡出, 'bounce' 弹入, 'zoom' 缩放弹入, 'slide_left' 左滑入,
'slide_right' 右滑入, 'zoom_in' 从小到大弹入, 'flash' 闪烁出现,
'shake' 震动出现, 'rotate_in' 旋转缩放进入, 'explode' 爆炸扩散进入
"""
enable = f"between(t,{start},{end})"
duration = end - start
fade_in = min(0.3, duration * 0.2)
fade_out = min(0.3, duration * 0.2)
if effect == "bounce":
# 弹入效果:从下方弹入 + 缩放
y_expr = f"h*0.75-if(gt(t,{start}),0,min(1,(t-{start})/{fade_in}))*40*if(lt(t,{start}+{fade_in}),1,1)"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fontsize}:"
f"borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y={y_expr}:"
f"alpha='if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
elif effect == "zoom":
# 缩放弹入效果
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fontsize}:"
f"borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y=h*0.75:"
f"alpha='if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
elif effect == "slide_left":
# 左滑入效果
x_expr = f"if(lt(t,{start+fade_in}),(w-text_w)*(1-(t-{start})/{fade_in}),(w-text_w)/2)"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fontsize}:"
f"borderw=4:bordercolor=black:"
f"x={x_expr}:y=h*0.75:"
f"alpha='if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
elif effect == "slide_right":
# 右滑入效果
x_expr = f"if(lt(t,{start+fade_in}),0+(w-text_w)/2*(t-{start})/{fade_in},(w-text_w)/2)"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fontsize}:"
f"borderw=4:bordercolor=black:"
f"x={x_expr}:y=h*0.75:"
f"alpha='if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
elif effect == "zoom_in":
# 从小到大弹入效果:fontsize 从 20 渐变到目标大小
fs_expr = f"if(lt(t,{start+fade_in}),20+({fontsize}-20)*(t-{start})/{fade_in},{fontsize})"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fs_expr}:"
f"borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y=h*0.75:"
f"alpha='if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
elif effect == "flash":
# 闪烁出现效果:快速闪烁 3 次后稳定显示
blink_period = fade_in / 3
alpha_expr = (
f"if(between(t,{start},{start+fade_in}),"
f"if(eq(mod(int((t-{start})/{blink_period}),2),0),1,0.2),"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fontsize}:"
f"borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y=h*0.75:"
f"alpha='{alpha_expr}':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
elif effect == "shake":
# 震动出现效果:出现时 x 方向随机偏移
shake_amp = 15
x_expr = f"(w-text_w)/2+{shake_amp}*sin(2*PI*12*(t-{start}))"
shake_alpha = (
f"if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))"
)
# 震动在 fade_in 后衰减
x_expr_damped = (
f"(w-text_w)/2+{shake_amp}*sin(2*PI*12*(t-{start}))"
f"*if(lt(t,{start+fade_in}),1,max(0,1-(t-{start+fade_in})/0.3))"
)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fontsize}:"
f"borderw=4:bordercolor=black:"
f"x='{x_expr_damped}':y=h*0.75:"
f"alpha='{shake_alpha}':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
elif effect == "rotate_in":
# 旋转缩放进入:用 fontsize 变化模拟旋转效果(从小到大+左右偏移)
fs_expr = f"if(lt(t,{start+fade_in}),20+({fontsize}-20)*(t-{start})/{fade_in},{fontsize})"
# 旋转用 x 偏移模拟:从左侧偏移到居中
x_expr = f"if(lt(t,{start+fade_in}),(w-text_w)/2-30*(1-(t-{start})/{fade_in}),(w-text_w)/2)"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fs_expr}:"
f"borderw=4:bordercolor=black:"
f"x='{x_expr}':y=h*0.75:"
f"alpha='if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
elif effect == "explode":
# 爆炸扩散进入:fontsize 从极大缩小到目标 + 透明度渐变
fs_expr = f"if(lt(t,{start+fade_in}),{fontsize*3}-({fontsize*3}-{fontsize})*(t-{start})/{fade_in},{fontsize})"
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fs_expr}:"
f"borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y=h*0.75:"
f"alpha='if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
else:
# 默认 fade 效果
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fontsize}:"
f"borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y=h*0.75:"
f"alpha='if(between(t,{start},{start+fade_in}),(t-{start})/{fade_in},"
f"if(between(t,{end-fade_out},{end}),({end}-t)/{fade_out},1))':"
f"enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
# fallback 到简单字幕
logger.warning(f"[add_subtitle] animated subtitle failed, falling back: {result.stderr[:200]}")
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex",
f"[0:v]drawtext=text='{text}':fontfile={fontfile}:"
f"fontcolor={fontcolor}:fontsize={fontsize}:"
f"borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y=h*0.75:enable='{enable}'[v]",
"-map", "[v]", "-c:a", "copy",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
def concatenate_with_transition(clips: list, output_path: str, transition: str = "fade", duration: float = 0.5, transitions: list = None):
"""合并多个片段,支持每个转场独立指定类型
transition: 全局默认转场类型(当 transitions 未提供时使用)
transitions: 每对相邻片段的转场类型列表,长度为 len(clips)-1
音频处理:使用 acrossfade 进行音频交叉淡入淡出,与视频 xfade 同步。
对于没有音频流的片段,自动填充静音轨道,确保合并后所有片段都有声音。
修复:添加视频标准化(scale+fps+format+setsar)确保所有片段格式一致,
避免 xfade 因分辨率/像素格式/帧率不一致而失败。
"""
if len(clips) == 1:
import shutil
shutil.copy2(clips[0], output_path)
return
# 构建 transitions 列表
if transitions is None:
transitions = [transition] * (len(clips) - 1)
# 检查每个片段是否有音频流,并获取时长
clip_has_audio = []
clip_durations = []
for clip in clips:
probe_a = ["ffprobe", "-v", "error", "-select_streams", "a",
"-show_entries", "stream=codec_name", "-of", "csv=p=0", clip]
probe_a_result = subprocess.run(probe_a, capture_output=True, text=True, timeout=10)
clip_has_audio.append(bool(probe_a_result.stdout.strip()))
probe_d = ["ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "csv=p=0", clip]
probe_d_result = subprocess.run(probe_d, capture_output=True, text=True, timeout=10)
try:
d = float(probe_d_result.stdout.strip())
clip_durations.append(max(0.1, d))
except (ValueError, TypeError):
clip_durations.append(0.0)
# 动态计算 FFmpeg 超时时间:基于片段总时长
# preset=fast 在 1080p 上约 20fps,xfade 滤镜更慢
# 每秒视频给 20 秒处理时间,最少 600 秒(10 分钟),上限 7200 秒(2 小时)
total_clip_duration = sum(clip_durations)
ffmpeg_timeout = min(7200, max(600, int(total_clip_duration * 20)))
# 获取目标尺寸(使用第一个片段的尺寸作为标准)
first_info = get_video_info(clips[0])
target_w = int(first_info.get("width", 1920))
target_h = int(first_info.get("height", 1080))
# 确保尺寸为偶数
target_w = target_w + (target_w % 2)
target_h = target_h + (target_h % 2)
# 优先使用 xfade + acrossfade(视频转场 + 音频交叉淡入淡出,音视频同步)
try:
# 大视频跳过 xfade(需同时解码所有片段,内存占用高且速度慢),直接用 concat filter
if total_clip_duration > 120 or len(clips) > 8:
logger.info(
f"[concatenate_with_transition] 跳过 xfade(视频过长或片段过多: "
f"{total_clip_duration:.1f}s, {len(clips)} clips),直接用 concat filter"
)
raise ValueError("skip_xfade")
inputs = []
for clip in clips:
inputs.extend(["-i", clip])
filter_parts = []
# 视频标准化:统一尺寸、帧率、像素格式、SAR,避免 xfade 因格式不一致失败
for i in range(len(clips)):
filter_parts.append(
f"[{i}:v]scale={target_w}:{target_h},fps=30,format=yuv420p,setsar=1[v{i}_norm]"
)
# 视频 xfade 链(使用标准化后的流)
# 标签流转:i=0 输入 [v0_norm][v1_norm] → 输出 [v0]
# i=1 输入 [v0][v2_norm] → 输出 [v1] (in1 引用上一个 xfade 的输出)
# i=2 输入 [v1][v3_norm] → 输出 [v2]
offset = 0
for i in range(len(clips) - 1):
if i == 0:
in1 = f"[v0_norm]"
else:
in1 = f"[v{i-1}]"
in2 = f"[v{i+1}_norm]"
out = f"[v{i}]"
offset += clip_durations[i] - duration
trans = transitions[i] if i < len(transitions) else transition
filter_parts.append(
f"{in1}{in2}xfade=transition={trans}:duration={duration}:offset={offset}{out}"
)
# 音频处理:统一格式(含 sample_fmts=fltp)+ 静音填充 + acrossfade 交叉淡入淡出
audio_filter_parts = []
for i in range(len(clips)):
if clip_has_audio[i]:
audio_filter_parts.append(
f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}_src]"
)
else:
d = max(0.1, clip_durations[i])
audio_filter_parts.append(
f"anullsrc=r=44100:cl=stereo:d={d:.3f}[a{i}_src]"
)
for i in range(len(clips) - 1):
if i == 0:
a_in1 = "[a0_src]"
else:
a_in1 = f"[a{i-1}]"
a_in2 = f"[a{i+1}_src]"
a_out = f"[a{i}]"
audio_filter_parts.append(
f"{a_in1}{a_in2}acrossfade=d={duration}{a_out}"
)
filter_complex = ";".join(filter_parts + audio_filter_parts)
audio_map = f"[a{len(clips)-2}]"
cmd = ["ffmpeg", "-y"] + inputs + [
"-filter_complex", filter_complex,
"-map", f"[v{len(clips)-2}]",
"-map", audio_map,
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=ffmpeg_timeout)
if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 10240:
# 验证输出时长是否合理(至少应该是片段总时长的一半)
expected_min_duration = sum(clip_durations) - duration * (len(clips) - 1) - 1.0
out_info = get_video_info(output_path)
out_dur = out_info.get("duration", 0)
if out_dur >= expected_min_duration * 0.5:
return
logger.warning(
f"[concatenate_with_transition] xfade output too short: {out_dur:.2f}s "
f"(expected ~{expected_min_duration:.2f}s), trying fallback. stderr: {result.stderr[:300]}"
)
else:
logger.warning(
f"[concatenate_with_transition] xfade+acrossfade failed. "
f"returncode={result.returncode}, stderr: {result.stderr[:500]}"
)
except ValueError as e:
if str(e) == "skip_xfade":
pass # 主动跳过 xfade,继续走 concat filter fallback
else:
logger.warning(f"[concatenate_with_transition] xfade+acrossfade failed: {e}")
except subprocess.TimeoutExpired:
logger.warning(f"[concatenate_with_transition] xfade+acrossfade timed out ({ffmpeg_timeout}s)")
except Exception as e:
logger.warning(f"[concatenate_with_transition] xfade+acrossfade failed: {e}")
# Fallback 1: concat filter(无转场效果,但保证音视频同步且所有片段有音频)
try:
inputs = []
for clip in clips:
inputs.extend(["-i", clip])
filter_parts = []
# 视频统一尺寸+格式后 concat
v_labels = []
for i in range(len(clips)):
label = f"[v{i}]"
v_labels.append(label)
filter_parts.append(
f"[{i}:v]scale={target_w}:{target_h},fps=30,format=yuv420p,setsar=1{label}"
)
filter_parts.append(f"{''.join(v_labels)}concat=n={len(clips)}:v=1:a=0[vout]")
# 音频统一格式(含 sample_fmts)+ 静音填充后 concat
a_labels = []
for i in range(len(clips)):
label = f"[a{i}]"
a_labels.append(label)
if clip_has_audio[i]:
filter_parts.append(f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo{label}")
else:
d = max(0.1, clip_durations[i])
filter_parts.append(f"anullsrc=r=44100:cl=stereo:d={d:.3f}{label}")
filter_parts.append(f"{''.join(a_labels)}concat=n={len(clips)}:v=0:a=1[aout]")
filter_complex = ";".join(filter_parts)
cmd = ["ffmpeg", "-y"] + inputs + [
"-filter_complex", filter_complex,
"-map", "[vout]", "-map", "[aout]",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=ffmpeg_timeout)
if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 10240:
# 验证输出时长
expected_min_duration = sum(clip_durations) - 1.0
out_info = get_video_info(output_path)
out_dur = out_info.get("duration", 0)
if out_dur >= expected_min_duration * 0.5:
return
logger.warning(
f"[concatenate_with_transition] concat filter output too short: {out_dur:.2f}s, "
f"trying next fallback. stderr: {result.stderr[:300]}"
)
else:
logger.warning(f"[concatenate_with_transition] concat filter failed. stderr: {result.stderr[:500]}")
except subprocess.TimeoutExpired:
logger.warning(f"[concatenate_with_transition] concat filter timed out ({ffmpeg_timeout}s)")
except Exception as e:
logger.warning(f"[concatenate_with_transition] concat filter failed: {e}")
# Fallback 2: concat demuxer + 重编码(无转场,最可靠)
try:
import tempfile
tmp_dir = tempfile.mkdtemp(prefix="concat_")
concat_file = os.path.join(tmp_dir, "concat.txt")
with open(concat_file, "w") as f:
for clip in clips:
f.write(f"file '{clip}'\n")
cmd = [
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=ffmpeg_timeout)
import shutil
shutil.rmtree(tmp_dir, ignore_errors=True)
if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 10240:
# 验证输出时长
expected_min_duration = sum(clip_durations) - 1.0
out_info = get_video_info(output_path)
out_dur = out_info.get("duration", 0)
if out_dur >= expected_min_duration * 0.5:
return
logger.warning(
f"[concatenate_with_transition] concat demuxer output too short: {out_dur:.2f}s, "
f"trying per-clip re-encode fallback. stderr: {result.stderr[:300]}"
)
else:
logger.warning(f"[concatenate_with_transition] concat demuxer failed. stderr: {result.stderr[:500]}")
except subprocess.TimeoutExpired:
logger.warning(f"[concatenate_with_transition] concat demuxer timed out ({ffmpeg_timeout}s)")
except Exception as e:
logger.warning(f"[concatenate_with_transition] concat demuxer failed: {e}")
# Fallback 3: 逐片段重编码到统一格式后再用 concat demuxer 合并
# 这是最可靠的方案:先标准化每个片段,再合并
try:
import tempfile
import shutil
tmp_dir = tempfile.mkdtemp(prefix="concat_norm_")
normalized_clips = []
success = True
for i, clip in enumerate(clips):
norm_path = os.path.join(tmp_dir, f"norm_{i:03d}.mp4")
cmd = [
"ffmpeg", "-y", "-i", clip,
"-vf", f"scale={target_w}:{target_h},fps=30,format=yuv420p,setsar=1",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k", "-ar", "44100", "-ac", "2",
"-movflags", "+faststart",
norm_path,
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=max(300, int(clip_durations[i] * 20)))
if r.returncode != 0 or not os.path.exists(norm_path) or os.path.getsize(norm_path) < 1024:
logger.warning(f"[concatenate_with_transition] normalize clip {i} failed: {r.stderr[:200]}")
success = False
break
normalized_clips.append(norm_path)
if success and len(normalized_clips) == len(clips):
concat_file = os.path.join(tmp_dir, "concat.txt")
with open(concat_file, "w") as f:
for clip in normalized_clips:
f.write(f"file '{clip}'\n")
cmd = [
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=ffmpeg_timeout)
shutil.rmtree(tmp_dir, ignore_errors=True)
if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 10240:
out_info = get_video_info(output_path)
out_dur = out_info.get("duration", 0)
expected_min_duration = sum(clip_durations) - 1.0
if out_dur >= expected_min_duration * 0.5:
logger.info(f"[concatenate_with_transition] per-clip re-encode fallback succeeded, duration={out_dur:.2f}s")
return
logger.warning(f"[concatenate_with_transition] per-clip re-encode output too short: {out_dur:.2f}s")
else:
logger.warning(f"[concatenate_with_transition] per-clip re-encode concat failed: {result.stderr[:300]}")
else:
shutil.rmtree(tmp_dir, ignore_errors=True)
except subprocess.TimeoutExpired:
logger.warning("[concatenate_with_transition] per-clip re-encode timed out")
except Exception as e:
logger.warning(f"[concatenate_with_transition] per-clip re-encode failed: {e}")
# 所有方案都失败,抛出异常而非静默复制第一个片段
raise RuntimeError(
f"[concatenate_with_transition] ALL methods failed! "
f"clips={len(clips)}, durations={clip_durations}, timeout={ffmpeg_timeout}s. "
f"Unable to concatenate highlight clips."
)
def generate_hls(video_path: str, output_dir: str):
os.makedirs(output_dir, exist_ok=True)
cmd = [
"ffmpeg", "-y", "-i", video_path,
"-c:v", "libx264", "-c:a", "aac",
"-hls_time", "2", "-hls_list_size", "0",
"-hls_segment_filename", os.path.join(output_dir, "segment_%03d.ts"),
os.path.join(output_dir, "playlist.m3u8"),
]
subprocess.run(cmd, capture_output=True, check=True, timeout=120)
def generate_gif(video_path: str, output_path: str, width: int = 480, fps: int = 10):
cmd = [
"ffmpeg", "-y", "-i", video_path,
"-vf", f"fps={fps},scale={width}:-1:flags=lanczos",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=60)
def crop_vertical(input_path: str, output_path: str, track_data: Dict[str, Any] = None):
"""竖屏裁剪:优先使用 pyautoflip 智能裁剪,回退到 FFmpeg 居中裁剪"""
# 尝试使用 pyautoflip 智能裁剪
try:
from pyautoflip import AutoFlip
autoflip = AutoFlip(
input_path=input_path,
output_path=output_path,
target_aspect_ratio="9:16",
detection_mode="person",
)
autoflip.process()
if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
logger.info("[crop_vertical] pyautoflip 智能裁剪成功")
return
except ImportError:
logger.info("[crop_vertical] pyautoflip 不可用,使用 FFmpeg 裁剪")
except Exception as e:
logger.warning(f"[crop_vertical] pyautoflip 失败,回退到 FFmpeg: {e}")
# 原有的 FFmpeg 裁剪逻辑
info = get_video_info(input_path)
width = info["width"]
height = info["height"]
if width <= 0 or height <= 0:
import shutil
shutil.copy2(input_path, output_path)
return
target_w = 1080
target_h = 1920
target_ratio = target_w / target_h
use_tracking = (
track_data is not None
and "track_center_x" in track_data
and "track_center_y" in track_data
)
if use_tracking:
input_ratio = width / height
if input_ratio > target_ratio:
crop_h = height
crop_w = int(height * target_ratio)
else:
crop_w = width
crop_h = int(width / target_ratio)
crop_w = min(crop_w, width)
crop_h = min(crop_h, height)
cx = int(track_data["track_center_x"] * width)
cy = int(track_data["track_center_y"] * height)
x = max(0, min(cx - crop_w // 2, width - crop_w))
y = max(0, min(cy - crop_h // 2, height - crop_h))
out_w = min(target_w, crop_w)
out_h = min(target_h, crop_h)
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", f"[0:v]crop={crop_w}:{crop_h}:{x}:{y},scale={out_w}:{out_h}[v]",
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=60)
else:
input_ratio = width / height
if input_ratio > target_ratio:
scale_w = target_w
scale_h = int(target_w / input_ratio)
else:
scale_h = target_h
scale_w = int(target_h * input_ratio)
scale_w = scale_w + (scale_w % 2)
scale_h = scale_h + (scale_h % 2)
pad_x = (target_w - scale_w) // 2
pad_y = (target_h - scale_h) // 2
cmd = [
"ffmpeg", "-y", "-i", input_path,
"-filter_complex", f"[0:v]scale={scale_w}:{scale_h},pad={target_w}:{target_h}:{pad_x}:{pad_y}:black[v]",
"-map", "[v]", "-map", "0:a?",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-maxrate", "6M", "-bufsize", "12M",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=60)
def interpolate_frames(input_path: str, output_path: str, target_fps: int = 60):
# Skip minterpolate on CPU - extremely slow, minimal benefit
logger.info("[interpolate_frames] Skipping frame interpolation on CPU for performance")
import shutil
shutil.copy2(input_path, output_path)
# ── 片头片尾渲染引擎(numpy + PIL 逐帧渲染,炫酷+篮球+科技感)──
# 设计:深蓝黑背景 + 透视科技网格 + 飘动粒子 + 发光篮球 + 旋转 HUD 环 +
# 弹性弹出文字 + 扫描线 + 闪光 + 混入 sfx 音效
_IO_BG = (5, 8, 22) # 深蓝黑
_IO_ORANGE = (255, 107, 43) # 篮球橙
_IO_RED = (204, 0, 0) # 红
_IO_GOLD = (255, 215, 0) # 金
_IO_CYAN = (0, 229, 255) # 科技青
_IO_WHITE = (255, 255, 255)
_IO_BALL_DARK = (180, 70, 20)
# glow 缓存,避免每帧重复高斯模糊
_glow_cache: Dict[Tuple[int, Tuple[int, int, int], float], Any] = {}
def _io_clamp(v, lo=0.0, hi=1.0):
return max(lo, min(hi, v))
def _io_ease_out_cubic(t):
return 1 - (1 - t) ** 3
def _io_ease_out_back(t, s=1.7):
return 1 + (s + 1) * (t - 1) ** 3 + s * (t - 1) ** 2
def _io_make_basketball(size):
"""绘制带纹路和高光的篮球(RGBA PIL Image)"""
from PIL import Image, ImageDraw, ImageFilter
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
cx = cy = size / 2
r = size / 2 - 2
# 球体径向渐变
for i in range(int(r), 0, -1):
ratio = i / r
cr = int(_IO_ORANGE[0] * (0.55 + 0.45 * (1 - ratio)) + _IO_BALL_DARK[0] * (ratio * 0.3))
cg = int(_IO_ORANGE[1] * (0.55 + 0.45 * (1 - ratio)) + _IO_BALL_DARK[1] * (ratio * 0.3))
cb = int(_IO_ORANGE[2] * (0.55 + 0.45 * (1 - ratio)) + _IO_BALL_DARK[2] * (ratio * 0.3))
d.ellipse([cx - i, cy - i, cx + i, cy + i], fill=(cr, cg, cb, 255))
# 高光
hl = Image.new("RGBA", (size, size), (0, 0, 0, 0))
hd = ImageDraw.Draw(hl)
hd.ellipse([cx - r * 0.5, cy - r * 0.6, cx + r * 0.1, cy - r * 0.1], fill=(255, 230, 180, 90))
hl = hl.filter(ImageFilter.GaussianBlur(size // 12))
img = Image.alpha_composite(img, hl)
d = ImageDraw.Draw(img)
# 经纬线
lw = max(2, size // 60)
d.line([cx, cy - r, cx, cy + r], fill=(20, 10, 0, 230), width=lw)
d.line([cx - r, cy, cx + r, cy], fill=(20, 10, 0, 230), width=lw)
bbox = [cx - r, cy - r, cx + r, cy + r]
d.arc(bbox, start=30, end=150, fill=(20, 10, 0, 230), width=lw)
d.arc(bbox, start=210, end=330, fill=(20, 10, 0, 230), width=lw)
d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=(30, 15, 0, 255), width=lw)
return img
def _io_make_glow(size, color, intensity=1.0):
"""生成发光圆斑(带缓存)"""
from PIL import Image, ImageDraw, ImageFilter
key = (size, color, round(intensity, 2))
if key in _glow_cache:
return _glow_cache[key]
size = max(4, int(size))
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
cx = cy = size / 2
r = size / 2
for i in range(int(r), 0, -2):
a = int(255 * intensity * (1 - i / r) ** 2.5)
d.ellipse([cx - i, cy - i, cx + i, cy + i], fill=(color[0], color[1], color[2], a))
img = img.filter(ImageFilter.GaussianBlur(size // 8))
if len(_glow_cache) < 16:
_glow_cache[key] = img
return img
def _io_draw_hud_rings(base, cx, cy, t, scale=1.0):
"""绘制旋转的科技 HUD 环"""
from PIL import ImageDraw
import math
d = ImageDraw.Draw(base, "RGBA")
r1 = 180 * scale
r2 = 220 * scale
r3 = 260 * scale
rot = t * 60
rot2 = -t * 40
# 外环刻度
for i in range(60):
ang = math.radians(rot + i * 6)
long = (i % 5 == 0)
r_in = r3 - (18 if long else 8)
d.line([cx + r_in * math.cos(ang), cy + r_in * math.sin(ang),
cx + r3 * math.cos(ang), cy + r3 * math.sin(ang)],
fill=(*(_IO_CYAN if long else (0, 150, 200)), 200), width=2 if long else 1)
# 中环弧
d.arc([cx - r2, cy - r2, cx + r2, cy + r2], start=rot, end=rot + 120, fill=(*_IO_ORANGE, 220), width=4)
d.arc([cx - r2, cy - r2, cx + r2, cy + r2], start=rot + 180, end=rot + 260, fill=(*_IO_ORANGE, 180), width=4)
# 内环刻度
for i in range(12):
ang = math.radians(rot2 + i * 30)
d.line([cx + (r1 - 14) * math.cos(ang), cy + (r1 - 14) * math.sin(ang),
cx + r1 * math.cos(ang), cy + r1 * math.sin(ang)], fill=(*_IO_CYAN, 160), width=3)
d.ellipse([cx - r1, cy - r1, cx + r1, cy + r1], outline=(*_IO_CYAN, 120), width=2)
# 角标
for ang_deg in [45, 135, 225, 315]:
ang = math.radians(ang_deg + rot * 0.3)
px = cx + r3 * math.cos(ang)
py = cy + r3 * math.sin(ang)
sz = 16 * scale
d.line([px - sz, py, px + sz, py], fill=(*_IO_GOLD, 220), width=3)
d.line([px, py - sz, px, py + sz], fill=(*_IO_GOLD, 220), width=3)
def _io_draw_grid(base, t, w, h):
"""透视科技网格背景"""
from PIL import ImageDraw
d = ImageDraw.Draw(base, "RGBA")
horizon = h * 0.55
scroll = (t * 80) % 60
for i in range(20):
ratio = (i * 60 + scroll) / (20 * 60)
y = horizon + (h - horizon) * (ratio ** 2.2)
if horizon < y < h:
alpha = int(80 * (1 - ratio * 0.5))
d.line([0, y, w, y], fill=(*_IO_CYAN, alpha), width=1)
vanish_x = w / 2
for i in range(-12, 13):
x_bottom = w / 2 + i * 160
alpha = int(60 * (1 - abs(i) / 14))
d.line([vanish_x, horizon, x_bottom, h], fill=(*_IO_CYAN, alpha), width=1)
for i in range(30):
a = int(60 * (1 - i / 30))
d.line([0, horizon - i, w, horizon - i], fill=(*_IO_ORANGE, a), width=1)
def _io_draw_glow_text(base, text, font, pos, color, glow_color, glow_radius=8, alpha=255, border_color=None, border_w=0):
"""绘制带发光+描边的文字"""
from PIL import Image, ImageDraw, ImageFilter
glow_img = Image.new("RGBA", base.size, (0, 0, 0, 0))
gd = ImageDraw.Draw(glow_img)
gd.text(pos, text, font=font, fill=(*glow_color, 200), anchor="mm")
glow_img = glow_img.filter(ImageFilter.GaussianBlur(glow_radius))
base.alpha_composite(glow_img)
d = ImageDraw.Draw(base, "RGBA")
if border_color and border_w > 0:
for dx in range(-border_w, border_w + 1):
for dy in range(-border_w, border_w + 1):
if dx == 0 and dy == 0:
continue
d.text((pos[0] + dx, pos[1] + dy), text, font=font, fill=(*border_color, alpha), anchor="mm")
d.text(pos, text, font=font, fill=(*color, alpha), anchor="mm")
class _IOParticles:
"""粒子系统"""
def __init__(self, n, w, h, seed=0):
import numpy as np
rng = np.random.default_rng(seed)
self.n = n
self.x = rng.uniform(0, w, n).astype(np.float32)
self.y = rng.uniform(0, h, n).astype(np.float32)
self.vx = rng.uniform(-15, 15, n).astype(np.float32)
self.vy = rng.uniform(-25, -5, n).astype(np.float32)
self.size = rng.uniform(1, 3, n).astype(np.float32)
self.hue = rng.uniform(0, 1, n)
self.w = w
self.h = h
self._rng = np.random.default_rng()
def update(self, dt, upward_boost=0.0):
import numpy as np
self.x += self.vx * dt
self.y += self.vy * dt - upward_boost * dt
self.vy += 5 * dt
mask = (self.y < -10) | (self.y > self.h + 10) | (self.x < -10) | (self.x > self.w + 10)
if mask.any():
self.x[mask] = self._rng.uniform(0, self.w, mask.sum())
self.y[mask] = self.h + 5
self.vy[mask] = self._rng.uniform(-40, -10, mask.sum())
def draw(self, base):
from PIL import ImageDraw
d = ImageDraw.Draw(base, "RGBA")
for i in range(self.n):
r = self.size[i]
col = _IO_ORANGE if self.hue[i] < 0.5 else _IO_GOLD
a = int(180 * _io_clamp(1 - (self.h - self.y[i]) / self.h))
d.ellipse([self.x[i] - r, self.y[i] - r, self.x[i] + r, self.y[i] + r], fill=(*col, a))
def _io_render_intro_frame(t, duration, w, h, ball, particles, font_path):
"""渲染片头单帧"""
from PIL import Image, ImageDraw, ImageFont
import math
frame = Image.new("RGBA", (w, h), (*_IO_BG, 255))
_io_draw_grid(frame, t * 0.5, w, h)
particles.draw(frame)
cx, cy = w * 0.32, h * 0.5
# 篮球入场
if t < 0.7:
p = _io_ease_out_cubic(t / 0.7)
scale = 0.2 + 0.8 * p
glow = _io_make_glow(int(600 * p), _IO_ORANGE, 0.8)
frame.alpha_composite(glow, (int(cx - glow.width / 2), int(cy - glow.height / 2)))
else:
scale = 1.0 + 0.03 * math.sin(t * 4)
ball_rot = ball.rotate(t * 120, expand=False, resample=Image.BICUBIC)
bs = int(ball.width * scale)
if bs > 10:
ball_sized = ball_rot.resize((bs, bs), Image.LANCZOS)
glow = _io_make_glow(int(bs * 1.6), _IO_ORANGE, 0.5)
frame.alpha_composite(glow, (int(cx - glow.width / 2), int(cy - glow.height / 2)))
frame.alpha_composite(ball_sized, (int(cx - bs / 2), int(cy - bs / 2)))
# HUD 环
hud_alpha = _io_clamp((t - 0.5) / 0.5)
if hud_alpha > 0:
hud_layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
_io_draw_hud_rings(hud_layer, cx, cy, t, 1.0)
hud_layer.putalpha(hud_layer.split()[3].point(lambda a: int(a * hud_alpha)))
frame.alpha_composite(hud_layer)
# 文字
if t >= 0.9 and font_path:
tx, ty = w * 0.68, h * 0.42
title_p = _io_ease_out_back(_io_clamp((t - 0.9) / 0.6))
title_alpha = int(255 * _io_clamp((t - 0.9) / 0.3))
ts = int(130 * title_p)
if ts > 10:
ft = ImageFont.truetype(font_path, ts)
_io_draw_glow_text(frame, "篮球高光工坊", ft, (tx, ty), _IO_WHITE, _IO_ORANGE,
glow_radius=12, alpha=title_alpha, border_color=_IO_RED, border_w=4)
sub_p = _io_clamp((t - 1.3) / 0.4)
if sub_p > 0:
fs = ImageFont.truetype(font_path, 56)
_io_draw_glow_text(frame, "BASKETBALL HIGHLIGHTS", fs, (tx, ty + 110), _IO_GOLD, _IO_ORANGE,
glow_radius=6, alpha=int(255 * sub_p), border_color=(0, 0, 0), border_w=3)
tag_p = _io_clamp((t - 1.7) / 0.4)
if tag_p > 0:
tag_alpha = int(255 * tag_p * (0.7 + 0.3 * math.sin(t * 5)))
ftg = ImageFont.truetype(font_path, 40)
_io_draw_glow_text(frame, "◆ HOOPS ◆ TECH ◆", ftg, (tx, ty + 190), _IO_CYAN, _IO_CYAN,
glow_radius=4, alpha=tag_alpha)
# 扫描线
if 1.8 < t < 2.6:
sp = (t - 1.8) / 0.8
sy = int(h * sp)
scan = Image.new("RGBA", (w, 60), (0, 0, 0, 0))
sd = ImageDraw.Draw(scan)
for i in range(60):
a = int(120 * (1 - abs(i - 30) / 30))
sd.line([0, i, w, i], fill=(*_IO_CYAN, a), width=1)
frame.alpha_composite(scan, (0, sy - 30))
# 闪光
if 2.75 < t < 2.95:
flash_a = int(180 * (1 - (t - 2.75) / 0.2))
frame.alpha_composite(Image.new("RGBA", (w, h), (255, 255, 255, flash_a)))
# 淡入淡出
bg = Image.new("RGBA", (w, h), (*_IO_BG, 255))
if t < 0.2:
a = int(255 * (t / 0.2))
frame.putalpha(frame.split()[3].point(lambda x: int(x * a / 255)))
frame = Image.alpha_composite(bg, frame)
if t > duration - 0.3:
a = int(255 * (duration - t) / 0.3)
frame.putalpha(frame.split()[3].point(lambda x: int(x * a / 255)))
frame = Image.alpha_composite(bg, frame)
return frame.convert("RGB")
def _io_render_outro_frame(t, duration, w, h, ball, particles, font_path):
"""渲染片尾单帧"""
from PIL import Image, ImageDraw, ImageFont
import math
import numpy as np
frame = Image.new("RGBA", (w, h), (*_IO_BG, 255))
_io_draw_grid(frame, t * 0.4, w, h)
particles.draw(frame)
cx, cy = w * 0.5, h * 0.42
# 篮球弹入
if t < 0.8:
bt = t / 0.8
bounce = abs(math.sin(bt * math.pi * 2))
scale = 0.6 + 0.4 * _io_ease_out_cubic(bt)
cy_b = cy + (1 - bounce) * 60
else:
scale = 1.0 + 0.03 * math.sin(t * 4)
cy_b = cy
ball_rot = ball.rotate(t * 90, expand=False, resample=Image.BICUBIC)
bs = int(ball.width * scale)
if bs > 10:
ball_sized = ball_rot.resize((bs, bs), Image.LANCZOS)
glow = _io_make_glow(int(bs * 1.5), _IO_ORANGE, 0.5)
frame.alpha_composite(glow, (int(cx - glow.width / 2), int(cy_b - glow.height / 2)))
frame.alpha_composite(ball_sized, (int(cx - bs / 2), int(cy_b - bs / 2)))
# HUD 环
hud_alpha = _io_clamp((t - 0.4) / 0.5)
if hud_alpha > 0:
hud_layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
_io_draw_hud_rings(hud_layer, cx, cy_b, t, 1.0)
hud_layer.putalpha(hud_layer.split()[3].point(lambda a: int(a * hud_alpha)))
frame.alpha_composite(hud_layer)
# 文字
if t >= 0.7 and font_path:
ty = h * 0.72
title_p = _io_ease_out_back(_io_clamp((t - 0.7) / 0.5))
title_alpha = int(255 * _io_clamp((t - 0.7) / 0.3))
ts = int(130 * title_p)
if ts > 10:
ft = ImageFont.truetype(font_path, ts)
_io_draw_glow_text(frame, "感谢观看", ft, (w / 2, ty), _IO_WHITE, _IO_ORANGE,
glow_radius=12, alpha=title_alpha, border_color=_IO_RED, border_w=4)
sub_p = _io_clamp((t - 1.1) / 0.4)
if sub_p > 0:
fs = ImageFont.truetype(font_path, 56)
_io_draw_glow_text(frame, "更多高光 敬请期待", fs, (w / 2, ty + 100), _IO_GOLD, _IO_ORANGE,
glow_radius=6, alpha=int(255 * sub_p), border_color=(0, 0, 0), border_w=3)
# 庆祝粒子
if t >= 1.3:
d = ImageDraw.Draw(frame, "RGBA")
rng = np.random.default_rng(int(t * 10))
for _ in range(8):
px = rng.uniform(w * 0.2, w * 0.8)
py = h - (t - 1.3) * 200 - rng.uniform(0, 100)
if py > 0:
r = rng.uniform(2, 5)
d.ellipse([px - r, py - r, px + r, py + r], fill=(*_IO_GOLD, 200))
# 淡入淡出
bg = Image.new("RGBA", (w, h), (*_IO_BG, 255))
if t < 0.2:
a = int(255 * (t / 0.2))
frame.putalpha(frame.split()[3].point(lambda x: int(x * a / 255)))
frame = Image.alpha_composite(bg, frame)
if t > duration - 0.4:
a = int(255 * (duration - t) / 0.4)
frame.putalpha(frame.split()[3].point(lambda x: int(x * a / 255)))
frame = Image.alpha_composite(bg, frame)
return frame.convert("RGB")
def _io_encode(render_fn, output_path, duration, w, h, font_path, audio_mix):
"""逐帧渲染并通过 stdin pipe 给 ffmpeg 编码,混入音效"""
import subprocess
fps = 30
n_frames = int(fps * duration)
sfx_dir = os.path.join(_ASSETS_DIR, "sfx")
cmd = ["ffmpeg", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24",
"-s", f"{w}x{h}", "-r", str(fps), "-i", "-"]
valid_audio = []
for sfx_name, start, dur in audio_mix:
p = os.path.join(sfx_dir, sfx_name)
if os.path.exists(p):
cmd += ["-i", p]
valid_audio.append((start, dur))
audio_parts = []
for i, (start, dur) in enumerate(valid_audio, 1):
audio_parts.append(f"[{i}:a]atrim=0:{dur},adelay={int(start*1000)}|{int(start*1000)},asetpts=N/SR[a{i}]")
if audio_parts:
amix = "".join(f"[a{i}]" for i in range(1, len(valid_audio) + 1))
filter_complex = ";".join(audio_parts) + f";{amix}amix=inputs={len(valid_audio)}:duration=longest[aout]"
cmd += ["-filter_complex", filter_complex, "-map", "0:v", "-map", "[aout]"]
else:
cmd += ["-map", "0:v"]
cmd += ["-c:v", "libx264", "-crf", "20", "-preset", "fast", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k", "-t", f"{duration}", "-movflags", "+faststart", output_path]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
try:
ball = _io_make_basketball(400)
particles = _IOParticles(60, w, h, seed=42)
for i in range(n_frames):
t = i / fps
particles.update(1 / fps, upward_boost=20)
frame = render_fn(t, duration, w, h, ball, particles, font_path)
proc.stdin.write(frame.tobytes())
proc.stdin.close()
except (BrokenPipeError, Exception) as e:
logger.warning(f"[intro/outro] render pipe error: {e}")
_, err = proc.communicate()
if proc.returncode != 0:
logger.warning(f"[intro/outro] ffmpeg failed: {err.decode()[-400:]}")
return False
return True
def _try_use_pregenerated(clip_type: str, output_path: str, width: int, height: int) -> bool:
"""优先使用预生成的片头/片尾视频文件(效果与预览完全一致)。
预生成文件为 1920x1080@30fps,如果目标分辨率相同则直接复制;
否则用 ffmpeg scale 到目标分辨率。
"""
import shutil
src = os.path.join(_ASSETS_DIR, f"{clip_type}_1920x1080.mp4")
if not os.path.isfile(src):
return False
if width == 1920 and height == 1080:
shutil.copy2(src, output_path)
logger.info(f"[{clip_type}] 使用预生成文件(直接复制)")
return True
# 分辨率不同,scale 后复制
try:
cmd = ["ffmpeg", "-y", "-i", src,
"-vf", f"scale={width}:{height}",
"-c:v", "libx264", "-crf", "20", "-preset", "fast", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", output_path]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if r.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
logger.info(f"[{clip_type}] 使用预生成文件(scale 到 {width}x{height})")
return True
logger.warning(f"[{clip_type}] scale failed: {r.stderr[:200]}")
except Exception as e:
logger.warning(f"[{clip_type}] scale error: {e}")
return False
def generate_intro(output_path: str, duration: float = 3.0, width: int = 1920, height: int = 1080):
"""生成炫酷的篮球科技风片头(3秒)
优先使用预生成文件(效果与预览完全一致),失败则动态渲染,再失败回退到简版。
"""
# 方案1:预生成文件
if _try_use_pregenerated("intro", output_path, width, height):
return
# 方案2:numpy+PIL 动态渲染
font_path = _find_cjk_font()
audio_mix = [
("impact_01.mp3", 0.0, 0.8),
("whistle_swoosh.mp3", 0.6, 1.0),
("basketball_buzzer.mp3", 2.7, 0.5),
]
try:
ok = _io_encode(_io_render_intro_frame, output_path, duration, width, height, font_path, audio_mix)
if not ok:
logger.warning("[generate_intro] numpy+PIL render failed, fallback to simple")
_generate_intro_simple(output_path, duration, width, height)
except Exception as e:
logger.warning(f"[generate_intro] render failed: {e}, fallback to simple")
_generate_intro_simple(output_path, duration, width, height)
def _generate_intro_simple(output_path: str, duration: float, width: int, height: int):
"""简单版片头(回退方案)"""
fontfile = _find_cjk_font()
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"color=c=0x0a0014:s={width}x{height}:d={duration}:r=30",
"-filter_complex",
f"[0:v]drawtext=text='篮球高光工坊':fontfile={fontfile}:"
f"fontcolor=white:fontsize=140:borderw=8:bordercolor=0xCC0000:"
f"x=(w-text_w)/2:y=(h-text_h)/2-60,"
f"drawtext=text='BASKETBALL HIGHLIGHTS':fontfile={fontfile}:"
f"fontcolor=0xFFD700:fontsize=70:borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y=(h-text_h)/2+100,"
f"fade=in:0:15,fade=out:st={duration-0.5}:d=0.5[v]",
"-map", "[v]",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=30)
def generate_outro(output_path: str, duration: float = 3.0, width: int = 1920, height: int = 1080):
"""生成炫酷的篮球科技风片尾(3秒)
优先使用预生成文件(效果与预览完全一致),失败则动态渲染,再失败回退到简版。
"""
# 方案1:预生成文件
if _try_use_pregenerated("outro", output_path, width, height):
return
# 方案2:numpy+PIL 动态渲染
font_path = _find_cjk_font()
audio_mix = [
("basketball_bounce_01.mp3", 0.0, 0.8),
("crowd_cheering.mp3", 0.7, 1.8),
("whistle_short.mp3", 2.5, 0.5),
]
try:
ok = _io_encode(_io_render_outro_frame, output_path, duration, width, height, font_path, audio_mix)
if not ok:
logger.warning("[generate_outro] numpy+PIL render failed, fallback to simple")
_generate_outro_simple(output_path, duration, width, height)
except Exception as e:
logger.warning(f"[generate_outro] render failed: {e}, fallback to simple")
_generate_outro_simple(output_path, duration, width, height)
def _generate_outro_simple(output_path: str, duration: float, width: int, height: int):
"""简单版片尾(回退方案)"""
fontfile = _find_cjk_font()
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"color=c=0x0a0014:s={width}x{height}:d={duration}:r=30",
"-filter_complex",
f"[0:v]drawtext=text='感谢观看':fontfile={fontfile}:"
f"fontcolor=white:fontsize=140:borderw=8:bordercolor=0xCC0000:"
f"x=(w-text_w)/2:y=(h-text_h)/2-60,"
f"drawtext=text='更多高光敬请期待':fontfile={fontfile}:"
f"fontcolor=0xFFD700:fontsize=70:borderw=4:bordercolor=black:"
f"x=(w-text_w)/2:y=(h-text_h)/2+100,"
f"fade=in:0:10,fade=out:st={duration-0.5}:d=0.5[v]",
"-map", "[v]",
"-c:v", "libx264", "-crf", "24", "-preset", "fast",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True, timeout=30)
def generate_sound_effect(effect_type: str, duration: float, output_path: str):
# Try to use real SFX file from assets/sfx/
real_sfx = _find_real_sfx(effect_type)
if real_sfx:
logger.info(f"[SFX] Using real audio file: {real_sfx}")
try:
# Trim the real SFX to the desired duration
fade_out_start = max(0, duration - 0.1)
cmd = [
"ffmpeg", "-y", "-i", real_sfx,
"-t", f"{duration:.2f}",
"-af", f"afade=t=in:st=0:d=0.05,afade=t=out:st={fade_out_start:.2f}:d=0.1",
"-c:a", "aac", "-b:a", "64k",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
return
logger.warning(f"[SFX] Real SFX failed: {result.stderr[:200]}")
except Exception as e:
logger.warning(f"[SFX] Failed to use real SFX {real_sfx}: {e}, falling back to synthetic")
# Fallback: generate synthetic sound effect
fade_out_start = max(0, duration - 0.1)
if effect_type == "swish":
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"anoisesrc=d={duration}:c=white:r=44100:a=0.3",
"-filter_complex",
f"[0:a]highpass=f=2000,lowpass=f=8000,"
f"afade=t=in:st=0:d=0.05,afade=t=out:st={fade_out_start:.2f}:d=0.1,"
f"asetrate=44100,aformat=sample_fmts=s16[out]",
"-map", "[out]",
"-c:a", "aac", "-b:a", "64k",
output_path,
]
elif effect_type == "slam":
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"anoisesrc=d={duration}:c=brown:r=44100:a=0.5",
"-filter_complex",
f"[0:a]lowpass=f=500,"
f"afade=t=in:st=0:d=0.02,afade=t=out:st={fade_out_start:.2f}:d=0.15,"
f"asetrate=44100,aformat=sample_fmts=s16[out]",
"-map", "[out]",
"-c:a", "aac", "-b:a", "64k",
output_path,
]
elif effect_type == "whistle":
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"sine=frequency=3500:duration={duration}",
"-filter_complex",
f"[0:a]volume=0.2,"
f"afade=t=in:st=0:d=0.05,afade=t=out:st={fade_out_start:.2f}:d=0.1,"
f"asetrate=44100,aformat=sample_fmts=s16[out]",
"-map", "[out]",
"-c:a", "aac", "-b:a", "64k",
output_path,
]
else:
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"anoisesrc=d={duration}:c=pink:r=44100:a=0.1",
"-filter_complex",
f"[0:a]lowpass=f=1000,afade=t=in:st=0:d=0.05,afade=t=out:st={fade_out_start:.2f}:d=0.1[out]",
"-map", "[out]",
"-c:a", "aac", "-b:a", "64k",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.warning(f"[SFX] Synthetic SFX generation failed: {result.stderr[:200]}")
# Last resort: create a minimal silent file
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"anullsrc=r=44100:cl=mono",
"-t", f"{duration:.2f}",
"-c:a", "aac", "-b:a", "64k",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
def add_sound_effect(video_path: str, effect_type: str, output_path: str, offset: float = 0.0):
sfx_path = video_path + f".{effect_type}.m4a"
try:
generate_sound_effect(effect_type, 0.8, sfx_path)
# 检查视频是否有音频流
probe_cmd = ["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_name", "-of", "csv=p=0", video_path]
probe_result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
has_audio = bool(probe_result.stdout.strip())
if has_audio:
# 有音频:混音
cmd = [
"ffmpeg", "-y", "-i", video_path, "-i", sfx_path,
"-filter_complex",
f"[1:a]adelay={int(offset*1000)}|{int(offset*1000)},volume=0.5[sfx];"
f"[0:a][sfx]amix=inputs=2:duration=first:dropout_transition=2[a]",
"-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac",
"-shortest",
output_path,
]
else:
# 无音频:直接添加音效作为音轨
cmd = [
"ffmpeg", "-y", "-i", video_path, "-i", sfx_path,
"-filter_complex",
f"[1:a]adelay={int(offset*1000)}|{int(offset*1000)},volume=0.5[a]",
"-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac",
"-shortest",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
import shutil
shutil.copy2(video_path, output_path)
except Exception:
import shutil
shutil.copy2(video_path, output_path)
finally:
if os.path.exists(sfx_path):
os.remove(sfx_path)
def _probe_streams(path: str) -> dict:
"""探测视频文件的视频和音频流参数"""
info = {"has_video": False, "has_audio": False, "width": 0, "height": 0, "fps": 0,
"v_codec": "", "pix_fmt": "", "a_codec": "", "sample_rate": 0, "channels": 0, "duration": 0}
try:
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", path]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if r.returncode == 0:
data = json.loads(r.stdout)
info["duration"] = float(data.get("format", {}).get("duration", 0))
for s in data.get("streams", []):
if s["codec_type"] == "video":
info["has_video"] = True
info["width"] = int(s.get("width", 0))
info["height"] = int(s.get("height", 0))
info["v_codec"] = s.get("codec_name", "")
info["pix_fmt"] = s.get("pix_fmt", "")
fr = s.get("r_frame_rate", "30/1").split("/")
if len(fr) == 2 and int(fr[1]) > 0:
info["fps"] = round(int(fr[0]) / int(fr[1]))
elif s["codec_type"] == "audio":
info["has_audio"] = True
info["a_codec"] = s.get("codec_name", "")
info["sample_rate"] = int(s.get("sample_rate", 0))
info["channels"] = int(s.get("channels", 0))
except Exception:
pass
return info
def _concat_demuxer(files: list, output_path: str, timeout: int = 60) -> bool:
"""使用 concat demuxer + -c copy 拼接视频(不重编码,极快)"""
import tempfile
list_path = os.path.join(os.path.dirname(output_path), f"_concat_{os.path.basename(output_path)}.txt")
try:
with open(list_path, "w") as f:
for fp in files:
# 转义单引号
safe = fp.replace("'", r"'\''")
f.write(f"file '{safe}'\n")
cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path,
"-c", "copy",
"-fflags", "+genpts",
"-avoid_negative_ts", "make_zero",
"-movflags", "+faststart", output_path]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if r.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
return True
logger.warning(f"[concat_demuxer] failed: {r.stderr[:300]}")
return False
except Exception as e:
logger.warning(f"[concat_demuxer] error: {e}")
return False
finally:
if os.path.exists(list_path):
os.remove(list_path)
def _normalize_clip(clip_path: str, ref_info: dict, output_path: str, timeout: int = 60) -> bool:
"""将片头/片尾的参数统一到与主视频一致(只重编码短片段,很快)"""
clip_info = _probe_streams(clip_path)
clip_duration = clip_info.get("duration", 3.0) or 3.0
filters = []
if ref_info.get("width") and ref_info.get("height"):
filters.append(f"scale={ref_info['width']}:{ref_info['height']}")
filters.append("fps=30")
filters.append("format=yuv420p")
vf = ",".join(filters)
cmd = ["ffmpeg", "-y", "-i", clip_path]
if ref_info.get("has_audio"):
sr = ref_info.get("sample_rate", 44100) or 44100
ch = ref_info.get("channels", 2) or 2
cmd += ["-f", "lavfi", "-t", f"{clip_duration}",
"-i", f"anullsrc=r={sr}:cl={'stereo' if ch >= 2 else 'mono'}"]
cmd += ["-filter_complex", f"[0:v]{vf}[v];[1:a][0:a?]amix=inputs=2:duration=first:dropout_transition=0[a]"]
cmd += ["-map", "[v]", "-map", "[a]"]
else:
cmd += ["-vf", vf, "-map", "0:v"]
cmd += ["-c:v", "libx264", "-crf", "20", "-preset", "fast", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k", "-r", "30",
"-video_track_timescale", "15360",
"-x264-params", "bframes=0",
"-g", "30", "-keyint_min", "30",
"-fflags", "+genpts",
"-avoid_negative_ts", "make_zero",
"-movflags", "+faststart", output_path]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if r.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
return True
logger.warning(f"[normalize_clip] failed: {r.stderr[:300]}")
return False
except Exception as e:
logger.warning(f"[normalize_clip] error: {e}")
return False
def prepend_intro(main_path: str, intro_path: str, output_path: str):
"""将片头拼接到主视频前面。使用 concat filter 重编码确保时间戳连续。"""
main_info = _probe_streams(main_path)
intro_info = _probe_streams(intro_path)
intro_duration = intro_info.get("duration", 3.0) or 3.0
has_main_audio = main_info.get("has_audio", False)
has_intro_audio = intro_info.get("has_audio", False)
# 统一使用主视频的音频参数
sr = main_info.get("sample_rate", 44100) or 44100
ch = 2 if main_info.get("channels", 2) >= 2 else 1
ch_str = "stereo" if ch >= 2 else "mono"
ch_layout = "stereo" if ch >= 2 else "mono"
# concat filter 重编码(禁用B帧确保PTS严格递增,浏览器兼容性好)
# 用 aformat 统一音频参数,避免采样率/通道不一致导致静音
try:
if has_main_audio and has_intro_audio:
# 两者都有音频,统一格式后 concat
filter_complex = (
f"[0:a]aformat=sample_rates={sr}:channel_layouts={ch_layout}[a0];"
f"[1:a]aformat=sample_rates={sr}:channel_layouts={ch_layout}[a1];"
f"[0:v][1:v]concat=n=2:v=1:a=0[v];"
f"[a0][a1]concat=n=2:v=0:a=1[a]"
)
cmd = [
"ffmpeg", "-y", "-i", intro_path, "-i", main_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "[a]",
]
elif has_main_audio:
# 主视频有音频,片头无音频:用静音补片头
filter_complex = (
f"anullsrc=r={sr}:cl={ch_str}[silent];"
f"[silent]atrim=0:{intro_duration},aformat=sample_rates={sr}:channel_layouts={ch_layout}[sil];"
f"[1:a]aformat=sample_rates={sr}:channel_layouts={ch_layout}[a1];"
f"[0:v][1:v]concat=n=2:v=1:a=0[v];"
f"[sil][a1]concat=n=2:v=0:a=1[a]"
)
cmd = [
"ffmpeg", "-y", "-i", intro_path, "-i", main_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "[a]",
]
else:
# 主视频无音频,只拼视频
filter_complex = "[0:v][1:v]concat=n=2:v=1:a=0[v]"
cmd = [
"ffmpeg", "-y", "-i", intro_path, "-i", main_path,
"-filter_complex", filter_complex,
"-map", "[v]",
]
cmd += [
"-c:v", "libx264", "-crf", "22", "-preset", "ultrafast",
"-pix_fmt", "yuv420p",
"-x264-params", "bframes=0",
"-video_track_timescale", "15360",
"-r", "30",
"-g", "30", "-keyint_min", "30",
"-fflags", "+genpts",
"-avoid_negative_ts", "make_zero",
]
if has_main_audio:
cmd += ["-c:a", "aac", "-b:a", "128k", "-ar", str(sr), "-ac", str(ch)]
cmd += ["-movflags", "+faststart", output_path]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
logger.info("[prepend_intro] concat filter succeeded")
return
logger.warning(f"[prepend_intro] concat filter failed: {result.stderr[:300]}")
except Exception as e:
logger.warning(f"[prepend_intro] concat filter error: {e}")
# 回退:直接复制主视频
import shutil
logger.warning("[prepend_intro] all methods failed, copying main video")
shutil.copy2(main_path, output_path)
def append_outro(main_path: str, outro_path: str, output_path: str):
"""将片尾拼接到主视频后面。使用 concat filter 重编码确保时间戳连续。"""
main_info = _probe_streams(main_path)
outro_info = _probe_streams(outro_path)
outro_duration = outro_info.get("duration", 3.0) or 3.0
has_main_audio = main_info.get("has_audio", False)
has_outro_audio = outro_info.get("has_audio", False)
sr = main_info.get("sample_rate", 44100) or 44100
ch = 2 if main_info.get("channels", 2) >= 2 else 1
ch_str = "stereo" if ch >= 2 else "mono"
ch_layout = "stereo" if ch >= 2 else "mono"
try:
if has_main_audio and has_outro_audio:
filter_complex = (
f"[0:a]aformat=sample_rates={sr}:channel_layouts={ch_layout}[a0];"
f"[1:a]aformat=sample_rates={sr}:channel_layouts={ch_layout}[a1];"
f"[0:v][1:v]concat=n=2:v=1:a=0[v];"
f"[a0][a1]concat=n=2:v=0:a=1[a]"
)
cmd = [
"ffmpeg", "-y", "-i", main_path, "-i", outro_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "[a]",
]
elif has_main_audio:
filter_complex = (
f"anullsrc=r={sr}:cl={ch_str}[silent];"
f"[silent]atrim=0:{outro_duration},aformat=sample_rates={sr}:channel_layouts={ch_layout}[sil];"
f"[0:a]aformat=sample_rates={sr}:channel_layouts={ch_layout}[a0];"
f"[0:v][1:v]concat=n=2:v=1:a=0[v];"
f"[a0][sil]concat=n=2:v=0:a=1[a]"
)
cmd = [
"ffmpeg", "-y", "-i", main_path, "-i", outro_path,
"-filter_complex", filter_complex,
"-map", "[v]", "-map", "[a]",
]
else:
filter_complex = "[0:v][1:v]concat=n=2:v=1:a=0[v]"
cmd = [
"ffmpeg", "-y", "-i", main_path, "-i", outro_path,
"-filter_complex", filter_complex,
"-map", "[v]",
]
cmd += [
"-c:v", "libx264", "-crf", "22", "-preset", "ultrafast",
"-pix_fmt", "yuv420p",
"-x264-params", "bframes=0",
"-video_track_timescale", "15360",
"-r", "30",
"-g", "30", "-keyint_min", "30",
"-fflags", "+genpts",
"-avoid_negative_ts", "make_zero",
]
if has_main_audio:
cmd += ["-c:a", "aac", "-b:a", "128k", "-ar", str(sr), "-ac", str(ch)]
cmd += ["-movflags", "+faststart", output_path]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
logger.info("[append_outro] concat filter succeeded")
return
logger.warning(f"[append_outro] concat filter failed: {result.stderr[:300]}")
except Exception as e:
logger.warning(f"[append_outro] concat filter error: {e}")
# 回退:直接复制主视频
import shutil
logger.warning("[append_outro] all methods failed, copying main video")
shutil.copy2(main_path, output_path)
def generate_ambient_bgm(duration: float, output_path: str):
"""Generate background music for the highlight video.
Priority:
1. Use a real BGM file from assets/bgm/ (trimmed/faded to match duration)
2. Fallback: generate beat-driven synthetic BGM with FFmpeg
"""
# Try to use real BGM from assets
real_bgm = _find_real_bgm()
if real_bgm:
logger.info(f"[BGM] Using real audio file: {real_bgm}")
try:
dur = max(1.0, duration + 4.0)
fade_in = 2.0
fade_out = 3.0
cmd = [
"ffmpeg", "-y", "-i", real_bgm,
"-t", str(dur),
"-af", f"afade=t=in:st=0:d={fade_in},afade=t=out:st={dur - fade_out}:d={fade_out},volume=0.5",
"-c:a", "aac", "-b:a", "128k",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
return
except Exception as e:
logger.warning(f"[BGM] Failed to use real BGM {real_bgm}: {e}, falling back to synthetic")
# Fallback: generate beat-driven synthetic BGM
_generate_synthetic_bgm(duration, output_path)
def _generate_synthetic_bgm(duration: float, output_path: str):
"""Generate a beat-driven synthetic BGM using FFmpeg aevalsrc.
Creates a hip-hop/sports style beat with:
- Kick drum on every beat (exponential decay envelope)
- Snare on beats 2 and 4 (offset pattern)
- Hi-hat on every 8th note (fast decay)
- Sustained bass line
- Atmospheric pad chord
"""
dur = max(1.0, duration + 4.0)
bpm = 90
beat = 60.0 / bpm # ~0.667s per beat
hbeat = beat / 2 # ~0.333s per half beat
# Kick drum: low freq burst with exponential decay, repeating every beat
kick_expr = f"0.4*exp(-12*mod(t,{beat}))*sin(2*PI*60*t)"
# Snare: mid-freq burst on beats 2 and 4 (offset by half beat, period = 2 beats)
snare_expr = f"0.25*exp(-15*mod(t+{hbeat},{beat*2}))*sin(2*PI*200*t)"
# Hi-hat: high freq burst every half beat with fast decay
hihat_expr = f"0.12*exp(-25*mod(t,{hbeat}))*sin(2*PI*9000*t)"
# Bass: sustained low note
bass_expr = f"0.18*sin(2*PI*55*t)"
# Pad: soft chord (A minor: A3, C4, E4)
pad_expr = f"0.04*sin(2*PI*220*t)+0.03*sin(2*PI*277*t)+0.03*sin(2*PI*330*t)"
filter_complex = (
f"aevalsrc='{kick_expr}':s=44100:d={dur},"
f"afade=t=in:st=0:d=2,afade=t=out:st={dur-3}:d=3[kick];"
f"aevalsrc='{snare_expr}':s=44100:d={dur},"
f"afade=t=in:st=0:d=2,afade=t=out:st={dur-3}:d=3[snare];"
f"aevalsrc='{hihat_expr}':s=44100:d={dur},"
f"afade=t=in:st=0:d=2,afade=t=out:st={dur-3}:d=3[hihat];"
f"aevalsrc='{bass_expr}':s=44100:d={dur},"
f"afade=t=in:st=0:d=2,afade=t=out:st={dur-3}:d=3[bass];"
f"aevalsrc='{pad_expr}':s=44100:d={dur},"
f"afade=t=in:st=0:d=3,afade=t=out:st={dur-3}:d=3[pad];"
f"[kick][snare][hihat][bass][pad]amix=inputs=5:duration=longest,"
f"lowpass=f=4000,highpass=f=30,"
f"afade=t=in:st=0:d=2,afade=t=out:st={dur-3}:d=3,"
f"aformat=sample_fmts=s16:sample_rates=44100[out]"
)
cmd = [
"ffmpeg", "-y",
"-filter_complex", filter_complex,
"-map", "[out]",
"-c:a", "aac", "-b:a", "128k",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.warning(f"[BGM] Beat-driven synthetic BGM failed: {result.stderr[:300]}")
# Ultimate fallback: simple ambient drone
_generate_simple_bgm(duration, output_path)
def _generate_simple_bgm(duration: float, output_path: str):
"""Simple ambient BGM fallback (original sine wave approach)."""
dur = max(1.0, duration + 4.0)
cmd = [
"ffmpeg", "-y",
"-f", "lavfi", "-i", f"sine=frequency=110:duration={dur}",
"-f", "lavfi", "-i", f"sine=frequency=165:duration={dur}",
"-f", "lavfi", "-i", f"sine=frequency=220:duration={dur}",
"-f", "lavfi", "-i", f"anoisesrc=d={dur}:c=pink:r=22050:a=0.02",
"-filter_complex",
f"[0:a]volume=0.15[bass];"
f"[1:a]volume=0.10[mid];"
f"[2:a]volume=0.08[high];"
f"[3:a]lowpass=f=400,volume=0.05[noise];"
f"[bass][mid][high][noise]amix=inputs=4:duration=longest,"
f"lowpass=f=1200,afade=t=in:st=0:d=3,afade=t=out:st={dur-3}:d=3[out]",
"-map", "[out]",
"-c:a", "aac", "-b:a", "128k",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
def detect_beat_times(audio_path: str) -> List[float]:
"""用 librosa 检测音频节拍时间点
返回: 节拍时间列表,如 [0.23, 0.69, 1.15, ...]
"""
try:
import librosa
y, sr = librosa.load(audio_path, sr=22050)
tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)
beat_times = librosa.frames_to_time(beat_frames, sr=sr).tolist()
logger.info(f"[BeatSync] Detected {len(beat_times)} beats, tempo={tempo:.1f} BPM")
return beat_times
except ImportError:
logger.warning("[BeatSync] librosa not available, skipping beat detection")
return []
except Exception as e:
logger.warning(f"[BeatSync] Beat detection failed: {e}")
return []
def align_to_beat(time_point: float, beat_times: List[float], tolerance: float = 0.5) -> float:
"""将时间点对齐到最近的节拍点
tolerance: 最大对齐距离(秒),超过则不对齐
"""
if not beat_times:
return time_point
closest = min(beat_times, key=lambda b: abs(b - time_point))
if abs(closest - time_point) <= tolerance:
return closest
return time_point
def mix_audio(video_path: str, bgm_path: str, output_path: str, bgm_volume: float = 0.3, video_volume: float = 0.8):
probe_cmd = ["ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_name", "-of", "csv=p=0", video_path]
result = subprocess.run(probe_cmd, capture_output=True, text=True)
if not result.stdout.strip():
cmd = [
"ffmpeg", "-y", "-i", video_path, "-i", bgm_path,
"-filter_complex",
f"[1:a]volume={bgm_volume},afade=t=in:st=0:d=2,afade=t=out:st=30:d=2[a2];"
f"[0:v]copy[v]",
"-map", "[v]", "-map", "[a2]",
"-c:v", "copy", "-c:a", "aac",
"-shortest",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)
return
cmd = [
"ffmpeg", "-y", "-i", video_path, "-i", bgm_path,
"-filter_complex",
f"[0:a]volume={video_volume}[a1];"
f"[1:a]volume={bgm_volume},afade=t=in:st=0:d=2,afade=t=out:st=30:d=2[a2];"
f"[a2]sidechaincompress=threshold=0.15:ratio=6:attack=10:release=200[bgm_ducked];"
f"[a1][bgm_ducked]amix=inputs=2:duration=first:dropout_transition=3[a]",
"-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac",
"-shortest",
output_path,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
logger.warning(f"[mix_audio] sidechaincompress failed, falling back to simple mix: {result.stderr[:200]}")
cmd = [
"ffmpeg", "-y", "-i", video_path, "-i", bgm_path,
"-filter_complex",
f"[0:a]volume={video_volume}[a1];"
f"[1:a]volume={bgm_volume},afade=t=in:st=0:d=2,afade=t=out:st=30:d=2[a2];"
f"[a1][a2]amix=inputs=2:duration=first[a]",
"-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac",
"-shortest",
output_path,
]
subprocess.run(cmd, capture_output=True, check=True)