import subprocess from pathlib import Path import os from utils.helpers import get_media_info, get_media_duration # --- 標準化設定 --- TARGET_WIDTH = 1920 TARGET_HEIGHT = 1080 TARGET_FPS = 30 def escape_ffmpeg_path_for_filter(path: Path) -> str: """為在 FFmpeg 濾鏡圖中安全使用而轉義路徑。""" path_str = str(path.resolve()) if os.name == 'nt': return path_str.replace('\\', '\\\\').replace(':', '\\:') else: return path_str.replace("'", "'\\\\\\''") def run_step6_assemble_video( project_path: Path, logo_video: Path, open_video: Path, end_video: Path, p_loop_count: int, transition_duration: float = 1.0 ): """ 使用 FFmpeg 的 xfade 濾鏡組裝最終影片,並在過程中統一影片屬性與進行色彩校正。 """ try: # --- 1. 路徑與檔案檢查 --- output_dir = project_path / "output" test_dir = output_dir / "test" audio_path = output_dir / "combined_audio.wav" ass_path = output_dir / "subtitles.ass" bg_final_path = output_dir / "bg_final.mp4" final_video_path = output_dir / "final_video_full_sequence.mp4" for file_path in [logo_video, open_video, end_video, audio_path, ass_path]: if not file_path or not file_path.exists(): return False, f"❌ 找不到必要的輸入檔案: {file_path}", None base_videos = sorted([p for p in test_dir.iterdir() if p.is_file() and p.suffix.lower() in ['.mp4', '.mov']]) if not base_videos: return False, "❌ `test` 資料夾中沒有影片可供合成。", None # --- 2. 一步式生成主要內容影片 (bg_final.mp4) --- if not bg_final_path.exists(): print(f"⚙️ 準備一步式生成主要內容影片 (循環 {p_loop_count} 次)...") looped_video_list = base_videos * p_loop_count inputs_cmd = [] for video_path in looped_video_list: inputs_cmd.extend(["-i", str(video_path)]) inputs_cmd.extend(["-i", str(audio_path)]) ass_path_str = escape_ffmpeg_path_for_filter(ass_path) filter_parts = [] stream_count = len(looped_video_list) # 【核心修改】定義色彩校正濾鏡 color_correction_filter = "eq=brightness=-0.05:contrast=0.95:saturation=0.7" # 將標準化 (尺寸、影格率) 與色彩校正合併 for i in range(stream_count): filter_parts.append( f"[{i}:v]scale={TARGET_WIDTH}:{TARGET_HEIGHT}:force_original_aspect_ratio=decrease,pad={TARGET_WIDTH}:{TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={TARGET_FPS},{color_correction_filter}[v_scaled_{i}]" ) last_v_out = "[v_scaled_0]" for i in range(stream_count - 1): offset = sum(get_media_duration(v) for v in looped_video_list[:i+1]) - (i + 1) * transition_duration v_in_next = f"[v_scaled_{i+1}]" v_out_current = f"[v_out{i+1}]" if i < stream_count - 2 else "[bg_video_stream]" filter_parts.append(f"{last_v_out}{v_in_next}xfade=transition=fade:duration={transition_duration}:offset={offset:.4f}{v_out_current}") last_v_out = v_out_current drawbox_filter = "drawbox=w=iw*0.8:h=ih*0.8:x=(iw-iw*0.8)/2:y=(ih-ih*0.8)/2:color=0x808080@0.7:t=fill" filter_parts.append(f"[bg_video_stream]{drawbox_filter}[v_with_mask]") filter_parts.append(f"[v_with_mask]ass=filename='{ass_path_str}'[final_v]") filter_complex_str = ";".join(filter_parts) ffmpeg_main_content_cmd = [ "ffmpeg", "-y", *inputs_cmd, "-filter_complex", filter_complex_str, "-map", "[final_v]", "-map", f"{stream_count}:a:0", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "fast", "-c:a", "aac", "-b:a", "192k", "-shortest", str(bg_final_path) ] print("🚀 正在執行 FFmpeg 主要內容合成指令 (含色彩校正)...") result = subprocess.run(ffmpeg_main_content_cmd, check=True, capture_output=True, text=True, encoding='utf-8') if result.returncode != 0: raise subprocess.CalledProcessError(result.returncode, result.args, stderr=result.stderr) print(f"✅ 主要內容影片已生成: {bg_final_path}") # --- 3. 合成最終的影片序列 (logo -> open -> bg_final -> end) --- if final_video_path.exists(): print(f"✅ 最終影片已存在,跳過組裝: {final_video_path}") return True, f"✅ 影片已存在於 {final_video_path}", final_video_path print("🎬 開始動態生成 xfade 合成指令...") videos_to_concat = [logo_video, open_video, bg_final_path, end_video] final_inputs_cmd, final_filter_parts, video_infos = [], [], [] for video_path in videos_to_concat: final_inputs_cmd.extend(["-i", str(video_path)]) info = get_media_info(video_path) video_infos.append(info) for i, info in enumerate(video_infos): if not info["has_audio"]: duration = info['duration'] final_filter_parts.append(f"anullsrc=r=44100:cl=stereo:d={duration:.4f}[silent_a_{i}]") for i in range(len(videos_to_concat)): # 注意:這裡我們不對 logo/open/end 影片進行調色,以保留它們的原始風格 final_filter_parts.append( f"[{i}:v]scale={TARGET_WIDTH}:{TARGET_HEIGHT}:force_original_aspect_ratio=decrease,pad={TARGET_WIDTH}:{TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={TARGET_FPS}[v_final_scaled_{i}]" ) last_v_out = "[v_final_scaled_0]" last_a_out = "[0:a]" if video_infos[0]["has_audio"] else "[silent_a_0]" for i in range(len(videos_to_concat) - 1): offset = sum(info['duration'] for info in video_infos[:i+1]) - (i + 1) * transition_duration v_in_next = f"[v_final_scaled_{i+1}]" a_in_next = f"[{i+1}:a]" if video_infos[i+1]["has_audio"] else f"[silent_a_{i+1}]" v_out_current = f"[v_out{i+1}]" if i < len(videos_to_concat) - 2 else "[video]" a_out_current = f"[a_out{i+1}]" if i < len(videos_to_concat) - 2 else "[audio]" final_filter_parts.append(f"{last_v_out}{v_in_next}xfade=transition=fade:duration={transition_duration}:offset={offset:.4f}{v_out_current}") final_filter_parts.append(f"{last_a_out}{a_in_next}acrossfade=d={transition_duration}{a_out_current}") last_v_out, last_a_out = v_out_current, a_out_current final_filter_complex_str = ";".join(final_filter_parts) ffmpeg_concat_cmd = [ "ffmpeg", "-y", *final_inputs_cmd, "-filter_complex", final_filter_complex_str, "-map", "[video]", "-map", "[audio]", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "fast", "-movflags", "+faststart", "-c:a", "aac", "-b:a", "192k", str(final_video_path) ] print("🚀 執行 FFmpeg 最終序列合成指令...") result = subprocess.run(ffmpeg_concat_cmd, check=True, capture_output=True, text=True, encoding='utf-8') if result.returncode != 0: raise subprocess.CalledProcessError(result.returncode, result.args, stderr=result.stderr) print(f"✅ 影片已成功組裝並儲存至 {final_video_path}") return True, f"✅ 影片已成功組裝並儲存至 {final_video_path}", final_video_path except subprocess.CalledProcessError as e: error_output = e.stderr if e.stderr else str(e) return False, f"❌ FFmpeg 執行失敗:\n{error_output}", None except Exception as e: return False, f"❌ 發生未預期錯誤: {e}", None