karaoke-gen 0.65.0__py3-none-any.whl → 0.66.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of karaoke-gen might be problematic. Click here for more details.
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -0
- karaoke_gen/karaoke_gen.py +93 -0
- karaoke_gen/utils/gen_cli.py +14 -0
- karaoke_gen/video_background_processor.py +353 -0
- {karaoke_gen-0.65.0.dist-info → karaoke_gen-0.66.1.dist-info}/METADATA +21 -1
- {karaoke_gen-0.65.0.dist-info → karaoke_gen-0.66.1.dist-info}/RECORD +9 -8
- {karaoke_gen-0.65.0.dist-info → karaoke_gen-0.66.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.65.0.dist-info → karaoke_gen-0.66.1.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.65.0.dist-info → karaoke_gen-0.66.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1376,6 +1376,12 @@ class KaraokeFinalise:
|
|
|
1376
1376
|
self.logger.info("Email template file not provided, skipping email draft creation.")
|
|
1377
1377
|
return
|
|
1378
1378
|
|
|
1379
|
+
if not self.youtube_client_secrets_file:
|
|
1380
|
+
self.logger.error("Email template file was provided, but youtube_client_secrets_file is required for Gmail authentication.")
|
|
1381
|
+
self.logger.error("Please provide --youtube_client_secrets_file parameter to enable email draft creation.")
|
|
1382
|
+
self.logger.info("Skipping email draft creation.")
|
|
1383
|
+
return
|
|
1384
|
+
|
|
1379
1385
|
with open(self.email_template_file, "r") as f:
|
|
1380
1386
|
template = f.read()
|
|
1381
1387
|
|
karaoke_gen/karaoke_gen.py
CHANGED
|
@@ -28,6 +28,7 @@ from .file_handler import FileHandler
|
|
|
28
28
|
from .audio_processor import AudioProcessor
|
|
29
29
|
from .lyrics_processor import LyricsProcessor
|
|
30
30
|
from .video_generator import VideoGenerator
|
|
31
|
+
from .video_background_processor import VideoBackgroundProcessor
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
class KaraokePrep:
|
|
@@ -70,6 +71,9 @@ class KaraokePrep:
|
|
|
70
71
|
style_overrides=None,
|
|
71
72
|
# Add the new parameter
|
|
72
73
|
skip_separation=False,
|
|
74
|
+
# Video Background Configuration
|
|
75
|
+
background_video=None,
|
|
76
|
+
background_video_darkness=0,
|
|
73
77
|
# YouTube/Online Configuration
|
|
74
78
|
cookies_str=None,
|
|
75
79
|
):
|
|
@@ -129,6 +133,10 @@ class KaraokePrep:
|
|
|
129
133
|
self.style_overrides = style_overrides
|
|
130
134
|
self.temp_style_file = None
|
|
131
135
|
|
|
136
|
+
# Video Background Config
|
|
137
|
+
self.background_video = background_video
|
|
138
|
+
self.background_video_darkness = background_video_darkness
|
|
139
|
+
|
|
132
140
|
# YouTube/Online Config
|
|
133
141
|
self.cookies_str = cookies_str # Passed to metadata extraction and file download
|
|
134
142
|
|
|
@@ -192,6 +200,16 @@ class KaraokePrep:
|
|
|
192
200
|
output_jpg=self.output_jpg,
|
|
193
201
|
)
|
|
194
202
|
|
|
203
|
+
# Instantiate VideoBackgroundProcessor if background_video is provided
|
|
204
|
+
if self.background_video:
|
|
205
|
+
self.logger.info(f"Video background enabled: {self.background_video}")
|
|
206
|
+
self.video_background_processor = VideoBackgroundProcessor(
|
|
207
|
+
logger=self.logger,
|
|
208
|
+
ffmpeg_base_command=self.ffmpeg_base_command,
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
self.video_background_processor = None
|
|
212
|
+
|
|
195
213
|
self.logger.debug(f"Initialized title_format with extra_text: {self.title_format['extra_text']}")
|
|
196
214
|
self.logger.debug(f"Initialized title_format with extra_text_region: {self.title_format['extra_text_region']}")
|
|
197
215
|
|
|
@@ -493,6 +511,9 @@ class KaraokePrep:
|
|
|
493
511
|
processed_track["countdown_padding_seconds"] = transcriber_outputs.get("countdown_padding_seconds", 0.0)
|
|
494
512
|
processed_track["padded_vocals_audio"] = transcriber_outputs.get("padded_audio_filepath")
|
|
495
513
|
|
|
514
|
+
# Store ASS filepath for video background processing
|
|
515
|
+
processed_track["ass_filepath"] = transcriber_outputs.get("ass_filepath")
|
|
516
|
+
|
|
496
517
|
if processed_track["countdown_padding_added"]:
|
|
497
518
|
self.logger.info(
|
|
498
519
|
f"=== COUNTDOWN PADDING DETECTED ==="
|
|
@@ -544,6 +565,78 @@ class KaraokePrep:
|
|
|
544
565
|
|
|
545
566
|
self.logger.info("=== Parallel Processing Complete ===")
|
|
546
567
|
|
|
568
|
+
# Apply video background if requested and lyrics were processed
|
|
569
|
+
if self.video_background_processor and processed_track.get("lyrics"):
|
|
570
|
+
self.logger.info("=== Processing Video Background ===")
|
|
571
|
+
|
|
572
|
+
# Find the With Vocals video file
|
|
573
|
+
with_vocals_video = os.path.join(track_output_dir, f"{artist_title} (With Vocals).mkv")
|
|
574
|
+
|
|
575
|
+
# Get ASS file from transcriber outputs if available
|
|
576
|
+
ass_file = processed_track.get("ass_filepath")
|
|
577
|
+
|
|
578
|
+
# If not in processed_track, try to find it in common locations
|
|
579
|
+
if not ass_file or not os.path.exists(ass_file):
|
|
580
|
+
self.logger.info("ASS filepath not found in transcriber outputs, searching for it...")
|
|
581
|
+
from .utils import sanitize_filename
|
|
582
|
+
sanitized_artist = sanitize_filename(self.artist)
|
|
583
|
+
sanitized_title = sanitize_filename(self.title)
|
|
584
|
+
lyrics_dir = os.path.join(track_output_dir, "lyrics")
|
|
585
|
+
|
|
586
|
+
possible_ass_files = [
|
|
587
|
+
os.path.join(lyrics_dir, f"{sanitized_artist} - {sanitized_title}.ass"),
|
|
588
|
+
os.path.join(track_output_dir, f"{sanitized_artist} - {sanitized_title}.ass"),
|
|
589
|
+
os.path.join(lyrics_dir, f"{artist_title}.ass"),
|
|
590
|
+
os.path.join(track_output_dir, f"{artist_title}.ass"),
|
|
591
|
+
os.path.join(track_output_dir, f"{artist_title} (Karaoke).ass"),
|
|
592
|
+
os.path.join(lyrics_dir, f"{artist_title} (Karaoke).ass"),
|
|
593
|
+
]
|
|
594
|
+
|
|
595
|
+
for possible_file in possible_ass_files:
|
|
596
|
+
if os.path.exists(possible_file):
|
|
597
|
+
ass_file = possible_file
|
|
598
|
+
self.logger.info(f"Found ASS subtitle file: {ass_file}")
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
if os.path.exists(with_vocals_video) and ass_file and os.path.exists(ass_file):
|
|
602
|
+
self.logger.info(f"Found With Vocals video, will replace with video background: {with_vocals_video}")
|
|
603
|
+
self.logger.info(f"Using ASS subtitle file: {ass_file}")
|
|
604
|
+
|
|
605
|
+
# Get audio duration
|
|
606
|
+
audio_duration = self.video_background_processor.get_audio_duration(processed_track["input_audio_wav"])
|
|
607
|
+
|
|
608
|
+
# Check if we need to use the padded audio instead
|
|
609
|
+
if processed_track.get("countdown_padding_added") and processed_track.get("padded_vocals_audio"):
|
|
610
|
+
self.logger.info(f"Using padded vocals audio for video background processing")
|
|
611
|
+
audio_for_video = processed_track["padded_vocals_audio"]
|
|
612
|
+
else:
|
|
613
|
+
audio_for_video = processed_track["input_audio_wav"]
|
|
614
|
+
|
|
615
|
+
# Process video background
|
|
616
|
+
try:
|
|
617
|
+
self.video_background_processor.process_video_background(
|
|
618
|
+
video_path=self.background_video,
|
|
619
|
+
audio_path=audio_for_video,
|
|
620
|
+
ass_subtitles_path=ass_file,
|
|
621
|
+
output_path=with_vocals_video,
|
|
622
|
+
darkness_percent=self.background_video_darkness,
|
|
623
|
+
audio_duration=audio_duration,
|
|
624
|
+
)
|
|
625
|
+
self.logger.info(f"✓ Video background applied, With Vocals video updated: {with_vocals_video}")
|
|
626
|
+
except Exception as e:
|
|
627
|
+
self.logger.error(f"Failed to apply video background: {e}")
|
|
628
|
+
self.logger.exception("Full traceback:")
|
|
629
|
+
# Continue with original video if background processing fails
|
|
630
|
+
else:
|
|
631
|
+
if not os.path.exists(with_vocals_video):
|
|
632
|
+
self.logger.warning(f"With Vocals video not found at {with_vocals_video}, skipping video background processing")
|
|
633
|
+
elif not ass_file or not os.path.exists(ass_file):
|
|
634
|
+
self.logger.warning("Could not find ASS subtitle file, skipping video background processing")
|
|
635
|
+
if 'possible_ass_files' in locals():
|
|
636
|
+
self.logger.warning("Searched locations:")
|
|
637
|
+
for possible_file in possible_ass_files:
|
|
638
|
+
self.logger.warning(f" - {possible_file}")
|
|
639
|
+
|
|
547
640
|
output_image_filepath_noext = os.path.join(track_output_dir, f"{artist_title} (Title)")
|
|
548
641
|
processed_track["title_image_png"] = f"{output_image_filepath_noext}.png"
|
|
549
642
|
processed_track["title_image_jpg"] = f"{output_image_filepath_noext}.jpg"
|
karaoke_gen/utils/gen_cli.py
CHANGED
|
@@ -220,6 +220,16 @@ async def async_main():
|
|
|
220
220
|
action="append",
|
|
221
221
|
help="Optional: Override a style parameter. Can be used multiple times. Example: --style_override 'intro.background_image=/path/to/new_image.png'",
|
|
222
222
|
)
|
|
223
|
+
style_group.add_argument(
|
|
224
|
+
"--background_video",
|
|
225
|
+
help="Optional: Path to video file to use as background instead of static image. Example: --background_video='/path/to/video.mp4'",
|
|
226
|
+
)
|
|
227
|
+
style_group.add_argument(
|
|
228
|
+
"--background_video_darkness",
|
|
229
|
+
type=int,
|
|
230
|
+
default=0,
|
|
231
|
+
help="Optional: Darkness overlay percentage (0-100) for video background (default: %(default)s). Example: --background_video_darkness=50",
|
|
232
|
+
)
|
|
223
233
|
|
|
224
234
|
# Finalisation Configuration
|
|
225
235
|
finalise_group = parser.add_argument_group("Finalisation Configuration")
|
|
@@ -371,6 +381,8 @@ async def async_main():
|
|
|
371
381
|
subtitle_offset_ms=args.subtitle_offset_ms,
|
|
372
382
|
style_params_json=args.style_params_json,
|
|
373
383
|
style_overrides=style_overrides,
|
|
384
|
+
background_video=args.background_video,
|
|
385
|
+
background_video_darkness=args.background_video_darkness,
|
|
374
386
|
)
|
|
375
387
|
# No await needed for constructor
|
|
376
388
|
kprep = kprep_coroutine
|
|
@@ -689,6 +701,8 @@ async def async_main():
|
|
|
689
701
|
subtitle_offset_ms=args.subtitle_offset_ms,
|
|
690
702
|
style_params_json=args.style_params_json,
|
|
691
703
|
style_overrides=style_overrides,
|
|
704
|
+
background_video=args.background_video,
|
|
705
|
+
background_video_darkness=args.background_video_darkness,
|
|
692
706
|
)
|
|
693
707
|
# No await needed for constructor
|
|
694
708
|
kprep = kprep_coroutine
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VideoBackgroundProcessor:
|
|
9
|
+
"""
|
|
10
|
+
Handles video background processing for karaoke videos.
|
|
11
|
+
Responsible for scaling, looping/trimming, darkening, and subtitle rendering.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, logger, ffmpeg_base_command):
|
|
15
|
+
"""
|
|
16
|
+
Initialize the VideoBackgroundProcessor.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
logger: Logger instance for output
|
|
20
|
+
ffmpeg_base_command: Base ffmpeg command with common flags
|
|
21
|
+
"""
|
|
22
|
+
self.logger = logger
|
|
23
|
+
self.ffmpeg_base_command = ffmpeg_base_command
|
|
24
|
+
|
|
25
|
+
# Detect and configure hardware acceleration
|
|
26
|
+
self.nvenc_available = self.detect_nvenc_support()
|
|
27
|
+
self.configure_hardware_acceleration()
|
|
28
|
+
|
|
29
|
+
def detect_nvenc_support(self):
|
|
30
|
+
"""Detect if NVENC hardware encoding is available with comprehensive checks."""
|
|
31
|
+
try:
|
|
32
|
+
self.logger.info("🔍 Detecting NVENC hardware acceleration support...")
|
|
33
|
+
|
|
34
|
+
# Step 1: Check for nvidia-smi (indicates NVIDIA driver presence)
|
|
35
|
+
try:
|
|
36
|
+
nvidia_smi_result = subprocess.run(
|
|
37
|
+
["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=10,
|
|
41
|
+
)
|
|
42
|
+
if nvidia_smi_result.returncode == 0:
|
|
43
|
+
gpu_info = nvidia_smi_result.stdout.strip()
|
|
44
|
+
self.logger.info(f"✓ NVIDIA GPU detected: {gpu_info}")
|
|
45
|
+
else:
|
|
46
|
+
self.logger.warning("⚠️ nvidia-smi not available or no NVIDIA GPU detected")
|
|
47
|
+
return False
|
|
48
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.CalledProcessError):
|
|
49
|
+
self.logger.warning("⚠️ nvidia-smi not available or failed")
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
# Step 2: Check for NVENC encoders in FFmpeg
|
|
53
|
+
try:
|
|
54
|
+
encoders_cmd = f"{self.ffmpeg_base_command} -hide_banner -encoders 2>/dev/null | grep nvenc"
|
|
55
|
+
encoders_result = subprocess.run(encoders_cmd, shell=True, capture_output=True, text=True, timeout=10)
|
|
56
|
+
if encoders_result.returncode == 0 and "nvenc" in encoders_result.stdout:
|
|
57
|
+
nvenc_encoders = [line.strip() for line in encoders_result.stdout.split("\n") if "nvenc" in line]
|
|
58
|
+
self.logger.info("✓ Found NVENC encoders in FFmpeg:")
|
|
59
|
+
for encoder in nvenc_encoders:
|
|
60
|
+
if encoder:
|
|
61
|
+
self.logger.info(f" {encoder}")
|
|
62
|
+
else:
|
|
63
|
+
self.logger.warning("⚠️ No NVENC encoders found in FFmpeg")
|
|
64
|
+
return False
|
|
65
|
+
except Exception as e:
|
|
66
|
+
self.logger.warning(f"⚠️ Failed to check FFmpeg NVENC encoders: {e}")
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
# Step 3: Check for libcuda.so.1 (critical for NVENC)
|
|
70
|
+
try:
|
|
71
|
+
libcuda_check = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=10)
|
|
72
|
+
if libcuda_check.returncode == 0 and "libcuda.so.1" in libcuda_check.stdout:
|
|
73
|
+
self.logger.info("✅ libcuda.so.1 found in system libraries")
|
|
74
|
+
else:
|
|
75
|
+
self.logger.warning("❌ libcuda.so.1 NOT found in system libraries")
|
|
76
|
+
self.logger.warning("💡 This usually indicates the CUDA runtime image is needed instead of devel")
|
|
77
|
+
return False
|
|
78
|
+
except Exception as e:
|
|
79
|
+
self.logger.warning(f"⚠️ Failed to check for libcuda.so.1: {e}")
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
# Step 4: Test h264_nvenc encoder with simple test
|
|
83
|
+
self.logger.info("🧪 Testing h264_nvenc encoder...")
|
|
84
|
+
test_cmd = f"{self.ffmpeg_base_command} -hide_banner -loglevel warning -f lavfi -i testsrc=duration=1:size=320x240:rate=1 -c:v h264_nvenc -f null -"
|
|
85
|
+
self.logger.debug(f"Running test command: {test_cmd}")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=30)
|
|
89
|
+
|
|
90
|
+
if result.returncode == 0:
|
|
91
|
+
self.logger.info("✅ NVENC hardware encoding available for video generation")
|
|
92
|
+
self.logger.info(f"Test command succeeded. Output: {result.stderr if result.stderr else '...'}")
|
|
93
|
+
return True
|
|
94
|
+
else:
|
|
95
|
+
self.logger.warning(f"❌ NVENC test failed with exit code {result.returncode}")
|
|
96
|
+
if result.stderr:
|
|
97
|
+
self.logger.warning(f"Error output: {result.stderr}")
|
|
98
|
+
if "Cannot load libcuda.so.1" in result.stderr:
|
|
99
|
+
self.logger.warning("💡 Root cause: libcuda.so.1 cannot be loaded by NVENC")
|
|
100
|
+
self.logger.warning("💡 Solution: Use nvidia/cuda:*-devel-* image instead of runtime")
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
self.logger.warning("❌ NVENC test timed out")
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
self.logger.warning(f"❌ Failed to detect NVENC support: {e}, falling back to software encoding")
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
def configure_hardware_acceleration(self):
|
|
112
|
+
"""Configure hardware acceleration settings based on detected capabilities."""
|
|
113
|
+
if self.nvenc_available:
|
|
114
|
+
self.video_encoder = "h264_nvenc"
|
|
115
|
+
self.hwaccel_decode_flags = "-hwaccel cuda"
|
|
116
|
+
self.scale_filter = "scale"
|
|
117
|
+
self.logger.info("Configured for NVIDIA hardware acceleration")
|
|
118
|
+
else:
|
|
119
|
+
self.video_encoder = "libx264"
|
|
120
|
+
self.hwaccel_decode_flags = ""
|
|
121
|
+
self.scale_filter = "scale"
|
|
122
|
+
self.logger.info("Configured for software encoding")
|
|
123
|
+
|
|
124
|
+
def get_nvenc_quality_settings(self):
|
|
125
|
+
"""Get NVENC settings for high quality encoding."""
|
|
126
|
+
return "-preset p4 -tune hq -rc vbr -cq 18 -spatial-aq 1 -temporal-aq 1 -b:v 8000k -maxrate 15000k -bufsize 16000k"
|
|
127
|
+
|
|
128
|
+
def get_audio_duration(self, audio_path):
|
|
129
|
+
"""
|
|
130
|
+
Get duration of audio file in seconds using ffprobe.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
audio_path: Path to audio file
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
float: Duration in seconds
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
cmd = [
|
|
140
|
+
"ffprobe",
|
|
141
|
+
"-v",
|
|
142
|
+
"error",
|
|
143
|
+
"-show_entries",
|
|
144
|
+
"format=duration",
|
|
145
|
+
"-of",
|
|
146
|
+
"default=noprint_wrappers=1:nokey=1",
|
|
147
|
+
audio_path,
|
|
148
|
+
]
|
|
149
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
150
|
+
duration = float(result.stdout.strip())
|
|
151
|
+
self.logger.info(f"Audio duration: {duration:.2f} seconds")
|
|
152
|
+
return duration
|
|
153
|
+
except Exception as e:
|
|
154
|
+
self.logger.error(f"Failed to get audio duration: {e}")
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
def escape_filter_path(self, path):
|
|
158
|
+
"""
|
|
159
|
+
Escape a file path for use in ffmpeg filter expressions.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
path: File path to escape
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
str: Escaped path
|
|
166
|
+
"""
|
|
167
|
+
# Escape backslashes and colons for ffmpeg filter syntax
|
|
168
|
+
escaped = path.replace("\\", "\\\\").replace(":", "\\:")
|
|
169
|
+
return escaped
|
|
170
|
+
|
|
171
|
+
def build_video_filter(self, ass_subtitles_path, darkness_percent, fonts_dir=None):
|
|
172
|
+
"""
|
|
173
|
+
Build the video filter chain for scaling, darkening, and subtitles.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
ass_subtitles_path: Path to ASS subtitle file
|
|
177
|
+
darkness_percent: Darkness overlay percentage (0-100)
|
|
178
|
+
fonts_dir: Optional fonts directory for ASS rendering
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
str: FFmpeg filter string
|
|
182
|
+
"""
|
|
183
|
+
filters = []
|
|
184
|
+
|
|
185
|
+
# Scale to 4K with intelligent cropping (not stretching)
|
|
186
|
+
# force_original_aspect_ratio=increase ensures we scale up to fill the frame
|
|
187
|
+
# then crop to exact 4K dimensions
|
|
188
|
+
filters.append("scale=w=3840:h=2160:force_original_aspect_ratio=increase,crop=3840:2160")
|
|
189
|
+
|
|
190
|
+
# Add darkening overlay if requested (before ASS subtitles)
|
|
191
|
+
if darkness_percent > 0:
|
|
192
|
+
# Convert percentage (0-100) to alpha (0.0-1.0)
|
|
193
|
+
alpha = darkness_percent / 100.0
|
|
194
|
+
filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color=black@{alpha:.2f}:t=fill")
|
|
195
|
+
|
|
196
|
+
# Add ASS subtitle filter
|
|
197
|
+
ass_escaped = self.escape_filter_path(ass_subtitles_path)
|
|
198
|
+
ass_filter = f"ass={ass_escaped}"
|
|
199
|
+
|
|
200
|
+
# Add fonts directory if provided
|
|
201
|
+
if fonts_dir and os.path.isdir(fonts_dir):
|
|
202
|
+
fonts_escaped = self.escape_filter_path(fonts_dir)
|
|
203
|
+
ass_filter += f":fontsdir={fonts_escaped}"
|
|
204
|
+
|
|
205
|
+
filters.append(ass_filter)
|
|
206
|
+
|
|
207
|
+
# Combine all filters with commas
|
|
208
|
+
return ",".join(filters)
|
|
209
|
+
|
|
210
|
+
def execute_command_with_fallback(self, gpu_command, cpu_command, description):
|
|
211
|
+
"""
|
|
212
|
+
Execute GPU command with automatic fallback to CPU if it fails.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
gpu_command: Command to try with GPU acceleration
|
|
216
|
+
cpu_command: Fallback command for CPU encoding
|
|
217
|
+
description: Description for logging
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
Exception: If both GPU and CPU commands fail
|
|
221
|
+
"""
|
|
222
|
+
self.logger.info(f"{description}")
|
|
223
|
+
|
|
224
|
+
# Try GPU-accelerated command first if available
|
|
225
|
+
if self.nvenc_available and gpu_command != cpu_command:
|
|
226
|
+
self.logger.debug(f"Attempting hardware-accelerated encoding: {gpu_command}")
|
|
227
|
+
try:
|
|
228
|
+
result = subprocess.run(gpu_command, shell=True, capture_output=True, text=True, timeout=600)
|
|
229
|
+
|
|
230
|
+
if result.returncode == 0:
|
|
231
|
+
self.logger.info(f"✓ Hardware acceleration successful")
|
|
232
|
+
return
|
|
233
|
+
else:
|
|
234
|
+
self.logger.warning(f"✗ Hardware acceleration failed (exit code {result.returncode})")
|
|
235
|
+
self.logger.warning(f"GPU Command: {gpu_command}")
|
|
236
|
+
|
|
237
|
+
if result.stderr:
|
|
238
|
+
self.logger.warning(f"FFmpeg STDERR: {result.stderr}")
|
|
239
|
+
if result.stdout:
|
|
240
|
+
self.logger.warning(f"FFmpeg STDOUT: {result.stdout}")
|
|
241
|
+
self.logger.info("Falling back to software encoding...")
|
|
242
|
+
|
|
243
|
+
except subprocess.TimeoutExpired:
|
|
244
|
+
self.logger.warning("✗ Hardware acceleration timed out, falling back to software encoding")
|
|
245
|
+
except Exception as e:
|
|
246
|
+
self.logger.warning(f"✗ Hardware acceleration failed with exception: {e}, falling back to software encoding")
|
|
247
|
+
|
|
248
|
+
# Use CPU command (either as fallback or primary method)
|
|
249
|
+
self.logger.debug(f"Running software encoding: {cpu_command}")
|
|
250
|
+
try:
|
|
251
|
+
result = subprocess.run(cpu_command, shell=True, capture_output=True, text=True, timeout=600)
|
|
252
|
+
|
|
253
|
+
if result.returncode != 0:
|
|
254
|
+
error_msg = f"Software encoding failed with exit code {result.returncode}"
|
|
255
|
+
self.logger.error(error_msg)
|
|
256
|
+
self.logger.error(f"CPU Command: {cpu_command}")
|
|
257
|
+
if result.stderr:
|
|
258
|
+
self.logger.error(f"FFmpeg STDERR: {result.stderr}")
|
|
259
|
+
if result.stdout:
|
|
260
|
+
self.logger.error(f"FFmpeg STDOUT: {result.stdout}")
|
|
261
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
262
|
+
else:
|
|
263
|
+
self.logger.info(f"✓ Software encoding successful")
|
|
264
|
+
|
|
265
|
+
except subprocess.TimeoutExpired:
|
|
266
|
+
error_msg = "Software encoding timed out"
|
|
267
|
+
self.logger.error(error_msg)
|
|
268
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
269
|
+
except Exception as e:
|
|
270
|
+
if "Software encoding failed" not in str(e):
|
|
271
|
+
error_msg = f"Software encoding failed with exception: {e}"
|
|
272
|
+
self.logger.error(error_msg)
|
|
273
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
274
|
+
else:
|
|
275
|
+
raise
|
|
276
|
+
|
|
277
|
+
def process_video_background(
|
|
278
|
+
self, video_path, audio_path, ass_subtitles_path, output_path, darkness_percent=0, audio_duration=None
|
|
279
|
+
):
|
|
280
|
+
"""
|
|
281
|
+
Process video background with scaling, looping/trimming, darkening, and subtitle rendering.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
video_path: Path to input video file
|
|
285
|
+
audio_path: Path to audio file (used for duration and audio track)
|
|
286
|
+
ass_subtitles_path: Path to ASS subtitle file
|
|
287
|
+
output_path: Path to output video file
|
|
288
|
+
darkness_percent: Darkness overlay percentage (0-100), default 0
|
|
289
|
+
audio_duration: Optional pre-calculated audio duration (will calculate if not provided)
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
str: Path to output file
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
Exception: If video processing fails
|
|
296
|
+
"""
|
|
297
|
+
self.logger.info(f"Processing video background: {video_path}")
|
|
298
|
+
self.logger.info(f" Output: {output_path}")
|
|
299
|
+
self.logger.info(f" Darkness: {darkness_percent}%")
|
|
300
|
+
|
|
301
|
+
# Validate inputs
|
|
302
|
+
if not os.path.isfile(video_path):
|
|
303
|
+
raise FileNotFoundError(f"Video file not found: {video_path}")
|
|
304
|
+
if not os.path.isfile(audio_path):
|
|
305
|
+
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
|
306
|
+
if not os.path.isfile(ass_subtitles_path):
|
|
307
|
+
raise FileNotFoundError(f"ASS subtitle file not found: {ass_subtitles_path}")
|
|
308
|
+
|
|
309
|
+
# Validate darkness parameter
|
|
310
|
+
if not 0 <= darkness_percent <= 100:
|
|
311
|
+
raise ValueError(f"Darkness percentage must be between 0 and 100, got {darkness_percent}")
|
|
312
|
+
|
|
313
|
+
# Get audio duration if not provided
|
|
314
|
+
if audio_duration is None:
|
|
315
|
+
audio_duration = self.get_audio_duration(audio_path)
|
|
316
|
+
|
|
317
|
+
# Check for optional fonts directory (matching video.py behavior)
|
|
318
|
+
fonts_dir = os.environ.get("KARAOKE_FONTS_DIR")
|
|
319
|
+
|
|
320
|
+
# Build video filter chain
|
|
321
|
+
vf_filter = self.build_video_filter(ass_subtitles_path, darkness_percent, fonts_dir)
|
|
322
|
+
|
|
323
|
+
# Build commands for GPU and CPU encoding
|
|
324
|
+
# Use -stream_loop -1 to loop video indefinitely, -shortest to cut when audio ends
|
|
325
|
+
base_inputs = f'-stream_loop -1 -i "{video_path}" -i "{audio_path}"'
|
|
326
|
+
|
|
327
|
+
# GPU-accelerated version
|
|
328
|
+
gpu_command = (
|
|
329
|
+
f"{self.ffmpeg_base_command} {self.hwaccel_decode_flags} {base_inputs} "
|
|
330
|
+
f'-c:a flac -vf "{vf_filter}" -c:v {self.video_encoder} '
|
|
331
|
+
f"{self.get_nvenc_quality_settings()} -shortest -y \"{output_path}\""
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Software fallback version
|
|
335
|
+
cpu_command = (
|
|
336
|
+
f'{self.ffmpeg_base_command} {base_inputs} '
|
|
337
|
+
f'-c:a flac -vf "{vf_filter}" -c:v libx264 -preset fast '
|
|
338
|
+
f"-b:v 5000k -minrate 5000k -maxrate 20000k -bufsize 10000k "
|
|
339
|
+
f'-shortest -y "{output_path}"'
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Execute with fallback
|
|
343
|
+
self.execute_command_with_fallback(
|
|
344
|
+
gpu_command, cpu_command, f"Rendering video with background, subtitles, and effects"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Verify output was created
|
|
348
|
+
if not os.path.isfile(output_path):
|
|
349
|
+
raise Exception(f"Output video file was not created: {output_path}")
|
|
350
|
+
|
|
351
|
+
self.logger.info(f"✓ Video background processing complete: {output_path}")
|
|
352
|
+
return output_path
|
|
353
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: karaoke-gen
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.66.1
|
|
4
4
|
Summary: Generate karaoke videos with synchronized lyrics. Handles the entire process from downloading audio and lyrics to creating the final video with title screens.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -161,6 +161,23 @@ karaoke-gen --lyrics_file="path/to/lyrics.txt" "Rick Astley" "Never Gonna Give Y
|
|
|
161
161
|
karaoke-gen --subtitle_offset_ms=500 "Rick Astley" "Never Gonna Give You Up"
|
|
162
162
|
```
|
|
163
163
|
|
|
164
|
+
### Video Background
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# Use a video as background instead of static image
|
|
168
|
+
karaoke-gen --background_video="path/to/video.mp4" "Rick Astley" "Never Gonna Give You Up"
|
|
169
|
+
|
|
170
|
+
# Use a video background with darkening overlay (0-100%)
|
|
171
|
+
karaoke-gen --background_video="path/to/video.mp4" --background_video_darkness=50 "Rick Astley" "Never Gonna Give You Up"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The video background feature automatically:
|
|
175
|
+
- Scales the video to 4K resolution (3840x2160) with intelligent cropping
|
|
176
|
+
- Loops the video if it's shorter than the audio
|
|
177
|
+
- Trims the video if it's longer than the audio
|
|
178
|
+
- Applies an optional darkening overlay to improve subtitle readability
|
|
179
|
+
- Renders synchronized ASS subtitles on top of the video
|
|
180
|
+
|
|
164
181
|
### Finalisation Options
|
|
165
182
|
|
|
166
183
|
```bash
|
|
@@ -173,6 +190,9 @@ karaoke-gen --enable_txt "Rick Astley" "Never Gonna Give You Up"
|
|
|
173
190
|
# Upload to YouTube
|
|
174
191
|
karaoke-gen --youtube_client_secrets_file="path/to/client_secret.json" --youtube_description_file="path/to/description.txt" "Rick Astley" "Never Gonna Give You Up"
|
|
175
192
|
|
|
193
|
+
# Draft completion emails (requires youtube_client_secrets_file for Gmail OAuth)
|
|
194
|
+
karaoke-gen --email_template_file="path/to/template.txt" --youtube_client_secrets_file="path/to/client_secret.json" "Rick Astley" "Never Gonna Give You Up"
|
|
195
|
+
|
|
176
196
|
# Organize files with brand code
|
|
177
197
|
karaoke-gen --brand_prefix="BRAND" --organised_dir="path/to/Tracks-Organized" "Rick Astley" "Never Gonna Give You Up"
|
|
178
198
|
```
|
|
@@ -3,8 +3,8 @@ karaoke_gen/audio_processor.py,sha256=uU7uqms2hQn_xWGEZ105tnUMwHuLTDSGKwzGRGUv1I
|
|
|
3
3
|
karaoke_gen/config.py,sha256=YY0QOlGcS342iF12be48WJ9mF_SdXW6TzFG8LXLT_J4,8354
|
|
4
4
|
karaoke_gen/file_handler.py,sha256=c86-rGF7Fusl0uEIZFnreT7PJfK7lmUaEgauU8BBzjY,10024
|
|
5
5
|
karaoke_gen/karaoke_finalise/__init__.py,sha256=HqZ7TIhgt_tYZ-nb_NNCaejWAcF_aK-7wJY5TaW_keM,46
|
|
6
|
-
karaoke_gen/karaoke_finalise/karaoke_finalise.py,sha256=
|
|
7
|
-
karaoke_gen/karaoke_gen.py,sha256=
|
|
6
|
+
karaoke_gen/karaoke_finalise/karaoke_finalise.py,sha256=qyyrjmD-Fqc2Emvi2RCtALOy4Ad2q0ZVRhE92JitFKc,85733
|
|
7
|
+
karaoke_gen/karaoke_gen.py,sha256=PZE3wjfRyGW2ZyETgDgZSJw_i9Pc1sr7lgjvF0Fknc8,48691
|
|
8
8
|
karaoke_gen/lyrics_processor.py,sha256=R8vO0tF7-k5PVDiXrUMGd-4Fqa4M3QNInLy9Y3XhsgA,14912
|
|
9
9
|
karaoke_gen/metadata.py,sha256=TprFzWj-iJ7ghrXlHFMPzzqzuHzWeNvs3zGaND-z9Ds,6503
|
|
10
10
|
karaoke_gen/resources/AvenirNext-Bold.ttf,sha256=YxgKz2OP46lwLPCpIZhVa8COi_9KRDSXw4n8dIHHQSs,327048
|
|
@@ -14,10 +14,11 @@ karaoke_gen/resources/Oswald-SemiBold.ttf,sha256=G-vSJeeyEVft7D4s7FZQtGfXAViWPjz
|
|
|
14
14
|
karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf,sha256=WNG5LOQ-uGUF_WWT5aQHzVbyWvQqGO5sZ4E-nRmvPuI,37780
|
|
15
15
|
karaoke_gen/utils/__init__.py,sha256=FpOHyeBRB06f3zMoLBUJHTDZACrabg-DoyBTxNKYyNY,722
|
|
16
16
|
karaoke_gen/utils/bulk_cli.py,sha256=uqAHnlidY-f_RhsQIHqZDnrznWRKhqpEDX2uiR1CUQs,18841
|
|
17
|
-
karaoke_gen/utils/gen_cli.py,sha256=
|
|
17
|
+
karaoke_gen/utils/gen_cli.py,sha256=2PgSboVEv7zSkWYmA152n4nHmolMZcagDa9ol4Vt_bs,37129
|
|
18
|
+
karaoke_gen/video_background_processor.py,sha256=XVKi-D1SX8FLLg8eBxNx-wLfT6JlMlnaCT6c-bGbWnA,15486
|
|
18
19
|
karaoke_gen/video_generator.py,sha256=B7BQBrjkyvk3L3sctnPXnvr1rzkw0NYx5UCAl0ZiVx0,18464
|
|
19
|
-
karaoke_gen-0.
|
|
20
|
-
karaoke_gen-0.
|
|
21
|
-
karaoke_gen-0.
|
|
22
|
-
karaoke_gen-0.
|
|
23
|
-
karaoke_gen-0.
|
|
20
|
+
karaoke_gen-0.66.1.dist-info/METADATA,sha256=X_DYMVVheLmrFVAGWWome7SXGdet2id7ss35OQFUP2s,8308
|
|
21
|
+
karaoke_gen-0.66.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
22
|
+
karaoke_gen-0.66.1.dist-info/entry_points.txt,sha256=IZY3O8i7m-qkmPuqgpAcxiS2fotNc6hC-CDWvNmoUEY,107
|
|
23
|
+
karaoke_gen-0.66.1.dist-info/licenses/LICENSE,sha256=81R_4XwMZDODHD7JcZeUR8IiCU8AD7Ajl6bmwR9tYDk,1074
|
|
24
|
+
karaoke_gen-0.66.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|