karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__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 (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,590 @@
1
+ """
2
+ Local Encoding Service.
3
+
4
+ Provides local FFmpeg-based video encoding functionality, extracted from KaraokeFinalise
5
+ for use by both the cloud backend (video_worker as a fallback) and local CLI.
6
+
7
+ This service handles:
8
+ - Concatenating title, karaoke, and end videos
9
+ - Encoding to multiple output formats (4K lossless, 4K lossy, 720p, MKV)
10
+ - Hardware acceleration detection and fallback to software encoding
11
+ - Remuxing video with instrumental audio
12
+ """
13
+
14
+ import logging
15
+ import os
16
+ import shlex
17
+ import subprocess
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from typing import Optional, List, Dict, Any, Tuple
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class EncodingConfig:
27
+ """Configuration for video encoding."""
28
+ title_video: str # Path to title video
29
+ karaoke_video: str # Path to karaoke video (with vocals)
30
+ instrumental_audio: str # Path to instrumental audio
31
+ end_video: Optional[str] = None # Optional path to end credits video
32
+
33
+ # Output paths
34
+ output_karaoke_mp4: Optional[str] = None
35
+ output_with_vocals_mp4: Optional[str] = None
36
+ output_lossless_4k_mp4: Optional[str] = None
37
+ output_lossy_4k_mp4: Optional[str] = None
38
+ output_lossless_mkv: Optional[str] = None
39
+ output_720p_mp4: Optional[str] = None
40
+
41
+
42
+ @dataclass
43
+ class EncodingResult:
44
+ """Result of video encoding operation."""
45
+ success: bool
46
+ output_files: Dict[str, str]
47
+ error: Optional[str] = None
48
+
49
+
50
+ class LocalEncodingService:
51
+ """
52
+ Service for local FFmpeg-based video encoding.
53
+
54
+ Supports hardware acceleration (NVENC) with automatic fallback
55
+ to software encoding (libx264) when unavailable.
56
+ """
57
+
58
+ # MP4 flags for better compatibility
59
+ MP4_FLAGS = "-movflags +faststart"
60
+
61
+ def __init__(
62
+ self,
63
+ dry_run: bool = False,
64
+ log_level: int = logging.INFO,
65
+ logger: Optional[logging.Logger] = None,
66
+ ):
67
+ """
68
+ Initialize the local encoding service.
69
+
70
+ Args:
71
+ dry_run: If True, log commands without executing them
72
+ log_level: Logging level (affects FFmpeg verbosity)
73
+ logger: Optional logger instance
74
+ """
75
+ self.dry_run = dry_run
76
+ self.log_level = log_level
77
+ self.logger = logger or logging.getLogger(__name__)
78
+
79
+ # Hardware acceleration settings (detected on first use)
80
+ self._hwaccel_available: Optional[bool] = None
81
+ self._video_encoder: Optional[str] = None
82
+ self._scale_filter: Optional[str] = None
83
+ self._hwaccel_decode_flags: Optional[str] = None
84
+ self._aac_codec: Optional[str] = None
85
+
86
+ # Build FFmpeg base command
87
+ self._ffmpeg_base_command = self._build_ffmpeg_base_command()
88
+
89
+ def _build_ffmpeg_base_command(self) -> str:
90
+ """Build the FFmpeg base command with appropriate flags."""
91
+ # Use bundled FFmpeg for frozen builds
92
+ ffmpeg_path = os.path.join(sys._MEIPASS, "ffmpeg.exe") if getattr(sys, "frozen", False) else "ffmpeg"
93
+
94
+ base_cmd = f"{ffmpeg_path} -hide_banner -nostats -y"
95
+
96
+ if self.log_level == logging.DEBUG:
97
+ base_cmd += " -loglevel verbose"
98
+ else:
99
+ base_cmd += " -loglevel fatal"
100
+
101
+ return base_cmd
102
+
103
+ def _detect_hardware_acceleration(self) -> Tuple[bool, str, str, str, str]:
104
+ """
105
+ Detect available hardware acceleration.
106
+
107
+ Returns:
108
+ Tuple of (hwaccel_available, video_encoder, scale_filter,
109
+ hwaccel_decode_flags, aac_codec)
110
+ """
111
+ self.logger.info("Detecting hardware acceleration capabilities...")
112
+
113
+ # Try NVENC (NVIDIA)
114
+ try:
115
+ test_cmd = f"{self._ffmpeg_base_command} -hide_banner -loglevel error " \
116
+ f"-f lavfi -i testsrc=duration=1:size=320x240:rate=1 " \
117
+ f"-c:v h264_nvenc -f null -"
118
+ subprocess.run(
119
+ test_cmd, shell=True, check=True,
120
+ capture_output=True, timeout=30
121
+ )
122
+ self.logger.info("NVIDIA NVENC hardware acceleration available")
123
+ return (
124
+ True,
125
+ "h264_nvenc",
126
+ "scale_cuda",
127
+ "-hwaccel cuda -hwaccel_output_format cuda",
128
+ "aac"
129
+ )
130
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
131
+ pass
132
+
133
+ # No hardware acceleration available
134
+ self.logger.info("No hardware acceleration available, using software encoding")
135
+ return (False, "libx264", "scale", "", "aac")
136
+
137
+ @property
138
+ def hwaccel_available(self) -> bool:
139
+ """Check if hardware acceleration is available."""
140
+ if self._hwaccel_available is None:
141
+ self._detect_and_set_hwaccel()
142
+ return self._hwaccel_available
143
+
144
+ @property
145
+ def video_encoder(self) -> str:
146
+ """Get the video encoder to use."""
147
+ if self._video_encoder is None:
148
+ self._detect_and_set_hwaccel()
149
+ return self._video_encoder
150
+
151
+ @property
152
+ def scale_filter(self) -> str:
153
+ """Get the scale filter to use."""
154
+ if self._scale_filter is None:
155
+ self._detect_and_set_hwaccel()
156
+ return self._scale_filter
157
+
158
+ @property
159
+ def hwaccel_decode_flags(self) -> str:
160
+ """Get hardware acceleration decode flags."""
161
+ if self._hwaccel_decode_flags is None:
162
+ self._detect_and_set_hwaccel()
163
+ return self._hwaccel_decode_flags
164
+
165
+ @property
166
+ def aac_codec(self) -> str:
167
+ """Get the AAC codec to use."""
168
+ if self._aac_codec is None:
169
+ self._detect_and_set_hwaccel()
170
+ return self._aac_codec
171
+
172
+ def _detect_and_set_hwaccel(self) -> None:
173
+ """Detect and set hardware acceleration settings."""
174
+ (
175
+ self._hwaccel_available,
176
+ self._video_encoder,
177
+ self._scale_filter,
178
+ self._hwaccel_decode_flags,
179
+ self._aac_codec
180
+ ) = self._detect_hardware_acceleration()
181
+
182
+ def _get_nvenc_quality_settings(self, preset: str = "medium") -> str:
183
+ """Get NVENC quality settings for different presets."""
184
+ if not self.hwaccel_available:
185
+ return ""
186
+
187
+ settings = {
188
+ "lossless": "-preset p7 -tune hq -rc vbr -cq 0 -qmin 0 -qmax 0",
189
+ "high": "-preset p7 -tune hq -rc vbr -cq 19 -b:v 0",
190
+ "medium": "-preset p4 -tune hq -rc vbr -cq 23 -b:v 0",
191
+ "fast": "-preset p2 -tune ll -rc vbr -cq 28 -b:v 0",
192
+ }
193
+ return settings.get(preset, settings["medium"])
194
+
195
+ def _execute_command(
196
+ self,
197
+ command: str,
198
+ description: str,
199
+ timeout: int = 3600, # 1 hour default
200
+ ) -> bool:
201
+ """
202
+ Execute an FFmpeg command.
203
+
204
+ Args:
205
+ command: The FFmpeg command to execute
206
+ description: Human-readable description of the operation
207
+ timeout: Command timeout in seconds
208
+
209
+ Returns:
210
+ True if successful, False otherwise
211
+ """
212
+ self.logger.info(f"Executing: {description}")
213
+ self.logger.debug(f"Command: {command}")
214
+
215
+ if self.dry_run:
216
+ self.logger.info(f"DRY RUN: Would execute: {command}")
217
+ return True
218
+
219
+ try:
220
+ result = subprocess.run(
221
+ command,
222
+ shell=True,
223
+ check=True,
224
+ capture_output=True,
225
+ text=True,
226
+ timeout=timeout
227
+ )
228
+ self.logger.info(f"Completed: {description}")
229
+ return True
230
+ except subprocess.CalledProcessError as e:
231
+ self.logger.error(f"Failed: {description}")
232
+ self.logger.error(f"Error: {e.stderr}")
233
+ return False
234
+ except subprocess.TimeoutExpired:
235
+ self.logger.error(f"Timeout: {description}")
236
+ return False
237
+
238
+ def _execute_command_with_fallback(
239
+ self,
240
+ gpu_command: str,
241
+ cpu_command: str,
242
+ description: str,
243
+ ) -> bool:
244
+ """
245
+ Execute a command with GPU, falling back to CPU if it fails.
246
+
247
+ Args:
248
+ gpu_command: Hardware-accelerated command
249
+ cpu_command: Software fallback command
250
+ description: Human-readable description
251
+
252
+ Returns:
253
+ True if successful, False otherwise
254
+ """
255
+ if self.hwaccel_available:
256
+ self.logger.info(f"Trying hardware-accelerated encoding for: {description}")
257
+ if self._execute_command(gpu_command, description):
258
+ return True
259
+ self.logger.warning(f"Hardware encoding failed, falling back to software")
260
+
261
+ return self._execute_command(cpu_command, description)
262
+
263
+ def remux_with_instrumental(
264
+ self,
265
+ input_video: str,
266
+ instrumental_audio: str,
267
+ output_file: str,
268
+ ) -> bool:
269
+ """
270
+ Remux video with instrumental audio track.
271
+
272
+ Args:
273
+ input_video: Path to input video file
274
+ instrumental_audio: Path to instrumental audio file
275
+ output_file: Path for output file
276
+
277
+ Returns:
278
+ True if successful, False otherwise
279
+ """
280
+ command = (
281
+ f'{self._ffmpeg_base_command} -i "{input_video}" '
282
+ f'-i "{instrumental_audio}" -map 0:v -map 1:a -c copy '
283
+ f'{self.MP4_FLAGS} "{output_file}"'
284
+ )
285
+ return self._execute_command(command, "Remuxing with instrumental audio")
286
+
287
+ def convert_mov_to_mp4(
288
+ self,
289
+ input_file: str,
290
+ output_file: str,
291
+ ) -> bool:
292
+ """
293
+ Convert MOV to MP4 format.
294
+
295
+ Args:
296
+ input_file: Path to input MOV file
297
+ output_file: Path for output MP4 file
298
+
299
+ Returns:
300
+ True if successful, False otherwise
301
+ """
302
+ gpu_command = (
303
+ f'{self._ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
304
+ f'-c:v {self.video_encoder} -c:a copy {self.MP4_FLAGS} "{output_file}"'
305
+ )
306
+ cpu_command = (
307
+ f'{self._ffmpeg_base_command} -i "{input_file}" '
308
+ f'-c:v libx264 -c:a copy {self.MP4_FLAGS} "{output_file}"'
309
+ )
310
+ return self._execute_command_with_fallback(
311
+ gpu_command, cpu_command, "Converting MOV to MP4"
312
+ )
313
+
314
+ def encode_lossless_mp4(
315
+ self,
316
+ title_video: str,
317
+ karaoke_video: str,
318
+ output_file: str,
319
+ end_video: Optional[str] = None,
320
+ ) -> bool:
321
+ """
322
+ Create lossless 4K MP4 by concatenating title, karaoke, and optionally end videos.
323
+
324
+ Args:
325
+ title_video: Path to title video
326
+ karaoke_video: Path to karaoke video
327
+ output_file: Path for output file
328
+ end_video: Optional path to end credits video
329
+
330
+ Returns:
331
+ True if successful, False otherwise
332
+ """
333
+ # Quote file paths
334
+ title_quoted = shlex.quote(os.path.abspath(title_video))
335
+ karaoke_quoted = shlex.quote(os.path.abspath(karaoke_video))
336
+
337
+ # Build filter and inputs for concatenation
338
+ if end_video and os.path.isfile(end_video):
339
+ end_quoted = shlex.quote(os.path.abspath(end_video))
340
+ extra_input = f"-i {end_quoted}"
341
+ concat_filter = (
342
+ '-filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0][2:v:0][2:a:0]'
343
+ 'concat=n=3:v=1:a=1[outv][outa]"'
344
+ )
345
+ else:
346
+ extra_input = ""
347
+ concat_filter = (
348
+ '-filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0]'
349
+ 'concat=n=2:v=1:a=1[outv][outa]"'
350
+ )
351
+
352
+ gpu_command = (
353
+ f"{self._ffmpeg_base_command} {self.hwaccel_decode_flags} -i {title_quoted} "
354
+ f"{self.hwaccel_decode_flags} -i {karaoke_quoted} {extra_input} "
355
+ f'{concat_filter} -map "[outv]" -map "[outa]" -c:v {self.video_encoder} '
356
+ f'{self._get_nvenc_quality_settings("lossless")} -c:a pcm_s16le '
357
+ f'{self.MP4_FLAGS} "{output_file}"'
358
+ )
359
+ cpu_command = (
360
+ f"{self._ffmpeg_base_command} -i {title_quoted} -i {karaoke_quoted} {extra_input} "
361
+ f'{concat_filter} -map "[outv]" -map "[outa]" -c:v libx264 -c:a pcm_s16le '
362
+ f'{self.MP4_FLAGS} "{output_file}"'
363
+ )
364
+
365
+ return self._execute_command_with_fallback(
366
+ gpu_command, cpu_command, "Encoding lossless 4K MP4"
367
+ )
368
+
369
+ def encode_lossy_mp4(
370
+ self,
371
+ input_file: str,
372
+ output_file: str,
373
+ ) -> bool:
374
+ """
375
+ Create lossy 4K MP4 with AAC audio.
376
+
377
+ Args:
378
+ input_file: Path to input file (typically lossless MP4)
379
+ output_file: Path for output file
380
+
381
+ Returns:
382
+ True if successful, False otherwise
383
+ """
384
+ command = (
385
+ f'{self._ffmpeg_base_command} -i "{input_file}" '
386
+ f'-c:v copy -c:a {self.aac_codec} -ar 48000 -b:a 320k '
387
+ f'{self.MP4_FLAGS} "{output_file}"'
388
+ )
389
+ return self._execute_command(command, "Encoding lossy 4K MP4 with AAC")
390
+
391
+ def encode_lossless_mkv(
392
+ self,
393
+ input_file: str,
394
+ output_file: str,
395
+ ) -> bool:
396
+ """
397
+ Create MKV with FLAC audio (for YouTube upload).
398
+
399
+ Args:
400
+ input_file: Path to input file
401
+ output_file: Path for output file
402
+
403
+ Returns:
404
+ True if successful, False otherwise
405
+ """
406
+ command = (
407
+ f'{self._ffmpeg_base_command} -i "{input_file}" '
408
+ f'-c:v copy -c:a flac "{output_file}"'
409
+ )
410
+ return self._execute_command(command, "Creating MKV with FLAC for YouTube")
411
+
412
+ def encode_720p(
413
+ self,
414
+ input_file: str,
415
+ output_file: str,
416
+ ) -> bool:
417
+ """
418
+ Create 720p MP4 with AAC audio.
419
+
420
+ Args:
421
+ input_file: Path to input file
422
+ output_file: Path for output file
423
+
424
+ Returns:
425
+ True if successful, False otherwise
426
+ """
427
+ gpu_command = (
428
+ f'{self._ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
429
+ f'-c:v {self.video_encoder} -vf "{self.scale_filter}=1280:720" '
430
+ f'{self._get_nvenc_quality_settings("medium")} -b:v 2000k '
431
+ f'-c:a {self.aac_codec} -ar 48000 -b:a 128k '
432
+ f'{self.MP4_FLAGS} "{output_file}"'
433
+ )
434
+ cpu_command = (
435
+ f'{self._ffmpeg_base_command} -i "{input_file}" '
436
+ f'-c:v libx264 -vf "scale=1280:720" -b:v 2000k -preset medium -tune animation '
437
+ f'-c:a {self.aac_codec} -ar 48000 -b:a 128k '
438
+ f'{self.MP4_FLAGS} "{output_file}"'
439
+ )
440
+ return self._execute_command_with_fallback(
441
+ gpu_command, cpu_command, "Encoding 720p MP4"
442
+ )
443
+
444
+ def encode_all_formats(
445
+ self,
446
+ config: EncodingConfig,
447
+ ) -> EncodingResult:
448
+ """
449
+ Encode video to all output formats.
450
+
451
+ This performs the full encoding pipeline:
452
+ 1. Remux with instrumental audio
453
+ 2. Convert to MP4 if needed
454
+ 3. Encode lossless 4K MP4 (concatenated)
455
+ 4. Encode lossy 4K MP4
456
+ 5. Encode lossless MKV (for YouTube)
457
+ 6. Encode 720p MP4
458
+
459
+ Args:
460
+ config: Encoding configuration with input/output paths
461
+
462
+ Returns:
463
+ EncodingResult with success status and output file paths
464
+ """
465
+ output_files = {}
466
+
467
+ try:
468
+ # Step 1: Remux with instrumental audio
469
+ if config.output_karaoke_mp4:
470
+ self.logger.info("[Step 1/6] Remuxing video with instrumental audio...")
471
+ if not self.remux_with_instrumental(
472
+ config.karaoke_video,
473
+ config.instrumental_audio,
474
+ config.output_karaoke_mp4
475
+ ):
476
+ return EncodingResult(
477
+ success=False,
478
+ output_files=output_files,
479
+ error="Failed to remux with instrumental audio"
480
+ )
481
+ output_files["karaoke_mp4"] = config.output_karaoke_mp4
482
+
483
+ # Step 2: Convert to MP4 if needed
484
+ if config.output_with_vocals_mp4:
485
+ if not config.karaoke_video.endswith(".mp4"):
486
+ self.logger.info("[Step 2/6] Converting karaoke video to MP4...")
487
+ if not self.convert_mov_to_mp4(
488
+ config.karaoke_video,
489
+ config.output_with_vocals_mp4
490
+ ):
491
+ return EncodingResult(
492
+ success=False,
493
+ output_files=output_files,
494
+ error="Failed to convert to MP4"
495
+ )
496
+ output_files["with_vocals_mp4"] = config.output_with_vocals_mp4
497
+ else:
498
+ self.logger.info("[Step 2/6] Skipped - video already MP4")
499
+
500
+ # Step 3: Encode lossless 4K MP4
501
+ if config.output_lossless_4k_mp4:
502
+ self.logger.info("[Step 3/6] Encoding lossless 4K MP4...")
503
+ karaoke_for_concat = config.output_karaoke_mp4 or config.karaoke_video
504
+ if not self.encode_lossless_mp4(
505
+ config.title_video,
506
+ karaoke_for_concat,
507
+ config.output_lossless_4k_mp4,
508
+ config.end_video
509
+ ):
510
+ return EncodingResult(
511
+ success=False,
512
+ output_files=output_files,
513
+ error="Failed to encode lossless 4K MP4"
514
+ )
515
+ output_files["lossless_4k_mp4"] = config.output_lossless_4k_mp4
516
+
517
+ # Step 4: Encode lossy 4K MP4
518
+ if config.output_lossy_4k_mp4 and config.output_lossless_4k_mp4:
519
+ self.logger.info("[Step 4/6] Encoding lossy 4K MP4...")
520
+ if not self.encode_lossy_mp4(
521
+ config.output_lossless_4k_mp4,
522
+ config.output_lossy_4k_mp4
523
+ ):
524
+ return EncodingResult(
525
+ success=False,
526
+ output_files=output_files,
527
+ error="Failed to encode lossy 4K MP4"
528
+ )
529
+ output_files["lossy_4k_mp4"] = config.output_lossy_4k_mp4
530
+
531
+ # Step 5: Create MKV with FLAC audio
532
+ if config.output_lossless_mkv and config.output_lossless_4k_mp4:
533
+ self.logger.info("[Step 5/6] Creating MKV with FLAC audio...")
534
+ if not self.encode_lossless_mkv(
535
+ config.output_lossless_4k_mp4,
536
+ config.output_lossless_mkv
537
+ ):
538
+ return EncodingResult(
539
+ success=False,
540
+ output_files=output_files,
541
+ error="Failed to create MKV"
542
+ )
543
+ output_files["lossless_mkv"] = config.output_lossless_mkv
544
+
545
+ # Step 6: Encode 720p version
546
+ if config.output_720p_mp4 and config.output_lossless_4k_mp4:
547
+ self.logger.info("[Step 6/6] Encoding 720p MP4...")
548
+ if not self.encode_720p(
549
+ config.output_lossless_4k_mp4,
550
+ config.output_720p_mp4
551
+ ):
552
+ return EncodingResult(
553
+ success=False,
554
+ output_files=output_files,
555
+ error="Failed to encode 720p"
556
+ )
557
+ output_files["720p_mp4"] = config.output_720p_mp4
558
+
559
+ self.logger.info("All encoding steps completed successfully")
560
+ return EncodingResult(success=True, output_files=output_files)
561
+
562
+ except Exception as e:
563
+ self.logger.error(f"Encoding failed with exception: {e}")
564
+ return EncodingResult(
565
+ success=False,
566
+ output_files=output_files,
567
+ error=str(e)
568
+ )
569
+
570
+
571
+ # Singleton instance and factory function (following existing service pattern)
572
+ _local_encoding_service: Optional[LocalEncodingService] = None
573
+
574
+
575
+ def get_local_encoding_service(**kwargs) -> LocalEncodingService:
576
+ """
577
+ Get a local encoding service instance.
578
+
579
+ Args:
580
+ **kwargs: Arguments passed to LocalEncodingService
581
+
582
+ Returns:
583
+ LocalEncodingService instance
584
+ """
585
+ global _local_encoding_service
586
+
587
+ if _local_encoding_service is None:
588
+ _local_encoding_service = LocalEncodingService(**kwargs)
589
+
590
+ return _local_encoding_service