Spaces:
Sleeping
Sleeping
| """ | |
| 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)") | |