""" Stage 9 — Final Video Merge Replaces original audio with dubbed audio in the video. Video stream is copied (no re-encoding) for speed. Handles audio-only uploads by generating a video with black background. """ import logging import subprocess import json from pathlib import Path logger = logging.getLogger(__name__) def _probe_streams(file_path: Path) -> dict: """Use ffprobe to detect video and audio streams in a file.""" cmd = [ "ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", str(file_path) ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if result.returncode != 0: logger.warning(f"ffprobe failed: {result.stderr[:200]}") return {"has_video": False, "has_audio": False, "duration": 0} data = json.loads(result.stdout) streams = data.get("streams", []) has_video = any(s.get("codec_type") == "video" for s in streams) has_audio = any(s.get("codec_type") == "audio" for s in streams) # Get duration from first stream duration = 0 for s in streams: d = float(s.get("duration", 0)) if d > duration: duration = d return {"has_video": has_video, "has_audio": has_audio, "duration": duration} except Exception as e: logger.warning(f"ffprobe error: {e}") return {"has_video": False, "has_audio": False, "duration": 0} def _get_duration(file_path: Path) -> float: """Get duration of a media file in seconds.""" cmd = [ "ffprobe", "-v", "quiet", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(file_path) ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) return float(result.stdout.strip()) except Exception: return 0 def merge_video_audio( video_path: Path, audio_path: Path, output_dir: Path, video_title: str = "dubbed_video", progress_callback=None ) -> Path: """ Replace the original audio track in the video with the dubbed audio. If source has no video stream (audio-only upload), generates a video with black background + dubbed audio. Returns: Path to final dubbed video file. """ safe_title = "".join(c if c.isalnum() or c in " -_" else "_" for c in video_title) safe_title = safe_title[:100].strip() or "dubbed_video" output_path = output_dir / f"{safe_title}_dubbed.mp4" logger.info(f"Merging video + dubbed audio → {output_path.name}") if progress_callback: progress_callback(10) # Probe the source file to check for video stream probe = _probe_streams(video_path) logger.info(f"Source probe: has_video={probe['has_video']}, has_audio={probe['has_audio']}, duration={probe['duration']:.1f}s") if probe["has_video"]: # Normal case: source has video stream — copy video, replace audio _merge_with_video(video_path, audio_path, output_path) else: # Audio-only upload: generate black video with dubbed audio logger.warning("Source file has NO video stream — generating video with black background") _merge_audio_only(audio_path, output_path) file_size_mb = output_path.stat().st_size / 1e6 logger.info(f"Final video: {output_path} ({file_size_mb:.1f} MB)") if progress_callback: progress_callback(100) return output_path def _merge_with_video(video_path: Path, audio_path: Path, output_path: Path): """Standard merge: copy video stream, replace audio.""" cmd = [ "ffmpeg", "-y", "-i", str(video_path), "-i", str(audio_path), "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-map", "0:v:0", "-map", "1:a:0", "-shortest", "-movflags", "+faststart", str(output_path) ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200) if result.returncode != 0: logger.warning(f"Fast merge failed, trying fallback. Error: {result.stderr[:300]}") cmd_fallback = [ "ffmpeg", "-y", "-i", str(video_path), "-i", str(audio_path), "-c:v", "copy", "-c:a", "aac", "-b:a", "128k", "-map", "0:v:0", "-map", "1:a:0", "-shortest", str(output_path) ] result = subprocess.run(cmd_fallback, capture_output=True, text=True, timeout=7200) if result.returncode != 0: raise RuntimeError(f"Video merge failed: {result.stderr}") def _merge_audio_only(audio_path: Path, output_path: Path): """ Source has no video — create a video with black background + dubbed audio. Uses a simple black frame at low resolution to keep file size small. """ duration = _get_duration(audio_path) if duration <= 0: duration = 36000 # fallback: 10 hours max, -shortest will handle it cmd = [ "ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=black:s=1280x720:r=1:d={duration}", "-i", str(audio_path), "-c:v", "libx264", "-preset", "ultrafast", "-tune", "stillimage", "-crf", "28", "-c:a", "aac", "-b:a", "192k", "-map", "0:v:0", "-map", "1:a:0", "-shortest", "-movflags", "+faststart", str(output_path) ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=7200) if result.returncode != 0: raise RuntimeError(f"Audio-only merge failed: {result.stderr}") logger.info("Generated video from audio-only source (black background)")