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,483 @@
1
+ """
2
+ Render Video Worker
3
+
4
+ Generates the karaoke video with synchronized lyrics AFTER human review.
5
+
6
+ This worker:
7
+ 1. Downloads the corrected lyrics data from GCS
8
+ 2. Downloads the audio file
9
+ 3. Downloads style assets from GCS
10
+ 4. Uses LyricsTranscriber's OutputGenerator to render video
11
+ 5. Uploads the with_vocals.mkv to GCS
12
+ 6. Transitions to AWAITING_INSTRUMENTAL_SELECTION
13
+
14
+ Key insight: We use OutputGenerator from lyrics_transcriber library
15
+ WITHOUT using its blocking ReviewServer. This allows async operation
16
+ in Cloud Run.
17
+
18
+ Observability:
19
+ - All operations wrapped in tracing spans for Cloud Trace visibility
20
+ - Logs include [job:ID] prefix for easy filtering in Cloud Logging
21
+ - Worker start/end timing logged with WORKER_START/WORKER_END markers
22
+ """
23
+ import logging
24
+ import os
25
+ import tempfile
26
+ import time
27
+ import json
28
+ from typing import Optional, Dict, Any
29
+ from dataclasses import dataclass
30
+
31
+ from backend.models.job import JobStatus
32
+ from backend.services.job_manager import JobManager
33
+ from backend.services.storage_service import StorageService
34
+ from backend.config import get_settings
35
+ from backend.workers.worker_logging import create_job_logger, setup_job_logging, job_logging_context
36
+ from backend.services.tracing import job_span, add_span_event, add_span_attribute
37
+
38
+ # Import from lyrics_transcriber (submodule)
39
+ from lyrics_transcriber.output.generator import OutputGenerator
40
+ from lyrics_transcriber.output.countdown_processor import CountdownProcessor
41
+ from lyrics_transcriber.types import CorrectionResult
42
+ from lyrics_transcriber.core.config import OutputConfig
43
+
44
+ # Import from the unified style loader
45
+ from karaoke_gen.style_loader import load_styles_from_gcs
46
+ from karaoke_gen.utils import sanitize_filename
47
+
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+
52
+ # Loggers to capture for render video worker
53
+ RENDER_VIDEO_WORKER_LOGGERS = [
54
+ "lyrics_transcriber.output",
55
+ "lyrics_transcriber.output.generator",
56
+ "lyrics_transcriber.output.video",
57
+ "lyrics_transcriber.output.ass",
58
+ ]
59
+
60
+
61
+ async def process_render_video(job_id: str) -> bool:
62
+ """
63
+ Render karaoke video with corrected lyrics.
64
+
65
+ Called after human review is complete (REVIEW_COMPLETE state).
66
+ Uses OutputGenerator from lyrics_transcriber to generate the video.
67
+
68
+ Args:
69
+ job_id: Job ID to process
70
+
71
+ Returns:
72
+ True if successful, False otherwise
73
+ """
74
+ start_time = time.time()
75
+ job_manager = JobManager()
76
+ storage = StorageService()
77
+ settings = get_settings()
78
+
79
+ # Create job logger for remote debugging FIRST
80
+ job_log = create_job_logger(job_id, "render_video")
81
+
82
+ # Log with structured markers for easy Cloud Logging queries
83
+ logger.info(f"[job:{job_id}] WORKER_START worker=render-video")
84
+ job_log.info("=== RENDER VIDEO WORKER STARTED ===")
85
+ job_log.info(f"Job ID: {job_id}")
86
+
87
+ # Set up log capture for OutputGenerator
88
+ log_handler = setup_job_logging(job_id, "render_video", *RENDER_VIDEO_WORKER_LOGGERS)
89
+ job_log.info(f"Log handler attached for {len(RENDER_VIDEO_WORKER_LOGGERS)} loggers")
90
+
91
+ job = job_manager.get_job(job_id)
92
+ if not job:
93
+ logger.error(f"[job:{job_id}] Job not found in Firestore")
94
+ job_log.error(f"Job {job_id} not found in Firestore!")
95
+ return False
96
+
97
+ job_log.info(f"Starting video render for {job.artist} - {job.title}")
98
+ logger.info(f"[job:{job_id}] Starting video render (post-review)")
99
+
100
+ try:
101
+ # Wrap entire worker in a tracing span
102
+ with job_span("render-video-worker", job_id, {"artist": job.artist, "title": job.title}) as root_span:
103
+ # Use job_logging_context for proper log isolation when multiple jobs run concurrently
104
+ # This ensures logs from third-party libraries (lyrics_transcriber.output) are only
105
+ # captured by this job's handler, not handlers from other concurrent jobs
106
+ with job_logging_context(job_id):
107
+ # Transition to RENDERING_VIDEO
108
+ job_manager.transition_to_state(
109
+ job_id=job_id,
110
+ new_status=JobStatus.RENDERING_VIDEO,
111
+ progress=75,
112
+ message="Rendering karaoke video with corrected lyrics"
113
+ )
114
+
115
+ with tempfile.TemporaryDirectory() as temp_dir:
116
+ job_log.info(f"Created temp directory: {temp_dir}")
117
+
118
+ # Try updated corrections first (from human review), fall back to original
119
+ corrections_gcs_updated = f"jobs/{job_id}/lyrics/corrections_updated.json"
120
+ corrections_gcs_original = f"jobs/{job_id}/lyrics/corrections.json"
121
+
122
+ # Get GCS path from file_urls
123
+ corrections_url = job.file_urls.get('lyrics', {}).get('corrections_updated')
124
+ if not corrections_url:
125
+ corrections_url = job.file_urls.get('lyrics', {}).get('corrections')
126
+
127
+ if not corrections_url:
128
+ # Try direct GCS paths
129
+ if storage.file_exists(corrections_gcs_updated):
130
+ corrections_gcs = corrections_gcs_updated
131
+ elif storage.file_exists(corrections_gcs_original):
132
+ corrections_gcs = corrections_gcs_original
133
+ else:
134
+ raise FileNotFoundError(f"No corrections file found for job {job_id}")
135
+ else:
136
+ # Extract GCS path from URL
137
+ corrections_gcs = _extract_gcs_path(corrections_url)
138
+
139
+ # 2. Load the ORIGINAL corrections (has full structure)
140
+ original_corrections_gcs = f"jobs/{job_id}/lyrics/corrections.json"
141
+ original_corrections_path = os.path.join(temp_dir, "corrections_original.json")
142
+
143
+ job_log.info(f"Downloading original corrections from {original_corrections_gcs}")
144
+ logger.info(f"Job {job_id}: Downloading original corrections from {original_corrections_gcs}")
145
+ storage.download_file(original_corrections_gcs, original_corrections_path)
146
+
147
+ with open(original_corrections_path, 'r', encoding='utf-8') as f:
148
+ original_data = json.load(f)
149
+
150
+ # 3. Check if there are updated corrections (from review UI)
151
+ # The frontend sends only partial data: {corrections, corrected_segments}
152
+ updated_corrections_gcs = f"jobs/{job_id}/lyrics/corrections_updated.json"
153
+
154
+ if storage.file_exists(updated_corrections_gcs):
155
+ job_log.info("Found updated corrections from review, merging")
156
+ logger.info(f"Job {job_id}: Found updated corrections, merging")
157
+ updated_path = os.path.join(temp_dir, "corrections_updated.json")
158
+ storage.download_file(updated_corrections_gcs, updated_path)
159
+
160
+ with open(updated_path, 'r', encoding='utf-8') as f:
161
+ updated_data = json.load(f)
162
+
163
+ # Merge: update the original with the user's corrections
164
+ if 'corrections' in updated_data:
165
+ original_data['corrections'] = updated_data['corrections']
166
+ if 'corrected_segments' in updated_data:
167
+ original_data['corrected_segments'] = updated_data['corrected_segments']
168
+
169
+ job_log.info("Merged user corrections into original data")
170
+ logger.info(f"Job {job_id}: Merged user corrections into original data")
171
+
172
+ # 4. Convert to CorrectionResult
173
+ correction_result = CorrectionResult.from_dict(original_data)
174
+ job_log.info(f"Loaded CorrectionResult with {len(correction_result.corrected_segments)} segments")
175
+ logger.info(f"Job {job_id}: Loaded CorrectionResult with {len(correction_result.corrected_segments)} segments")
176
+
177
+ # 5. Download audio file
178
+ audio_path = os.path.join(temp_dir, "audio.flac")
179
+ audio_gcs_path = job.input_media_gcs_path
180
+
181
+ if not audio_gcs_path:
182
+ raise FileNotFoundError(f"No input audio path for job {job_id}")
183
+
184
+ job_log.info(f"Downloading audio from {audio_gcs_path}")
185
+ logger.info(f"Job {job_id}: Downloading audio from {audio_gcs_path}")
186
+ storage.download_file(audio_gcs_path, audio_path)
187
+ job_log.info(f"Audio downloaded: {os.path.getsize(audio_path)} bytes")
188
+
189
+ # Process countdown intro if needed (for songs that start too quickly)
190
+ # This adds "3... 2... 1..." segment and pads audio with 3s silence
191
+ # Note: Countdown is deferred to this stage (not during lyrics transcription)
192
+ # so the review UI can show the original timing without the 3s shift
193
+ countdown_processor = CountdownProcessor(cache_dir=temp_dir, logger=logger)
194
+ correction_result, audio_path, padding_added, padding_seconds = countdown_processor.process(
195
+ correction_result=correction_result,
196
+ audio_filepath=audio_path,
197
+ )
198
+ if padding_added:
199
+ job_log.info(f"Countdown added: {padding_seconds}s padding applied to audio and timestamps shifted")
200
+ else:
201
+ job_log.info("No countdown needed - song starts after 3 seconds")
202
+
203
+ # 6. Get or create styles using the unified style loader
204
+ job_log.info("Loading style configuration...")
205
+ job_log.info(f" job.style_params_gcs_path: {job.style_params_gcs_path}")
206
+ job_log.info(f" job.style_assets: {list(job.style_assets.keys()) if job.style_assets else 'None'}")
207
+
208
+ styles_path, style_data = load_styles_from_gcs(
209
+ style_params_gcs_path=job.style_params_gcs_path,
210
+ style_assets=job.style_assets,
211
+ temp_dir=temp_dir,
212
+ download_func=storage.download_file,
213
+ logger=job_log,
214
+ )
215
+
216
+ # 7. Configure OutputGenerator
217
+ output_dir = os.path.join(temp_dir, "output")
218
+ cache_dir = os.path.join(temp_dir, "cache")
219
+ os.makedirs(output_dir, exist_ok=True)
220
+ os.makedirs(cache_dir, exist_ok=True)
221
+
222
+ job_log.info(f"Using styles from: {styles_path}")
223
+
224
+ # Get subtitle offset from job (user-specified timing adjustment)
225
+ subtitle_offset = getattr(job, 'subtitle_offset_ms', 0) or 0
226
+ if subtitle_offset != 0:
227
+ job_log.info(f"Applying subtitle offset: {subtitle_offset}ms")
228
+
229
+ config = OutputConfig(
230
+ output_dir=output_dir,
231
+ cache_dir=cache_dir,
232
+ output_styles_json=styles_path,
233
+ render_video=True,
234
+ generate_cdg=False, # CDG optional, generated separately
235
+ generate_plain_text=True,
236
+ generate_lrc=True,
237
+ video_resolution="4k",
238
+ subtitle_offset_ms=subtitle_offset
239
+ )
240
+
241
+ job_log.info(f"OutputConfig: output_styles_json={config.output_styles_json}, render_video={config.render_video}")
242
+
243
+ output_generator = OutputGenerator(config, logger)
244
+
245
+ # 8. Generate outputs (video, LRC, ASS, etc.)
246
+ # Sanitize artist/title to handle Unicode characters (curly quotes, em dashes, etc.)
247
+ safe_artist = sanitize_filename(job.artist) if job.artist else "Unknown"
248
+ safe_title = sanitize_filename(job.title) if job.title else "Unknown"
249
+ output_prefix = f"{safe_artist} - {safe_title}"
250
+ job_log.info(f"Generating outputs with prefix '{output_prefix}'")
251
+ logger.info(f"Job {job_id}: Generating outputs with prefix '{output_prefix}'")
252
+
253
+ outputs = output_generator.generate_outputs(
254
+ transcription_corrected=correction_result,
255
+ lyrics_results={}, # Reference lyrics already in correction_result
256
+ output_prefix=output_prefix,
257
+ audio_filepath=audio_path,
258
+ artist=job.artist,
259
+ title=job.title
260
+ )
261
+
262
+ # 9. Upload video to GCS
263
+ if outputs.video and os.path.exists(outputs.video):
264
+ video_size = os.path.getsize(outputs.video)
265
+ job_log.info(f"Video generated: {video_size} bytes")
266
+ video_gcs_path = f"jobs/{job_id}/videos/with_vocals.mkv"
267
+ video_url = storage.upload_file(outputs.video, video_gcs_path)
268
+ job_manager.update_file_url(job_id, 'videos', 'with_vocals', video_url)
269
+ job_log.info(f"Uploaded with_vocals.mkv to GCS")
270
+ logger.info(f"Job {job_id}: Uploaded with_vocals.mkv ({video_size} bytes)")
271
+ else:
272
+ job_log.error("Video generation failed - no output file produced!")
273
+ raise Exception("Video generation failed - no output file produced")
274
+
275
+ # 10. Upload LRC file
276
+ if outputs.lrc and os.path.exists(outputs.lrc):
277
+ lrc_gcs_path = f"jobs/{job_id}/lyrics/karaoke.lrc"
278
+ lrc_url = storage.upload_file(outputs.lrc, lrc_gcs_path)
279
+ job_manager.update_file_url(job_id, 'lyrics', 'lrc', lrc_url)
280
+ job_log.info("Uploaded karaoke.lrc")
281
+ logger.info(f"Job {job_id}: Uploaded karaoke.lrc")
282
+
283
+ # 11. Upload ASS subtitle file
284
+ if outputs.ass and os.path.exists(outputs.ass):
285
+ ass_gcs_path = f"jobs/{job_id}/lyrics/karaoke.ass"
286
+ ass_url = storage.upload_file(outputs.ass, ass_gcs_path)
287
+ job_manager.update_file_url(job_id, 'lyrics', 'ass', ass_url)
288
+ job_log.info("Uploaded karaoke.ass")
289
+ logger.info(f"Job {job_id}: Uploaded karaoke.ass")
290
+
291
+ # 12. Upload corrected text files
292
+ if outputs.corrected_txt and os.path.exists(outputs.corrected_txt):
293
+ txt_gcs_path = f"jobs/{job_id}/lyrics/corrected.txt"
294
+ txt_url = storage.upload_file(outputs.corrected_txt, txt_gcs_path)
295
+ job_manager.update_file_url(job_id, 'lyrics', 'corrected_txt', txt_url)
296
+ job_log.info("Uploaded corrected.txt")
297
+ logger.info(f"Job {job_id}: Uploaded corrected.txt")
298
+
299
+ # 13. Analyze backing vocals for intelligent instrumental selection
300
+ job_log.info("Analyzing backing vocals for instrumental selection...")
301
+ await _analyze_backing_vocals(job_id, job_manager, storage, job_log)
302
+
303
+ # 14. Transition based on prep_only flag and existing instrumental
304
+ if getattr(job, 'prep_only', False):
305
+ # Prep-only mode: stop here and mark as prep complete
306
+ job_manager.transition_to_state(
307
+ job_id=job_id,
308
+ new_status=JobStatus.PREP_COMPLETE,
309
+ progress=100,
310
+ message="Prep phase complete - download outputs to continue locally"
311
+ )
312
+ job_log.info("=== RENDER VIDEO WORKER COMPLETE (PREP ONLY) ===")
313
+ duration = time.time() - start_time
314
+ root_span.set_attribute("duration_seconds", duration)
315
+ root_span.set_attribute("prep_only", True)
316
+ logger.info(f"[job:{job_id}] WORKER_END worker=render-video status=success duration={duration:.1f}s prep_only=true")
317
+ elif getattr(job, 'existing_instrumental_gcs_path', None):
318
+ # Existing instrumental provided - skip selection, auto-use it
319
+ # Store selection as 'custom' to indicate user-provided instrumental
320
+ job_manager.update_state_data(job_id, 'instrumental_selection', 'custom')
321
+ job_log.info("Existing instrumental provided - skipping selection, using user-provided file")
322
+
323
+ job_manager.transition_to_state(
324
+ job_id=job_id,
325
+ new_status=JobStatus.INSTRUMENTAL_SELECTED,
326
+ progress=82,
327
+ message="Using user-provided instrumental"
328
+ )
329
+ job_log.info("=== RENDER VIDEO WORKER COMPLETE (EXISTING INSTRUMENTAL) ===")
330
+ duration = time.time() - start_time
331
+ root_span.set_attribute("duration_seconds", duration)
332
+ root_span.set_attribute("existing_instrumental", True)
333
+ logger.info(f"[job:{job_id}] WORKER_END worker=render-video status=success duration={duration:.1f}s existing_instrumental=true")
334
+
335
+ # Trigger video worker directly since no user selection needed
336
+ from backend.workers.video_worker import run_video_worker
337
+ job_log.info("Triggering video worker for final encoding...")
338
+ await run_video_worker(job_id, job_manager, storage)
339
+ else:
340
+ # Normal mode: proceed to instrumental selection
341
+ job_manager.transition_to_state(
342
+ job_id=job_id,
343
+ new_status=JobStatus.AWAITING_INSTRUMENTAL_SELECTION,
344
+ progress=80,
345
+ message="Video rendered - select your instrumental"
346
+ )
347
+ job_log.info("=== RENDER VIDEO WORKER COMPLETE ===")
348
+ duration = time.time() - start_time
349
+ root_span.set_attribute("duration_seconds", duration)
350
+ logger.info(f"[job:{job_id}] WORKER_END worker=render-video status=success duration={duration:.1f}s")
351
+ return True
352
+
353
+ except Exception as e:
354
+ duration = time.time() - start_time
355
+ job_log.error(f"Video render failed: {e}")
356
+ logger.error(f"[job:{job_id}] WORKER_END worker=render-video status=error duration={duration:.1f}s error={e}")
357
+ job_manager.fail_job(job_id, f"Video render failed: {str(e)}")
358
+ return False
359
+
360
+
361
+ def _extract_gcs_path(url: str) -> str:
362
+ """Extract GCS path from a URL or return as-is if already a path."""
363
+ if url.startswith('gs://'):
364
+ # Already a GCS path
365
+ return url.replace('gs://', '').split('/', 1)[1] if '/' in url else url
366
+ if url.startswith('https://storage.googleapis.com/'):
367
+ # Signed URL - extract path
368
+ path = url.replace('https://storage.googleapis.com/', '')
369
+ # Remove query params
370
+ if '?' in path:
371
+ path = path.split('?')[0]
372
+ # Skip bucket name
373
+ parts = path.split('/', 1)
374
+ return parts[1] if len(parts) > 1 else path
375
+ return url
376
+
377
+
378
+ async def _analyze_backing_vocals(
379
+ job_id: str,
380
+ job_manager: JobManager,
381
+ storage: StorageService,
382
+ job_log: logging.Logger,
383
+ ) -> None:
384
+ """
385
+ Analyze backing vocals to help with intelligent instrumental selection.
386
+
387
+ This function:
388
+ 1. Downloads the backing vocals stem from GCS
389
+ 2. Runs audio analysis to detect audible content
390
+ 3. Generates a waveform visualization image
391
+ 4. Stores analysis results and waveform URL in job state
392
+
393
+ The analysis data is then used by the frontend to provide an
394
+ intelligent instrumental selection experience.
395
+ """
396
+ from backend.services.audio_analysis_service import AudioAnalysisService
397
+
398
+ try:
399
+ # Get the job to access file URLs
400
+ job = job_manager.get_job(job_id)
401
+ if not job:
402
+ job_log.warning(f"Could not get job {job_id} for backing vocals analysis")
403
+ return
404
+
405
+ # Get backing vocals path
406
+ backing_vocals_path = job.file_urls.get('stems', {}).get('backing_vocals')
407
+ if not backing_vocals_path:
408
+ job_log.warning("No backing vocals file found - skipping analysis")
409
+ return
410
+
411
+ job_log.info(f"Analyzing backing vocals: {backing_vocals_path}")
412
+
413
+ # Create analysis service and run analysis
414
+ analysis_service = AudioAnalysisService()
415
+
416
+ # Define output path for waveform
417
+ waveform_gcs_path = f"jobs/{job_id}/analysis/backing_vocals_waveform.png"
418
+
419
+ # Run analysis and generate waveform
420
+ result, waveform_path = analysis_service.analyze_and_generate_waveform(
421
+ gcs_audio_path=backing_vocals_path,
422
+ job_id=job_id,
423
+ gcs_waveform_destination=waveform_gcs_path,
424
+ )
425
+
426
+ # Store analysis results in job state_data
427
+ analysis_data = {
428
+ 'has_audible_content': result.has_audible_content,
429
+ 'total_duration_seconds': result.total_duration_seconds,
430
+ 'audible_segments': [
431
+ {
432
+ 'start_seconds': seg.start_seconds,
433
+ 'end_seconds': seg.end_seconds,
434
+ 'duration_seconds': seg.duration_seconds,
435
+ 'avg_amplitude_db': seg.avg_amplitude_db,
436
+ 'peak_amplitude_db': seg.peak_amplitude_db,
437
+ }
438
+ for seg in result.audible_segments
439
+ ],
440
+ 'recommended_selection': result.recommended_selection.value,
441
+ 'total_audible_duration_seconds': result.total_audible_duration_seconds,
442
+ 'audible_percentage': result.audible_percentage,
443
+ 'silence_threshold_db': result.silence_threshold_db,
444
+ }
445
+
446
+ job_manager.update_state_data(job_id, 'backing_vocals_analysis', analysis_data)
447
+
448
+ # Store waveform URL
449
+ job_manager.update_file_url(job_id, 'analysis', 'backing_vocals_waveform', waveform_path)
450
+
451
+ job_log.info(
452
+ f"Backing vocals analysis complete: "
453
+ f"has_audible={result.has_audible_content}, "
454
+ f"segments={result.segment_count}, "
455
+ f"recommendation={result.recommended_selection.value}"
456
+ )
457
+
458
+ # Log segment details if there are any
459
+ if result.audible_segments:
460
+ job_log.info(f"Audible segments ({len(result.audible_segments)}):")
461
+ for i, seg in enumerate(result.audible_segments[:5]): # Log first 5
462
+ job_log.info(
463
+ f" [{i+1}] {seg.start_seconds:.1f}s - {seg.end_seconds:.1f}s "
464
+ f"({seg.duration_seconds:.1f}s, avg: {seg.avg_amplitude_db:.1f}dB)"
465
+ )
466
+ if len(result.audible_segments) > 5:
467
+ job_log.info(f" ... and {len(result.audible_segments) - 5} more")
468
+
469
+ except Exception as e:
470
+ # Log the error but don't fail the job - analysis is a nice-to-have
471
+ job_log.warning(f"Backing vocals analysis failed (non-fatal): {e}")
472
+ logger.warning(f"Job {job_id}: Backing vocals analysis failed: {e}")
473
+
474
+ # Store empty analysis so the frontend knows analysis was attempted
475
+ job_manager.update_state_data(job_id, 'backing_vocals_analysis', {
476
+ 'has_audible_content': None,
477
+ 'analysis_error': str(e),
478
+ 'recommended_selection': 'review_needed',
479
+ })
480
+
481
+
482
+ # For compatibility with worker service
483
+ render_video_worker = process_render_video