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)