karaoke-gen 0.90.1__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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.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
|