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.
Files changed (36) hide show
  1. karaoke_gen/audio_fetcher.py +218 -0
  2. karaoke_gen/karaoke_gen.py +190 -25
  3. karaoke_gen/lyrics_processor.py +14 -25
  4. karaoke_gen/utils/__init__.py +26 -0
  5. karaoke_gen/utils/cli_args.py +9 -1
  6. karaoke_gen/utils/gen_cli.py +1 -1
  7. karaoke_gen/utils/remote_cli.py +33 -6
  8. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/METADATA +2 -2
  9. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/RECORD +36 -32
  10. lyrics_transcriber/frontend/index.html +5 -1
  11. lyrics_transcriber/frontend/package-lock.json +4553 -0
  12. lyrics_transcriber/frontend/package.json +3 -0
  13. lyrics_transcriber/frontend/playwright.config.ts +69 -0
  14. lyrics_transcriber/frontend/public/nomad-karaoke-logo.svg +5 -0
  15. lyrics_transcriber/frontend/src/App.tsx +88 -59
  16. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +55 -21
  17. lyrics_transcriber/frontend/src/components/AppHeader.tsx +65 -0
  18. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +5 -5
  19. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +9 -9
  20. lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -1
  21. lyrics_transcriber/frontend/src/components/EditWordList.tsx +1 -1
  22. lyrics_transcriber/frontend/src/components/Header.tsx +34 -48
  23. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +22 -21
  24. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  25. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  26. lyrics_transcriber/frontend/src/components/WordDivider.tsx +3 -3
  27. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +2 -2
  28. lyrics_transcriber/frontend/src/components/shared/constants.ts +15 -5
  29. lyrics_transcriber/frontend/src/main.tsx +1 -7
  30. lyrics_transcriber/frontend/src/theme.ts +337 -135
  31. lyrics_transcriber/frontend/vite.config.ts +5 -0
  32. lyrics_transcriber/frontend/yarn.lock +1005 -1046
  33. lyrics_transcriber/review/server.py +1 -1
  34. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/WHEEL +0 -0
  35. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/entry_points.txt +0 -0
  36. {karaoke_gen-0.75.53.dist-info → karaoke_gen-0.76.20.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -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
- # Search and download audio using the AudioFetcher
490
- fetch_result = self.audio_fetcher.search_and_download(
491
- artist=self.artist,
492
- title=self.title,
493
- output_dir=track_output_dir,
494
- output_filename=f"{artist_title} (flacfetch)",
495
- auto_select=self.auto_download,
496
- )
497
-
498
- # Update extractor to reflect the actual provider used
499
- self.extractor = f"flacfetch-{fetch_result.provider}"
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
  )
@@ -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
- # Detect if we're running in a serverless environment (Modal)
368
- # Modal sets specific environment variables we can check for
369
- is_serverless = (
370
- os.getenv("MODAL_TASK_ID") is not None or
371
- os.getenv("MODAL_FUNCTION_NAME") is not None or
372
- os.path.exists("/.modal") # Modal creates this directory in containers
373
- )
374
-
375
- # In serverless environment, disable interactive review even if skip_transcription_review=False
376
- # This preserves CLI behavior while fixing serverless hanging
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=serverless_render_video, # Disable video in serverless Phase 1
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, # Also defer CDG generation to Phase 2
386
+ generate_cdg=False, # CDG generation disabled (not currently supported)
399
387
  video_resolution="4k",
400
- enable_review=enable_review_setting,
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
@@ -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, "_")
@@ -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. Example: --style_params_json='/path/to/style_params.json'",
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",
@@ -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://lyrics.nomadkaraoke.com'
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)