233 lines
6.6 KiB
Python
Executable File
233 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Build a final voice-over video on Ubuntu with ffmpeg."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
|
|
|
|
|
|
def run(cmd: list[str]) -> None:
|
|
print("+ " + " ".join(cmd))
|
|
subprocess.run(cmd, check=True)
|
|
|
|
|
|
def require_tool(name: str) -> str:
|
|
path = shutil.which(name)
|
|
if not path:
|
|
raise SystemExit(f"{name} is required. Install it with: sudo apt install -y ffmpeg")
|
|
return path
|
|
|
|
|
|
def media_duration(path: Path) -> float:
|
|
result = subprocess.check_output(
|
|
[
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
"format=duration",
|
|
"-of",
|
|
"default=nw=1:nk=1",
|
|
str(path),
|
|
],
|
|
text=True,
|
|
).strip()
|
|
return float(result)
|
|
|
|
|
|
def audio_files(audio_dir: Path) -> list[Path]:
|
|
files = [
|
|
path
|
|
for path in sorted(audio_dir.iterdir())
|
|
if path.is_file() and path.suffix.lower() in AUDIO_EXTS
|
|
]
|
|
if not files:
|
|
raise FileNotFoundError(f"No audio files found in {audio_dir}")
|
|
return files
|
|
|
|
|
|
def concat_audio_dir(audio_dir: Path, work_dir: Path, silence: float) -> Path:
|
|
work_dir.mkdir(parents=True, exist_ok=True)
|
|
normalized: list[Path] = []
|
|
silence_path = work_dir / "silence.wav"
|
|
run(
|
|
[
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-t",
|
|
f"{silence:.3f}",
|
|
"-i",
|
|
"anullsrc=channel_layout=stereo:sample_rate=48000",
|
|
"-c:a",
|
|
"pcm_s16le",
|
|
str(silence_path),
|
|
]
|
|
)
|
|
|
|
for index, src in enumerate(audio_files(audio_dir), start=1):
|
|
dst = work_dir / f"audio_{index:02d}.wav"
|
|
run(
|
|
[
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-i",
|
|
str(src),
|
|
"-vn",
|
|
"-ar",
|
|
"48000",
|
|
"-ac",
|
|
"2",
|
|
"-c:a",
|
|
"pcm_s16le",
|
|
str(dst),
|
|
]
|
|
)
|
|
normalized.append(dst)
|
|
|
|
concat_items: list[Path] = []
|
|
for index, item in enumerate(normalized):
|
|
concat_items.append(item)
|
|
if index != len(normalized) - 1 and silence > 0:
|
|
concat_items.append(silence_path)
|
|
|
|
list_path = work_dir / "audio_concat.txt"
|
|
with list_path.open("w", encoding="utf-8") as handle:
|
|
for item in concat_items:
|
|
escaped = item.resolve().as_posix().replace("'", "'\\''")
|
|
handle.write(f"file '{escaped}'\n")
|
|
|
|
out_audio = work_dir / "combined_voice.wav"
|
|
run(
|
|
[
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"concat",
|
|
"-safe",
|
|
"0",
|
|
"-i",
|
|
str(list_path),
|
|
"-c:a",
|
|
"pcm_s16le",
|
|
str(out_audio),
|
|
]
|
|
)
|
|
return out_audio
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Combine one video with voice-over audio.")
|
|
parser.add_argument("--video", type=Path, required=True, help="Source video path.")
|
|
parser.add_argument("--audio", type=Path, default=None, help="Single voice-over audio file.")
|
|
parser.add_argument("--audio-dir", type=Path, default=None, help="Directory of ordered audio files.")
|
|
parser.add_argument("--output", type=Path, default=Path("05_outputs/final_voiceover.mp4"))
|
|
parser.add_argument("--work-dir", type=Path, default=Path("04_intermediate/ubuntu_voiceover"))
|
|
parser.add_argument("--silence", type=float, default=0.35, help="Gap seconds between audio files.")
|
|
parser.add_argument("--width", type=int, default=1920)
|
|
parser.add_argument("--height", type=int, default=1080)
|
|
parser.add_argument("--fps", type=int, default=30)
|
|
parser.add_argument("--crf", type=int, default=20)
|
|
parser.add_argument("--preset", default="medium")
|
|
parser.add_argument("--video-speed", type=float, default=None, help="Override automatic speed.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
require_tool("ffmpeg")
|
|
require_tool("ffprobe")
|
|
|
|
if not args.video.exists():
|
|
raise FileNotFoundError(args.video)
|
|
if bool(args.audio) == bool(args.audio_dir):
|
|
raise SystemExit("Use exactly one of --audio or --audio-dir.")
|
|
|
|
args.work_dir.mkdir(parents=True, exist_ok=True)
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
audio_path = args.audio if args.audio else concat_audio_dir(args.audio_dir, args.work_dir, args.silence)
|
|
if not audio_path or not audio_path.exists():
|
|
raise FileNotFoundError(audio_path)
|
|
|
|
video_duration = media_duration(args.video)
|
|
audio_duration = media_duration(audio_path)
|
|
if video_duration <= 0 or audio_duration <= 0:
|
|
raise RuntimeError("Invalid media duration.")
|
|
|
|
speed = args.video_speed if args.video_speed else video_duration / audio_duration
|
|
if speed <= 0:
|
|
raise ValueError("--video-speed must be greater than 0.")
|
|
|
|
print(f"video_duration={video_duration:.3f}s")
|
|
print(f"audio_duration={audio_duration:.3f}s")
|
|
print(f"video_speed={speed:.6f}x")
|
|
|
|
vf = (
|
|
f"[0:v]setpts=PTS/{speed:.8f},fps={args.fps},"
|
|
f"scale={args.width}:{args.height}:force_original_aspect_ratio=decrease,"
|
|
f"pad={args.width}:{args.height}:(ow-iw)/2:(oh-ih)/2:black,"
|
|
"setsar=1,format=yuv420p[v];"
|
|
"[1:a]aresample=48000,apad[a]"
|
|
)
|
|
run(
|
|
[
|
|
"ffmpeg",
|
|
"-hide_banner",
|
|
"-y",
|
|
"-i",
|
|
str(args.video),
|
|
"-i",
|
|
str(audio_path),
|
|
"-filter_complex",
|
|
vf,
|
|
"-map",
|
|
"[v]",
|
|
"-map",
|
|
"[a]",
|
|
"-t",
|
|
f"{audio_duration:.3f}",
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
args.preset,
|
|
"-crf",
|
|
str(args.crf),
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"192k",
|
|
"-ar",
|
|
"48000",
|
|
"-ac",
|
|
"2",
|
|
"-movflags",
|
|
"+faststart",
|
|
str(args.output),
|
|
]
|
|
)
|
|
final_duration = media_duration(args.output)
|
|
print(f"output={args.output}")
|
|
print(f"final_duration={final_duration:.3f}s")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|