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,535 @@
1
+ """
2
+ Title and end screen generation worker.
3
+
4
+ Handles screen generation after parallel processing completes:
5
+ 1. Generate title screen with artist/song info
6
+ 2. Generate end screen ("Thank you for singing!")
7
+ 3. Upload both screens to GCS
8
+ 4. Transition to AWAITING_REVIEW (human must review lyrics)
9
+
10
+ After review:
11
+ 5. Render video worker generates with_vocals.mkv
12
+ 6. Then AWAITING_INSTRUMENTAL_SELECTION
13
+
14
+ This worker is triggered automatically when both audio and lyrics
15
+ processing complete (via mark_audio_complete/mark_lyrics_complete coordination).
16
+
17
+ Integrates with karaoke_gen.video_generator.VideoGenerator.
18
+
19
+ Observability:
20
+ - All operations wrapped in tracing spans for Cloud Trace visibility
21
+ - Logs include [job:ID] prefix for easy filtering in Cloud Logging
22
+ - Worker start/end timing logged with WORKER_START/WORKER_END markers
23
+ """
24
+ import logging
25
+ import os
26
+ import shutil
27
+ import tempfile
28
+ import time
29
+ from typing import Optional, Dict, Any
30
+ from pathlib import Path
31
+
32
+ from backend.models.job import JobStatus
33
+ from backend.services.job_manager import JobManager
34
+ from backend.services.storage_service import StorageService
35
+ from backend.config import get_settings
36
+ from backend.workers.style_helper import load_style_config, StyleConfig
37
+ from backend.workers.worker_logging import create_job_logger, setup_job_logging, job_logging_context
38
+ from backend.services.tracing import job_span, add_span_event, add_span_attribute
39
+
40
+ # Import from karaoke_gen package
41
+ from karaoke_gen.video_generator import VideoGenerator
42
+ from karaoke_gen.utils import sanitize_filename
43
+
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ # Loggers to capture for screens worker
49
+ SCREENS_WORKER_LOGGERS = [
50
+ "karaoke_gen.video_generator",
51
+ "backend.workers.style_helper",
52
+ ]
53
+
54
+
55
+ async def generate_screens(job_id: str) -> bool:
56
+ """
57
+ Generate title and end screen videos for a job.
58
+
59
+ This is the main entry point for the screens worker.
60
+ Called automatically when both audio and lyrics processing complete.
61
+
62
+ Args:
63
+ job_id: Job ID to process
64
+
65
+ Returns:
66
+ True if successful, False otherwise
67
+ """
68
+ start_time = time.time()
69
+ job_manager = JobManager()
70
+ storage = StorageService()
71
+ settings = get_settings()
72
+
73
+ # Create job logger for remote debugging
74
+ job_log = create_job_logger(job_id, "screens")
75
+
76
+ # Log with structured markers for easy Cloud Logging queries
77
+ logger.info(f"[job:{job_id}] WORKER_START worker=screens")
78
+
79
+ # Set up log capture for VideoGenerator and style_helper
80
+ log_handler = setup_job_logging(job_id, "screens", *SCREENS_WORKER_LOGGERS)
81
+
82
+ job = job_manager.get_job(job_id)
83
+ if not job:
84
+ logger.error(f"[job:{job_id}] Job not found")
85
+ return False
86
+
87
+ # Validate both audio and lyrics are complete
88
+ if not _validate_prerequisites(job):
89
+ logger.error(f"[job:{job_id}] Prerequisites not met for screen generation")
90
+ return False
91
+
92
+ # Create temporary working directory
93
+ temp_dir = tempfile.mkdtemp(prefix=f"karaoke_screens_{job_id}_")
94
+
95
+ try:
96
+ # Wrap entire worker in a tracing span
97
+ with job_span("screens-worker", job_id, {"artist": job.artist, "title": job.title}) as root_span:
98
+ # Use job_logging_context for proper log isolation when multiple jobs run concurrently
99
+ with job_logging_context(job_id):
100
+ job_log.info(f"Starting screen generation for {job.artist} - {job.title}")
101
+ logger.info(f"[job:{job_id}] Starting screen generation for {job.artist} - {job.title}")
102
+
103
+ # Transition to GENERATING_SCREENS state
104
+ job_manager.transition_to_state(
105
+ job_id=job_id,
106
+ new_status=JobStatus.GENERATING_SCREENS,
107
+ progress=50,
108
+ message="Generating title and end screens"
109
+ )
110
+
111
+ # Log style assets info
112
+ style_assets = getattr(job, 'style_assets', {}) or {}
113
+ job_log.info(f"Style assets from job: {list(style_assets.keys()) if style_assets else 'None'}")
114
+ if style_assets:
115
+ for key, path in style_assets.items():
116
+ job_log.info(f" {key}: {path}")
117
+
118
+ # Load style configuration (downloads assets from GCS if available)
119
+ with job_span("load-style-config", job_id):
120
+ job_log.info("Loading style configuration from GCS...")
121
+ style_config = await load_style_config(job, storage, temp_dir)
122
+ if style_config.has_custom_styles():
123
+ job_log.info("Using CUSTOM style configuration")
124
+ logger.info(f"[job:{job_id}] Using custom style configuration")
125
+ add_span_attribute("style_type", "custom")
126
+ else:
127
+ job_log.warning("Using DEFAULT style configuration (no custom styles found)")
128
+ logger.info(f"[job:{job_id}] Using default style configuration")
129
+ add_span_attribute("style_type", "default")
130
+
131
+ # Initialize video generator
132
+ video_generator = _create_video_generator(temp_dir)
133
+
134
+ # Generate title screen with style config
135
+ with job_span("generate-title-screen", job_id):
136
+ job_log.info("Generating title screen...")
137
+ title_screen_path = await _generate_title_screen(
138
+ job_id=job_id,
139
+ job=job,
140
+ video_generator=video_generator,
141
+ style_config=style_config,
142
+ temp_dir=temp_dir,
143
+ job_log=job_log
144
+ )
145
+
146
+ if not title_screen_path:
147
+ raise Exception("Title screen generation failed")
148
+ job_log.info(f"Title screen generated: {title_screen_path}")
149
+
150
+ # Generate end screen with style config
151
+ with job_span("generate-end-screen", job_id):
152
+ job_log.info("Generating end screen...")
153
+ end_screen_path = await _generate_end_screen(
154
+ job_id=job_id,
155
+ job=job,
156
+ video_generator=video_generator,
157
+ style_config=style_config,
158
+ temp_dir=temp_dir,
159
+ job_log=job_log
160
+ )
161
+
162
+ if not end_screen_path:
163
+ raise Exception("End screen generation failed")
164
+ job_log.info(f"End screen generated: {end_screen_path}")
165
+
166
+ # Upload screens to GCS
167
+ with job_span("upload-screens", job_id):
168
+ await _upload_screens(
169
+ job_id=job_id,
170
+ job_manager=job_manager,
171
+ storage=storage,
172
+ title_screen_path=title_screen_path,
173
+ end_screen_path=end_screen_path
174
+ )
175
+
176
+ # Apply countdown padding if needed
177
+ await _apply_countdown_padding_if_needed(job_id, job_manager, job)
178
+
179
+ # Transition to AWAITING_REVIEW
180
+ # Human must review lyrics before video can be rendered
181
+ logger.info(f"[job:{job_id}] Screens generated, awaiting lyrics review")
182
+ job_manager.transition_to_state(
183
+ job_id=job_id,
184
+ new_status=JobStatus.AWAITING_REVIEW,
185
+ progress=55,
186
+ message="Ready for lyrics review. Please review and correct lyrics."
187
+ )
188
+
189
+ duration = time.time() - start_time
190
+ root_span.set_attribute("duration_seconds", duration)
191
+ logger.info(f"[job:{job_id}] WORKER_END worker=screens status=success duration={duration:.1f}s")
192
+ return True
193
+
194
+ except Exception as e:
195
+ duration = time.time() - start_time
196
+ logger.error(f"[job:{job_id}] WORKER_END worker=screens status=error duration={duration:.1f}s error={e}")
197
+ job_manager.mark_job_failed(
198
+ job_id=job_id,
199
+ error_message=f"Screen generation failed: {str(e)}",
200
+ error_details={"stage": "screen_generation", "error": str(e)}
201
+ )
202
+ return False
203
+
204
+ finally:
205
+ # Remove log handler to avoid duplicate logging on future runs
206
+ for logger_name in SCREENS_WORKER_LOGGERS:
207
+ try:
208
+ logging.getLogger(logger_name).removeHandler(log_handler)
209
+ except Exception:
210
+ pass
211
+
212
+ # Cleanup temporary directory
213
+ if os.path.exists(temp_dir):
214
+ shutil.rmtree(temp_dir)
215
+ logger.debug(f"[job:{job_id}] Cleaned up temp directory: {temp_dir}")
216
+
217
+
218
+ def _validate_prerequisites(job) -> bool:
219
+ """
220
+ Validate that both audio and lyrics processing are complete.
221
+
222
+ Single Responsibility: Validation logic separated from main flow.
223
+
224
+ Args:
225
+ job: Job object
226
+
227
+ Returns:
228
+ True if prerequisites met, False otherwise
229
+ """
230
+ # SAFETY NET: Enforce theme requirement at processing time
231
+ # This catches any jobs that somehow bypassed JobManager.create_job() validation
232
+ if not job.theme_id:
233
+ logger.error(
234
+ f"Job {job.job_id}: CRITICAL - No theme_id configured. "
235
+ "All jobs must have a theme to generate styled videos. "
236
+ "This job should have been rejected at creation time."
237
+ )
238
+ return False
239
+
240
+ audio_complete = job.state_data.get('audio_complete', False)
241
+ lyrics_complete = job.state_data.get('lyrics_complete', False)
242
+
243
+ if not audio_complete:
244
+ logger.error(f"Job {job.job_id}: Audio processing not complete")
245
+ return False
246
+
247
+ if not lyrics_complete:
248
+ logger.error(f"Job {job.job_id}: Lyrics processing not complete")
249
+ return False
250
+
251
+ if not job.artist or not job.title:
252
+ logger.error(f"Job {job.job_id}: Missing artist or title")
253
+ return False
254
+
255
+ return True
256
+
257
+
258
+ def _create_video_generator(temp_dir: str) -> VideoGenerator:
259
+ """
260
+ Create video generator with appropriate parameters.
261
+
262
+ Dependency Inversion: Depends on VideoGenerator abstraction.
263
+
264
+ Args:
265
+ temp_dir: Temporary directory for output
266
+
267
+ Returns:
268
+ Configured VideoGenerator instance
269
+ """
270
+ # FFmpeg base command (same as in audio_worker)
271
+ ffmpeg_base_command = "ffmpeg -hide_banner -loglevel error -nostats -y"
272
+
273
+ return VideoGenerator(
274
+ logger=logger,
275
+ ffmpeg_base_command=ffmpeg_base_command,
276
+ render_bounding_boxes=False,
277
+ output_png=True,
278
+ output_jpg=True
279
+ )
280
+
281
+
282
+ async def _generate_title_screen(
283
+ job_id: str,
284
+ job,
285
+ video_generator: VideoGenerator,
286
+ style_config: StyleConfig,
287
+ temp_dir: str,
288
+ job_log = None
289
+ ) -> Optional[str]:
290
+ """
291
+ Generate title screen video with custom style configuration.
292
+
293
+ Single Responsibility: Only handles title screen generation.
294
+
295
+ Args:
296
+ job_id: Job ID
297
+ job: Job object with artist/title
298
+ video_generator: Video generator instance
299
+ style_config: Style configuration with formats and assets
300
+ temp_dir: Temporary directory
301
+ job_log: Optional JobLogger for remote debugging
302
+
303
+ Returns:
304
+ Path to generated title screen, or None if failed
305
+ """
306
+ try:
307
+ logger.info(f"Job {job_id}: Generating title screen")
308
+
309
+ # Set up output paths
310
+ # Sanitize artist/title to handle Unicode characters (curly quotes, em dashes, etc.)
311
+ safe_artist = sanitize_filename(job.artist) if job.artist else "Unknown"
312
+ safe_title = sanitize_filename(job.title) if job.title else "Unknown"
313
+ artist_title = f"{safe_artist} - {safe_title}"
314
+ output_image_filepath_noext = os.path.join(temp_dir, f"{artist_title} (Title)")
315
+ output_video_filepath = os.path.join(temp_dir, f"{artist_title} (Title).mov")
316
+
317
+ # Get title format settings from style config
318
+ title_format = style_config.get_intro_format()
319
+ intro_duration = style_config.intro_video_duration
320
+
321
+ # Log detailed style info for debugging
322
+ if job_log:
323
+ job_log.info("Title screen format configuration:")
324
+ job_log.info(f" background_image: {title_format.get('background_image')}")
325
+ job_log.info(f" background_color: {title_format.get('background_color')}")
326
+ job_log.info(f" font: {title_format.get('font')}")
327
+ job_log.info(f" title_color: {title_format.get('title_color')}")
328
+ job_log.info(f" artist_color: {title_format.get('artist_color')}")
329
+ job_log.info(f" duration: {intro_duration}s")
330
+
331
+ # Check if background image exists
332
+ bg_image = title_format.get('background_image')
333
+ if bg_image and os.path.exists(bg_image):
334
+ job_log.info(f" background_image file EXISTS: {os.path.getsize(bg_image)} bytes")
335
+ elif bg_image:
336
+ job_log.warning(f" background_image file NOT FOUND: {bg_image}")
337
+
338
+ logger.info(f"Job {job_id}: Title format - bg_image: {title_format.get('background_image')}, font: {title_format.get('font')}")
339
+
340
+ # Generate title screen (synchronous method)
341
+ video_generator.create_title_video(
342
+ artist=job.artist,
343
+ title=job.title,
344
+ format=title_format,
345
+ output_image_filepath_noext=output_image_filepath_noext,
346
+ output_video_filepath=output_video_filepath,
347
+ existing_title_image=title_format.get('existing_image'),
348
+ intro_video_duration=intro_duration
349
+ )
350
+
351
+ if os.path.exists(output_video_filepath):
352
+ logger.info(f"Job {job_id}: Title screen generated at {output_video_filepath}")
353
+ return output_video_filepath
354
+ else:
355
+ logger.error(f"Job {job_id}: Title screen generation returned no file")
356
+ return None
357
+
358
+ except Exception as e:
359
+ logger.error(f"Job {job_id}: Title screen generation error: {e}", exc_info=True)
360
+ return None
361
+
362
+
363
+ async def _generate_end_screen(
364
+ job_id: str,
365
+ job,
366
+ video_generator: VideoGenerator,
367
+ style_config: StyleConfig,
368
+ temp_dir: str,
369
+ job_log = None
370
+ ) -> Optional[str]:
371
+ """
372
+ Generate end screen video with custom style configuration.
373
+
374
+ Single Responsibility: Only handles end screen generation.
375
+
376
+ Args:
377
+ job_id: Job ID
378
+ job: Job object with artist/title
379
+ video_generator: Video generator instance
380
+ style_config: Style configuration with formats and assets
381
+ temp_dir: Temporary directory
382
+ job_log: Optional JobLogger for remote debugging
383
+
384
+ Returns:
385
+ Path to generated end screen, or None if failed
386
+ """
387
+ try:
388
+ logger.info(f"Job {job_id}: Generating end screen")
389
+
390
+ # Set up output paths
391
+ # Sanitize artist/title to handle Unicode characters (curly quotes, em dashes, etc.)
392
+ safe_artist = sanitize_filename(job.artist) if job.artist else "Unknown"
393
+ safe_title = sanitize_filename(job.title) if job.title else "Unknown"
394
+ artist_title = f"{safe_artist} - {safe_title}"
395
+ output_image_filepath_noext = os.path.join(temp_dir, f"{artist_title} (End)")
396
+ output_video_filepath = os.path.join(temp_dir, f"{artist_title} (End).mov")
397
+
398
+ # Get end format settings from style config
399
+ end_format = style_config.get_end_format()
400
+ end_duration = style_config.end_video_duration
401
+
402
+ # Log detailed style info for debugging
403
+ if job_log:
404
+ job_log.info("End screen format configuration:")
405
+ job_log.info(f" background_image: {end_format.get('background_image')}")
406
+ job_log.info(f" background_color: {end_format.get('background_color')}")
407
+ job_log.info(f" font: {end_format.get('font')}")
408
+ job_log.info(f" duration: {end_duration}s")
409
+
410
+ logger.info(f"Job {job_id}: End format - bg_image: {end_format.get('background_image')}, font: {end_format.get('font')}")
411
+
412
+ # Generate end screen (synchronous method)
413
+ video_generator.create_end_video(
414
+ artist=job.artist,
415
+ title=job.title,
416
+ format=end_format,
417
+ output_image_filepath_noext=output_image_filepath_noext,
418
+ output_video_filepath=output_video_filepath,
419
+ existing_end_image=end_format.get('existing_image'),
420
+ end_video_duration=end_duration
421
+ )
422
+
423
+ if os.path.exists(output_video_filepath):
424
+ logger.info(f"Job {job_id}: End screen generated at {output_video_filepath}")
425
+ return output_video_filepath
426
+ else:
427
+ logger.error(f"Job {job_id}: End screen generation returned no file")
428
+ return None
429
+
430
+ except Exception as e:
431
+ logger.error(f"Job {job_id}: End screen generation error: {e}", exc_info=True)
432
+ return None
433
+
434
+
435
+ async def _upload_screens(
436
+ job_id: str,
437
+ job_manager: JobManager,
438
+ storage: StorageService,
439
+ title_screen_path: str,
440
+ end_screen_path: str
441
+ ) -> None:
442
+ """
443
+ Upload title and end screens to GCS (video + images).
444
+
445
+ Single Responsibility: Only handles uploads.
446
+
447
+ VideoGenerator creates .mov, .jpg, and .png files when configured with
448
+ output_png=True and output_jpg=True. We upload all three formats
449
+ for feature parity with the local CLI.
450
+
451
+ Args:
452
+ job_id: Job ID
453
+ job_manager: Job manager instance
454
+ storage: Storage service instance
455
+ title_screen_path: Path to title screen video (.mov)
456
+ end_screen_path: Path to end screen video (.mov)
457
+ """
458
+ # Upload title screen video
459
+ title_gcs_path = f"jobs/{job_id}/screens/title.mov"
460
+ title_url = storage.upload_file(title_screen_path, title_gcs_path)
461
+ job_manager.update_file_url(job_id, 'screens', 'title', title_url)
462
+ logger.info(f"Job {job_id}: Uploaded title screen video")
463
+
464
+ # Upload title screen images (.jpg and .png - created by VideoGenerator)
465
+ title_base = title_screen_path.replace('.mov', '')
466
+ for ext, key in [('.jpg', 'title_jpg'), ('.png', 'title_png')]:
467
+ image_path = f"{title_base}{ext}"
468
+ if os.path.exists(image_path):
469
+ gcs_path = f"jobs/{job_id}/screens/title{ext}"
470
+ url = storage.upload_file(image_path, gcs_path)
471
+ job_manager.update_file_url(job_id, 'screens', key, url)
472
+ logger.info(f"Job {job_id}: Uploaded title screen image ({ext})")
473
+
474
+ # Upload end screen video
475
+ end_gcs_path = f"jobs/{job_id}/screens/end.mov"
476
+ end_url = storage.upload_file(end_screen_path, end_gcs_path)
477
+ job_manager.update_file_url(job_id, 'screens', 'end', end_url)
478
+ logger.info(f"Job {job_id}: Uploaded end screen video")
479
+
480
+ # Upload end screen images (.jpg and .png - created by VideoGenerator)
481
+ end_base = end_screen_path.replace('.mov', '')
482
+ for ext, key in [('.jpg', 'end_jpg'), ('.png', 'end_png')]:
483
+ image_path = f"{end_base}{ext}"
484
+ if os.path.exists(image_path):
485
+ gcs_path = f"jobs/{job_id}/screens/end{ext}"
486
+ url = storage.upload_file(image_path, gcs_path)
487
+ job_manager.update_file_url(job_id, 'screens', key, url)
488
+ logger.info(f"Job {job_id}: Uploaded end screen image ({ext})")
489
+
490
+
491
+ async def _apply_countdown_padding_if_needed(
492
+ job_id: str,
493
+ job_manager: JobManager,
494
+ job
495
+ ) -> None:
496
+ """
497
+ Apply countdown padding to instrumentals if needed.
498
+
499
+ If lyrics start very early (within 3 seconds), LyricsTranscriber
500
+ adds a countdown intro ("3... 2... 1...") to the vocal audio.
501
+ We need to pad the instrumentals to match.
502
+
503
+ Single Responsibility: Only handles padding logic.
504
+
505
+ Args:
506
+ job_id: Job ID
507
+ job_manager: Job manager instance
508
+ job: Job object with lyrics metadata
509
+ """
510
+ # Check if countdown padding was added
511
+ lyrics_metadata = job.state_data.get('lyrics_metadata', {})
512
+ has_countdown = lyrics_metadata.get('has_countdown_padding', False)
513
+
514
+ if has_countdown:
515
+ logger.info(f"Job {job_id}: Countdown padding detected, applying to instrumentals")
516
+
517
+ # Transition to APPLYING_PADDING state
518
+ job_manager.transition_to_state(
519
+ job_id=job_id,
520
+ new_status=JobStatus.APPLYING_PADDING,
521
+ progress=52,
522
+ message="Synchronizing countdown padding"
523
+ )
524
+
525
+ # TODO: Implement padding application
526
+ # This requires:
527
+ # 1. Download instrumental stems from GCS
528
+ # 2. Add silence to beginning
529
+ # 3. Re-upload padded versions
530
+ # For now, we'll skip this and just transition back
531
+
532
+ logger.info(f"Job {job_id}: Padding applied (TODO: implement actual padding)")
533
+ else:
534
+ logger.info(f"Job {job_id}: No countdown padding needed")
535
+