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,525 @@
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
+ audio_complete = job.state_data.get('audio_complete', False)
231
+ lyrics_complete = job.state_data.get('lyrics_complete', False)
232
+
233
+ if not audio_complete:
234
+ logger.error(f"Job {job.job_id}: Audio processing not complete")
235
+ return False
236
+
237
+ if not lyrics_complete:
238
+ logger.error(f"Job {job.job_id}: Lyrics processing not complete")
239
+ return False
240
+
241
+ if not job.artist or not job.title:
242
+ logger.error(f"Job {job.job_id}: Missing artist or title")
243
+ return False
244
+
245
+ return True
246
+
247
+
248
+ def _create_video_generator(temp_dir: str) -> VideoGenerator:
249
+ """
250
+ Create video generator with appropriate parameters.
251
+
252
+ Dependency Inversion: Depends on VideoGenerator abstraction.
253
+
254
+ Args:
255
+ temp_dir: Temporary directory for output
256
+
257
+ Returns:
258
+ Configured VideoGenerator instance
259
+ """
260
+ # FFmpeg base command (same as in audio_worker)
261
+ ffmpeg_base_command = "ffmpeg -hide_banner -loglevel error -nostats -y"
262
+
263
+ return VideoGenerator(
264
+ logger=logger,
265
+ ffmpeg_base_command=ffmpeg_base_command,
266
+ render_bounding_boxes=False,
267
+ output_png=True,
268
+ output_jpg=True
269
+ )
270
+
271
+
272
+ async def _generate_title_screen(
273
+ job_id: str,
274
+ job,
275
+ video_generator: VideoGenerator,
276
+ style_config: StyleConfig,
277
+ temp_dir: str,
278
+ job_log = None
279
+ ) -> Optional[str]:
280
+ """
281
+ Generate title screen video with custom style configuration.
282
+
283
+ Single Responsibility: Only handles title screen generation.
284
+
285
+ Args:
286
+ job_id: Job ID
287
+ job: Job object with artist/title
288
+ video_generator: Video generator instance
289
+ style_config: Style configuration with formats and assets
290
+ temp_dir: Temporary directory
291
+ job_log: Optional JobLogger for remote debugging
292
+
293
+ Returns:
294
+ Path to generated title screen, or None if failed
295
+ """
296
+ try:
297
+ logger.info(f"Job {job_id}: Generating title screen")
298
+
299
+ # Set up output paths
300
+ # Sanitize artist/title to handle Unicode characters (curly quotes, em dashes, etc.)
301
+ safe_artist = sanitize_filename(job.artist) if job.artist else "Unknown"
302
+ safe_title = sanitize_filename(job.title) if job.title else "Unknown"
303
+ artist_title = f"{safe_artist} - {safe_title}"
304
+ output_image_filepath_noext = os.path.join(temp_dir, f"{artist_title} (Title)")
305
+ output_video_filepath = os.path.join(temp_dir, f"{artist_title} (Title).mov")
306
+
307
+ # Get title format settings from style config
308
+ title_format = style_config.get_intro_format()
309
+ intro_duration = style_config.intro_video_duration
310
+
311
+ # Log detailed style info for debugging
312
+ if job_log:
313
+ job_log.info("Title screen format configuration:")
314
+ job_log.info(f" background_image: {title_format.get('background_image')}")
315
+ job_log.info(f" background_color: {title_format.get('background_color')}")
316
+ job_log.info(f" font: {title_format.get('font')}")
317
+ job_log.info(f" title_color: {title_format.get('title_color')}")
318
+ job_log.info(f" artist_color: {title_format.get('artist_color')}")
319
+ job_log.info(f" duration: {intro_duration}s")
320
+
321
+ # Check if background image exists
322
+ bg_image = title_format.get('background_image')
323
+ if bg_image and os.path.exists(bg_image):
324
+ job_log.info(f" background_image file EXISTS: {os.path.getsize(bg_image)} bytes")
325
+ elif bg_image:
326
+ job_log.warning(f" background_image file NOT FOUND: {bg_image}")
327
+
328
+ logger.info(f"Job {job_id}: Title format - bg_image: {title_format.get('background_image')}, font: {title_format.get('font')}")
329
+
330
+ # Generate title screen (synchronous method)
331
+ video_generator.create_title_video(
332
+ artist=job.artist,
333
+ title=job.title,
334
+ format=title_format,
335
+ output_image_filepath_noext=output_image_filepath_noext,
336
+ output_video_filepath=output_video_filepath,
337
+ existing_title_image=title_format.get('existing_image'),
338
+ intro_video_duration=intro_duration
339
+ )
340
+
341
+ if os.path.exists(output_video_filepath):
342
+ logger.info(f"Job {job_id}: Title screen generated at {output_video_filepath}")
343
+ return output_video_filepath
344
+ else:
345
+ logger.error(f"Job {job_id}: Title screen generation returned no file")
346
+ return None
347
+
348
+ except Exception as e:
349
+ logger.error(f"Job {job_id}: Title screen generation error: {e}", exc_info=True)
350
+ return None
351
+
352
+
353
+ async def _generate_end_screen(
354
+ job_id: str,
355
+ job,
356
+ video_generator: VideoGenerator,
357
+ style_config: StyleConfig,
358
+ temp_dir: str,
359
+ job_log = None
360
+ ) -> Optional[str]:
361
+ """
362
+ Generate end screen video with custom style configuration.
363
+
364
+ Single Responsibility: Only handles end screen generation.
365
+
366
+ Args:
367
+ job_id: Job ID
368
+ job: Job object with artist/title
369
+ video_generator: Video generator instance
370
+ style_config: Style configuration with formats and assets
371
+ temp_dir: Temporary directory
372
+ job_log: Optional JobLogger for remote debugging
373
+
374
+ Returns:
375
+ Path to generated end screen, or None if failed
376
+ """
377
+ try:
378
+ logger.info(f"Job {job_id}: Generating end screen")
379
+
380
+ # Set up output paths
381
+ # Sanitize artist/title to handle Unicode characters (curly quotes, em dashes, etc.)
382
+ safe_artist = sanitize_filename(job.artist) if job.artist else "Unknown"
383
+ safe_title = sanitize_filename(job.title) if job.title else "Unknown"
384
+ artist_title = f"{safe_artist} - {safe_title}"
385
+ output_image_filepath_noext = os.path.join(temp_dir, f"{artist_title} (End)")
386
+ output_video_filepath = os.path.join(temp_dir, f"{artist_title} (End).mov")
387
+
388
+ # Get end format settings from style config
389
+ end_format = style_config.get_end_format()
390
+ end_duration = style_config.end_video_duration
391
+
392
+ # Log detailed style info for debugging
393
+ if job_log:
394
+ job_log.info("End screen format configuration:")
395
+ job_log.info(f" background_image: {end_format.get('background_image')}")
396
+ job_log.info(f" background_color: {end_format.get('background_color')}")
397
+ job_log.info(f" font: {end_format.get('font')}")
398
+ job_log.info(f" duration: {end_duration}s")
399
+
400
+ logger.info(f"Job {job_id}: End format - bg_image: {end_format.get('background_image')}, font: {end_format.get('font')}")
401
+
402
+ # Generate end screen (synchronous method)
403
+ video_generator.create_end_video(
404
+ artist=job.artist,
405
+ title=job.title,
406
+ format=end_format,
407
+ output_image_filepath_noext=output_image_filepath_noext,
408
+ output_video_filepath=output_video_filepath,
409
+ existing_end_image=end_format.get('existing_image'),
410
+ end_video_duration=end_duration
411
+ )
412
+
413
+ if os.path.exists(output_video_filepath):
414
+ logger.info(f"Job {job_id}: End screen generated at {output_video_filepath}")
415
+ return output_video_filepath
416
+ else:
417
+ logger.error(f"Job {job_id}: End screen generation returned no file")
418
+ return None
419
+
420
+ except Exception as e:
421
+ logger.error(f"Job {job_id}: End screen generation error: {e}", exc_info=True)
422
+ return None
423
+
424
+
425
+ async def _upload_screens(
426
+ job_id: str,
427
+ job_manager: JobManager,
428
+ storage: StorageService,
429
+ title_screen_path: str,
430
+ end_screen_path: str
431
+ ) -> None:
432
+ """
433
+ Upload title and end screens to GCS (video + images).
434
+
435
+ Single Responsibility: Only handles uploads.
436
+
437
+ VideoGenerator creates .mov, .jpg, and .png files when configured with
438
+ output_png=True and output_jpg=True. We upload all three formats
439
+ for feature parity with the local CLI.
440
+
441
+ Args:
442
+ job_id: Job ID
443
+ job_manager: Job manager instance
444
+ storage: Storage service instance
445
+ title_screen_path: Path to title screen video (.mov)
446
+ end_screen_path: Path to end screen video (.mov)
447
+ """
448
+ # Upload title screen video
449
+ title_gcs_path = f"jobs/{job_id}/screens/title.mov"
450
+ title_url = storage.upload_file(title_screen_path, title_gcs_path)
451
+ job_manager.update_file_url(job_id, 'screens', 'title', title_url)
452
+ logger.info(f"Job {job_id}: Uploaded title screen video")
453
+
454
+ # Upload title screen images (.jpg and .png - created by VideoGenerator)
455
+ title_base = title_screen_path.replace('.mov', '')
456
+ for ext, key in [('.jpg', 'title_jpg'), ('.png', 'title_png')]:
457
+ image_path = f"{title_base}{ext}"
458
+ if os.path.exists(image_path):
459
+ gcs_path = f"jobs/{job_id}/screens/title{ext}"
460
+ url = storage.upload_file(image_path, gcs_path)
461
+ job_manager.update_file_url(job_id, 'screens', key, url)
462
+ logger.info(f"Job {job_id}: Uploaded title screen image ({ext})")
463
+
464
+ # Upload end screen video
465
+ end_gcs_path = f"jobs/{job_id}/screens/end.mov"
466
+ end_url = storage.upload_file(end_screen_path, end_gcs_path)
467
+ job_manager.update_file_url(job_id, 'screens', 'end', end_url)
468
+ logger.info(f"Job {job_id}: Uploaded end screen video")
469
+
470
+ # Upload end screen images (.jpg and .png - created by VideoGenerator)
471
+ end_base = end_screen_path.replace('.mov', '')
472
+ for ext, key in [('.jpg', 'end_jpg'), ('.png', 'end_png')]:
473
+ image_path = f"{end_base}{ext}"
474
+ if os.path.exists(image_path):
475
+ gcs_path = f"jobs/{job_id}/screens/end{ext}"
476
+ url = storage.upload_file(image_path, gcs_path)
477
+ job_manager.update_file_url(job_id, 'screens', key, url)
478
+ logger.info(f"Job {job_id}: Uploaded end screen image ({ext})")
479
+
480
+
481
+ async def _apply_countdown_padding_if_needed(
482
+ job_id: str,
483
+ job_manager: JobManager,
484
+ job
485
+ ) -> None:
486
+ """
487
+ Apply countdown padding to instrumentals if needed.
488
+
489
+ If lyrics start very early (within 3 seconds), LyricsTranscriber
490
+ adds a countdown intro ("3... 2... 1...") to the vocal audio.
491
+ We need to pad the instrumentals to match.
492
+
493
+ Single Responsibility: Only handles padding logic.
494
+
495
+ Args:
496
+ job_id: Job ID
497
+ job_manager: Job manager instance
498
+ job: Job object with lyrics metadata
499
+ """
500
+ # Check if countdown padding was added
501
+ lyrics_metadata = job.state_data.get('lyrics_metadata', {})
502
+ has_countdown = lyrics_metadata.get('has_countdown_padding', False)
503
+
504
+ if has_countdown:
505
+ logger.info(f"Job {job_id}: Countdown padding detected, applying to instrumentals")
506
+
507
+ # Transition to APPLYING_PADDING state
508
+ job_manager.transition_to_state(
509
+ job_id=job_id,
510
+ new_status=JobStatus.APPLYING_PADDING,
511
+ progress=52,
512
+ message="Synchronizing countdown padding"
513
+ )
514
+
515
+ # TODO: Implement padding application
516
+ # This requires:
517
+ # 1. Download instrumental stems from GCS
518
+ # 2. Add silence to beginning
519
+ # 3. Re-upload padded versions
520
+ # For now, we'll skip this and just transition back
521
+
522
+ logger.info(f"Job {job_id}: Padding applied (TODO: implement actual padding)")
523
+ else:
524
+ logger.info(f"Job {job_id}: No countdown padding needed")
525
+