dashhdata's picture
Fix: handle audio-only uploads in video merger
bf16718 verified
Raw
History Blame Contribute Delete
5.78 kB
"""
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)")