feat: more optional options
This commit is contained in:
@@ -72,12 +72,13 @@ 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:
|
||||
def speak_windows(text: str, voice: str, rate: int, volume: 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.Volume = {volume}
|
||||
$synth.SetOutputToWaveFile({ps_quote(str(output))})
|
||||
$synth.Speak({ps_quote(text)})
|
||||
$synth.Dispose()
|
||||
@@ -101,18 +102,29 @@ def default_rate() -> int:
|
||||
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:
|
||||
def speak(text: str, voice: str, rate: int, volume: float, 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)
|
||||
windows_volume = max(0, min(100, round(volume * 100)))
|
||||
speak_windows(text, voice, rate, windows_volume, 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:
|
||||
def create_voiceover_from_srt(
|
||||
srt_path: Path,
|
||||
wav_output: Path,
|
||||
voice: str,
|
||||
rate: int,
|
||||
work_dir: Path,
|
||||
volume: float,
|
||||
silence_padding: float,
|
||||
sample_rate: int,
|
||||
channels: int,
|
||||
) -> None:
|
||||
captions = read_srt(srt_path)
|
||||
segment_files = []
|
||||
|
||||
@@ -121,18 +133,23 @@ def create_voiceover_from_srt(srt_path: Path, wav_output: Path, voice: str, rate
|
||||
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)
|
||||
speak(text, voice, rate, volume, raw_audio)
|
||||
|
||||
spoken_duration = media_duration(raw_audio)
|
||||
padding = min(silence_padding, max(duration - spoken_duration, 0))
|
||||
effective_duration = duration if silence_padding == 0 else min(duration, spoken_duration + padding)
|
||||
|
||||
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.")
|
||||
|
||||
audio_filter = f"volume={volume},apad,atrim=0:{effective_duration}"
|
||||
|
||||
run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(raw_audio),
|
||||
"-af", f"apad,atrim=0:{duration}",
|
||||
"-ar", "44100",
|
||||
"-ac", "2",
|
||||
"-af", audio_filter,
|
||||
"-ar", str(sample_rate),
|
||||
"-ac", str(channels),
|
||||
str(segment_wav),
|
||||
])
|
||||
segment_files.append(segment_wav)
|
||||
@@ -142,7 +159,7 @@ def create_voiceover_from_srt(srt_path: Path, wav_output: Path, voice: str, rate
|
||||
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:
|
||||
def combine_video_audio(video_file: Path, audio_file: Path, output_file: Path, reencode_video: bool, volume: float) -> None:
|
||||
video_codec_args = ["-c:v", "libx264", "-preset", "medium", "-crf", "18"] if reencode_video else ["-c:v", "copy"]
|
||||
|
||||
command = [
|
||||
@@ -152,6 +169,7 @@ def combine_video_audio(video_file: Path, audio_file: Path, output_file: Path, r
|
||||
"-map", "0:v:0",
|
||||
"-map", "1:a:0",
|
||||
*video_codec_args,
|
||||
"-af", f"volume={volume}",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
"-movflags", "+faststart",
|
||||
@@ -170,6 +188,12 @@ def main() -> None:
|
||||
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("--audio-output", default=None, help="Optional copy of the generated voiceover WAV in one-step SRT mode")
|
||||
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary voice parts for debugging in one-step SRT mode")
|
||||
parser.add_argument("--volume", type=float, default=1.0, help="Voiceover volume multiplier. Default: 1.0")
|
||||
parser.add_argument("--silence-padding", type=float, default=0.0, help="Seconds of silence after each caption in one-step SRT mode, if the SRT slot allows it. Default: 0.0")
|
||||
parser.add_argument("--sample-rate", type=int, default=44100, help="Generated voiceover sample rate in one-step SRT mode. Default: 44100")
|
||||
parser.add_argument("--channels", type=int, default=2, choices=[1, 2], help="Generated voiceover channels in one-step SRT mode. Default: 2")
|
||||
parser.add_argument(
|
||||
"--reencode-video",
|
||||
action="store_true",
|
||||
@@ -181,6 +205,10 @@ def main() -> None:
|
||||
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.")
|
||||
if args.volume <= 0:
|
||||
parser.error("--volume must be greater than 0.")
|
||||
if args.silence_padding < 0:
|
||||
parser.error("--silence-padding must be 0 or greater.")
|
||||
|
||||
video_file = resolve_path(args.video)
|
||||
output_file = resolve_path(args.output)
|
||||
@@ -194,7 +222,7 @@ def main() -> None:
|
||||
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)
|
||||
combine_video_audio(video_file, audio_file, output_file, args.reencode_video, args.volume)
|
||||
else:
|
||||
srt_path = resolve_path(args.srt)
|
||||
if not srt_path.exists():
|
||||
@@ -202,14 +230,33 @@ def main() -> None:
|
||||
|
||||
voice = args.voice or default_voice()
|
||||
rate = args.rate if args.rate is not None else default_rate()
|
||||
audio_output = resolve_path(args.audio_output) if args.audio_output else None
|
||||
if audio_output:
|
||||
audio_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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)
|
||||
create_voiceover_from_srt(
|
||||
srt_path,
|
||||
temp_wav,
|
||||
voice,
|
||||
rate,
|
||||
temp_dir,
|
||||
args.volume,
|
||||
args.silence_padding,
|
||||
args.sample_rate,
|
||||
args.channels,
|
||||
)
|
||||
if audio_output:
|
||||
run(["ffmpeg", "-y", "-i", str(temp_wav), "-c:a", "pcm_s16le", str(audio_output)])
|
||||
print(f"Done: {audio_output}")
|
||||
combine_video_audio(video_file, temp_wav, output_file, args.reencode_video, 1.0)
|
||||
finally:
|
||||
remove_temp_dir(temp_dir)
|
||||
if not args.keep_temp:
|
||||
remove_temp_dir(temp_dir)
|
||||
else:
|
||||
print(f"Kept temporary voice parts: {temp_dir}")
|
||||
|
||||
print(f"Done: {output_file}")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -33,7 +34,13 @@ def resolve_path(value: str) -> Path:
|
||||
|
||||
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,
|
||||
@@ -46,6 +53,15 @@ def run(command: list[str]) -> None:
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def remove_temp_dir(path: Path) -> None:
|
||||
try:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except PermissionError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Create a timed voiceover WAV/MP3 from an SRT file on macOS.")
|
||||
parser.add_argument("--srt", required=True, help="Input SRT file")
|
||||
@@ -53,54 +69,80 @@ def main() -> None:
|
||||
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")
|
||||
parser.add_argument("--audio-output", default=None, help="Optional extra copy of the generated WAV file")
|
||||
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary voice parts for debugging")
|
||||
parser.add_argument("--volume", type=float, default=1.0, help="Voiceover volume multiplier. Default: 1.0")
|
||||
parser.add_argument("--silence-padding", type=float, default=0.0, help="Seconds of silence after each caption, if the SRT slot allows it. Default: 0.0")
|
||||
parser.add_argument("--sample-rate", type=int, default=44100, help="Output sample rate. Default: 44100")
|
||||
parser.add_argument("--channels", type=int, default=2, choices=[1, 2], help="Output audio channels. Default: 2")
|
||||
args = parser.parse_args()
|
||||
|
||||
srt_path = resolve_path(args.srt)
|
||||
wav_output = resolve_path(args.wav)
|
||||
mp3_output = resolve_path(args.mp3) if args.mp3 else None
|
||||
audio_output = resolve_path(args.audio_output) if args.audio_output else None
|
||||
work_dir = Path(__file__).resolve().parent / "voice_parts"
|
||||
|
||||
if not srt_path.exists():
|
||||
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
||||
if args.volume <= 0:
|
||||
raise ValueError("--volume must be greater than 0")
|
||||
if args.silence_padding < 0:
|
||||
raise ValueError("--silence-padding must be 0 or greater")
|
||||
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mp3_output:
|
||||
mp3_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if audio_output:
|
||||
audio_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"
|
||||
try:
|
||||
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])
|
||||
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.")
|
||||
spoken_duration = media_duration(raw_aiff)
|
||||
padding = min(args.silence_padding, max(duration - spoken_duration, 0))
|
||||
effective_duration = duration if args.silence_padding == 0 else min(duration, spoken_duration + padding)
|
||||
|
||||
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)
|
||||
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.")
|
||||
|
||||
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")
|
||||
audio_filter = f"volume={args.volume},apad,atrim=0:{effective_duration}"
|
||||
|
||||
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
||||
print(f"Done: {wav_output}")
|
||||
run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(raw_aiff),
|
||||
"-af", audio_filter,
|
||||
"-ar", str(args.sample_rate),
|
||||
"-ac", str(args.channels),
|
||||
str(segment_wav),
|
||||
])
|
||||
segment_files.append(segment_wav)
|
||||
|
||||
if mp3_output:
|
||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
||||
print(f"Done: {mp3_output}")
|
||||
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)])
|
||||
print(f"Done: {wav_output}")
|
||||
|
||||
if audio_output:
|
||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-c:a", "pcm_s16le", str(audio_output)])
|
||||
print(f"Done: {audio_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}")
|
||||
finally:
|
||||
if not args.keep_temp:
|
||||
remove_temp_dir(work_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -56,12 +57,22 @@ def run(command: list[str]) -> None:
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def speak_to_wav(text: str, voice: str, rate: int, output: Path) -> None:
|
||||
def remove_temp_dir(path: Path) -> None:
|
||||
try:
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
except PermissionError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def speak_to_wav(text: str, voice: str, rate: int, volume: 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.Volume = {volume}
|
||||
$synth.SetOutputToWaveFile({ps_quote(str(output))})
|
||||
$synth.Speak({ps_quote(text)})
|
||||
$synth.Dispose()
|
||||
@@ -76,54 +87,84 @@ def main() -> None:
|
||||
parser.add_argument("--rate", type=int, default=0, help="Windows speech rate from -10 to 10. Higher is faster.")
|
||||
parser.add_argument("--wav", required=True, help="Output WAV file")
|
||||
parser.add_argument("--mp3", default=None, help="Optional output MP3 file")
|
||||
parser.add_argument("--audio-output", default=None, help="Optional extra copy of the generated WAV file")
|
||||
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary voice parts for debugging")
|
||||
parser.add_argument("--volume", type=float, default=1.0, help="Voiceover volume multiplier. Default: 1.0")
|
||||
parser.add_argument("--silence-padding", type=float, default=0.0, help="Seconds of silence after each caption, if the SRT slot allows it. Default: 0.0")
|
||||
parser.add_argument("--sample-rate", type=int, default=44100, help="Output sample rate. Default: 44100")
|
||||
parser.add_argument("--channels", type=int, default=2, choices=[1, 2], help="Output audio channels. Default: 2")
|
||||
args = parser.parse_args()
|
||||
|
||||
srt_path = resolve_path(args.srt)
|
||||
wav_output = resolve_path(args.wav)
|
||||
mp3_output = resolve_path(args.mp3) if args.mp3 else None
|
||||
audio_output = resolve_path(args.audio_output) if args.audio_output else None
|
||||
work_dir = Path(__file__).resolve().parent / "voice_parts"
|
||||
|
||||
if not srt_path.exists():
|
||||
raise FileNotFoundError(f"SRT not found: {srt_path}")
|
||||
if args.volume <= 0:
|
||||
raise ValueError("--volume must be greater than 0")
|
||||
if args.silence_padding < 0:
|
||||
raise ValueError("--silence-padding must be 0 or greater")
|
||||
|
||||
windows_volume = max(0, min(100, round(args.volume * 100)))
|
||||
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
wav_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if mp3_output:
|
||||
mp3_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if audio_output:
|
||||
audio_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"
|
||||
try:
|
||||
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)
|
||||
speak_to_wav(text, args.voice, args.rate, windows_volume, 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.")
|
||||
spoken_duration = media_duration(raw_wav)
|
||||
padding = min(args.silence_padding, max(duration - spoken_duration, 0))
|
||||
effective_duration = duration if args.silence_padding == 0 else min(duration, spoken_duration + padding)
|
||||
|
||||
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)
|
||||
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.")
|
||||
|
||||
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")
|
||||
# Windows System.Speech volume is limited to 0-100. Use ffmpeg volume only for multipliers above 1.0.
|
||||
ffmpeg_volume = max(args.volume, 1.0)
|
||||
audio_filter = f"volume={ffmpeg_volume},apad,atrim=0:{effective_duration}"
|
||||
|
||||
run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:a", "pcm_s16le", str(wav_output)])
|
||||
print(f"Done: {wav_output}")
|
||||
run([
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(raw_wav),
|
||||
"-af", audio_filter,
|
||||
"-ar", str(args.sample_rate),
|
||||
"-ac", str(args.channels),
|
||||
str(segment_wav),
|
||||
])
|
||||
segment_files.append(segment_wav)
|
||||
|
||||
if mp3_output:
|
||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-codec:a", "libmp3lame", "-b:a", "192k", str(mp3_output)])
|
||||
print(f"Done: {mp3_output}")
|
||||
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)])
|
||||
print(f"Done: {wav_output}")
|
||||
|
||||
if audio_output:
|
||||
run(["ffmpeg", "-y", "-i", str(wav_output), "-c:a", "pcm_s16le", str(audio_output)])
|
||||
print(f"Done: {audio_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}")
|
||||
finally:
|
||||
if not args.keep_temp:
|
||||
remove_temp_dir(work_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user