karaoke-gen 0.86.7__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 (188) 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/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,405 @@
1
+ """
2
+ GCE Encoding Worker Service.
3
+
4
+ This service dispatches video encoding jobs to a dedicated high-performance
5
+ GCE instance (C4-standard with Intel Granite Rapids CPU) for faster encoding.
6
+
7
+ The GCE worker provides:
8
+ - 3.9 GHz all-core frequency (vs 3.7 GHz on Cloud Run)
9
+ - Dedicated vCPUs (no contention)
10
+ - 2-3x faster FFmpeg libx264 encoding
11
+
12
+ Usage:
13
+ encoding_service = get_encoding_service()
14
+ if encoding_service.is_configured:
15
+ result = await encoding_service.encode_videos(job_id, input_gcs_path, config)
16
+ """
17
+
18
+ import asyncio
19
+ import logging
20
+ from typing import Optional, Dict, Any
21
+
22
+ import aiohttp
23
+
24
+ from backend.config import get_settings
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class EncodingService:
30
+ """Service for dispatching encoding jobs to GCE worker."""
31
+
32
+ def __init__(self):
33
+ self.settings = get_settings()
34
+ self._url = None
35
+ self._api_key = None
36
+ self._initialized = False
37
+
38
+ def _load_credentials(self):
39
+ """Load encoding worker URL and API key from config/secrets."""
40
+ if self._initialized:
41
+ return
42
+
43
+ # Try environment variables first, then Secret Manager
44
+ self._url = self.settings.encoding_worker_url
45
+ self._api_key = self.settings.encoding_worker_api_key
46
+
47
+ # Fall back to Secret Manager
48
+ if not self._url:
49
+ self._url = self.settings.get_secret("encoding-worker-url")
50
+ if not self._api_key:
51
+ self._api_key = self.settings.get_secret("encoding-worker-api-key")
52
+
53
+ self._initialized = True
54
+
55
+ @property
56
+ def is_configured(self) -> bool:
57
+ """Check if encoding service is configured with URL and API key."""
58
+ self._load_credentials()
59
+ return bool(self._url and self._api_key)
60
+
61
+ @property
62
+ def is_enabled(self) -> bool:
63
+ """Check if GCE encoding is enabled and configured."""
64
+ return self.settings.use_gce_encoding and self.is_configured
65
+
66
+ @property
67
+ def is_preview_enabled(self) -> bool:
68
+ """Check if GCE preview encoding is enabled and configured."""
69
+ return self.settings.use_gce_preview_encoding and self.is_configured
70
+
71
+ async def submit_encoding_job(
72
+ self,
73
+ job_id: str,
74
+ input_gcs_path: str,
75
+ output_gcs_path: str,
76
+ encoding_config: Dict[str, Any],
77
+ ) -> Dict[str, Any]:
78
+ """
79
+ Submit an encoding job to the GCE worker.
80
+
81
+ Args:
82
+ job_id: Unique job identifier
83
+ input_gcs_path: GCS path to input files (gs://bucket/path/)
84
+ output_gcs_path: GCS path for output files (gs://bucket/path/)
85
+ encoding_config: Configuration for encoding (formats, quality, etc.)
86
+
87
+ Returns:
88
+ Response from the encoding worker
89
+
90
+ Raises:
91
+ Exception: If submission fails
92
+ """
93
+ self._load_credentials()
94
+
95
+ if not self.is_configured:
96
+ raise RuntimeError("Encoding service not configured")
97
+
98
+ url = f"{self._url}/encode"
99
+ headers = {"X-API-Key": self._api_key, "Content-Type": "application/json"}
100
+ payload = {
101
+ "job_id": job_id,
102
+ "input_gcs_path": input_gcs_path,
103
+ "output_gcs_path": output_gcs_path,
104
+ "encoding_config": encoding_config,
105
+ }
106
+
107
+ logger.info(f"[job:{job_id}] Submitting encoding job to GCE worker: {url}")
108
+
109
+ async with aiohttp.ClientSession() as session:
110
+ async with session.post(url, json=payload, headers=headers, timeout=30) as resp:
111
+ if resp.status == 401:
112
+ raise RuntimeError("Invalid API key for encoding worker")
113
+ if resp.status == 409:
114
+ raise RuntimeError(f"Encoding job {job_id} already exists")
115
+ if resp.status != 200:
116
+ text = await resp.text()
117
+ raise RuntimeError(f"Failed to submit encoding job: {resp.status} - {text}")
118
+
119
+ return await resp.json()
120
+
121
+ async def get_job_status(self, job_id: str) -> Dict[str, Any]:
122
+ """
123
+ Get the status of an encoding job.
124
+
125
+ Args:
126
+ job_id: Job identifier
127
+
128
+ Returns:
129
+ Job status including: status, progress, error, output_files
130
+ """
131
+ self._load_credentials()
132
+
133
+ if not self.is_configured:
134
+ raise RuntimeError("Encoding service not configured")
135
+
136
+ url = f"{self._url}/status/{job_id}"
137
+ headers = {"X-API-Key": self._api_key}
138
+
139
+ async with aiohttp.ClientSession() as session:
140
+ async with session.get(url, headers=headers, timeout=30) as resp:
141
+ if resp.status == 401:
142
+ raise RuntimeError("Invalid API key for encoding worker")
143
+ if resp.status == 404:
144
+ raise RuntimeError(f"Encoding job {job_id} not found")
145
+ if resp.status != 200:
146
+ text = await resp.text()
147
+ raise RuntimeError(f"Failed to get job status: {resp.status} - {text}")
148
+
149
+ return await resp.json()
150
+
151
+ async def wait_for_completion(
152
+ self,
153
+ job_id: str,
154
+ poll_interval: float = 10.0,
155
+ timeout: float = 3600.0,
156
+ progress_callback=None,
157
+ ) -> Dict[str, Any]:
158
+ """
159
+ Poll for encoding job completion.
160
+
161
+ Args:
162
+ job_id: Job identifier
163
+ poll_interval: Seconds between status checks
164
+ timeout: Maximum time to wait (default 1 hour)
165
+ progress_callback: Optional callback(progress: int) for progress updates
166
+
167
+ Returns:
168
+ Final job status with output files
169
+
170
+ Raises:
171
+ TimeoutError: If job doesn't complete within timeout
172
+ RuntimeError: If job fails
173
+ """
174
+ logger.info(f"[job:{job_id}] Waiting for GCE encoding to complete...")
175
+
176
+ start_time = asyncio.get_event_loop().time()
177
+ last_progress = 0
178
+
179
+ while True:
180
+ elapsed = asyncio.get_event_loop().time() - start_time
181
+ if elapsed > timeout:
182
+ raise TimeoutError(f"Encoding job {job_id} timed out after {timeout}s")
183
+
184
+ status = await self.get_job_status(job_id)
185
+
186
+ # Handle case where GCE worker returns a list instead of dict
187
+ if isinstance(status, list):
188
+ logger.warning(f"[job:{job_id}] GCE returned list instead of dict: {status}")
189
+ status = status[0] if status and isinstance(status[0], dict) else {}
190
+ if not isinstance(status, dict):
191
+ logger.error(f"[job:{job_id}] Unexpected status type: {type(status)}")
192
+ status = {}
193
+
194
+ job_status = status.get("status", "unknown")
195
+ progress = status.get("progress", 0)
196
+
197
+ # Report progress
198
+ if progress != last_progress:
199
+ logger.info(f"[job:{job_id}] Encoding progress: {progress}%")
200
+ last_progress = progress
201
+ if progress_callback:
202
+ try:
203
+ progress_callback(progress)
204
+ except Exception as e:
205
+ logger.warning(f"Progress callback failed: {e}")
206
+
207
+ if job_status == "complete":
208
+ logger.info(f"[job:{job_id}] GCE encoding complete in {elapsed:.1f}s")
209
+ return status
210
+
211
+ if job_status == "failed":
212
+ error = status.get("error", "Unknown error")
213
+ raise RuntimeError(f"Encoding job {job_id} failed: {error}")
214
+
215
+ await asyncio.sleep(poll_interval)
216
+
217
+ async def encode_videos(
218
+ self,
219
+ job_id: str,
220
+ input_gcs_path: str,
221
+ output_gcs_path: str,
222
+ encoding_config: Optional[Dict[str, Any]] = None,
223
+ progress_callback=None,
224
+ ) -> Dict[str, Any]:
225
+ """
226
+ Submit encoding job and wait for completion.
227
+
228
+ This is a convenience method that combines submit + wait.
229
+
230
+ Args:
231
+ job_id: Unique job identifier
232
+ input_gcs_path: GCS path to input files
233
+ output_gcs_path: GCS path for output files
234
+ encoding_config: Optional encoding configuration
235
+ progress_callback: Optional callback for progress updates
236
+
237
+ Returns:
238
+ Final job status with output files
239
+ """
240
+ config = encoding_config or {"formats": ["mp4_4k", "mp4_720p"]}
241
+
242
+ # Submit the job
243
+ await self.submit_encoding_job(job_id, input_gcs_path, output_gcs_path, config)
244
+
245
+ # Wait for completion
246
+ return await self.wait_for_completion(
247
+ job_id, progress_callback=progress_callback
248
+ )
249
+
250
+ async def submit_preview_encoding_job(
251
+ self,
252
+ job_id: str,
253
+ ass_gcs_path: str,
254
+ audio_gcs_path: str,
255
+ output_gcs_path: str,
256
+ background_color: str = "black",
257
+ background_image_gcs_path: Optional[str] = None,
258
+ font_gcs_path: Optional[str] = None,
259
+ ) -> Dict[str, Any]:
260
+ """
261
+ Submit a preview video encoding job to the GCE worker.
262
+
263
+ Args:
264
+ job_id: Unique job identifier
265
+ ass_gcs_path: GCS path to ASS subtitles file (gs://bucket/path/file.ass)
266
+ audio_gcs_path: GCS path to audio file
267
+ output_gcs_path: GCS path for output video
268
+ background_color: Background color (default: black)
269
+ background_image_gcs_path: Optional GCS path to background image
270
+ font_gcs_path: Optional GCS path to custom font file
271
+
272
+ Returns:
273
+ Response from the encoding worker
274
+
275
+ Raises:
276
+ Exception: If submission fails
277
+ """
278
+ self._load_credentials()
279
+
280
+ if not self.is_configured:
281
+ raise RuntimeError("Encoding service not configured")
282
+
283
+ url = f"{self._url}/encode-preview"
284
+ headers = {"X-API-Key": self._api_key, "Content-Type": "application/json"}
285
+ payload = {
286
+ "job_id": job_id,
287
+ "ass_gcs_path": ass_gcs_path,
288
+ "audio_gcs_path": audio_gcs_path,
289
+ "output_gcs_path": output_gcs_path,
290
+ "background_color": background_color,
291
+ }
292
+ if background_image_gcs_path:
293
+ payload["background_image_gcs_path"] = background_image_gcs_path
294
+ if font_gcs_path:
295
+ payload["font_gcs_path"] = font_gcs_path
296
+
297
+ logger.info(f"[job:{job_id}] Submitting preview encoding job to GCE worker: {url}")
298
+
299
+ async with aiohttp.ClientSession() as session:
300
+ async with session.post(url, json=payload, headers=headers, timeout=30) as resp:
301
+ if resp.status == 401:
302
+ raise RuntimeError("Invalid API key for encoding worker")
303
+ if resp.status == 409:
304
+ raise RuntimeError(f"Preview encoding job {job_id} already exists")
305
+ if resp.status != 200:
306
+ text = await resp.text()
307
+ raise RuntimeError(f"Failed to submit preview encoding job: {resp.status} - {text}")
308
+
309
+ return await resp.json()
310
+
311
+ async def encode_preview_video(
312
+ self,
313
+ job_id: str,
314
+ ass_gcs_path: str,
315
+ audio_gcs_path: str,
316
+ output_gcs_path: str,
317
+ background_color: str = "black",
318
+ background_image_gcs_path: Optional[str] = None,
319
+ font_gcs_path: Optional[str] = None,
320
+ timeout: float = 90.0,
321
+ poll_interval: float = 2.0,
322
+ ) -> Dict[str, Any]:
323
+ """
324
+ Submit preview encoding job and wait for completion.
325
+
326
+ This is a convenience method that combines submit + wait with shorter
327
+ timeouts suitable for preview videos.
328
+
329
+ Args:
330
+ job_id: Unique job identifier
331
+ ass_gcs_path: GCS path to ASS subtitles file
332
+ audio_gcs_path: GCS path to audio file
333
+ output_gcs_path: GCS path for output video
334
+ background_color: Background color (default: black)
335
+ background_image_gcs_path: Optional GCS path to background image
336
+ font_gcs_path: Optional GCS path to custom font file
337
+ timeout: Maximum time to wait (default 90s for preview)
338
+ poll_interval: Seconds between status checks (default 2s)
339
+
340
+ Returns:
341
+ Final job status with output files
342
+ """
343
+ # Submit the job
344
+ submit_result = await self.submit_preview_encoding_job(
345
+ job_id=job_id,
346
+ ass_gcs_path=ass_gcs_path,
347
+ audio_gcs_path=audio_gcs_path,
348
+ output_gcs_path=output_gcs_path,
349
+ background_color=background_color,
350
+ background_image_gcs_path=background_image_gcs_path,
351
+ font_gcs_path=font_gcs_path,
352
+ )
353
+
354
+ # If cached, return immediately - video already exists in GCS
355
+ submit_status = submit_result.get("status")
356
+ if submit_status == "cached":
357
+ logger.info(f"[job:{job_id}] Preview already cached, returning immediately")
358
+ return {"status": "complete", "output_path": submit_result.get("output_path")}
359
+
360
+ # If in_progress, another request is encoding it - just wait for that
361
+ if submit_status == "in_progress":
362
+ logger.info(f"[job:{job_id}] Preview encoding already in progress, waiting")
363
+
364
+ # Wait for completion with shorter timeout
365
+ return await self.wait_for_completion(
366
+ job_id=job_id,
367
+ poll_interval=poll_interval,
368
+ timeout=timeout,
369
+ )
370
+
371
+ async def health_check(self) -> Dict[str, Any]:
372
+ """
373
+ Check the health of the encoding worker.
374
+
375
+ Returns:
376
+ Health status including active jobs and FFmpeg version
377
+ """
378
+ self._load_credentials()
379
+
380
+ if not self.is_configured:
381
+ return {"status": "not_configured"}
382
+
383
+ url = f"{self._url}/health"
384
+ headers = {"X-API-Key": self._api_key}
385
+
386
+ try:
387
+ async with aiohttp.ClientSession() as session:
388
+ async with session.get(url, headers=headers, timeout=10) as resp:
389
+ if resp.status == 200:
390
+ return await resp.json()
391
+ return {"status": "error", "code": resp.status}
392
+ except Exception as e:
393
+ return {"status": "error", "error": str(e)}
394
+
395
+
396
+ # Singleton instance
397
+ _encoding_service: Optional[EncodingService] = None
398
+
399
+
400
+ def get_encoding_service() -> EncodingService:
401
+ """Get the singleton encoding service instance."""
402
+ global _encoding_service
403
+ if _encoding_service is None:
404
+ _encoding_service = EncodingService()
405
+ return _encoding_service