karaoke-gen 0.75.53__py3-none-any.whl → 0.76.20__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.
- karaoke_gen/audio_fetcher.py +218 -0
- karaoke_gen/karaoke_gen.py +190 -25
- karaoke_gen/lyrics_processor.py +14 -25
- karaoke_gen/utils/__init__.py +26 -0
- karaoke_gen/utils/cli_args.py +9 -1
- karaoke_gen/utils/gen_cli.py +1 -1
- karaoke_gen/utils/remote_cli.py +33 -6
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/METADATA +2 -2
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/RECORD +36 -32
- lyrics_transcriber/frontend/index.html +5 -1
- lyrics_transcriber/frontend/package-lock.json +4553 -0
- lyrics_transcriber/frontend/package.json +3 -0
- lyrics_transcriber/frontend/playwright.config.ts +69 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/frontend/src/App.tsx +88 -59
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +55 -21
- lyrics_transcriber/frontend/src/components/AppHeader.tsx +65 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +5 -5
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +9 -9
- lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +1 -1
- lyrics_transcriber/frontend/src/components/Header.tsx +34 -48
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +22 -21
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +3 -3
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +2 -2
- lyrics_transcriber/frontend/src/components/shared/constants.ts +15 -5
- lyrics_transcriber/frontend/src/main.tsx +1 -7
- lyrics_transcriber/frontend/src/theme.ts +337 -135
- lyrics_transcriber/frontend/vite.config.ts +5 -0
- lyrics_transcriber/frontend/yarn.lock +1005 -1046
- lyrics_transcriber/review/server.py +1 -1
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/licenses/LICENSE +0 -0
karaoke_gen/audio_fetcher.py
CHANGED
|
@@ -264,6 +264,36 @@ class AudioFetcher(ABC):
|
|
|
264
264
|
"""
|
|
265
265
|
pass
|
|
266
266
|
|
|
267
|
+
@abstractmethod
|
|
268
|
+
def download_from_url(
|
|
269
|
+
self,
|
|
270
|
+
url: str,
|
|
271
|
+
output_dir: str,
|
|
272
|
+
output_filename: Optional[str] = None,
|
|
273
|
+
artist: Optional[str] = None,
|
|
274
|
+
title: Optional[str] = None,
|
|
275
|
+
) -> AudioFetchResult:
|
|
276
|
+
"""
|
|
277
|
+
Download audio directly from a URL (e.g., YouTube URL).
|
|
278
|
+
|
|
279
|
+
This bypasses the search step and downloads directly from the provided URL.
|
|
280
|
+
Useful when the user provides a specific YouTube URL rather than artist/title.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
url: The URL to download from (e.g., YouTube video URL)
|
|
284
|
+
output_dir: Directory to save the downloaded file
|
|
285
|
+
output_filename: Optional filename (without extension)
|
|
286
|
+
artist: Optional artist name for metadata
|
|
287
|
+
title: Optional title for metadata
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
AudioFetchResult with the downloaded file path
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
DownloadError: If download fails
|
|
294
|
+
"""
|
|
295
|
+
pass
|
|
296
|
+
|
|
267
297
|
|
|
268
298
|
class FlacFetchAudioFetcher(AudioFetcher):
|
|
269
299
|
"""
|
|
@@ -645,6 +675,94 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
645
675
|
except Exception as e:
|
|
646
676
|
raise DownloadError(f"Failed to download {artist} - {title}: {e}") from e
|
|
647
677
|
|
|
678
|
+
def download_from_url(
|
|
679
|
+
self,
|
|
680
|
+
url: str,
|
|
681
|
+
output_dir: str,
|
|
682
|
+
output_filename: Optional[str] = None,
|
|
683
|
+
artist: Optional[str] = None,
|
|
684
|
+
title: Optional[str] = None,
|
|
685
|
+
) -> AudioFetchResult:
|
|
686
|
+
"""
|
|
687
|
+
Download audio directly from a URL (e.g., YouTube URL).
|
|
688
|
+
|
|
689
|
+
Uses flacfetch's download_by_id() method which supports direct YouTube downloads.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
url: The URL to download from (e.g., YouTube video URL)
|
|
693
|
+
output_dir: Directory to save the downloaded file
|
|
694
|
+
output_filename: Optional filename (without extension)
|
|
695
|
+
artist: Optional artist name for metadata
|
|
696
|
+
title: Optional title for metadata
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
AudioFetchResult with the downloaded file path
|
|
700
|
+
|
|
701
|
+
Raises:
|
|
702
|
+
DownloadError: If download fails
|
|
703
|
+
"""
|
|
704
|
+
import re
|
|
705
|
+
|
|
706
|
+
manager = self._get_manager()
|
|
707
|
+
|
|
708
|
+
# Ensure output directory exists
|
|
709
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
710
|
+
|
|
711
|
+
# Detect source type from URL
|
|
712
|
+
source_name = "YouTube" # Default to YouTube for now
|
|
713
|
+
source_id = None
|
|
714
|
+
|
|
715
|
+
# Extract YouTube video ID from URL
|
|
716
|
+
youtube_patterns = [
|
|
717
|
+
r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
|
|
718
|
+
r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
|
|
719
|
+
r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
|
|
720
|
+
]
|
|
721
|
+
for pattern in youtube_patterns:
|
|
722
|
+
match = re.search(pattern, url)
|
|
723
|
+
if match:
|
|
724
|
+
source_id = match.group(1)
|
|
725
|
+
break
|
|
726
|
+
|
|
727
|
+
if not source_id:
|
|
728
|
+
# For other URLs, use the full URL as the source_id
|
|
729
|
+
source_id = url
|
|
730
|
+
|
|
731
|
+
# Generate filename if not provided
|
|
732
|
+
if output_filename is None:
|
|
733
|
+
if artist and title:
|
|
734
|
+
output_filename = f"{artist} - {title}"
|
|
735
|
+
else:
|
|
736
|
+
output_filename = source_id
|
|
737
|
+
|
|
738
|
+
self.logger.info(f"Downloading from URL: {url}")
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
filepath = manager.download_by_id(
|
|
742
|
+
source_name=source_name,
|
|
743
|
+
source_id=source_id,
|
|
744
|
+
output_path=output_dir,
|
|
745
|
+
output_filename=output_filename,
|
|
746
|
+
download_url=url, # Pass full URL for direct download
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
if not filepath:
|
|
750
|
+
raise DownloadError(f"Download returned no file path for URL: {url}")
|
|
751
|
+
|
|
752
|
+
self.logger.info(f"Downloaded to: {filepath}")
|
|
753
|
+
|
|
754
|
+
return AudioFetchResult(
|
|
755
|
+
filepath=filepath,
|
|
756
|
+
artist=artist or "",
|
|
757
|
+
title=title or "",
|
|
758
|
+
provider=source_name,
|
|
759
|
+
duration=None, # Could extract from yt-dlp info if needed
|
|
760
|
+
quality=None,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
except Exception as e:
|
|
764
|
+
raise DownloadError(f"Failed to download from URL {url}: {e}") from e
|
|
765
|
+
|
|
648
766
|
def _interruptible_search(self, manager, query) -> list:
|
|
649
767
|
"""
|
|
650
768
|
Run search in a way that can be interrupted by Ctrl+C.
|
|
@@ -1548,6 +1666,106 @@ class RemoteFlacFetchAudioFetcher(AudioFetcher):
|
|
|
1548
1666
|
print("\nCancelled")
|
|
1549
1667
|
raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
|
|
1550
1668
|
|
|
1669
|
+
def download_from_url(
|
|
1670
|
+
self,
|
|
1671
|
+
url: str,
|
|
1672
|
+
output_dir: str,
|
|
1673
|
+
output_filename: Optional[str] = None,
|
|
1674
|
+
artist: Optional[str] = None,
|
|
1675
|
+
title: Optional[str] = None,
|
|
1676
|
+
) -> AudioFetchResult:
|
|
1677
|
+
"""
|
|
1678
|
+
Download audio directly from a URL (e.g., YouTube URL).
|
|
1679
|
+
|
|
1680
|
+
For YouTube URLs, this uses local flacfetch since YouTube downloads
|
|
1681
|
+
don't require the remote flacfetch infrastructure (no torrents).
|
|
1682
|
+
|
|
1683
|
+
Args:
|
|
1684
|
+
url: The URL to download from (e.g., YouTube video URL)
|
|
1685
|
+
output_dir: Directory to save the downloaded file
|
|
1686
|
+
output_filename: Optional filename (without extension)
|
|
1687
|
+
artist: Optional artist name for metadata
|
|
1688
|
+
title: Optional title for metadata
|
|
1689
|
+
|
|
1690
|
+
Returns:
|
|
1691
|
+
AudioFetchResult with the downloaded file path
|
|
1692
|
+
|
|
1693
|
+
Raises:
|
|
1694
|
+
DownloadError: If download fails
|
|
1695
|
+
"""
|
|
1696
|
+
import re
|
|
1697
|
+
|
|
1698
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloading from URL: {url}")
|
|
1699
|
+
self.logger.info("[RemoteFlacFetcher] Using local flacfetch for YouTube download (no remote API needed)")
|
|
1700
|
+
|
|
1701
|
+
try:
|
|
1702
|
+
# Use local flacfetch for YouTube downloads - no need for remote API
|
|
1703
|
+
# This avoids needing yt-dlp directly in karaoke-gen
|
|
1704
|
+
from flacfetch.core.manager import FetchManager
|
|
1705
|
+
from flacfetch.providers.youtube import YoutubeProvider
|
|
1706
|
+
from flacfetch.downloaders.youtube import YoutubeDownloader
|
|
1707
|
+
|
|
1708
|
+
# Create a minimal local manager for YouTube downloads
|
|
1709
|
+
manager = FetchManager()
|
|
1710
|
+
manager.add_provider(YoutubeProvider())
|
|
1711
|
+
manager.register_downloader("YouTube", YoutubeDownloader())
|
|
1712
|
+
|
|
1713
|
+
# Ensure output directory exists
|
|
1714
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1715
|
+
|
|
1716
|
+
# Extract video ID from URL
|
|
1717
|
+
source_id = None
|
|
1718
|
+
youtube_patterns = [
|
|
1719
|
+
r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
|
|
1720
|
+
r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
|
|
1721
|
+
r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
|
|
1722
|
+
]
|
|
1723
|
+
for pattern in youtube_patterns:
|
|
1724
|
+
match = re.search(pattern, url)
|
|
1725
|
+
if match:
|
|
1726
|
+
source_id = match.group(1)
|
|
1727
|
+
break
|
|
1728
|
+
|
|
1729
|
+
if not source_id:
|
|
1730
|
+
source_id = url
|
|
1731
|
+
|
|
1732
|
+
# Generate filename if not provided
|
|
1733
|
+
if output_filename is None:
|
|
1734
|
+
if artist and title:
|
|
1735
|
+
output_filename = f"{artist} - {title}"
|
|
1736
|
+
else:
|
|
1737
|
+
output_filename = source_id
|
|
1738
|
+
|
|
1739
|
+
# Use flacfetch's download_by_id for direct URL download
|
|
1740
|
+
filepath = manager.download_by_id(
|
|
1741
|
+
source_name="YouTube",
|
|
1742
|
+
source_id=source_id,
|
|
1743
|
+
output_path=output_dir,
|
|
1744
|
+
output_filename=output_filename,
|
|
1745
|
+
download_url=url,
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
if not filepath:
|
|
1749
|
+
raise DownloadError(f"Download returned no file path for URL: {url}")
|
|
1750
|
+
|
|
1751
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloaded to: {filepath}")
|
|
1752
|
+
|
|
1753
|
+
return AudioFetchResult(
|
|
1754
|
+
filepath=filepath,
|
|
1755
|
+
artist=artist or "",
|
|
1756
|
+
title=title or "",
|
|
1757
|
+
provider="YouTube",
|
|
1758
|
+
duration=None,
|
|
1759
|
+
quality=None,
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
except ImportError as e:
|
|
1763
|
+
raise DownloadError(
|
|
1764
|
+
f"flacfetch is required for URL downloads but import failed: {e}"
|
|
1765
|
+
) from e
|
|
1766
|
+
except Exception as e:
|
|
1767
|
+
raise DownloadError(f"Failed to download from URL {url}: {e}") from e
|
|
1768
|
+
|
|
1551
1769
|
|
|
1552
1770
|
# Alias for shorter name
|
|
1553
1771
|
RemoteFlacFetcher = RemoteFlacFetchAudioFetcher
|
karaoke_gen/karaoke_gen.py
CHANGED
|
@@ -31,6 +31,12 @@ from .video_generator import VideoGenerator
|
|
|
31
31
|
from .video_background_processor import VideoBackgroundProcessor
|
|
32
32
|
from .audio_fetcher import create_audio_fetcher, AudioFetcherError, NoResultsError, UserCancelledError
|
|
33
33
|
|
|
34
|
+
# Import lyrics_transcriber components for post-review countdown and video rendering
|
|
35
|
+
from lyrics_transcriber.output.countdown_processor import CountdownProcessor
|
|
36
|
+
from lyrics_transcriber.output.generator import OutputGenerator
|
|
37
|
+
from lyrics_transcriber.types import CorrectionResult
|
|
38
|
+
from lyrics_transcriber.core.config import OutputConfig as LyricsOutputConfig
|
|
39
|
+
|
|
34
40
|
|
|
35
41
|
class KaraokePrep:
|
|
36
42
|
def __init__(
|
|
@@ -482,41 +488,56 @@ class KaraokePrep:
|
|
|
482
488
|
self.logger.info(f"Found existing media files matching extractor '{self.extractor}', skipping download/conversion.")
|
|
483
489
|
|
|
484
490
|
elif getattr(self, '_use_audio_fetcher', False):
|
|
485
|
-
# Use flacfetch to search and download audio
|
|
486
|
-
self.logger.info(f"Using flacfetch to search and download: {self.artist} - {self.title}")
|
|
487
|
-
|
|
488
491
|
try:
|
|
489
|
-
#
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
492
|
+
# Check if this is a URL download or search+download
|
|
493
|
+
if getattr(self, '_use_url_download', False):
|
|
494
|
+
# Direct URL download (e.g., YouTube URL)
|
|
495
|
+
self.logger.info(f"Using flacfetch to download from URL: {self.url}")
|
|
496
|
+
|
|
497
|
+
fetch_result = self.audio_fetcher.download_from_url(
|
|
498
|
+
url=self.url,
|
|
499
|
+
output_dir=track_output_dir,
|
|
500
|
+
output_filename=f"{artist_title} (youtube)" if artist_title != "Unknown - Unknown" else None,
|
|
501
|
+
artist=self.artist,
|
|
502
|
+
title=self.title,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Update extractor to reflect the source
|
|
506
|
+
self.extractor = "youtube"
|
|
507
|
+
else:
|
|
508
|
+
# Use flacfetch to search and download audio
|
|
509
|
+
self.logger.info(f"Using flacfetch to search and download: {self.artist} - {self.title}")
|
|
510
|
+
|
|
511
|
+
fetch_result = self.audio_fetcher.search_and_download(
|
|
512
|
+
artist=self.artist,
|
|
513
|
+
title=self.title,
|
|
514
|
+
output_dir=track_output_dir,
|
|
515
|
+
output_filename=f"{artist_title} (flacfetch)",
|
|
516
|
+
auto_select=self.auto_download,
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Update extractor to reflect the actual provider used
|
|
520
|
+
self.extractor = f"flacfetch-{fetch_result.provider}"
|
|
521
|
+
|
|
501
522
|
# Set up the output paths
|
|
502
523
|
output_filename_no_extension = os.path.join(track_output_dir, f"{artist_title} ({self.extractor})")
|
|
503
|
-
|
|
524
|
+
|
|
504
525
|
# Copy/move the downloaded file to the expected location
|
|
505
526
|
processed_track["input_media"] = self.file_handler.download_audio_from_fetcher_result(
|
|
506
527
|
fetch_result.filepath, output_filename_no_extension
|
|
507
528
|
)
|
|
508
|
-
|
|
529
|
+
|
|
509
530
|
self.logger.info(f"Audio downloaded from {fetch_result.provider}: {processed_track['input_media']}")
|
|
510
|
-
|
|
531
|
+
|
|
511
532
|
# Convert to WAV for audio processing
|
|
512
533
|
self.logger.info("Converting downloaded audio to WAV for processing...")
|
|
513
534
|
processed_track["input_audio_wav"] = self.file_handler.convert_to_wav(
|
|
514
535
|
processed_track["input_media"], output_filename_no_extension
|
|
515
536
|
)
|
|
516
|
-
|
|
537
|
+
|
|
517
538
|
# No still image for audio-only downloads
|
|
518
539
|
processed_track["input_still_image"] = None
|
|
519
|
-
|
|
540
|
+
|
|
520
541
|
except UserCancelledError:
|
|
521
542
|
# User cancelled - propagate up to CLI for graceful exit
|
|
522
543
|
raise
|
|
@@ -692,6 +713,112 @@ class KaraokePrep:
|
|
|
692
713
|
|
|
693
714
|
self.logger.info("=== Parallel Processing Complete ===")
|
|
694
715
|
|
|
716
|
+
# === POST-TRANSCRIPTION: Add countdown and render video ===
|
|
717
|
+
# Since lyrics_processor.py now always defers countdown and video rendering,
|
|
718
|
+
# we handle it here after human review is complete. This ensures the review UI
|
|
719
|
+
# shows accurate, unshifted timestamps (same behavior as cloud backend).
|
|
720
|
+
if processed_track.get("lyrics") and self.render_video:
|
|
721
|
+
self.logger.info("=== Processing Countdown and Video Rendering ===")
|
|
722
|
+
|
|
723
|
+
from .utils import sanitize_filename
|
|
724
|
+
sanitized_artist = sanitize_filename(self.artist)
|
|
725
|
+
sanitized_title = sanitize_filename(self.title)
|
|
726
|
+
lyrics_dir = os.path.join(track_output_dir, "lyrics")
|
|
727
|
+
|
|
728
|
+
# Find the corrections JSON file
|
|
729
|
+
corrections_filename = f"{sanitized_artist} - {sanitized_title} (Lyrics Corrections).json"
|
|
730
|
+
corrections_filepath = os.path.join(lyrics_dir, corrections_filename)
|
|
731
|
+
|
|
732
|
+
if os.path.exists(corrections_filepath):
|
|
733
|
+
self.logger.info(f"Loading corrections from: {corrections_filepath}")
|
|
734
|
+
|
|
735
|
+
with open(corrections_filepath, 'r', encoding='utf-8') as f:
|
|
736
|
+
corrections_data = json.load(f)
|
|
737
|
+
|
|
738
|
+
# Convert to CorrectionResult
|
|
739
|
+
correction_result = CorrectionResult.from_dict(corrections_data)
|
|
740
|
+
self.logger.info(f"Loaded CorrectionResult with {len(correction_result.corrected_segments)} segments")
|
|
741
|
+
|
|
742
|
+
# Get the audio file path
|
|
743
|
+
audio_path = processed_track["input_audio_wav"]
|
|
744
|
+
|
|
745
|
+
# Add countdown intro if needed (songs that start within 3 seconds)
|
|
746
|
+
self.logger.info("Processing countdown intro (if needed)...")
|
|
747
|
+
cache_dir = os.path.join(track_output_dir, "cache")
|
|
748
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
749
|
+
|
|
750
|
+
countdown_processor = CountdownProcessor(
|
|
751
|
+
cache_dir=cache_dir,
|
|
752
|
+
logger=self.logger,
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
correction_result, audio_path, padding_added, padding_seconds = countdown_processor.process(
|
|
756
|
+
correction_result=correction_result,
|
|
757
|
+
audio_filepath=audio_path,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
# Update processed_track with countdown info
|
|
761
|
+
processed_track["countdown_padding_added"] = padding_added
|
|
762
|
+
processed_track["countdown_padding_seconds"] = padding_seconds
|
|
763
|
+
if padding_added:
|
|
764
|
+
processed_track["padded_vocals_audio"] = audio_path
|
|
765
|
+
self.logger.info(
|
|
766
|
+
f"=== COUNTDOWN PADDING ADDED ===\n"
|
|
767
|
+
f"Added {padding_seconds}s padding to audio and shifted timestamps.\n"
|
|
768
|
+
f"Instrumental tracks will be padded after separation to maintain sync."
|
|
769
|
+
)
|
|
770
|
+
else:
|
|
771
|
+
self.logger.info("No countdown needed - song starts after 3 seconds")
|
|
772
|
+
|
|
773
|
+
# Save the updated corrections with countdown timestamps
|
|
774
|
+
updated_corrections_data = correction_result.to_dict()
|
|
775
|
+
with open(corrections_filepath, 'w', encoding='utf-8') as f:
|
|
776
|
+
json.dump(updated_corrections_data, f, indent=2)
|
|
777
|
+
self.logger.info(f"Saved countdown-adjusted corrections to: {corrections_filepath}")
|
|
778
|
+
|
|
779
|
+
# Render video with lyrics
|
|
780
|
+
self.logger.info("Rendering karaoke video with synchronized lyrics...")
|
|
781
|
+
|
|
782
|
+
output_config = LyricsOutputConfig(
|
|
783
|
+
output_dir=lyrics_dir,
|
|
784
|
+
cache_dir=cache_dir,
|
|
785
|
+
output_styles_json=self.style_params_json,
|
|
786
|
+
render_video=True,
|
|
787
|
+
generate_cdg=False,
|
|
788
|
+
generate_plain_text=True,
|
|
789
|
+
generate_lrc=True,
|
|
790
|
+
video_resolution="4k",
|
|
791
|
+
subtitle_offset_ms=self.subtitle_offset_ms,
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
output_generator = OutputGenerator(output_config, self.logger)
|
|
795
|
+
output_prefix = f"{sanitized_artist} - {sanitized_title}"
|
|
796
|
+
|
|
797
|
+
outputs = output_generator.generate_outputs(
|
|
798
|
+
transcription_corrected=correction_result,
|
|
799
|
+
audio_filepath=audio_path,
|
|
800
|
+
output_prefix=output_prefix,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
# Copy video to expected location in parent directory
|
|
804
|
+
if outputs and outputs.get("video_filepath"):
|
|
805
|
+
source_video = outputs["video_filepath"]
|
|
806
|
+
dest_video = os.path.join(track_output_dir, f"{artist_title} (With Vocals).mkv")
|
|
807
|
+
shutil.copy2(source_video, dest_video)
|
|
808
|
+
self.logger.info(f"Video rendered successfully: {dest_video}")
|
|
809
|
+
processed_track["with_vocals_video"] = dest_video
|
|
810
|
+
|
|
811
|
+
# Update ASS filepath for video background processing
|
|
812
|
+
if outputs.get("ass_filepath"):
|
|
813
|
+
processed_track["ass_filepath"] = outputs["ass_filepath"]
|
|
814
|
+
else:
|
|
815
|
+
self.logger.warning("Video rendering did not produce expected output")
|
|
816
|
+
else:
|
|
817
|
+
self.logger.warning(f"Corrections file not found: {corrections_filepath}")
|
|
818
|
+
self.logger.warning("Skipping countdown processing and video rendering")
|
|
819
|
+
elif not self.render_video:
|
|
820
|
+
self.logger.info("Video rendering disabled - skipping countdown and video generation")
|
|
821
|
+
|
|
695
822
|
# Apply video background if requested and lyrics were processed
|
|
696
823
|
if self.video_background_processor and processed_track.get("lyrics"):
|
|
697
824
|
self.logger.info("=== Processing Video Background ===")
|
|
@@ -991,17 +1118,56 @@ class KaraokePrep:
|
|
|
991
1118
|
|
|
992
1119
|
return tracks
|
|
993
1120
|
|
|
1121
|
+
def _is_url(self, string: str) -> bool:
|
|
1122
|
+
"""Check if a string is a URL."""
|
|
1123
|
+
return string is not None and (string.startswith("http://") or string.startswith("https://"))
|
|
1124
|
+
|
|
994
1125
|
async def process(self):
|
|
995
1126
|
if self.input_media is not None and os.path.isdir(self.input_media):
|
|
996
1127
|
self.logger.info(f"Input media {self.input_media} is a local folder, processing each file individually...")
|
|
997
1128
|
return await self.process_folder()
|
|
998
1129
|
elif self.input_media is not None and os.path.isfile(self.input_media):
|
|
999
1130
|
self.logger.info(f"Input media {self.input_media} is a local file, audio download will be skipped")
|
|
1131
|
+
return [await self.prep_single_track()]
|
|
1132
|
+
elif self.input_media is not None and self._is_url(self.input_media):
|
|
1133
|
+
# URL provided - download directly via flacfetch
|
|
1134
|
+
self.logger.info(f"Input media {self.input_media} is a URL, downloading via flacfetch...")
|
|
1135
|
+
|
|
1136
|
+
# Extract video ID for metadata if it's a YouTube URL
|
|
1137
|
+
video_id = None
|
|
1138
|
+
youtube_patterns = [
|
|
1139
|
+
r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
|
|
1140
|
+
r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
|
|
1141
|
+
r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
|
|
1142
|
+
]
|
|
1143
|
+
for pattern in youtube_patterns:
|
|
1144
|
+
match = re.search(pattern, self.input_media)
|
|
1145
|
+
if match:
|
|
1146
|
+
video_id = match.group(1)
|
|
1147
|
+
break
|
|
1148
|
+
|
|
1149
|
+
# Set up the extracted_info for metadata consistency
|
|
1150
|
+
self.extracted_info = {
|
|
1151
|
+
"title": f"{self.artist} - {self.title}" if self.artist and self.title else video_id or "Unknown",
|
|
1152
|
+
"artist": self.artist or "",
|
|
1153
|
+
"track_title": self.title or "",
|
|
1154
|
+
"extractor_key": "youtube",
|
|
1155
|
+
"id": video_id or self.input_media,
|
|
1156
|
+
"url": self.input_media,
|
|
1157
|
+
"source": "youtube",
|
|
1158
|
+
}
|
|
1159
|
+
self.extractor = "youtube"
|
|
1160
|
+
self.url = self.input_media
|
|
1161
|
+
|
|
1162
|
+
# Mark that we need to use audio fetcher for URL download
|
|
1163
|
+
self._use_audio_fetcher = True
|
|
1164
|
+
self._use_url_download = True # New flag for URL-based download
|
|
1165
|
+
|
|
1000
1166
|
return [await self.prep_single_track()]
|
|
1001
1167
|
elif self.artist and self.title:
|
|
1002
1168
|
# No input file provided - use flacfetch to search and download audio
|
|
1003
1169
|
self.logger.info(f"No input file provided, using flacfetch to search for: {self.artist} - {self.title}")
|
|
1004
|
-
|
|
1170
|
+
|
|
1005
1171
|
# Set up the extracted_info for metadata consistency
|
|
1006
1172
|
self.extracted_info = {
|
|
1007
1173
|
"title": f"{self.artist} - {self.title}",
|
|
@@ -1014,13 +1180,12 @@ class KaraokePrep:
|
|
|
1014
1180
|
}
|
|
1015
1181
|
self.extractor = "flacfetch"
|
|
1016
1182
|
self.url = None # URL will be determined by flacfetch
|
|
1017
|
-
|
|
1183
|
+
|
|
1018
1184
|
# Mark that we need to use audio fetcher for download
|
|
1019
1185
|
self._use_audio_fetcher = True
|
|
1020
|
-
|
|
1186
|
+
|
|
1021
1187
|
return [await self.prep_single_track()]
|
|
1022
1188
|
else:
|
|
1023
1189
|
raise ValueError(
|
|
1024
|
-
"Either a local file path or both artist and title must be provided.
|
|
1025
|
-
"URL-based input has been replaced with flacfetch audio fetching."
|
|
1190
|
+
"Either a local file path, a URL, or both artist and title must be provided."
|
|
1026
1191
|
)
|
karaoke_gen/lyrics_processor.py
CHANGED
|
@@ -364,41 +364,30 @@ class LyricsProcessor:
|
|
|
364
364
|
self.logger.info(f" rapidapi_key: {env_config.get('rapidapi_key')[:3] + '...' if env_config.get('rapidapi_key') else 'None'}")
|
|
365
365
|
self.logger.info(f" lyrics_file: {self.lyrics_file}")
|
|
366
366
|
|
|
367
|
-
#
|
|
368
|
-
#
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
enable_review_setting = not self.skip_transcription_review and not is_serverless
|
|
378
|
-
|
|
379
|
-
if is_serverless and not self.skip_transcription_review:
|
|
380
|
-
self.logger.info("Detected serverless environment - disabling interactive review to prevent hanging")
|
|
381
|
-
|
|
382
|
-
# In serverless environment, disable video generation during Phase 1 to save compute
|
|
383
|
-
# Video will be generated in Phase 2 after human review
|
|
384
|
-
serverless_render_video = render_video and not is_serverless
|
|
385
|
-
|
|
386
|
-
if is_serverless and render_video:
|
|
387
|
-
self.logger.info("Detected serverless environment - deferring video generation until after review")
|
|
388
|
-
|
|
367
|
+
# Always defer countdown and video rendering to a later phase.
|
|
368
|
+
# This ensures the review UI (both local and cloud) shows original timing
|
|
369
|
+
# without the 3-second countdown shift. The caller is responsible for:
|
|
370
|
+
# - Local CLI: karaoke_gen.py adds countdown and renders video after transcription
|
|
371
|
+
# - Cloud backend: render_video_worker.py adds countdown and renders video
|
|
372
|
+
#
|
|
373
|
+
# This design ensures consistent behavior regardless of environment,
|
|
374
|
+
# and the review UI always shows accurate, unshifted timestamps.
|
|
375
|
+
self.logger.info("Deferring countdown and video rendering to post-review phase")
|
|
376
|
+
|
|
389
377
|
output_config = OutputConfig(
|
|
390
378
|
output_styles_json=self.style_params_json,
|
|
391
379
|
output_dir=lyrics_dir,
|
|
392
|
-
render_video=
|
|
380
|
+
render_video=False, # Always defer - caller handles video rendering after countdown
|
|
393
381
|
fetch_lyrics=True,
|
|
394
382
|
run_transcription=not self.skip_transcription,
|
|
395
383
|
run_correction=True,
|
|
396
384
|
generate_plain_text=True,
|
|
397
385
|
generate_lrc=True,
|
|
398
|
-
generate_cdg=False, #
|
|
386
|
+
generate_cdg=False, # CDG generation disabled (not currently supported)
|
|
399
387
|
video_resolution="4k",
|
|
400
|
-
enable_review=
|
|
388
|
+
enable_review=not self.skip_transcription_review, # Honor the caller's setting
|
|
401
389
|
subtitle_offset_ms=self.subtitle_offset_ms,
|
|
390
|
+
add_countdown=False, # Always defer - caller handles countdown after review
|
|
402
391
|
)
|
|
403
392
|
|
|
404
393
|
# Add this log entry to debug the OutputConfig
|
karaoke_gen/utils/__init__.py
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
import re
|
|
2
2
|
|
|
3
|
+
# Unicode character replacements for ASCII-safe filenames
|
|
4
|
+
# These characters cause issues with HTTP headers (latin-1 encoding) and some filesystems
|
|
5
|
+
UNICODE_REPLACEMENTS = {
|
|
6
|
+
# Curly/smart quotes -> straight quotes
|
|
7
|
+
"\u2018": "'", # LEFT SINGLE QUOTATION MARK
|
|
8
|
+
"\u2019": "'", # RIGHT SINGLE QUOTATION MARK (the one causing the bug)
|
|
9
|
+
"\u201A": "'", # SINGLE LOW-9 QUOTATION MARK
|
|
10
|
+
"\u201B": "'", # SINGLE HIGH-REVERSED-9 QUOTATION MARK
|
|
11
|
+
"\u201C": '"', # LEFT DOUBLE QUOTATION MARK
|
|
12
|
+
"\u201D": '"', # RIGHT DOUBLE QUOTATION MARK
|
|
13
|
+
"\u201E": '"', # DOUBLE LOW-9 QUOTATION MARK
|
|
14
|
+
"\u201F": '"', # DOUBLE HIGH-REVERSED-9 QUOTATION MARK
|
|
15
|
+
# Other common problematic characters
|
|
16
|
+
"\u2013": "-", # EN DASH
|
|
17
|
+
"\u2014": "-", # EM DASH
|
|
18
|
+
"\u2026": "...", # HORIZONTAL ELLIPSIS
|
|
19
|
+
"\u00A0": " ", # NON-BREAKING SPACE
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
3
23
|
def sanitize_filename(filename):
|
|
4
24
|
"""Replace or remove characters that are unsafe for filenames."""
|
|
5
25
|
if filename is None:
|
|
6
26
|
return None
|
|
27
|
+
|
|
28
|
+
# First, normalize Unicode characters that cause HTTP header encoding issues
|
|
29
|
+
# (e.g., curly quotes from macOS/Word that can't be encoded in latin-1)
|
|
30
|
+
for unicode_char, ascii_replacement in UNICODE_REPLACEMENTS.items():
|
|
31
|
+
filename = filename.replace(unicode_char, ascii_replacement)
|
|
32
|
+
|
|
7
33
|
# Replace problematic characters with underscores
|
|
8
34
|
for char in ["\\", "/", ":", "*", "?", '"', "<", ">", "|"]:
|
|
9
35
|
filename = filename.replace(char, "_")
|
karaoke_gen/utils/cli_args.py
CHANGED
|
@@ -242,9 +242,17 @@ def create_parser(prog: str = "karaoke-gen") -> argparse.ArgumentParser:
|
|
|
242
242
|
|
|
243
243
|
# Style Configuration
|
|
244
244
|
style_group = parser.add_argument_group("Style Configuration")
|
|
245
|
+
style_group.add_argument(
|
|
246
|
+
"--theme",
|
|
247
|
+
help="Optional: Theme ID for pre-made styles stored in GCS (e.g., 'nomad', 'default'). "
|
|
248
|
+
"When using a theme, CDG/TXT are enabled by default. "
|
|
249
|
+
"Example: --theme=nomad",
|
|
250
|
+
)
|
|
245
251
|
style_group.add_argument(
|
|
246
252
|
"--style_params_json",
|
|
247
|
-
help="Optional: Path to JSON file containing style configuration.
|
|
253
|
+
help="Optional: Path to JSON file containing style configuration. "
|
|
254
|
+
"Takes precedence over --theme if both are provided. "
|
|
255
|
+
"Example: --style_params_json='/path/to/style_params.json'",
|
|
248
256
|
)
|
|
249
257
|
style_group.add_argument(
|
|
250
258
|
"--style_override",
|
karaoke_gen/utils/gen_cli.py
CHANGED
|
@@ -320,7 +320,7 @@ async def async_main():
|
|
|
320
320
|
# Check if user provided a custom value (not the default hosted URL)
|
|
321
321
|
default_hosted_urls = [
|
|
322
322
|
'https://gen.nomadkaraoke.com/lyrics',
|
|
323
|
-
'https://
|
|
323
|
+
'https://gen.nomadkaraoke.com/lyrics/'
|
|
324
324
|
]
|
|
325
325
|
if args.review_ui_url.rstrip('/') not in [url.rstrip('/') for url in default_hosted_urls]:
|
|
326
326
|
# User explicitly wants a specific URL (e.g., Vite dev server)
|