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,589 @@
1
+ import asyncio
2
+ import glob as glob_module
3
+ import json
4
+ import logging
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import uuid
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from fastapi import FastAPI, BackgroundTasks, HTTPException, Header, Depends
15
+ from google.cloud import storage
16
+ from pydantic import BaseModel
17
+
18
+ logging.basicConfig(level=logging.INFO)
19
+ logger = logging.getLogger(__name__)
20
+
21
+ app = FastAPI(title="Encoding Worker", version="1.0.0")
22
+
23
+ # API key authentication
24
+ API_KEY = os.environ.get("ENCODING_API_KEY", "")
25
+
26
+
27
+ async def verify_api_key(x_api_key: str = Header(None)):
28
+ # Verify API key for authentication
29
+ if not API_KEY:
30
+ logger.warning("No API key configured - authentication disabled")
31
+ return True
32
+ if x_api_key != API_KEY:
33
+ raise HTTPException(status_code=401, detail="Invalid API key")
34
+ return True
35
+
36
+ # Job tracking
37
+ jobs: dict[str, dict] = {}
38
+ executor = ThreadPoolExecutor(max_workers=4) # 4 parallel encoding jobs
39
+
40
+ # GCS client
41
+ storage_client = storage.Client()
42
+
43
+ class EncodeRequest(BaseModel):
44
+ job_id: str
45
+ input_gcs_path: str # gs://bucket/path/to/inputs/
46
+ output_gcs_path: str # gs://bucket/path/to/outputs/
47
+ encoding_config: dict # Video formats to generate
48
+
49
+
50
+ class EncodePreviewRequest(BaseModel):
51
+ job_id: str
52
+ ass_gcs_path: str # gs://bucket/path/to/subtitles.ass
53
+ audio_gcs_path: str # gs://bucket/path/to/audio.flac
54
+ output_gcs_path: str # gs://bucket/path/to/output.mp4
55
+ background_color: str = "black"
56
+ background_image_gcs_path: Optional[str] = None
57
+ font_gcs_path: Optional[str] = None # gs://bucket/path/to/custom-font.ttf
58
+
59
+
60
+ class JobStatus(BaseModel):
61
+ job_id: str
62
+ status: str # pending, running, complete, failed
63
+ progress: int # 0-100
64
+ error: Optional[str] = None
65
+ output_files: Optional[list[str]] = None
66
+
67
+
68
+ def download_from_gcs(gcs_uri: str, local_path: Path):
69
+ # Download a file or folder from GCS
70
+ # Parse gs://bucket/path
71
+ parts = gcs_uri.replace("gs://", "").split("/", 1)
72
+ bucket_name = parts[0]
73
+ prefix = parts[1] if len(parts) > 1 else ""
74
+
75
+ bucket = storage_client.bucket(bucket_name)
76
+ blobs = list(bucket.list_blobs(prefix=prefix))
77
+
78
+ for blob in blobs:
79
+ # Get relative path from prefix
80
+ rel_path = blob.name[len(prefix):].lstrip("/")
81
+ if not rel_path:
82
+ continue
83
+ dest = local_path / rel_path
84
+ dest.parent.mkdir(parents=True, exist_ok=True)
85
+ blob.download_to_filename(str(dest))
86
+ logger.info(f"Downloaded: {blob.name} -> {dest}")
87
+
88
+
89
+ def upload_to_gcs(local_path: Path, gcs_uri: str):
90
+ # Upload a file or folder to GCS
91
+ parts = gcs_uri.replace("gs://", "").split("/", 1)
92
+ bucket_name = parts[0]
93
+ prefix = parts[1].rstrip("/") if len(parts) > 1 else ""
94
+
95
+ bucket = storage_client.bucket(bucket_name)
96
+
97
+ if local_path.is_file():
98
+ blob_name = f"{prefix}/{local_path.name}" if prefix else local_path.name
99
+ blob = bucket.blob(blob_name)
100
+ blob.upload_from_filename(str(local_path))
101
+ logger.info(f"Uploaded: {local_path} -> gs://{bucket_name}/{blob_name}")
102
+ else:
103
+ for file in local_path.rglob("*"):
104
+ if file.is_file():
105
+ rel_path = file.relative_to(local_path)
106
+ blob_name = f"{prefix}/{rel_path}" if prefix else str(rel_path)
107
+ blob = bucket.blob(blob_name)
108
+ blob.upload_from_filename(str(file))
109
+ logger.info(f"Uploaded: {file} -> gs://{bucket_name}/{blob_name}")
110
+
111
+
112
+ def download_single_file_from_gcs(gcs_uri: str, local_path: Path):
113
+ # Download a single file from GCS
114
+ parts = gcs_uri.replace("gs://", "").split("/", 1)
115
+ bucket_name = parts[0]
116
+ blob_name = parts[1] if len(parts) > 1 else ""
117
+
118
+ bucket = storage_client.bucket(bucket_name)
119
+ blob = bucket.blob(blob_name)
120
+ local_path.parent.mkdir(parents=True, exist_ok=True)
121
+ blob.download_to_filename(str(local_path))
122
+ logger.info(f"Downloaded: {gcs_uri} -> {local_path}")
123
+
124
+
125
+ def upload_single_file_to_gcs(local_path: Path, gcs_uri: str):
126
+ # Upload a single file to a specific GCS path
127
+ parts = gcs_uri.replace("gs://", "").split("/", 1)
128
+ bucket_name = parts[0]
129
+ blob_name = parts[1] if len(parts) > 1 else ""
130
+
131
+ bucket = storage_client.bucket(bucket_name)
132
+ blob = bucket.blob(blob_name)
133
+ blob.upload_from_filename(str(local_path))
134
+ logger.info(f"Uploaded: {local_path} -> {gcs_uri}")
135
+
136
+
137
+ def run_preview_encoding(job_id: str, work_dir: Path, request: "EncodePreviewRequest"):
138
+ # Run FFmpeg encoding for preview video (480x270, fast settings)
139
+ jobs[job_id]["status"] = "running"
140
+ jobs[job_id]["progress"] = 10
141
+
142
+ try:
143
+ # Download input files
144
+ ass_path = work_dir / "subtitles.ass"
145
+ audio_path = work_dir / "audio.flac"
146
+
147
+ download_single_file_from_gcs(request.ass_gcs_path, ass_path)
148
+ jobs[job_id]["progress"] = 20
149
+
150
+ download_single_file_from_gcs(request.audio_gcs_path, audio_path)
151
+ jobs[job_id]["progress"] = 30
152
+
153
+ # Download background image if provided
154
+ bg_image_path = None
155
+ if request.background_image_gcs_path:
156
+ bg_image_path = work_dir / "background.png"
157
+ download_single_file_from_gcs(request.background_image_gcs_path, bg_image_path)
158
+
159
+ # Download custom font if provided and register with fontconfig
160
+ if request.font_gcs_path:
161
+ # Use standard fontconfig location that's already in the search path
162
+ fonts_dir = Path("/usr/local/share/fonts/custom")
163
+ fonts_dir.mkdir(parents=True, exist_ok=True)
164
+ font_filename = request.font_gcs_path.split("/")[-1]
165
+ font_path = fonts_dir / font_filename
166
+ download_single_file_from_gcs(request.font_gcs_path, font_path)
167
+ logger.info(f"Downloaded custom font: {font_path}")
168
+ # Update fontconfig cache so libass can find the font
169
+ subprocess.run(["fc-cache", "-fv"], capture_output=True)
170
+ logger.info(f"Updated fontconfig cache with custom font: {font_filename}")
171
+
172
+ # Build FFmpeg command
173
+ output_path = work_dir / "preview.mp4"
174
+
175
+ # Escape special characters in path for FFmpeg filter syntax
176
+ # FFmpeg filter parsing requires escaping: \ : , [ ] ;
177
+ def escape_ffmpeg_filter_path(path: str) -> str:
178
+ # Note: Extra escaping needed since this is inside a triple-quoted string in Pulumi
179
+ return path.replace("\\", "\\\\").replace(":", "\\:").replace(",", "\\,").replace("[", "\\[").replace("]", "\\]").replace(";", "\\;")
180
+
181
+ escaped_ass_path = escape_ffmpeg_filter_path(str(ass_path))
182
+
183
+ # Base command with frame rate
184
+ cmd = ["ffmpeg", "-y", "-r", "24"]
185
+
186
+ # Video input: background image or solid color
187
+ if bg_image_path and bg_image_path.exists():
188
+ cmd.extend(["-loop", "1", "-i", str(bg_image_path)])
189
+ # Scale and pad background to 480x270
190
+ vf = f"scale=480:270:force_original_aspect_ratio=decrease,pad=480:270:(ow-iw)/2:(oh-ih)/2,ass={escaped_ass_path}"
191
+ else:
192
+ # Solid color background
193
+ color = request.background_color or "black"
194
+ cmd.extend(["-f", "lavfi", "-i", f"color=c={color}:s=480x270:r=24"])
195
+ vf = f"ass={escaped_ass_path}"
196
+
197
+ # Audio input
198
+ cmd.extend(["-i", str(audio_path)])
199
+
200
+ # Video filter and encoding settings
201
+ cmd.extend([
202
+ "-vf", vf,
203
+ "-c:a", "aac", "-b:a", "96k",
204
+ "-c:v", "libx264",
205
+ "-preset", "superfast",
206
+ "-crf", "28",
207
+ "-pix_fmt", "yuv420p",
208
+ "-movflags", "+faststart",
209
+ "-threads", "8",
210
+ "-shortest",
211
+ str(output_path)
212
+ ])
213
+
214
+ jobs[job_id]["progress"] = 40
215
+ logger.info(f"Running preview encoding: {' '.join(cmd)}")
216
+
217
+ result = subprocess.run(cmd, capture_output=True, text=True)
218
+
219
+ if result.returncode != 0:
220
+ logger.error(f"FFmpeg failed: {result.stderr}")
221
+ raise RuntimeError(f"FFmpeg preview encoding failed: {result.stderr[-500:]}")
222
+
223
+ jobs[job_id]["progress"] = 80
224
+ logger.info(f"Preview encoded: {output_path}")
225
+
226
+ # Upload output to GCS
227
+ upload_single_file_to_gcs(output_path, request.output_gcs_path)
228
+ jobs[job_id]["progress"] = 95
229
+
230
+ jobs[job_id]["output_files"] = [request.output_gcs_path]
231
+ return output_path
232
+
233
+ except Exception as e:
234
+ logger.error(f"Preview encoding failed: {e}")
235
+ jobs[job_id]["status"] = "failed"
236
+ jobs[job_id]["error"] = str(e)
237
+ raise
238
+
239
+
240
+ def ensure_latest_wheel():
241
+ '''Download and install latest karaoke-gen wheel from GCS.
242
+
243
+ Called at the start of each job to enable hot code updates without restart.
244
+ In-progress jobs continue with their version, new jobs get latest code.
245
+ '''
246
+ try:
247
+ logger.info("Checking for latest karaoke-gen wheel in GCS...")
248
+
249
+ # Download latest wheel
250
+ result = subprocess.run(
251
+ ["gsutil", "cp", "gs://karaoke-gen-storage-nomadkaraoke/wheels/karaoke_gen-*.whl", "/tmp/"],
252
+ capture_output=True, text=True, timeout=60
253
+ )
254
+
255
+ # Find the downloaded wheel (get the latest by version sorting)
256
+ wheels = glob_module.glob("/tmp/karaoke_gen-*.whl")
257
+ if not wheels:
258
+ logger.warning("No wheel found in GCS, using fallback encoding logic")
259
+ return False
260
+
261
+ # Sort to get latest version
262
+ wheel_path = sorted(wheels)[-1]
263
+ logger.info(f"Installing wheel: {wheel_path}")
264
+
265
+ # Install (or upgrade) the wheel
266
+ # Use 5-minute timeout - first install at job start may need to resolve dependencies
267
+ # Subsequent installs are faster since dependencies are cached
268
+ install_result = subprocess.run(
269
+ [sys.executable, "-m", "pip", "install", "--upgrade", "--quiet", wheel_path],
270
+ capture_output=True, text=True, timeout=300
271
+ )
272
+
273
+ if install_result.returncode != 0:
274
+ logger.warning(f"Wheel installation failed: {install_result.stderr}")
275
+ return False
276
+
277
+ logger.info(f"Successfully installed wheel: {wheel_path}")
278
+ return True
279
+
280
+ except subprocess.TimeoutExpired:
281
+ logger.warning("Wheel download/install timed out, using fallback")
282
+ return False
283
+ except Exception as e:
284
+ logger.warning(f"Failed to ensure latest wheel: {e}")
285
+ return False
286
+
287
+
288
+ def find_file(work_dir: Path, *patterns):
289
+ '''Find a file matching any of the given glob patterns.'''
290
+ for pattern in patterns:
291
+ matches = list(work_dir.glob(f"**/{pattern}"))
292
+ if matches:
293
+ return matches[0]
294
+ return None
295
+
296
+
297
+ def run_encoding(job_id: str, work_dir: Path, config: dict):
298
+ '''Run encoding using LocalEncodingService (single source of truth).
299
+
300
+ Uses LocalEncodingService from the installed karaoke-gen wheel to ensure
301
+ output files match local CLI exactly:
302
+ - Proper names like "Artist - Title (Final Karaoke Lossless 4k).mp4"
303
+ - Concatenated title + karaoke + end screens
304
+ - All formats: lossless 4K MP4, lossy 4K MP4, lossless MKV, 720p MP4
305
+
306
+ Requires the karaoke-gen wheel to be installed (done by ensure_latest_wheel).
307
+ '''
308
+ jobs[job_id]["status"] = "running"
309
+ jobs[job_id]["progress"] = 10
310
+
311
+ try:
312
+ # Import LocalEncodingService from installed wheel (required, no fallback)
313
+ from backend.services.local_encoding_service import LocalEncodingService, EncodingConfig
314
+ logger.info("Using LocalEncodingService from installed wheel")
315
+
316
+ # Get artist/title from config for proper naming
317
+ artist = config.get("artist", "Unknown Artist")
318
+ title = config.get("title", "Unknown Title")
319
+ base_name = f"{artist} - {title}"
320
+ logger.info(f"Encoding for: {base_name}")
321
+
322
+ # Find input files in work_dir
323
+ # Title/end screens are in screens/ subdirectory
324
+ title_video = find_file(work_dir, "screens/title.mov", "*Title*.mov", "*title*.mov")
325
+ end_video = find_file(work_dir, "screens/end.mov", "*End*.mov", "*end*.mov")
326
+
327
+ # Karaoke video - search for With Vocals or main karaoke video
328
+ karaoke_video = find_file(
329
+ work_dir,
330
+ "*With Vocals*.mov", "*With Vocals*.mkv",
331
+ "*Vocals*.mov", "*Vocals*.mkv",
332
+ "*.mkv", "*.mov"
333
+ )
334
+ # Exclude title/end/output videos
335
+ if karaoke_video:
336
+ name_lower = karaoke_video.name.lower()
337
+ if "title" in name_lower or "end" in name_lower or "outputs" in str(karaoke_video):
338
+ # Search more specifically for karaoke video
339
+ karaoke_video = find_file(work_dir, "*Karaoke*.mkv", "*Karaoke*.mov", "*vocals*.mkv")
340
+
341
+ # Instrumental audio
342
+ instrumental = find_file(
343
+ work_dir,
344
+ "*instrumental_clean*.flac", "*Instrumental Clean*.flac",
345
+ "*instrumental*.flac", "*Instrumental*.flac",
346
+ "*instrumental*.wav"
347
+ )
348
+
349
+ logger.info(f"Found files:")
350
+ logger.info(f" Title video: {title_video}")
351
+ logger.info(f" Karaoke video: {karaoke_video}")
352
+ logger.info(f" End video: {end_video}")
353
+ logger.info(f" Instrumental: {instrumental}")
354
+
355
+ # Validate required files
356
+ if not title_video:
357
+ raise ValueError(f"No title video found in {work_dir}. Check screens/ subdirectory.")
358
+ if not karaoke_video:
359
+ raise ValueError(f"No karaoke video found in {work_dir}")
360
+ if not instrumental:
361
+ raise ValueError(f"No instrumental audio found in {work_dir}")
362
+
363
+ output_dir = work_dir / "outputs"
364
+ output_dir.mkdir(exist_ok=True)
365
+
366
+ jobs[job_id]["progress"] = 20
367
+
368
+ # Build encoding config with proper file names
369
+ encoding_config = EncodingConfig(
370
+ title_video=str(title_video),
371
+ karaoke_video=str(karaoke_video),
372
+ instrumental_audio=str(instrumental),
373
+ end_video=str(end_video) if end_video else None,
374
+ output_karaoke_mp4=str(output_dir / f"{base_name} (Karaoke).mp4"),
375
+ output_with_vocals_mp4=str(output_dir / f"{base_name} (With Vocals).mp4"),
376
+ output_lossless_4k_mp4=str(output_dir / f"{base_name} (Final Karaoke Lossless 4k).mp4"),
377
+ output_lossy_4k_mp4=str(output_dir / f"{base_name} (Final Karaoke Lossy 4k).mp4"),
378
+ output_lossless_mkv=str(output_dir / f"{base_name} (Final Karaoke Lossless 4k).mkv"),
379
+ output_720p_mp4=str(output_dir / f"{base_name} (Final Karaoke Lossy 720p).mp4"),
380
+ )
381
+
382
+ # Create service and run encoding
383
+ service = LocalEncodingService(logger=logger)
384
+
385
+ jobs[job_id]["progress"] = 30
386
+ logger.info("Starting LocalEncodingService.encode_all_formats()")
387
+
388
+ result = service.encode_all_formats(encoding_config)
389
+
390
+ if not result.success:
391
+ raise RuntimeError(f"Encoding failed: {result.error}")
392
+
393
+ jobs[job_id]["progress"] = 90
394
+
395
+ # Collect output files
396
+ output_files = [str(f) for f in output_dir.glob("*") if f.is_file()]
397
+ jobs[job_id]["output_files"] = output_files
398
+
399
+ logger.info(f"Encoding complete. Output files: {output_files}")
400
+ return output_dir
401
+
402
+ except ImportError as e:
403
+ # No fallback - wheel must be installed
404
+ error_msg = (
405
+ f"LocalEncodingService not available: {e}. "
406
+ "The karaoke-gen wheel must be installed. "
407
+ "Check that ensure_latest_wheel() succeeded and wheel exists in GCS."
408
+ )
409
+ logger.error(error_msg)
410
+ jobs[job_id]["status"] = "failed"
411
+ jobs[job_id]["error"] = error_msg
412
+ raise RuntimeError(error_msg) from e
413
+
414
+ except Exception as e:
415
+ logger.error(f"Encoding failed: {e}")
416
+ jobs[job_id]["status"] = "failed"
417
+ jobs[job_id]["error"] = str(e)
418
+ raise
419
+
420
+
421
+ async def process_job(job_id: str, request: EncodeRequest):
422
+ # Process an encoding job asynchronously
423
+ try:
424
+ # Download and install latest wheel at job start (allows hot updates without restart)
425
+ # This means in-progress jobs continue with their version, new jobs get latest code
426
+ ensure_latest_wheel()
427
+
428
+ with tempfile.TemporaryDirectory() as temp_dir:
429
+ work_dir = Path(temp_dir) / "work"
430
+ work_dir.mkdir()
431
+
432
+ # Download input files
433
+ jobs[job_id]["progress"] = 5
434
+ logger.info(f"Downloading from {request.input_gcs_path}")
435
+ download_from_gcs(request.input_gcs_path, work_dir)
436
+
437
+ # Run encoding in thread pool (CPU-bound)
438
+ loop = asyncio.get_event_loop()
439
+ output_dir = await loop.run_in_executor(
440
+ executor,
441
+ run_encoding,
442
+ job_id,
443
+ work_dir,
444
+ request.encoding_config
445
+ )
446
+
447
+ # Upload outputs
448
+ jobs[job_id]["progress"] = 95
449
+ logger.info(f"Uploading to {request.output_gcs_path}")
450
+ upload_to_gcs(output_dir, request.output_gcs_path)
451
+
452
+ # Convert local paths to blob paths (backend expects blob paths, not full gs:// URIs)
453
+ # output_gcs_path is like "gs://bucket/jobs/id/encoded/"
454
+ # We need paths like "jobs/id/encoded/Artist - Title (Final Karaoke Lossless 4k).mp4"
455
+ gcs_path = request.output_gcs_path.replace("gs://", "")
456
+ parts = gcs_path.split("/", 1)
457
+ prefix = parts[1].rstrip("/") if len(parts) > 1 else ""
458
+ local_output_files = jobs[job_id].get("output_files", [])
459
+ blob_paths = []
460
+ for local_path in local_output_files:
461
+ filename = Path(local_path).name
462
+ blob_path = f"{prefix}/{filename}" if prefix else filename
463
+ blob_paths.append(blob_path)
464
+ jobs[job_id]["output_files"] = blob_paths
465
+ logger.info(f"Output files (blob paths): {blob_paths}")
466
+
467
+ jobs[job_id]["status"] = "complete"
468
+ jobs[job_id]["progress"] = 100
469
+ logger.info(f"Job {job_id} complete")
470
+
471
+ except Exception as e:
472
+ logger.error(f"Job {job_id} failed: {e}")
473
+ jobs[job_id]["status"] = "failed"
474
+ jobs[job_id]["error"] = str(e)
475
+
476
+
477
+ async def process_preview_job(job_id: str, request: EncodePreviewRequest):
478
+ # Process a preview encoding job asynchronously
479
+ try:
480
+ with tempfile.TemporaryDirectory() as temp_dir:
481
+ work_dir = Path(temp_dir) / "work"
482
+ work_dir.mkdir()
483
+
484
+ # Run preview encoding in thread pool (CPU-bound)
485
+ # Note: run_preview_encoding handles download/upload internally
486
+ loop = asyncio.get_event_loop()
487
+ await loop.run_in_executor(
488
+ executor,
489
+ run_preview_encoding,
490
+ job_id,
491
+ work_dir,
492
+ request
493
+ )
494
+
495
+ jobs[job_id]["status"] = "complete"
496
+ jobs[job_id]["progress"] = 100
497
+ logger.info(f"Preview job {job_id} complete")
498
+
499
+ except Exception as e:
500
+ logger.error(f"Preview job {job_id} failed: {e}")
501
+ jobs[job_id]["status"] = "failed"
502
+ jobs[job_id]["error"] = str(e)
503
+
504
+
505
+ @app.post("/encode-preview")
506
+ async def submit_preview_encode_job(request: EncodePreviewRequest, background_tasks: BackgroundTasks, _auth: bool = Depends(verify_api_key)):
507
+ # Submit a preview encoding job
508
+ job_id = request.job_id
509
+
510
+ # If job already exists, return cached result or current status
511
+ if job_id in jobs:
512
+ existing_job = jobs[job_id]
513
+ if existing_job["status"] == "complete":
514
+ # Return cached result - preview already encoded
515
+ return {"status": "cached", "job_id": job_id, "output_path": existing_job.get("output_path")}
516
+ elif existing_job["status"] == "failed":
517
+ # Previous attempt failed, allow retry by replacing the job
518
+ pass
519
+ else:
520
+ # Job is still in progress
521
+ return {"status": "in_progress", "job_id": job_id}
522
+
523
+ jobs[job_id] = {
524
+ "job_id": job_id,
525
+ "status": "pending",
526
+ "progress": 0,
527
+ "error": None,
528
+ "output_files": None,
529
+ }
530
+
531
+ background_tasks.add_task(process_preview_job, job_id, request)
532
+
533
+ return {"status": "accepted", "job_id": job_id}
534
+
535
+
536
+ @app.post("/encode")
537
+ async def submit_encode_job(request: EncodeRequest, background_tasks: BackgroundTasks, _auth: bool = Depends(verify_api_key)):
538
+ # Submit an encoding job
539
+ job_id = request.job_id
540
+
541
+ if job_id in jobs:
542
+ raise HTTPException(status_code=409, detail=f"Job {job_id} already exists")
543
+
544
+ jobs[job_id] = {
545
+ "job_id": job_id,
546
+ "status": "pending",
547
+ "progress": 0,
548
+ "error": None,
549
+ "output_files": None,
550
+ }
551
+
552
+ background_tasks.add_task(process_job, job_id, request)
553
+
554
+ return {"status": "accepted", "job_id": job_id}
555
+
556
+
557
+ @app.get("/status/{job_id}")
558
+ async def get_job_status(job_id: str, _auth: bool = Depends(verify_api_key)) -> JobStatus:
559
+ # Get the status of an encoding job
560
+ if job_id not in jobs:
561
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
562
+ return JobStatus(**jobs[job_id])
563
+
564
+
565
+ @app.get("/health")
566
+ async def health_check():
567
+ # Health check endpoint
568
+ active_jobs = sum(1 for j in jobs.values() if j["status"] == "running")
569
+
570
+ # Get karaoke-gen wheel version if installed
571
+ wheel_version = None
572
+ try:
573
+ from importlib.metadata import version as get_version
574
+ wheel_version = get_version("karaoke-gen")
575
+ except Exception:
576
+ pass
577
+
578
+ return {
579
+ "status": "ok",
580
+ "active_jobs": active_jobs,
581
+ "queue_length": sum(1 for j in jobs.values() if j["status"] == "pending"),
582
+ "ffmpeg_version": subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True).stdout.split("\n")[0],
583
+ "wheel_version": wheel_version,
584
+ }
585
+
586
+
587
+ if __name__ == "__main__":
588
+ import uvicorn
589
+ uvicorn.run(app, host="0.0.0.0", port=8080)
@@ -0,0 +1,16 @@
1
+ # GCE Encoding Worker dependencies
2
+ # These are installed in the VM's Python virtual environment
3
+
4
+ # Web framework
5
+ fastapi>=0.109.0
6
+ uvicorn>=0.27.0
7
+
8
+ # GCS client
9
+ google-cloud-storage>=2.14.0
10
+
11
+ # Async utilities (optional, for future improvements)
12
+ aiofiles>=23.2.0
13
+ aiohttp>=3.9.0
14
+
15
+ # Note: The karaoke-gen wheel is installed separately from GCS.
16
+ # It provides LocalEncodingService which does the actual encoding.