From cad4e681c3943deba6b5ca66ff7f8faa58828dfa Mon Sep 17 00:00:00 2001 From: Tutorial Builder Date: Tue, 16 Jun 2026 13:04:29 +0330 Subject: [PATCH] feat: one step implement --- video-generator/README.md | 56 +++-- .../scripts/combine_video_audio.py | 205 ++++++++++++++++-- 2 files changed, 225 insertions(+), 36 deletions(-) diff --git a/video-generator/README.md b/video-generator/README.md index a5b7c28..c9ec8ea 100644 --- a/video-generator/README.md +++ b/video-generator/README.md @@ -4,11 +4,11 @@ Small helper scripts for creating timed tutorial voiceovers from `.srt` files an ## Files -- `scripts/make_voiceover_macos.py` — creates timed WAV/optional MP3 voiceover on macOS -- `scripts/make_voiceover_windows.py` — creates timed WAV/optional MP3 voiceover on Windows -- `scripts/combine_video_audio.py` — replaces video audio and exports final MP4 -- `subtitles/` — optional place to keep SRT files -- `output/` — optional place to save generated audio/video files +- `video-generator/scripts/combine_video_audio.py` — one-step final video generation, or two-step audio replacement +- `video-generator/scripts/make_voiceover_macos.py` — creates timed WAV/optional MP3 voiceover on macOS +- `video-generator/scripts/make_voiceover_windows.py` — creates timed WAV/optional MP3 voiceover on Windows +- `video-generator/subtitles/` — optional place to keep SRT files +- `video-generator/output/` — optional place to save generated audio/video files ## Install @@ -33,14 +33,42 @@ brew install ffmpeg 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 ```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" ` --voice "Microsoft David Desktop" ` --rate 0 ` @@ -61,7 +89,7 @@ python video-generator/scripts/make_voiceover_macos.py \ `--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. @@ -100,9 +128,10 @@ Good tutorial voices: `Microsoft David Desktop`, `Microsoft Mark` if available. Windows speed uses `-10` to `10`: ```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" ` - --wav "video-generator/output/voiceover.wav" ` + --output "video-generator/output/final_video.mp4" ` --voice "Microsoft David Desktop" ` --rate 2 ``` @@ -120,9 +149,10 @@ Good tutorial voices: `Daniel`, `Reed (English (US))`, `Eddy (English (UK))`. Speed example: ```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" \ - --wav "video-generator/output/voiceover.wav" \ + --output "video-generator/output/final_video.mp4" \ --voice "Daniel" \ --rate 190 ``` diff --git a/video-generator/scripts/combine_video_audio.py b/video-generator/scripts/combine_video_audio.py index 25415af..613c9b2 100644 --- a/video-generator/scripts/combine_video_audio.py +++ b/video-generator/scripts/combine_video_audio.py @@ -1,7 +1,17 @@ import argparse +import os +import platform +import re +import shutil import subprocess +import tempfile 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: return Path(value).expanduser().resolve() @@ -12,39 +22,135 @@ def run(command: list[str]) -> None: subprocess.run(command, check=True) -def main() -> None: - parser = argparse.ArgumentParser( - description="Replace a video's original audio with a new voiceover audio file." +def remove_temp_dir(path: Path) -> None: + try: + 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") - 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() + return float(result.stdout.strip()) - 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}") +def ps_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" - 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 = [ "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 + "-map", "0:v:0", + "-map", "1:a:0", *video_codec_args, "-c:a", "aac", "-b:a", "192k", @@ -52,6 +158,59 @@ def main() -> None: str(output_file), ] 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}")