feat: one step implement
This commit is contained in:
+43
-13
@@ -4,11 +4,11 @@ Small helper scripts for creating timed tutorial voiceovers from `.srt` files an
|
|||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
- `scripts/make_voiceover_macos.py` — creates timed WAV/optional MP3 voiceover on macOS
|
- `video-generator/scripts/combine_video_audio.py` — one-step final video generation, or two-step audio replacement
|
||||||
- `scripts/make_voiceover_windows.py` — creates timed WAV/optional MP3 voiceover on Windows
|
- `video-generator/scripts/make_voiceover_macos.py` — creates timed WAV/optional MP3 voiceover on macOS
|
||||||
- `scripts/combine_video_audio.py` — replaces video audio and exports final MP4
|
- `video-generator/scripts/make_voiceover_windows.py` — creates timed WAV/optional MP3 voiceover on Windows
|
||||||
- `subtitles/` — optional place to keep SRT files
|
- `video-generator/subtitles/` — optional place to keep SRT files
|
||||||
- `output/` — optional place to save generated audio/video files
|
- `video-generator/output/` — optional place to save generated audio/video files
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -33,14 +33,42 @@ brew install ffmpeg
|
|||||||
|
|
||||||
Python is already available on most Macs. If needed, install it from `python.org`.
|
Python is already available on most Macs. If needed, install it from `python.org`.
|
||||||
|
|
||||||
## Create voiceover
|
## One-step flow: create final video from SRT
|
||||||
|
|
||||||
Run from the `video-generator` folder. Pass your own SRT and output paths.
|
Use this when you already have the tutorial video and the `.srt` narration file. It creates temporary voiceover audio, removes the video's original audio, exports the final MP4, and deletes temporary voice parts automatically.
|
||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python scripts/make_voiceover_windows.py `
|
python video-generator/scripts/combine_video_audio.py `
|
||||||
|
--video "video-generator/input/your_video.mp4" `
|
||||||
|
--srt "video-generator/subtitles/your_subtitles.srt" `
|
||||||
|
--voice "Microsoft David Desktop" `
|
||||||
|
--rate 0 `
|
||||||
|
--output "video-generator/output/final_video.mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python video-generator/scripts/combine_video_audio.py \
|
||||||
|
--video "video-generator/input/your_video.mp4" \
|
||||||
|
--srt "video-generator/subtitles/your_subtitles.srt" \
|
||||||
|
--voice "Daniel" \
|
||||||
|
--rate 185 \
|
||||||
|
--output "video-generator/output/final_video.mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
`--voice` and `--rate` are optional. If omitted, the script uses the default voice and speed for your operating system.
|
||||||
|
|
||||||
|
## Two-step flow: create voiceover
|
||||||
|
|
||||||
|
Use this when you want to review or edit the generated audio before combining it with the video.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python video-generator/scripts/make_voiceover_windows.py `
|
||||||
--srt "video-generator/subtitles/your_subtitles.srt" `
|
--srt "video-generator/subtitles/your_subtitles.srt" `
|
||||||
--voice "Microsoft David Desktop" `
|
--voice "Microsoft David Desktop" `
|
||||||
--rate 0 `
|
--rate 0 `
|
||||||
@@ -61,7 +89,7 @@ python video-generator/scripts/make_voiceover_macos.py \
|
|||||||
|
|
||||||
`--mp3` is optional. Use WAV when combining with video.
|
`--mp3` is optional. Use WAV when combining with video.
|
||||||
|
|
||||||
## Combine video + audio
|
## Two-step flow: combine video + audio
|
||||||
|
|
||||||
This removes the original video audio and uses the generated voiceover.
|
This removes the original video audio and uses the generated voiceover.
|
||||||
|
|
||||||
@@ -100,9 +128,10 @@ Good tutorial voices: `Microsoft David Desktop`, `Microsoft Mark` if available.
|
|||||||
Windows speed uses `-10` to `10`:
|
Windows speed uses `-10` to `10`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python video-generator/scripts/make_voiceover_windows.py `
|
python video-generator/scripts/combine_video_audio.py `
|
||||||
|
--video "video-generator/input/your_video.mp4" `
|
||||||
--srt "video-generator/subtitles/your_subtitles.srt" `
|
--srt "video-generator/subtitles/your_subtitles.srt" `
|
||||||
--wav "video-generator/output/voiceover.wav" `
|
--output "video-generator/output/final_video.mp4" `
|
||||||
--voice "Microsoft David Desktop" `
|
--voice "Microsoft David Desktop" `
|
||||||
--rate 2
|
--rate 2
|
||||||
```
|
```
|
||||||
@@ -120,9 +149,10 @@ Good tutorial voices: `Daniel`, `Reed (English (US))`, `Eddy (English (UK))`.
|
|||||||
Speed example:
|
Speed example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python video-generator/scripts/make_voiceover_macos.py \
|
python video-generator/scripts/combine_video_audio.py \
|
||||||
|
--video "video-generator/input/your_video.mp4" \
|
||||||
--srt "video-generator/subtitles/your_subtitles.srt" \
|
--srt "video-generator/subtitles/your_subtitles.srt" \
|
||||||
--wav "video-generator/output/voiceover.wav" \
|
--output "video-generator/output/final_video.mp4" \
|
||||||
--voice "Daniel" \
|
--voice "Daniel" \
|
||||||
--rate 190
|
--rate 190
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
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 resolve_path(value: str) -> Path:
|
def resolve_path(value: str) -> Path:
|
||||||
return Path(value).expanduser().resolve()
|
return Path(value).expanduser().resolve()
|
||||||
@@ -12,39 +22,135 @@ def run(command: list[str]) -> None:
|
|||||||
subprocess.run(command, check=True)
|
subprocess.run(command, check=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def remove_temp_dir(path: Path) -> None:
|
||||||
parser = argparse.ArgumentParser(
|
try:
|
||||||
description="Replace a video's original audio with a new voiceover audio file."
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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 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,
|
||||||
)
|
)
|
||||||
parser.add_argument("--video", required=True, help="Input video file, for example input/tutorial.mp4")
|
return float(result.stdout.strip())
|
||||||
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():
|
def ps_quote(value: str) -> str:
|
||||||
raise FileNotFoundError(f"Video not found: {video_file}")
|
return "'" + value.replace("'", "''") + "'"
|
||||||
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"]
|
def speak_macos(text: str, voice: str, rate: int, output: Path) -> None:
|
||||||
|
run(["say", "-v", voice, "-r", str(rate), "-o", str(output), text])
|
||||||
|
|
||||||
|
|
||||||
|
def speak_windows(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 default_voice() -> str:
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
return "Daniel"
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
return "Microsoft David Desktop"
|
||||||
|
raise RuntimeError("One-step SRT voiceover generation is supported only on macOS and Windows.")
|
||||||
|
|
||||||
|
|
||||||
|
def default_rate() -> int:
|
||||||
|
if platform.system() == "Darwin":
|
||||||
|
return 185
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
return 0
|
||||||
|
raise RuntimeError("One-step SRT voiceover generation is supported only on macOS and Windows.")
|
||||||
|
|
||||||
|
|
||||||
|
def speak(text: str, voice: str, rate: int, output: Path) -> None:
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Darwin":
|
||||||
|
speak_macos(text, voice, rate, output)
|
||||||
|
return
|
||||||
|
if system == "Windows":
|
||||||
|
speak_windows(text, voice, rate, output)
|
||||||
|
return
|
||||||
|
raise RuntimeError("One-step SRT voiceover generation is supported only on macOS and Windows.")
|
||||||
|
|
||||||
|
|
||||||
|
def create_voiceover_from_srt(srt_path: Path, wav_output: Path, voice: str, rate: int, work_dir: Path) -> None:
|
||||||
|
captions = read_srt(srt_path)
|
||||||
|
segment_files = []
|
||||||
|
|
||||||
|
for i, (start, end, text) in enumerate(captions, 1):
|
||||||
|
duration = end - start
|
||||||
|
raw_audio = work_dir / f"raw_{i:03d}.aiff" if platform.system() == "Darwin" else work_dir / f"raw_{i:03d}.wav"
|
||||||
|
segment_wav = work_dir / f"seg_{i:03d}.wav"
|
||||||
|
|
||||||
|
speak(text, voice, rate, raw_audio)
|
||||||
|
|
||||||
|
spoken_duration = media_duration(raw_audio)
|
||||||
|
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_audio),
|
||||||
|
"-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)])
|
||||||
|
|
||||||
|
|
||||||
|
def combine_video_audio(video_file: Path, audio_file: Path, output_file: Path, reencode_video: bool) -> None:
|
||||||
|
video_codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18"] if reencode_video else ["-c:v", "copy"]
|
||||||
|
|
||||||
command = [
|
command = [
|
||||||
"ffmpeg", "-y",
|
"ffmpeg", "-y",
|
||||||
"-i", str(video_file),
|
"-i", str(video_file),
|
||||||
"-i", str(audio_file),
|
"-i", str(audio_file),
|
||||||
"-map", "0:v:0", # video from first input
|
"-map", "0:v:0",
|
||||||
"-map", "1:a:0", # audio from second input only, so original video audio is muted/removed
|
"-map", "1:a:0",
|
||||||
*video_codec_args,
|
*video_codec_args,
|
||||||
"-c:a", "aac",
|
"-c:a", "aac",
|
||||||
"-b:a", "192k",
|
"-b:a", "192k",
|
||||||
@@ -52,6 +158,59 @@ def main() -> None:
|
|||||||
str(output_file),
|
str(output_file),
|
||||||
]
|
]
|
||||||
run(command)
|
run(command)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Replace a video's original audio with a voiceover audio file, or generate the voiceover from an SRT first."
|
||||||
|
)
|
||||||
|
parser.add_argument("--video", required=True, help="Input video file, for example input/tutorial.mp4")
|
||||||
|
parser.add_argument("--audio", default=None, help="Existing voiceover audio file, for example output/voiceover.wav")
|
||||||
|
parser.add_argument("--srt", default=None, help="Input SRT file for one-step voiceover generation")
|
||||||
|
parser.add_argument("--output", required=True, help="Output MP4 file, for example output/final_video.mp4")
|
||||||
|
parser.add_argument("--voice", default=None, help="Voice name for one-step SRT mode")
|
||||||
|
parser.add_argument("--rate", type=int, default=None, help="Speech rate for one-step SRT mode")
|
||||||
|
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()
|
||||||
|
|
||||||
|
if not args.audio and not args.srt:
|
||||||
|
parser.error("Provide either --audio for the current two-step flow or --srt for one-step generation.")
|
||||||
|
if args.audio and args.srt:
|
||||||
|
parser.error("Use either --audio or --srt, not both.")
|
||||||
|
|
||||||
|
video_file = resolve_path(args.video)
|
||||||
|
output_file = resolve_path(args.output)
|
||||||
|
|
||||||
|
if not video_file.exists():
|
||||||
|
raise FileNotFoundError(f"Video not found: {video_file}")
|
||||||
|
|
||||||
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if args.audio:
|
||||||
|
audio_file = resolve_path(args.audio)
|
||||||
|
if not audio_file.exists():
|
||||||
|
raise FileNotFoundError(f"Audio not found: {audio_file}")
|
||||||
|
combine_video_audio(video_file, audio_file, output_file, args.reencode_video)
|
||||||
|
else:
|
||||||
|
srt_path = resolve_path(args.srt)
|
||||||
|
if not srt_path.exists():
|
||||||
|
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
||||||
|
|
||||||
|
voice = args.voice or default_voice()
|
||||||
|
rate = args.rate if args.rate is not None else default_rate()
|
||||||
|
|
||||||
|
temp_dir = Path(tempfile.mkdtemp(prefix="video_generator_"))
|
||||||
|
try:
|
||||||
|
temp_wav = temp_dir / "voiceover.wav"
|
||||||
|
create_voiceover_from_srt(srt_path, temp_wav, voice, rate, temp_dir)
|
||||||
|
combine_video_audio(video_file, temp_wav, output_file, args.reencode_video)
|
||||||
|
finally:
|
||||||
|
remove_temp_dir(temp_dir)
|
||||||
|
|
||||||
print(f"Done: {output_file}")
|
print(f"Done: {output_file}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user