diff --git a/README.md b/README.md index 9de34f2..9ee308b 100644 --- a/README.md +++ b/README.md @@ -4,4 +4,5 @@ Reusable tutorial production assets and scripts. ## Projects -- `image-filter-tutorial/` — voiceover and video assembly scripts for the Image Filter tutorial. +- `video-generator/` — voiceover and video assembly scripts for the Image Filter tutorial. + diff --git a/image-filter-tutorial/README.md b/image-filter-tutorial/README.md deleted file mode 100644 index 0ab886e..0000000 --- a/image-filter-tutorial/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# Image Filter Tutorial Voiceover - -Small helper scripts for creating a timed tutorial voiceover from an SRT file, then replacing the original video audio with the generated voiceover. - -## Files - -- `subtitles/Image_Filter_Tutorial_Subtitles.srt` — timed narration text -- `scripts/make_voiceover_macos.py` — creates `voiceover.wav` and `voiceover.mp3` on macOS -- `scripts/make_voiceover_windows.py` — creates `voiceover.wav` and `voiceover.mp3` on Windows -- `scripts/combine_video_audio.py` — mutes/replaces video audio and exports final MP4 - -## Install - -### macOS - -```bash -brew install ffmpeg -``` - -Python is already available on most Macs. If needed, install it from `python.org`. - -### Windows - -```powershell -winget install Gyan.FFmpeg -``` - -Install Python from `python.org` or Microsoft Store, then reopen PowerShell and check: - -```powershell -ffmpeg -version -python --version -``` - -## Create voiceover - -Run from the `scripts` folder. - -### macOS - -```bash -python3 make_voiceover_macos.py --voice "Daniel" --rate 185 -``` - -### Windows - -```powershell -python make_voiceover_windows.py --voice "Microsoft David Desktop" --rate 0 -``` - -Outputs are saved in `../output/voiceover.wav` and `../output/voiceover.mp3`. - -## Combine video + audio - -Put the original video in the project folder or pass its path directly: - -```bash -python scripts/combine_video_audio.py --video "Image_Filter_Tutorial_Narrated.mp4" --audio "output/voiceover.wav" --output "output/Image_Filter_Tutorial_Final.mp4" -``` - -This removes the original video audio and uses the new voiceover. - -## Voice and speed options - -### macOS voice list - -```bash -say -v '?' -``` - -Good tutorial voices: `Daniel`, `Reed (English (US))`, `Eddy (English (UK))`. - -Speed example: - -```bash -python3 make_voiceover_macos.py --voice "Daniel" --rate 190 -``` - -### Windows voice list - -```powershell -Add-Type -AssemblyName System.Speech -$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer -$synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name } -``` - -Good tutorial voices: `Microsoft David Desktop`, `Microsoft Mark` if available. - -Windows speed uses `-10` to `10`: - -```powershell -python make_voiceover_windows.py --voice "Microsoft David Desktop" --rate 2 -``` - -To add voices: `Settings > Time & language > Speech > Manage voices > Add voices`. - -## Notes - -If a caption has too much text for its time slot, the script keeps the SRT timing and trims the speech. Fix that by increasing the subtitle duration, shortening the sentence, or increasing speed. diff --git a/image-filter-tutorial/subtitles/Image_Filter_Tutorial_Subtitles.srt b/image-filter-tutorial/subtitles/Image_Filter_Tutorial_Subtitles.srt deleted file mode 100644 index 12f28ce..0000000 --- a/image-filter-tutorial/subtitles/Image_Filter_Tutorial_Subtitles.srt +++ /dev/null @@ -1,47 +0,0 @@ -1 -00:00:00,000 --> 00:00:06,000 -First, we add an Image Reader tool. - -2 -00:00:06,000 --> 00:00:09,000 -Now, we add an Image Filter tool. - -3 -00:00:09,000 --> 00:00:14,000 -Next, we add an Image Writer tool. - -4 -00:00:14,000 --> 00:00:18,000 -Then, we connect all the nodes from the Reader to the Writer. - -5 -00:00:18,000 --> 00:00:27,000 -Now, we import an image. You can load either a single image or a folder containing multiple images from your local system. In this example, I use a single NIfTI image from the PET modality. - -6 -00:00:27,000 --> 00:00:29,000 -Next, we select the filtering method and define its parameters. - -7 -00:00:29,000 --> 00:00:36,000 -You can see different 2D and 3D filters, such as Mean, Log, Laws, Gabor, and Wavelet. - -8 -00:00:36,000 --> 00:00:43,000 -In this example, I use a 3D Mean filter. - -9 -00:00:43,000 --> 00:00:52,000 -The next step is to select the output folder where the filtered image will be saved. From the menu, you can choose different output formats such as NRRD, NIfTI, or single DICOM, and then specify the output path. - -10 -00:00:52,000 --> 00:01:00,000 -Here, I created a new folder called Filtered to save the output image. - -11 -00:01:00,000 --> 00:01:04,000 -Now, click the Play icon on the Writer node to run the workflow. This executes the process starting from the Reader node. You can monitor the progress in the log box at the bottom of the Radiuma window and confirm that the process has completed successfully. - -12 -00:01:04,000 --> 00:01:09,000 -Finally, open the output folder directly from the Writer node to view the filtered image. diff --git a/video-generator/README.md b/video-generator/README.md new file mode 100644 index 0000000..a5b7c28 --- /dev/null +++ b/video-generator/README.md @@ -0,0 +1,134 @@ +# Video Generator Voiceover + +Small helper scripts for creating timed tutorial voiceovers from `.srt` files and replacing a video's original audio with the generated voiceover. + +## 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 + +## Install + +### Windows + +```powershell +winget install Gyan.FFmpeg +``` + +Install Python from `python.org` or Microsoft Store, then reopen PowerShell and check: + +```powershell +ffmpeg -version +python --version +``` + +### macOS + +```bash +brew install ffmpeg +``` + +Python is already available on most Macs. If needed, install it from `python.org`. + +## Create voiceover + +Run from the `video-generator` folder. Pass your own SRT and output paths. + +### Windows + +```powershell +python scripts/make_voiceover_windows.py ` + --srt "video-generator/subtitles/your_subtitles.srt" ` + --voice "Microsoft David Desktop" ` + --rate 0 ` + --wav "video-generator/output/voiceover.wav" ` + --mp3 "video-generator/output/voiceover.mp3" +``` + +### macOS + +```bash +python video-generator/scripts/make_voiceover_macos.py \ + --srt "video-generator/subtitles/your_subtitles.srt" \ + --voice "Daniel" \ + --rate 185 \ + --wav "video-generator/output/voiceover.wav" \ + --mp3 "video-generator/output/voiceover.mp3" +``` + +`--mp3` is optional. Use WAV when combining with video. + +## Combine video + audio + +This removes the original video audio and uses the generated voiceover. + +### Windows + +```powershell +python video-generator/scripts/combine_video_audio.py ` + --video "video-generator/input/your_video.mp4" ` + --audio "video-generator/output/voiceover.wav" ` + --output "video-generator/output/final_video.mp4" +``` + +### macOS + +```bash +python video-generator/scripts/combine_video_audio.py \ + --video "video-generator/input/your_video.mp4" \ + --audio "video-generator/output/voiceover.wav" \ + --output "video-generator/output/final_video.mp4" +``` + +Use absolute paths if files are outside this folder. + +## Voice and speed options + +### Windows voice list + +```powershell +Add-Type -AssemblyName System.Speech +$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer +$synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name } +``` + +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 ` + --srt "video-generator/subtitles/your_subtitles.srt" ` + --wav "video-generator/output/voiceover.wav" ` + --voice "Microsoft David Desktop" ` + --rate 2 +``` + +To add voices: `Settings > Time & language > Speech > Manage voices > Add voices`. + +### macOS voice list + +```bash +say -v '?' +``` + +Good tutorial voices: `Daniel`, `Reed (English (US))`, `Eddy (English (UK))`. + +Speed example: + +```bash +python video-generator/scripts/make_voiceover_macos.py \ + --srt "video-generator/subtitles/your_subtitles.srt" \ + --wav "video-generator/output/voiceover.wav" \ + --voice "Daniel" \ + --rate 190 +``` + +## Notes + +If a caption has too much text for its time slot, the script keeps the SRT timing and trims the speech. Fix that by increasing the subtitle duration, shortening the sentence, or increasing speed. + +If the final MP4 has compatibility issues, add `--reencode-video` to the combine command. \ No newline at end of file diff --git a/image-filter-tutorial/output/.gitkeep b/video-generator/output/.gitkeep similarity index 100% rename from image-filter-tutorial/output/.gitkeep rename to video-generator/output/.gitkeep diff --git a/image-filter-tutorial/scripts/combine_video_audio.py b/video-generator/scripts/combine_video_audio.py similarity index 69% rename from image-filter-tutorial/scripts/combine_video_audio.py rename to video-generator/scripts/combine_video_audio.py index 477ca78..25415af 100644 --- a/image-filter-tutorial/scripts/combine_video_audio.py +++ b/video-generator/scripts/combine_video_audio.py @@ -3,6 +3,10 @@ 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) @@ -12,9 +16,9 @@ def main() -> None: parser = argparse.ArgumentParser( description="Replace a video's original audio with a new voiceover audio file." ) - parser.add_argument("--video", default="Image_Filter_Tutorial_Narrated.mp4", help="Input video file") - parser.add_argument("--audio", default="voiceover.wav", help="Input voiceover audio file") - parser.add_argument("--output", default="Image_Filter_Tutorial_Final.mp4", help="Output MP4 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", @@ -22,15 +26,17 @@ def main() -> None: ) args = parser.parse_args() - video_file = Path(args.video) - audio_file = Path(args.audio) - output_file = Path(args.output) + 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 = [ diff --git a/image-filter-tutorial/scripts/make_voiceover_macos.py b/video-generator/scripts/make_voiceover_macos.py similarity index 74% rename from image-filter-tutorial/scripts/make_voiceover_macos.py rename to video-generator/scripts/make_voiceover_macos.py index f94ea37..3241397 100644 --- a/image-filter-tutorial/scripts/make_voiceover_macos.py +++ b/video-generator/scripts/make_voiceover_macos.py @@ -27,6 +27,10 @@ def read_srt(path: Path) -> list[tuple[float, float, str]]: 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)], @@ -44,21 +48,25 @@ def run(command: list[str]) -> None: def main() -> None: parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on macOS.") - parser.add_argument("--srt", default="../subtitles/Image_Filter_Tutorial_Subtitles.srt", help="Input SRT file") + 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", default="185", help="Speech rate. Higher is faster. Example: 180-200") - parser.add_argument("--wav", default="../output/voiceover.wav", help="Output WAV file") - parser.add_argument("--mp3", default="../output/voiceover.mp3", help="Output MP3 file") + 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() - script_dir = Path(__file__).resolve().parent - srt_path = (script_dir / args.srt).resolve() - wav_output = (script_dir / args.wav).resolve() - mp3_output = (script_dir / args.mp3).resolve() - work_dir = script_dir / "voice_parts" + 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) - mp3_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 = [] @@ -88,10 +96,11 @@ def main() -> None: 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)]) - run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)]) - print(f"Done: {wav_output}") - print(f"Done: {mp3_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__": diff --git a/image-filter-tutorial/scripts/make_voiceover_windows.py b/video-generator/scripts/make_voiceover_windows.py similarity index 75% rename from image-filter-tutorial/scripts/make_voiceover_windows.py rename to video-generator/scripts/make_voiceover_windows.py index eb4944f..39929fb 100644 --- a/image-filter-tutorial/scripts/make_voiceover_windows.py +++ b/video-generator/scripts/make_voiceover_windows.py @@ -27,13 +27,23 @@ def read_srt(path: Path) -> list[tuple[float, float, str]]: 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)], + [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(path), + ], check=True, capture_output=True, text=True, @@ -61,21 +71,25 @@ $synth.Dispose() def main() -> None: parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on Windows.") - parser.add_argument("--srt", default="../subtitles/Image_Filter_Tutorial_Subtitles.srt", help="Input SRT file") + 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", default="../output/voiceover.wav", help="Output WAV file") - parser.add_argument("--mp3", default="../output/voiceover.mp3", help="Output MP3 file") + 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() - script_dir = Path(__file__).resolve().parent - srt_path = (script_dir / args.srt).resolve() - wav_output = (script_dir / args.wav).resolve() - mp3_output = (script_dir / args.mp3).resolve() - work_dir = script_dir / "voice_parts" + 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) - mp3_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 = [] @@ -105,10 +119,11 @@ def main() -> None: 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)]) - run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)]) - print(f"Done: {wav_output}") - print(f"Done: {mp3_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__":