karaoke-gen 0.65.0__tar.gz → 0.66.0__tar.gz

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.

Files changed (23) hide show
  1. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/PKG-INFO +18 -1
  2. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/README.md +17 -0
  3. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/karaoke_gen.py +93 -0
  4. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/utils/gen_cli.py +14 -0
  5. karaoke_gen-0.66.0/karaoke_gen/video_background_processor.py +353 -0
  6. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/pyproject.toml +1 -1
  7. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/LICENSE +0 -0
  8. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/__init__.py +0 -0
  9. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/audio_processor.py +0 -0
  10. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/config.py +0 -0
  11. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/file_handler.py +0 -0
  12. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/karaoke_finalise/__init__.py +0 -0
  13. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/karaoke_finalise/karaoke_finalise.py +0 -0
  14. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/lyrics_processor.py +0 -0
  15. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/metadata.py +0 -0
  16. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
  17. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
  18. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/resources/Oswald-Bold.ttf +0 -0
  19. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
  20. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
  21. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/utils/__init__.py +0 -0
  22. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/utils/bulk_cli.py +0 -0
  23. {karaoke_gen-0.65.0 → karaoke_gen-0.66.0}/karaoke_gen/video_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karaoke-gen
3
- Version: 0.65.0
3
+ Version: 0.66.0
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
@@ -112,6 +112,23 @@ karaoke-gen --lyrics_file="path/to/lyrics.txt" "Rick Astley" "Never Gonna Give Y
112
112
  karaoke-gen --subtitle_offset_ms=500 "Rick Astley" "Never Gonna Give You Up"
113
113
  ```
114
114
 
115
+ ### Video Background
116
+
117
+ ```bash
118
+ # Use a video as background instead of static image
119
+ karaoke-gen --background_video="path/to/video.mp4" "Rick Astley" "Never Gonna Give You Up"
120
+
121
+ # Use a video background with darkening overlay (0-100%)
122
+ karaoke-gen --background_video="path/to/video.mp4" --background_video_darkness=50 "Rick Astley" "Never Gonna Give You Up"
123
+ ```
124
+
125
+ The video background feature automatically:
126
+ - Scales the video to 4K resolution (3840x2160) with intelligent cropping
127
+ - Loops the video if it's shorter than the audio
128
+ - Trims the video if it's longer than the audio
129
+ - Applies an optional darkening overlay to improve subtitle readability
130
+ - Renders synchronized ASS subtitles on top of the video
131
+
115
132
  ### Finalisation Options
116
133
 
117
134
  ```bash
@@ -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"
@@ -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
  [tool.poetry]
2
2
  name = "karaoke-gen"
3
- version = "0.65.0"
3
+ version = "0.66.0"
4
4
  description = "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
  authors = ["Andrew Beveridge <andrew@beveridge.uk>"]
6
6
  license = "MIT"
File without changes