feat: completed with all features
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_path(value: str) -> Path:
|
||||
return Path(value).expanduser().resolve()
|
||||
|
||||
|
||||
def run(command: list[str]) -> None:
|
||||
print("Running:", " ".join(command))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Replace a video's original audio with a new voiceover audio file."
|
||||
)
|
||||
parser.add_argument("--video", required=True, help="Input video file, for example input/tutorial.mp4")
|
||||
parser.add_argument("--audio", required=True, help="Input voiceover audio file, for example output/voiceover.wav")
|
||||
parser.add_argument("--output", required=True, help="Output MP4 file, for example output/final_video.mp4")
|
||||
parser.add_argument(
|
||||
"--reencode-video",
|
||||
action="store_true",
|
||||
help="Re-encode video with H.264 instead of copying. Use only if the copied output has playback issues.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
video_file = resolve_path(args.video)
|
||||
audio_file = resolve_path(args.audio)
|
||||
output_file = resolve_path(args.output)
|
||||
|
||||
if not video_file.exists():
|
||||
raise FileNotFoundError(f"Video not found: {video_file}")
|
||||
if not audio_file.exists():
|
||||
raise FileNotFoundError(f"Audio not found: {audio_file}")
|
||||
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
video_codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18"] if args.reencode_video else ["-c:v", "copy"]
|
||||
|
||||
command = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(video_file),
|
||||
"-i", str(audio_file),
|
||||
"-map", "0:v:0", # video from first input
|
||||
"-map", "1:a:0", # audio from second input only, so original video audio is muted/removed
|
||||
*video_codec_args,
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
"-movflags", "+faststart",
|
||||
str(output_file),
|
||||
]
|
||||
run(command)
|
||||
print(f"Done: {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
SRT_BLOCK_RE = re.compile(
|
||||
r"\s*(\d+)\s+([\d:,]+)\s+-->\s+([\d:,]+)\s+(.+?)(?=\n\s*\d+\s+\d\d:|\Z)",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def to_seconds(timestamp: str) -> float:
|
||||
h, m, rest = timestamp.split(":")
|
||||
s, ms = rest.split(",")
|
||||
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
|
||||
|
||||
|
||||
def read_srt(path: Path) -> list[tuple[float, float, str]]:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
captions = []
|
||||
for _, start, end, body in SRT_BLOCK_RE.findall(text):
|
||||
clean_text = " ".join(line.strip() for line in body.strip().splitlines())
|
||||
captions.append((to_seconds(start), to_seconds(end), clean_text))
|
||||
if not captions:
|
||||
raise ValueError(f"No captions found in {path}")
|
||||
return captions
|
||||
|
||||
|
||||
def resolve_path(value: str) -> Path:
|
||||
return Path(value).expanduser().resolve()
|
||||
|
||||
|
||||
def media_duration(path: Path) -> float:
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
|
||||
def run(command: list[str]) -> None:
|
||||
print("Running:", " ".join(command))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on macOS.")
|
||||
parser.add_argument("--srt", required=True, help="Input SRT file")
|
||||
parser.add_argument("--voice", default="Daniel", help="macOS voice name, e.g. Daniel or Reed (English (US))")
|
||||
parser.add_argument("--rate", type=int, default=185, help="Speech rate. Higher is faster. Example: 180-200")
|
||||
parser.add_argument("--wav", required=True, help="Output WAV file")
|
||||
parser.add_argument("--mp3", default=None, help="Optional output MP3 file")
|
||||
args = parser.parse_args()
|
||||
|
||||
srt_path = resolve_path(args.srt)
|
||||
wav_output = resolve_path(args.wav)
|
||||
mp3_output = resolve_path(args.mp3) if args.mp3 else None
|
||||
work_dir = Path(__file__).resolve().parent / "voice_parts"
|
||||
|
||||
if not srt_path.exists():
|
||||
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
||||
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mp3_output:
|
||||
mp3_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
captions = read_srt(srt_path)
|
||||
segment_files = []
|
||||
|
||||
for i, (start, end, text) in enumerate(captions, 1):
|
||||
duration = end - start
|
||||
raw_aiff = work_dir / f"raw_{i:03d}.aiff"
|
||||
segment_wav = work_dir / f"seg_{i:03d}.wav"
|
||||
|
||||
run(["say", "-v", args.voice, "-r", str(args.rate), "-o", str(raw_aiff), text])
|
||||
|
||||
spoken_duration = media_duration(raw_aiff)
|
||||
if spoken_duration > duration:
|
||||
print(f"Warning: caption {i} speech is {spoken_duration:.2f}s but slot is {duration:.2f}s; it will be trimmed.")
|
||||
|
||||
run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(raw_aiff),
|
||||
"-af", f"apad,atrim=0:{duration}",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
str(segment_wav),
|
||||
])
|
||||
segment_files.append(segment_wav)
|
||||
|
||||
concat_list = work_dir / "list.txt"
|
||||
concat_list.write_text("".join(f"file '{os.path.abspath(file)}'\n" for file in segment_files), encoding="utf-8")
|
||||
|
||||
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
||||
print(f"Done: {wav_output}")
|
||||
|
||||
if mp3_output:
|
||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
||||
print(f"Done: {mp3_output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
SRT_BLOCK_RE = re.compile(
|
||||
r"\s*(\d+)\s+([\d:,]+)\s+-->\s+([\d:,]+)\s+(.+?)(?=\n\s*\d+\s+\d\d:|\Z)",
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
def to_seconds(timestamp: str) -> float:
|
||||
h, m, rest = timestamp.split(":")
|
||||
s, ms = rest.split(",")
|
||||
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
|
||||
|
||||
|
||||
def read_srt(path: Path) -> list[tuple[float, float, str]]:
|
||||
text = path.read_text(encoding="utf-8-sig")
|
||||
captions = []
|
||||
for _, start, end, body in SRT_BLOCK_RE.findall(text):
|
||||
clean_text = " ".join(line.strip() for line in body.strip().splitlines())
|
||||
captions.append((to_seconds(start), to_seconds(end), clean_text))
|
||||
if not captions:
|
||||
raise ValueError(f"No captions found in {path}")
|
||||
return captions
|
||||
|
||||
|
||||
def resolve_path(value: str) -> Path:
|
||||
return Path(value).expanduser().resolve()
|
||||
|
||||
|
||||
def ps_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def media_duration(path: Path) -> float:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return float(result.stdout.strip())
|
||||
|
||||
|
||||
def run(command: list[str]) -> None:
|
||||
print("Running:", " ".join(command))
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def speak_to_wav(text: str, voice: str, rate: int, output: Path) -> None:
|
||||
ps_script = f"""
|
||||
Add-Type -AssemblyName System.Speech
|
||||
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||||
$synth.SelectVoice({ps_quote(voice)})
|
||||
$synth.Rate = {rate}
|
||||
$synth.SetOutputToWaveFile({ps_quote(str(output))})
|
||||
$synth.Speak({ps_quote(text)})
|
||||
$synth.Dispose()
|
||||
"""
|
||||
run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_script])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on Windows.")
|
||||
parser.add_argument("--srt", required=True, help="Input SRT file")
|
||||
parser.add_argument("--voice", default="Microsoft David Desktop", help="Windows voice name")
|
||||
parser.add_argument("--rate", type=int, default=0, help="Windows speech rate from -10 to 10. Higher is faster.")
|
||||
parser.add_argument("--wav", required=True, help="Output WAV file")
|
||||
parser.add_argument("--mp3", default=None, help="Optional output MP3 file")
|
||||
args = parser.parse_args()
|
||||
|
||||
srt_path = resolve_path(args.srt)
|
||||
wav_output = resolve_path(args.wav)
|
||||
mp3_output = resolve_path(args.mp3) if args.mp3 else None
|
||||
work_dir = Path(__file__).resolve().parent / "voice_parts"
|
||||
|
||||
if not srt_path.exists():
|
||||
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
||||
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mp3_output:
|
||||
mp3_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
captions = read_srt(srt_path)
|
||||
segment_files = []
|
||||
|
||||
for i, (start, end, text) in enumerate(captions, 1):
|
||||
duration = end - start
|
||||
raw_wav = work_dir / f"raw_{i:03d}.wav"
|
||||
segment_wav = work_dir / f"seg_{i:03d}.wav"
|
||||
|
||||
speak_to_wav(text, args.voice, args.rate, raw_wav)
|
||||
|
||||
spoken_duration = media_duration(raw_wav)
|
||||
if spoken_duration > duration:
|
||||
print(f"Warning: caption {i} speech is {spoken_duration:.2f}s but slot is {duration:.2f}s; it will be trimmed.")
|
||||
|
||||
run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(raw_wav),
|
||||
"-af", f"apad,atrim=0:{duration}",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
str(segment_wav),
|
||||
])
|
||||
segment_files.append(segment_wav)
|
||||
|
||||
concat_list = work_dir / "list.txt"
|
||||
concat_list.write_text("".join(f"file '{os.path.abspath(file)}'\n" for file in segment_files), encoding="utf-8")
|
||||
|
||||
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
||||
print(f"Done: {wav_output}")
|
||||
|
||||
if mp3_output:
|
||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
||||
print(f"Done: {mp3_output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user