karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__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 (197) 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 +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -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 +502 -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 +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,502 @@
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
+ # Retry configuration for handling transient failures (e.g., worker restarts)
29
+ MAX_RETRIES = 3
30
+ INITIAL_BACKOFF_SECONDS = 2.0
31
+ MAX_BACKOFF_SECONDS = 10.0
32
+
33
+
34
+ class EncodingService:
35
+ """Service for dispatching encoding jobs to GCE worker."""
36
+
37
+ def __init__(self):
38
+ self.settings = get_settings()
39
+ self._url = None
40
+ self._api_key = None
41
+ self._initialized = False
42
+
43
+ def _load_credentials(self):
44
+ """Load encoding worker URL and API key from config/secrets."""
45
+ if self._initialized:
46
+ return
47
+
48
+ # Try environment variables first, then Secret Manager
49
+ self._url = self.settings.encoding_worker_url
50
+ self._api_key = self.settings.encoding_worker_api_key
51
+
52
+ # Fall back to Secret Manager
53
+ if not self._url:
54
+ self._url = self.settings.get_secret("encoding-worker-url")
55
+ if not self._api_key:
56
+ self._api_key = self.settings.get_secret("encoding-worker-api-key")
57
+
58
+ self._initialized = True
59
+
60
+ @property
61
+ def is_configured(self) -> bool:
62
+ """Check if encoding service is configured with URL and API key."""
63
+ self._load_credentials()
64
+ return bool(self._url and self._api_key)
65
+
66
+ @property
67
+ def is_enabled(self) -> bool:
68
+ """Check if GCE encoding is enabled and configured."""
69
+ return self.settings.use_gce_encoding and self.is_configured
70
+
71
+ @property
72
+ def is_preview_enabled(self) -> bool:
73
+ """Check if GCE preview encoding is enabled and configured."""
74
+ return self.settings.use_gce_preview_encoding and self.is_configured
75
+
76
+ async def _request_with_retry(
77
+ self,
78
+ method: str,
79
+ url: str,
80
+ headers: Dict[str, str],
81
+ json_payload: Optional[Dict[str, Any]] = None,
82
+ timeout: float = 30.0,
83
+ job_id: str = "unknown",
84
+ ) -> Dict[str, Any]:
85
+ """
86
+ Make an HTTP request with retry logic for transient failures.
87
+
88
+ This handles connection errors that occur when the GCE worker is
89
+ restarting (e.g., during deployments) by retrying with exponential backoff.
90
+
91
+ Args:
92
+ method: HTTP method (GET, POST)
93
+ url: Request URL
94
+ headers: Request headers
95
+ json_payload: JSON body for POST requests
96
+ timeout: Request timeout in seconds
97
+ job_id: Job ID for logging
98
+
99
+ Returns:
100
+ Dict with keys:
101
+ - status (int): HTTP status code
102
+ - json (Any): Parsed JSON response body (if status 200, else None)
103
+ - text (str): Raw response text (if status != 200, else None)
104
+
105
+ Raises:
106
+ aiohttp.ClientConnectorError: If all retries fail due to connection errors
107
+ aiohttp.ServerDisconnectedError: If all retries fail due to server disconnect
108
+ asyncio.TimeoutError: If all retries fail due to timeout
109
+ """
110
+ last_exception = None
111
+ backoff = INITIAL_BACKOFF_SECONDS
112
+
113
+ for attempt in range(MAX_RETRIES + 1):
114
+ try:
115
+ async with aiohttp.ClientSession() as session:
116
+ if method.upper() == "POST":
117
+ async with session.post(
118
+ url, json=json_payload, headers=headers, timeout=timeout
119
+ ) as resp:
120
+ # Return a copy of the response data since we exit the context
121
+ return {
122
+ "status": resp.status,
123
+ "json": await resp.json() if resp.status == 200 else None,
124
+ "text": await resp.text() if resp.status != 200 else None,
125
+ }
126
+ else: # GET
127
+ async with session.get(
128
+ url, headers=headers, timeout=timeout
129
+ ) as resp:
130
+ return {
131
+ "status": resp.status,
132
+ "json": await resp.json() if resp.status == 200 else None,
133
+ "text": await resp.text() if resp.status != 200 else None,
134
+ }
135
+ except (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
136
+ last_exception = e
137
+ if attempt < MAX_RETRIES:
138
+ logger.warning(
139
+ f"[job:{job_id}] GCE worker connection failed (attempt {attempt + 1}/{MAX_RETRIES + 1}): {e}. "
140
+ f"Retrying in {backoff:.1f}s..."
141
+ )
142
+ await asyncio.sleep(backoff)
143
+ backoff = min(backoff * 2, MAX_BACKOFF_SECONDS)
144
+ else:
145
+ logger.error(
146
+ f"[job:{job_id}] GCE worker connection failed after {MAX_RETRIES + 1} attempts: {e}"
147
+ )
148
+
149
+ raise last_exception
150
+
151
+ async def submit_encoding_job(
152
+ self,
153
+ job_id: str,
154
+ input_gcs_path: str,
155
+ output_gcs_path: str,
156
+ encoding_config: Dict[str, Any],
157
+ ) -> Dict[str, Any]:
158
+ """
159
+ Submit an encoding job to the GCE worker.
160
+
161
+ Args:
162
+ job_id: Unique job identifier
163
+ input_gcs_path: GCS path to input files (gs://bucket/path/)
164
+ output_gcs_path: GCS path for output files (gs://bucket/path/)
165
+ encoding_config: Configuration for encoding (formats, quality, etc.)
166
+
167
+ Returns:
168
+ Response from the encoding worker
169
+
170
+ Raises:
171
+ Exception: If submission fails
172
+ """
173
+ self._load_credentials()
174
+
175
+ if not self.is_configured:
176
+ raise RuntimeError("Encoding service not configured")
177
+
178
+ url = f"{self._url}/encode"
179
+ headers = {"X-API-Key": self._api_key, "Content-Type": "application/json"}
180
+ payload = {
181
+ "job_id": job_id,
182
+ "input_gcs_path": input_gcs_path,
183
+ "output_gcs_path": output_gcs_path,
184
+ "encoding_config": encoding_config,
185
+ }
186
+
187
+ logger.info(f"[job:{job_id}] Submitting encoding job to GCE worker: {url}")
188
+
189
+ resp = await self._request_with_retry(
190
+ method="POST",
191
+ url=url,
192
+ headers=headers,
193
+ json_payload=payload,
194
+ timeout=30.0,
195
+ job_id=job_id,
196
+ )
197
+
198
+ if resp["status"] == 401:
199
+ raise RuntimeError("Invalid API key for encoding worker")
200
+ if resp["status"] == 409:
201
+ raise RuntimeError(f"Encoding job {job_id} already exists")
202
+ if resp["status"] != 200:
203
+ raise RuntimeError(f"Failed to submit encoding job: {resp['status']} - {resp['text']}")
204
+
205
+ return resp["json"]
206
+
207
+ async def get_job_status(self, job_id: str) -> Dict[str, Any]:
208
+ """
209
+ Get the status of an encoding job.
210
+
211
+ Args:
212
+ job_id: Job identifier
213
+
214
+ Returns:
215
+ Job status including: status, progress, error, output_files
216
+ """
217
+ self._load_credentials()
218
+
219
+ if not self.is_configured:
220
+ raise RuntimeError("Encoding service not configured")
221
+
222
+ url = f"{self._url}/status/{job_id}"
223
+ headers = {"X-API-Key": self._api_key}
224
+
225
+ resp = await self._request_with_retry(
226
+ method="GET",
227
+ url=url,
228
+ headers=headers,
229
+ timeout=30.0,
230
+ job_id=job_id,
231
+ )
232
+
233
+ if resp["status"] == 401:
234
+ raise RuntimeError("Invalid API key for encoding worker")
235
+ if resp["status"] == 404:
236
+ raise RuntimeError(f"Encoding job {job_id} not found")
237
+ if resp["status"] != 200:
238
+ raise RuntimeError(f"Failed to get job status: {resp['status']} - {resp['text']}")
239
+
240
+ return resp["json"]
241
+
242
+ async def wait_for_completion(
243
+ self,
244
+ job_id: str,
245
+ poll_interval: float = 10.0,
246
+ timeout: float = 3600.0,
247
+ progress_callback=None,
248
+ ) -> Dict[str, Any]:
249
+ """
250
+ Poll for encoding job completion.
251
+
252
+ Args:
253
+ job_id: Job identifier
254
+ poll_interval: Seconds between status checks
255
+ timeout: Maximum time to wait (default 1 hour)
256
+ progress_callback: Optional callback(progress: int) for progress updates
257
+
258
+ Returns:
259
+ Final job status with output files
260
+
261
+ Raises:
262
+ TimeoutError: If job doesn't complete within timeout
263
+ RuntimeError: If job fails
264
+ """
265
+ logger.info(f"[job:{job_id}] Waiting for GCE encoding to complete...")
266
+
267
+ start_time = asyncio.get_event_loop().time()
268
+ last_progress = 0
269
+
270
+ while True:
271
+ elapsed = asyncio.get_event_loop().time() - start_time
272
+ if elapsed > timeout:
273
+ raise TimeoutError(f"Encoding job {job_id} timed out after {timeout}s")
274
+
275
+ status = await self.get_job_status(job_id)
276
+
277
+ # Handle case where GCE worker returns a list instead of dict
278
+ if isinstance(status, list):
279
+ logger.warning(f"[job:{job_id}] GCE returned list instead of dict: {status}")
280
+ status = status[0] if status and isinstance(status[0], dict) else {}
281
+ if not isinstance(status, dict):
282
+ logger.error(f"[job:{job_id}] Unexpected status type: {type(status)}")
283
+ status = {}
284
+
285
+ job_status = status.get("status", "unknown")
286
+ progress = status.get("progress", 0)
287
+
288
+ # Report progress
289
+ if progress != last_progress:
290
+ logger.info(f"[job:{job_id}] Encoding progress: {progress}%")
291
+ last_progress = progress
292
+ if progress_callback:
293
+ try:
294
+ progress_callback(progress)
295
+ except Exception as e:
296
+ logger.warning(f"Progress callback failed: {e}")
297
+
298
+ if job_status == "complete":
299
+ logger.info(f"[job:{job_id}] GCE encoding complete in {elapsed:.1f}s")
300
+ return status
301
+
302
+ if job_status == "failed":
303
+ error = status.get("error", "Unknown error")
304
+ raise RuntimeError(f"Encoding job {job_id} failed: {error}")
305
+
306
+ await asyncio.sleep(poll_interval)
307
+
308
+ async def encode_videos(
309
+ self,
310
+ job_id: str,
311
+ input_gcs_path: str,
312
+ output_gcs_path: str,
313
+ encoding_config: Optional[Dict[str, Any]] = None,
314
+ progress_callback=None,
315
+ ) -> Dict[str, Any]:
316
+ """
317
+ Submit encoding job and wait for completion.
318
+
319
+ This is a convenience method that combines submit + wait.
320
+
321
+ Args:
322
+ job_id: Unique job identifier
323
+ input_gcs_path: GCS path to input files
324
+ output_gcs_path: GCS path for output files
325
+ encoding_config: Optional encoding configuration
326
+ progress_callback: Optional callback for progress updates
327
+
328
+ Returns:
329
+ Final job status with output files
330
+ """
331
+ config = encoding_config or {"formats": ["mp4_4k", "mp4_720p"]}
332
+
333
+ # Submit the job
334
+ await self.submit_encoding_job(job_id, input_gcs_path, output_gcs_path, config)
335
+
336
+ # Wait for completion
337
+ return await self.wait_for_completion(
338
+ job_id, progress_callback=progress_callback
339
+ )
340
+
341
+ async def submit_preview_encoding_job(
342
+ self,
343
+ job_id: str,
344
+ ass_gcs_path: str,
345
+ audio_gcs_path: str,
346
+ output_gcs_path: str,
347
+ background_color: str = "black",
348
+ background_image_gcs_path: Optional[str] = None,
349
+ font_gcs_path: Optional[str] = None,
350
+ ) -> Dict[str, Any]:
351
+ """
352
+ Submit a preview video encoding job to the GCE worker.
353
+
354
+ Args:
355
+ job_id: Unique job identifier
356
+ ass_gcs_path: GCS path to ASS subtitles file (gs://bucket/path/file.ass)
357
+ audio_gcs_path: GCS path to audio file
358
+ output_gcs_path: GCS path for output video
359
+ background_color: Background color (default: black)
360
+ background_image_gcs_path: Optional GCS path to background image
361
+ font_gcs_path: Optional GCS path to custom font file
362
+
363
+ Returns:
364
+ Response from the encoding worker
365
+
366
+ Raises:
367
+ Exception: If submission fails
368
+ """
369
+ self._load_credentials()
370
+
371
+ if not self.is_configured:
372
+ raise RuntimeError("Encoding service not configured")
373
+
374
+ url = f"{self._url}/encode-preview"
375
+ headers = {"X-API-Key": self._api_key, "Content-Type": "application/json"}
376
+ payload = {
377
+ "job_id": job_id,
378
+ "ass_gcs_path": ass_gcs_path,
379
+ "audio_gcs_path": audio_gcs_path,
380
+ "output_gcs_path": output_gcs_path,
381
+ "background_color": background_color,
382
+ }
383
+ if background_image_gcs_path:
384
+ payload["background_image_gcs_path"] = background_image_gcs_path
385
+ if font_gcs_path:
386
+ payload["font_gcs_path"] = font_gcs_path
387
+
388
+ logger.info(f"[job:{job_id}] Submitting preview encoding job to GCE worker: {url}")
389
+
390
+ resp = await self._request_with_retry(
391
+ method="POST",
392
+ url=url,
393
+ headers=headers,
394
+ json_payload=payload,
395
+ timeout=30.0,
396
+ job_id=job_id,
397
+ )
398
+
399
+ if resp["status"] == 401:
400
+ raise RuntimeError("Invalid API key for encoding worker")
401
+ if resp["status"] == 409:
402
+ raise RuntimeError(f"Preview encoding job {job_id} already exists")
403
+ if resp["status"] != 200:
404
+ raise RuntimeError(f"Failed to submit preview encoding job: {resp['status']} - {resp['text']}")
405
+
406
+ return resp["json"]
407
+
408
+ async def encode_preview_video(
409
+ self,
410
+ job_id: str,
411
+ ass_gcs_path: str,
412
+ audio_gcs_path: str,
413
+ output_gcs_path: str,
414
+ background_color: str = "black",
415
+ background_image_gcs_path: Optional[str] = None,
416
+ font_gcs_path: Optional[str] = None,
417
+ timeout: float = 90.0,
418
+ poll_interval: float = 2.0,
419
+ ) -> Dict[str, Any]:
420
+ """
421
+ Submit preview encoding job and wait for completion.
422
+
423
+ This is a convenience method that combines submit + wait with shorter
424
+ timeouts suitable for preview videos.
425
+
426
+ Args:
427
+ job_id: Unique job identifier
428
+ ass_gcs_path: GCS path to ASS subtitles file
429
+ audio_gcs_path: GCS path to audio file
430
+ output_gcs_path: GCS path for output video
431
+ background_color: Background color (default: black)
432
+ background_image_gcs_path: Optional GCS path to background image
433
+ font_gcs_path: Optional GCS path to custom font file
434
+ timeout: Maximum time to wait (default 90s for preview)
435
+ poll_interval: Seconds between status checks (default 2s)
436
+
437
+ Returns:
438
+ Final job status with output files
439
+ """
440
+ # Submit the job
441
+ submit_result = await self.submit_preview_encoding_job(
442
+ job_id=job_id,
443
+ ass_gcs_path=ass_gcs_path,
444
+ audio_gcs_path=audio_gcs_path,
445
+ output_gcs_path=output_gcs_path,
446
+ background_color=background_color,
447
+ background_image_gcs_path=background_image_gcs_path,
448
+ font_gcs_path=font_gcs_path,
449
+ )
450
+
451
+ # If cached, return immediately - video already exists in GCS
452
+ submit_status = submit_result.get("status")
453
+ if submit_status == "cached":
454
+ logger.info(f"[job:{job_id}] Preview already cached, returning immediately")
455
+ return {"status": "complete", "output_path": submit_result.get("output_path")}
456
+
457
+ # If in_progress, another request is encoding it - just wait for that
458
+ if submit_status == "in_progress":
459
+ logger.info(f"[job:{job_id}] Preview encoding already in progress, waiting")
460
+
461
+ # Wait for completion with shorter timeout
462
+ return await self.wait_for_completion(
463
+ job_id=job_id,
464
+ poll_interval=poll_interval,
465
+ timeout=timeout,
466
+ )
467
+
468
+ async def health_check(self) -> Dict[str, Any]:
469
+ """
470
+ Check the health of the encoding worker.
471
+
472
+ Returns:
473
+ Health status including active jobs and FFmpeg version
474
+ """
475
+ self._load_credentials()
476
+
477
+ if not self.is_configured:
478
+ return {"status": "not_configured"}
479
+
480
+ url = f"{self._url}/health"
481
+ headers = {"X-API-Key": self._api_key}
482
+
483
+ try:
484
+ async with aiohttp.ClientSession() as session:
485
+ async with session.get(url, headers=headers, timeout=10) as resp:
486
+ if resp.status == 200:
487
+ return await resp.json()
488
+ return {"status": "error", "code": resp.status}
489
+ except Exception as e:
490
+ return {"status": "error", "error": str(e)}
491
+
492
+
493
+ # Singleton instance
494
+ _encoding_service: Optional[EncodingService] = None
495
+
496
+
497
+ def get_encoding_service() -> EncodingService:
498
+ """Get the singleton encoding service instance."""
499
+ global _encoding_service
500
+ if _encoding_service is None:
501
+ _encoding_service = EncodingService()
502
+ return _encoding_service