Add image filter tutorial voiceover scripts

This commit is contained in:
Tutorial Builder
2026-06-16 07:17:51 +00:00
commit aec8fe6a76
8 changed files with 426 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.DS_Store
__pycache__/
*.pyc
image-filter-tutorial/output/*
!image-filter-tutorial/output/.gitkeep
image-filter-tutorial/voice_parts/
image-filter-tutorial/scripts/voice_parts/
+7
View File
@@ -0,0 +1,7 @@
# Tutorials
Reusable tutorial production assets and scripts.
## Projects
- `image-filter-tutorial/` — voiceover and video assembly scripts for the Image Filter tutorial.
+99
View File
@@ -0,0 +1,99 @@
# 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.
@@ -0,0 +1,53 @@
import argparse
import subprocess
from pathlib import Path
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", 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(
"--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 = Path(args.video)
audio_file = Path(args.audio)
output_file = 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}")
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,98 @@
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 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", default="../subtitles/Image_Filter_Tutorial_Subtitles.srt", 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")
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"
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)
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)])
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 __name__ == "__main__":
main()
@@ -0,0 +1,115 @@
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 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", default="../subtitles/Image_Filter_Tutorial_Subtitles.srt", 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")
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"
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)
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)])
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 __name__ == "__main__":
main()
@@ -0,0 +1,47 @@
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.