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,469 @@
1
+ """
2
+ Theme service for managing pre-created style themes.
3
+
4
+ Themes are stored in GCS at themes/{theme_id}/ with:
5
+ - style_params.json: Complete style configuration
6
+ - preview.png: Preview image for theme selection UI
7
+ - youtube_description.txt: Optional YouTube description template
8
+ - assets/: Fonts, backgrounds, and other assets
9
+
10
+ The service provides:
11
+ - Theme listing with signed preview URLs
12
+ - Theme detail retrieval
13
+ - Color override application
14
+ - Job style preparation (copying theme to job folder)
15
+ """
16
+
17
+ import copy
18
+ import json
19
+ import logging
20
+ import os
21
+ import tempfile
22
+ import threading
23
+ from datetime import datetime, timedelta
24
+ from typing import Any, Dict, List, Optional, Tuple
25
+
26
+ from backend.models.theme import (
27
+ ColorOverrides,
28
+ ThemeDetail,
29
+ ThemeMetadata,
30
+ ThemeRegistry,
31
+ ThemeSummary,
32
+ hex_to_rgba,
33
+ )
34
+ from backend.services.storage_service import StorageService
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # GCS paths for themes
39
+ THEMES_PREFIX = "themes"
40
+ METADATA_FILE = f"{THEMES_PREFIX}/_metadata.json"
41
+
42
+
43
+ class ThemeService:
44
+ """Service for managing themes from GCS."""
45
+
46
+ def __init__(self, storage: Optional[StorageService] = None):
47
+ """
48
+ Initialize the theme service.
49
+
50
+ Args:
51
+ storage: StorageService instance (creates new one if not provided)
52
+ """
53
+ self.storage = storage or StorageService()
54
+ self._metadata_cache: Optional[ThemeRegistry] = None
55
+ self._cache_time: Optional[datetime] = None
56
+ self.CACHE_TTL_SECONDS = 300 # 5 minute cache for theme metadata
57
+
58
+ def _get_theme_path(self, theme_id: str, filename: str = "") -> str:
59
+ """Get the GCS path for a theme file."""
60
+ if filename:
61
+ return f"{THEMES_PREFIX}/{theme_id}/{filename}"
62
+ return f"{THEMES_PREFIX}/{theme_id}"
63
+
64
+ def _is_cache_valid(self) -> bool:
65
+ """Check if the metadata cache is still valid."""
66
+ if self._metadata_cache is None or self._cache_time is None:
67
+ return False
68
+ age = datetime.now() - self._cache_time
69
+ return age.total_seconds() < self.CACHE_TTL_SECONDS
70
+
71
+ def _load_metadata(self, force_refresh: bool = False) -> ThemeRegistry:
72
+ """
73
+ Load theme metadata from GCS with caching.
74
+
75
+ Args:
76
+ force_refresh: Force reload from GCS even if cache is valid
77
+
78
+ Returns:
79
+ ThemeRegistry containing all theme metadata
80
+ """
81
+ if not force_refresh and self._is_cache_valid():
82
+ return self._metadata_cache # type: ignore
83
+
84
+ try:
85
+ data = self.storage.download_json(METADATA_FILE)
86
+ self._metadata_cache = ThemeRegistry(**data)
87
+ self._cache_time = datetime.now()
88
+ logger.info(f"Loaded theme metadata: {len(self._metadata_cache.themes)} themes")
89
+ return self._metadata_cache
90
+ except Exception as e:
91
+ logger.error(f"Failed to load theme metadata: {e}")
92
+ # Return empty registry on error
93
+ return ThemeRegistry(version=1, themes=[])
94
+
95
+ def list_themes(self) -> List[ThemeSummary]:
96
+ """
97
+ List all available themes with signed preview URLs.
98
+
99
+ Returns:
100
+ List of ThemeSummary objects with preview URLs
101
+ """
102
+ metadata = self._load_metadata()
103
+ summaries = []
104
+
105
+ for theme in metadata.themes:
106
+ try:
107
+ # Generate signed URLs for preview images
108
+ preview_path = self._get_theme_path(theme.id, "preview.png")
109
+ thumbnail_path = self._get_theme_path(theme.id, "preview_thumbnail.png")
110
+
111
+ preview_url = None
112
+ thumbnail_url = None
113
+
114
+ if self.storage.file_exists(preview_path):
115
+ preview_url = self.storage.generate_signed_url(preview_path, expiration_minutes=60)
116
+
117
+ # Try thumbnail, fall back to main preview
118
+ if self.storage.file_exists(thumbnail_path):
119
+ thumbnail_url = self.storage.generate_signed_url(thumbnail_path, expiration_minutes=60)
120
+ elif preview_url:
121
+ thumbnail_url = preview_url
122
+
123
+ summaries.append(
124
+ ThemeSummary(
125
+ id=theme.id,
126
+ name=theme.name,
127
+ description=theme.description,
128
+ preview_url=preview_url,
129
+ thumbnail_url=thumbnail_url,
130
+ is_default=theme.is_default,
131
+ )
132
+ )
133
+ except Exception as e:
134
+ logger.warning(f"Error processing theme {theme.id}: {e}")
135
+ # Still include theme but without preview URLs
136
+ summaries.append(
137
+ ThemeSummary(
138
+ id=theme.id,
139
+ name=theme.name,
140
+ description=theme.description,
141
+ is_default=theme.is_default,
142
+ )
143
+ )
144
+
145
+ return summaries
146
+
147
+ def get_theme(self, theme_id: str) -> Optional[ThemeDetail]:
148
+ """
149
+ Get full theme details including style parameters.
150
+
151
+ Args:
152
+ theme_id: The theme identifier
153
+
154
+ Returns:
155
+ ThemeDetail if found, None otherwise
156
+ """
157
+ metadata = self._load_metadata()
158
+
159
+ # Find theme in metadata
160
+ theme_meta = next((t for t in metadata.themes if t.id == theme_id), None)
161
+ if not theme_meta:
162
+ logger.warning(f"Theme not found: {theme_id}")
163
+ return None
164
+
165
+ try:
166
+ # Load style_params.json
167
+ style_params_path = self._get_theme_path(theme_id, "style_params.json")
168
+ style_params = self.storage.download_json(style_params_path)
169
+
170
+ # Check for YouTube description
171
+ youtube_desc_path = self._get_theme_path(theme_id, "youtube_description.txt")
172
+ has_youtube_desc = self.storage.file_exists(youtube_desc_path)
173
+
174
+ # Generate preview URL
175
+ preview_path = self._get_theme_path(theme_id, "preview.png")
176
+ preview_url = None
177
+ if self.storage.file_exists(preview_path):
178
+ preview_url = self.storage.generate_signed_url(preview_path, expiration_minutes=60)
179
+
180
+ return ThemeDetail(
181
+ id=theme_id,
182
+ name=theme_meta.name,
183
+ description=theme_meta.description,
184
+ preview_url=preview_url,
185
+ is_default=theme_meta.is_default,
186
+ style_params=style_params,
187
+ has_youtube_description=has_youtube_desc,
188
+ )
189
+ except Exception as e:
190
+ logger.error(f"Error loading theme {theme_id}: {e}")
191
+ return None
192
+
193
+ def get_theme_style_params(self, theme_id: str) -> Optional[Dict[str, Any]]:
194
+ """
195
+ Get just the style_params.json for a theme.
196
+
197
+ Args:
198
+ theme_id: The theme identifier
199
+
200
+ Returns:
201
+ Style parameters dict if found, None otherwise
202
+ """
203
+ try:
204
+ style_params_path = self._get_theme_path(theme_id, "style_params.json")
205
+ return self.storage.download_json(style_params_path)
206
+ except Exception as e:
207
+ logger.error(f"Error loading style params for theme {theme_id}: {e}")
208
+ return None
209
+
210
+ def get_youtube_description(self, theme_id: str) -> Optional[str]:
211
+ """
212
+ Get the YouTube description template for a theme.
213
+
214
+ Args:
215
+ theme_id: The theme identifier
216
+
217
+ Returns:
218
+ YouTube description text if found, None otherwise
219
+ """
220
+ try:
221
+ youtube_desc_path = self._get_theme_path(theme_id, "youtube_description.txt")
222
+ if not self.storage.file_exists(youtube_desc_path):
223
+ return None
224
+
225
+ # Download to temp file and read
226
+ with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as tmp:
227
+ tmp_path = tmp.name
228
+
229
+ try:
230
+ self.storage.download_file(youtube_desc_path, tmp_path)
231
+ with open(tmp_path, "r") as f:
232
+ return f.read()
233
+ finally:
234
+ if os.path.exists(tmp_path):
235
+ os.remove(tmp_path)
236
+ except Exception as e:
237
+ logger.error(f"Error loading YouTube description for theme {theme_id}: {e}")
238
+ return None
239
+
240
+ def apply_color_overrides(
241
+ self, style_params: Dict[str, Any], overrides: ColorOverrides
242
+ ) -> Dict[str, Any]:
243
+ """
244
+ Apply user color overrides to theme style parameters.
245
+
246
+ Args:
247
+ style_params: Base style parameters from theme
248
+ overrides: User color overrides
249
+
250
+ Returns:
251
+ Modified style parameters with overrides applied
252
+ """
253
+ if not overrides.has_overrides():
254
+ return style_params
255
+
256
+ # Deep copy to avoid modifying original
257
+ result = copy.deepcopy(style_params)
258
+
259
+ # Apply artist_color to intro, end, and CDG sections
260
+ if overrides.artist_color:
261
+ if "intro" in result:
262
+ result["intro"]["artist_color"] = overrides.artist_color
263
+ if "end" in result:
264
+ result["end"]["artist_color"] = overrides.artist_color
265
+ if "cdg" in result:
266
+ result["cdg"]["artist_color"] = overrides.artist_color
267
+
268
+ # Apply title_color to intro, end, and CDG sections
269
+ if overrides.title_color:
270
+ if "intro" in result:
271
+ result["intro"]["title_color"] = overrides.title_color
272
+ if "end" in result:
273
+ result["end"]["title_color"] = overrides.title_color
274
+ if "cdg" in result:
275
+ result["cdg"]["title_color"] = overrides.title_color
276
+
277
+ # Apply sung_lyrics_color (karaoke uses RGBA, CDG uses hex)
278
+ if overrides.sung_lyrics_color:
279
+ if "karaoke" in result:
280
+ result["karaoke"]["primary_color"] = hex_to_rgba(overrides.sung_lyrics_color)
281
+ if "cdg" in result:
282
+ result["cdg"]["active_fill"] = overrides.sung_lyrics_color
283
+
284
+ # Apply unsung_lyrics_color (karaoke uses RGBA, CDG uses hex)
285
+ if overrides.unsung_lyrics_color:
286
+ if "karaoke" in result:
287
+ result["karaoke"]["secondary_color"] = hex_to_rgba(overrides.unsung_lyrics_color)
288
+ if "cdg" in result:
289
+ result["cdg"]["inactive_fill"] = overrides.unsung_lyrics_color
290
+
291
+ return result
292
+
293
+ def prepare_job_style(
294
+ self,
295
+ job_id: str,
296
+ theme_id: str,
297
+ color_overrides: Optional[ColorOverrides] = None,
298
+ ) -> Tuple[str, Dict[str, str]]:
299
+ """
300
+ Prepare style files for a job by copying theme to job folder.
301
+
302
+ This method:
303
+ 1. Loads the theme's style_params.json
304
+ 2. Applies any color overrides
305
+ 3. Updates asset paths to point to theme assets (shared, not copied)
306
+ 4. Uploads modified style_params.json to job folder
307
+ 5. Returns the GCS path and style_assets mapping
308
+
309
+ Args:
310
+ job_id: The job ID
311
+ theme_id: The theme to use
312
+ color_overrides: Optional color overrides
313
+
314
+ Returns:
315
+ Tuple of (style_params_gcs_path, style_assets dict)
316
+
317
+ Raises:
318
+ ValueError: If theme not found
319
+ """
320
+ # Load theme style params
321
+ style_params = self.get_theme_style_params(theme_id)
322
+ if style_params is None:
323
+ raise ValueError(f"Theme not found: {theme_id}")
324
+
325
+ # Apply color overrides if provided
326
+ if color_overrides:
327
+ style_params = self.apply_color_overrides(style_params, color_overrides)
328
+
329
+ # Update asset paths to point to theme's shared assets
330
+ # Theme assets stay in themes/{theme_id}/assets/ - they're shared
331
+ style_assets = self._build_style_assets_mapping(theme_id, style_params)
332
+
333
+ # Update style_params to use theme asset paths (not job-specific)
334
+ style_params = self._update_asset_paths_in_style(theme_id, style_params)
335
+
336
+ # Upload modified style_params.json to job's style folder
337
+ job_style_path = f"uploads/{job_id}/style/style_params.json"
338
+ self.storage.upload_json(job_style_path, style_params)
339
+
340
+ logger.info(f"Prepared job {job_id} style from theme {theme_id}")
341
+
342
+ return job_style_path, style_assets
343
+
344
+ def _build_style_assets_mapping(
345
+ self, theme_id: str, style_params: Dict[str, Any]
346
+ ) -> Dict[str, str]:
347
+ """
348
+ Build the style_assets mapping for a theme.
349
+
350
+ Maps asset keys to their GCS paths in the theme folder.
351
+ """
352
+ style_assets = {}
353
+ theme_assets_prefix = f"{THEMES_PREFIX}/{theme_id}/assets"
354
+
355
+ # Check each potential asset type
356
+ asset_checks = [
357
+ ("intro_background", "intro", "background_image"),
358
+ ("karaoke_background", "karaoke", "background_image"),
359
+ ("end_background", "end", "background_image"),
360
+ ("font", "intro", "font"), # Font is typically shared across sections
361
+ ("cdg_instrumental_background", "cdg", "instrumental_background"),
362
+ ("cdg_title_background", "cdg", "title_screen_background"),
363
+ ("cdg_outro_background", "cdg", "outro_background"),
364
+ ]
365
+
366
+ for asset_key, section, field in asset_checks:
367
+ if section in style_params and field in style_params[section]:
368
+ value = style_params[section][field]
369
+ if value and isinstance(value, str):
370
+ # Check if it's a theme asset path or already has the full path
371
+ if value.startswith(f"{THEMES_PREFIX}/"):
372
+ # Already a full theme path
373
+ style_assets[asset_key] = value
374
+ elif not value.startswith("/") and not value.startswith("gs://"):
375
+ # Relative path - prepend theme assets prefix
376
+ asset_path = f"{theme_assets_prefix}/{os.path.basename(value)}"
377
+ if self.storage.file_exists(asset_path):
378
+ style_assets[asset_key] = asset_path
379
+
380
+ return style_assets
381
+
382
+ def _update_asset_paths_in_style(
383
+ self, theme_id: str, style_params: Dict[str, Any]
384
+ ) -> Dict[str, Any]:
385
+ """
386
+ Update asset paths in style_params to use full GCS theme paths.
387
+
388
+ This ensures the style_loader can find assets when processing.
389
+ """
390
+ result = copy.deepcopy(style_params)
391
+ theme_assets_prefix = f"{THEMES_PREFIX}/{theme_id}/assets"
392
+
393
+ # Fields that contain asset paths
394
+ path_fields = {
395
+ "intro": ["background_image", "font"],
396
+ "end": ["background_image", "font"],
397
+ "karaoke": ["background_image", "font_path"],
398
+ "cdg": [
399
+ "font_path",
400
+ "instrumental_background",
401
+ "title_screen_background",
402
+ "outro_background",
403
+ ],
404
+ }
405
+
406
+ for section, fields in path_fields.items():
407
+ if section not in result:
408
+ continue
409
+ for field in fields:
410
+ if field not in result[section]:
411
+ continue
412
+ value = result[section][field]
413
+ if value and isinstance(value, str):
414
+ # Skip if already a full path or URL
415
+ if value.startswith(f"{THEMES_PREFIX}/") or value.startswith("gs://"):
416
+ continue
417
+ # Skip absolute local paths (shouldn't happen but be safe)
418
+ if value.startswith("/"):
419
+ continue
420
+ # Update to full theme asset path
421
+ result[section][field] = f"{theme_assets_prefix}/{os.path.basename(value)}"
422
+
423
+ return result
424
+
425
+ def theme_exists(self, theme_id: str) -> bool:
426
+ """
427
+ Check if a theme exists.
428
+
429
+ Args:
430
+ theme_id: The theme identifier
431
+
432
+ Returns:
433
+ True if theme exists, False otherwise
434
+ """
435
+ metadata = self._load_metadata()
436
+ return any(t.id == theme_id for t in metadata.themes)
437
+
438
+ def get_default_theme_id(self) -> Optional[str]:
439
+ """
440
+ Get the ID of the default theme.
441
+
442
+ Returns:
443
+ Default theme ID if one exists, None otherwise
444
+ """
445
+ metadata = self._load_metadata()
446
+ default_theme = next((t for t in metadata.themes if t.is_default), None)
447
+ return default_theme.id if default_theme else None
448
+
449
+ def invalidate_cache(self) -> None:
450
+ """Force invalidation of the metadata cache."""
451
+ self._metadata_cache = None
452
+ self._cache_time = None
453
+ logger.info("Theme metadata cache invalidated")
454
+
455
+
456
+ # Singleton instance with thread-safe initialization
457
+ _theme_service: Optional[ThemeService] = None
458
+ _theme_service_lock = threading.Lock()
459
+
460
+
461
+ def get_theme_service() -> ThemeService:
462
+ """Get or create the singleton ThemeService instance (thread-safe)."""
463
+ global _theme_service
464
+ if _theme_service is None:
465
+ with _theme_service_lock:
466
+ # Double-check after acquiring lock
467
+ if _theme_service is None:
468
+ _theme_service = ThemeService()
469
+ return _theme_service