commit aec8fe6a76d91b165906012311b00daf7195032b Author: Tutorial Builder Date: Tue Jun 16 07:17:51 2026 +0000 Add image filter tutorial voiceover scripts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..862cd53 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9de34f2 --- /dev/null +++ b/README.md @@ -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. diff --git a/image-filter-tutorial/README.md b/image-filter-tutorial/README.md new file mode 100644 index 0000000..0ab886e --- /dev/null +++ b/image-filter-tutorial/README.md @@ -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. diff --git a/image-filter-tutorial/output/.gitkeep b/image-filter-tutorial/output/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/image-filter-tutorial/scripts/combine_video_audio.py b/image-filter-tutorial/scripts/combine_video_audio.py new file mode 100644 index 0000000..477ca78 --- /dev/null +++ b/image-filter-tutorial/scripts/combine_video_audio.py @@ -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() diff --git a/image-filter-tutorial/scripts/make_voiceover_macos.py b/image-filter-tutorial/scripts/make_voiceover_macos.py new file mode 100644 index 0000000..f94ea37 --- /dev/null +++ b/image-filter-tutorial/scripts/make_voiceover_macos.py @@ -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() diff --git a/image-filter-tutorial/scripts/make_voiceover_windows.py b/image-filter-tutorial/scripts/make_voiceover_windows.py new file mode 100644 index 0000000..eb4944f --- /dev/null +++ b/image-filter-tutorial/scripts/make_voiceover_windows.py @@ -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() diff --git a/image-filter-tutorial/subtitles/Image_Filter_Tutorial_Subtitles.srt b/image-filter-tutorial/subtitles/Image_Filter_Tutorial_Subtitles.srt new file mode 100644 index 0000000..12f28ce --- /dev/null +++ b/image-filter-tutorial/subtitles/Image_Filter_Tutorial_Subtitles.srt @@ -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.