feat: completed with all features

This commit is contained in:
Tutorial Builder
2026-06-16 11:54:06 +03:30
parent aec8fe6a76
commit 2bd38682e4
8 changed files with 198 additions and 179 deletions
+2 -1
View File
@@ -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.
-99
View File
@@ -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.
@@ -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.
+134
View File
@@ -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.
@@ -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 = [
@@ -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__":
@@ -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__":