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.
- 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 +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -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 +502 -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 +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -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/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -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 +109 -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 +356 -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 +283 -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_spacy_preloader.py +119 -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/utils/test_data.py +27 -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 +535 -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.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- 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.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local Preview Encoding Service.
|
|
3
|
+
|
|
4
|
+
Provides local FFmpeg-based preview video encoding functionality, extracted from
|
|
5
|
+
VideoGenerator for use by the GCE worker. This ensures the same encoding logic
|
|
6
|
+
is used across local CLI, Cloud Run, and GCE worker environments.
|
|
7
|
+
|
|
8
|
+
This service handles:
|
|
9
|
+
- Generating preview videos with ASS subtitle overlay
|
|
10
|
+
- Hardware acceleration detection (NVENC) with fallback to software encoding
|
|
11
|
+
- Background image/color support
|
|
12
|
+
- Custom font support
|
|
13
|
+
- Optimized settings for fast preview generation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, List
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PreviewEncodingConfig:
|
|
28
|
+
"""Configuration for preview video encoding."""
|
|
29
|
+
ass_path: str # Path to ASS subtitles file
|
|
30
|
+
audio_path: str # Path to audio file
|
|
31
|
+
output_path: str # Path for output video file
|
|
32
|
+
|
|
33
|
+
# Optional background settings
|
|
34
|
+
background_image_path: Optional[str] = None # Path to background image
|
|
35
|
+
background_color: str = "black" # Fallback background color
|
|
36
|
+
|
|
37
|
+
# Optional font settings
|
|
38
|
+
font_path: Optional[str] = None # Path to custom font file
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class PreviewEncodingResult:
|
|
43
|
+
"""Result of preview encoding operation."""
|
|
44
|
+
success: bool
|
|
45
|
+
output_path: Optional[str] = None
|
|
46
|
+
error: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LocalPreviewEncodingService:
|
|
50
|
+
"""
|
|
51
|
+
Service for local FFmpeg-based preview video encoding.
|
|
52
|
+
|
|
53
|
+
This is the single source of truth for preview encoding logic, used by:
|
|
54
|
+
- Local CLI (via VideoGenerator which delegates here)
|
|
55
|
+
- Cloud Run (when GCE is unavailable)
|
|
56
|
+
- GCE worker (via installed wheel)
|
|
57
|
+
|
|
58
|
+
Supports hardware acceleration (NVENC) with automatic fallback
|
|
59
|
+
to software encoding (libx264) when unavailable.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Preview video settings - these are the canonical values
|
|
63
|
+
PREVIEW_WIDTH = 480
|
|
64
|
+
PREVIEW_HEIGHT = 270
|
|
65
|
+
PREVIEW_FPS = 24
|
|
66
|
+
PREVIEW_AUDIO_BITRATE = "96k"
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
logger: Optional[logging.Logger] = None,
|
|
71
|
+
):
|
|
72
|
+
"""
|
|
73
|
+
Initialize the local preview encoding service.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
logger: Optional logger instance
|
|
77
|
+
"""
|
|
78
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
79
|
+
|
|
80
|
+
# Hardware acceleration settings (detected on first use)
|
|
81
|
+
self._nvenc_available: Optional[bool] = None
|
|
82
|
+
self._video_encoder: Optional[str] = None
|
|
83
|
+
self._hwaccel_flags: Optional[List[str]] = None
|
|
84
|
+
|
|
85
|
+
def _detect_nvenc_support(self) -> bool:
|
|
86
|
+
"""
|
|
87
|
+
Detect if NVENC hardware encoding is available.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if NVENC is available, False otherwise
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
self.logger.info("Detecting NVENC hardware acceleration...")
|
|
94
|
+
|
|
95
|
+
# Test h264_nvenc encoder directly
|
|
96
|
+
test_cmd = [
|
|
97
|
+
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
98
|
+
"-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=1",
|
|
99
|
+
"-c:v", "h264_nvenc", "-f", "null", "-"
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
test_cmd,
|
|
104
|
+
capture_output=True,
|
|
105
|
+
text=True,
|
|
106
|
+
timeout=30
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if result.returncode == 0:
|
|
110
|
+
self.logger.info("NVENC hardware acceleration available")
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
# Try alternative test with different source
|
|
114
|
+
alt_test_cmd = [
|
|
115
|
+
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
|
116
|
+
"-f", "lavfi", "-i", "color=red:size=320x240:duration=0.1",
|
|
117
|
+
"-c:v", "h264_nvenc", "-preset", "fast", "-f", "null", "-"
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
alt_result = subprocess.run(
|
|
121
|
+
alt_test_cmd,
|
|
122
|
+
capture_output=True,
|
|
123
|
+
text=True,
|
|
124
|
+
timeout=30
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if alt_result.returncode == 0:
|
|
128
|
+
self.logger.info("NVENC hardware acceleration available")
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
self.logger.info("NVENC not available, using software encoding")
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
except subprocess.TimeoutExpired:
|
|
135
|
+
self.logger.debug("NVENC detection timed out")
|
|
136
|
+
return False
|
|
137
|
+
except Exception as e:
|
|
138
|
+
self.logger.debug(f"NVENC detection failed: {e}")
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
def _configure_hardware_acceleration(self) -> None:
|
|
142
|
+
"""Configure hardware acceleration settings based on detected capabilities."""
|
|
143
|
+
if self._nvenc_available is None:
|
|
144
|
+
self._nvenc_available = self._detect_nvenc_support()
|
|
145
|
+
|
|
146
|
+
if self._nvenc_available:
|
|
147
|
+
self._video_encoder = "h264_nvenc"
|
|
148
|
+
self._hwaccel_flags = ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"]
|
|
149
|
+
self.logger.info("Using NVENC hardware acceleration for preview encoding")
|
|
150
|
+
else:
|
|
151
|
+
self._video_encoder = "libx264"
|
|
152
|
+
self._hwaccel_flags = []
|
|
153
|
+
self.logger.info("Using software encoding (libx264) for preview")
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def nvenc_available(self) -> bool:
|
|
157
|
+
"""Check if NVENC hardware acceleration is available."""
|
|
158
|
+
if self._nvenc_available is None:
|
|
159
|
+
self._configure_hardware_acceleration()
|
|
160
|
+
return self._nvenc_available
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def video_encoder(self) -> str:
|
|
164
|
+
"""Get the video encoder to use."""
|
|
165
|
+
if self._video_encoder is None:
|
|
166
|
+
self._configure_hardware_acceleration()
|
|
167
|
+
return self._video_encoder
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def hwaccel_flags(self) -> List[str]:
|
|
171
|
+
"""Get hardware acceleration flags."""
|
|
172
|
+
if self._hwaccel_flags is None:
|
|
173
|
+
self._configure_hardware_acceleration()
|
|
174
|
+
return self._hwaccel_flags
|
|
175
|
+
|
|
176
|
+
def _escape_ffmpeg_filter_path(self, path: str) -> str:
|
|
177
|
+
"""
|
|
178
|
+
Escape a path for FFmpeg filter expressions (for subprocess without shell).
|
|
179
|
+
|
|
180
|
+
When using subprocess with a command list (no shell), FFmpeg receives the
|
|
181
|
+
filter string directly. FFmpeg's filter parser requires escaping:
|
|
182
|
+
- Backslashes: double them (\\ -> \\\\)
|
|
183
|
+
- Single quotes/apostrophes: escape with three backslashes (' -> \\\\')
|
|
184
|
+
- Spaces: escape with backslash ( -> \\ )
|
|
185
|
+
- Special characters: :,[];
|
|
186
|
+
|
|
187
|
+
Example: "I'm With You" becomes "I\\\\'m\\ With\\ You"
|
|
188
|
+
"""
|
|
189
|
+
# First escape existing backslashes
|
|
190
|
+
escaped = path.replace("\\", "\\\\")
|
|
191
|
+
# Escape single quotes
|
|
192
|
+
escaped = escaped.replace("'", "\\\\\\'")
|
|
193
|
+
# Escape spaces
|
|
194
|
+
escaped = escaped.replace(" ", "\\ ")
|
|
195
|
+
# Escape FFmpeg filter special characters
|
|
196
|
+
escaped = escaped.replace(":", "\\:")
|
|
197
|
+
escaped = escaped.replace(",", "\\,")
|
|
198
|
+
escaped = escaped.replace("[", "\\[")
|
|
199
|
+
escaped = escaped.replace("]", "\\]")
|
|
200
|
+
escaped = escaped.replace(";", "\\;")
|
|
201
|
+
return escaped
|
|
202
|
+
|
|
203
|
+
def _build_ass_filter(self, ass_path: str, font_path: Optional[str] = None) -> str:
|
|
204
|
+
"""
|
|
205
|
+
Build ASS filter with optional font directory support.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
ass_path: Path to ASS subtitles file
|
|
209
|
+
font_path: Optional path to custom font file
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
FFmpeg ASS filter string
|
|
213
|
+
"""
|
|
214
|
+
escaped_ass_path = self._escape_ffmpeg_filter_path(ass_path)
|
|
215
|
+
ass_filter = f"ass={escaped_ass_path}"
|
|
216
|
+
|
|
217
|
+
if font_path and os.path.isfile(font_path):
|
|
218
|
+
font_dir = os.path.dirname(font_path)
|
|
219
|
+
escaped_font_dir = self._escape_ffmpeg_filter_path(font_dir)
|
|
220
|
+
ass_filter += f":fontsdir={escaped_font_dir}"
|
|
221
|
+
self.logger.debug(f"Using font directory: {font_dir}")
|
|
222
|
+
|
|
223
|
+
return ass_filter
|
|
224
|
+
|
|
225
|
+
def _build_preview_ffmpeg_command(self, config: PreviewEncodingConfig) -> List[str]:
|
|
226
|
+
"""
|
|
227
|
+
Build FFmpeg command for preview video generation.
|
|
228
|
+
|
|
229
|
+
This is the canonical preview encoding command, ensuring consistency
|
|
230
|
+
across all environments (local CLI, Cloud Run, GCE worker).
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
config: Preview encoding configuration
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
FFmpeg command as a list of arguments
|
|
237
|
+
"""
|
|
238
|
+
width, height = self.PREVIEW_WIDTH, self.PREVIEW_HEIGHT
|
|
239
|
+
|
|
240
|
+
cmd = [
|
|
241
|
+
"ffmpeg",
|
|
242
|
+
"-hide_banner",
|
|
243
|
+
"-loglevel", "error",
|
|
244
|
+
"-r", str(self.PREVIEW_FPS),
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
# Add hardware acceleration flags if available
|
|
248
|
+
cmd.extend(self.hwaccel_flags)
|
|
249
|
+
|
|
250
|
+
# Input source (background image or solid color)
|
|
251
|
+
if config.background_image_path and os.path.isfile(config.background_image_path):
|
|
252
|
+
self.logger.debug(f"Using background image: {config.background_image_path}")
|
|
253
|
+
cmd.extend([
|
|
254
|
+
"-loop", "1",
|
|
255
|
+
"-i", config.background_image_path,
|
|
256
|
+
])
|
|
257
|
+
# Build video filter with scaling and ASS subtitles
|
|
258
|
+
video_filter = (
|
|
259
|
+
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
|
260
|
+
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,"
|
|
261
|
+
f"{self._build_ass_filter(config.ass_path, config.font_path)}"
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
self.logger.debug(f"Using solid {config.background_color} background")
|
|
265
|
+
cmd.extend([
|
|
266
|
+
"-f", "lavfi",
|
|
267
|
+
"-i", f"color=c={config.background_color}:s={width}x{height}:r={self.PREVIEW_FPS}",
|
|
268
|
+
])
|
|
269
|
+
# Just ASS subtitles, no scaling needed
|
|
270
|
+
video_filter = self._build_ass_filter(config.ass_path, config.font_path)
|
|
271
|
+
|
|
272
|
+
cmd.extend([
|
|
273
|
+
"-i", config.audio_path,
|
|
274
|
+
"-vf", video_filter,
|
|
275
|
+
"-c:a", "aac",
|
|
276
|
+
"-b:a", self.PREVIEW_AUDIO_BITRATE,
|
|
277
|
+
"-c:v", self.video_encoder,
|
|
278
|
+
])
|
|
279
|
+
|
|
280
|
+
# Add encoder-specific settings optimized for speed
|
|
281
|
+
if self.nvenc_available:
|
|
282
|
+
cmd.extend([
|
|
283
|
+
"-preset", "p1", # Fastest NVENC preset
|
|
284
|
+
"-tune", "ll", # Low latency
|
|
285
|
+
"-rc", "cbr", # Constant bitrate for speed
|
|
286
|
+
"-b:v", "800k", # Lower bitrate for speed
|
|
287
|
+
"-profile:v", "baseline", # Most compatible profile
|
|
288
|
+
"-level", "3.1", # Lower level for speed
|
|
289
|
+
])
|
|
290
|
+
self.logger.debug("Using NVENC with maximum speed settings")
|
|
291
|
+
else:
|
|
292
|
+
cmd.extend([
|
|
293
|
+
"-profile:v", "baseline",
|
|
294
|
+
"-level", "3.0",
|
|
295
|
+
"-preset", "superfast",
|
|
296
|
+
"-tune", "fastdecode",
|
|
297
|
+
"-b:v", "600k",
|
|
298
|
+
"-maxrate", "800k",
|
|
299
|
+
"-bufsize", "1200k",
|
|
300
|
+
"-crf", "28",
|
|
301
|
+
])
|
|
302
|
+
self.logger.debug("Using software encoding with maximum speed settings")
|
|
303
|
+
|
|
304
|
+
cmd.extend([
|
|
305
|
+
"-pix_fmt", "yuv420p", # Required for browser compatibility
|
|
306
|
+
"-movflags", "+faststart+frag_keyframe+empty_moov+dash",
|
|
307
|
+
"-g", "48", # Keyframe every 48 frames (2 seconds at 24fps)
|
|
308
|
+
"-keyint_min", "48",
|
|
309
|
+
"-sc_threshold", "0", # Disable scene change detection for speed
|
|
310
|
+
"-threads", "0", # Use all available CPU threads
|
|
311
|
+
"-shortest",
|
|
312
|
+
"-y",
|
|
313
|
+
config.output_path,
|
|
314
|
+
])
|
|
315
|
+
|
|
316
|
+
return cmd
|
|
317
|
+
|
|
318
|
+
def encode_preview(self, config: PreviewEncodingConfig) -> PreviewEncodingResult:
|
|
319
|
+
"""
|
|
320
|
+
Encode a preview video.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
config: Preview encoding configuration
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
PreviewEncodingResult with success status and output path
|
|
327
|
+
"""
|
|
328
|
+
self.logger.info(f"Encoding preview video: {config.output_path}")
|
|
329
|
+
|
|
330
|
+
# Validate input files
|
|
331
|
+
if not os.path.isfile(config.ass_path):
|
|
332
|
+
return PreviewEncodingResult(
|
|
333
|
+
success=False,
|
|
334
|
+
error=f"ASS subtitles file not found: {config.ass_path}"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
if not os.path.isfile(config.audio_path):
|
|
338
|
+
return PreviewEncodingResult(
|
|
339
|
+
success=False,
|
|
340
|
+
error=f"Audio file not found: {config.audio_path}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
# Ensure output directory exists
|
|
344
|
+
output_dir = os.path.dirname(config.output_path)
|
|
345
|
+
if output_dir:
|
|
346
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
# Build and execute FFmpeg command
|
|
350
|
+
cmd = self._build_preview_ffmpeg_command(config)
|
|
351
|
+
self.logger.debug(f"FFmpeg command: {' '.join(cmd)}")
|
|
352
|
+
|
|
353
|
+
result = subprocess.run(
|
|
354
|
+
cmd,
|
|
355
|
+
capture_output=True,
|
|
356
|
+
text=True,
|
|
357
|
+
timeout=300 # 5 minute timeout for preview encoding
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
if result.returncode != 0:
|
|
361
|
+
error_msg = result.stderr[-500:] if result.stderr else "Unknown error"
|
|
362
|
+
self.logger.error(f"FFmpeg failed: {error_msg}")
|
|
363
|
+
return PreviewEncodingResult(
|
|
364
|
+
success=False,
|
|
365
|
+
error=f"FFmpeg preview encoding failed: {error_msg}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
self.logger.info(f"Preview encoded successfully: {config.output_path}")
|
|
369
|
+
return PreviewEncodingResult(
|
|
370
|
+
success=True,
|
|
371
|
+
output_path=config.output_path
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
except subprocess.TimeoutExpired:
|
|
375
|
+
self.logger.error("Preview encoding timed out")
|
|
376
|
+
return PreviewEncodingResult(
|
|
377
|
+
success=False,
|
|
378
|
+
error="Preview encoding timed out after 5 minutes"
|
|
379
|
+
)
|
|
380
|
+
except Exception as e:
|
|
381
|
+
self.logger.error(f"Preview encoding failed: {e}")
|
|
382
|
+
return PreviewEncodingResult(
|
|
383
|
+
success=False,
|
|
384
|
+
error=str(e)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Singleton instance and factory function
|
|
389
|
+
_local_preview_encoding_service: Optional[LocalPreviewEncodingService] = None
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def get_local_preview_encoding_service(**kwargs) -> LocalPreviewEncodingService:
|
|
393
|
+
"""
|
|
394
|
+
Get a local preview encoding service instance.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
**kwargs: Arguments passed to LocalPreviewEncodingService
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
LocalPreviewEncodingService instance
|
|
401
|
+
"""
|
|
402
|
+
global _local_preview_encoding_service
|
|
403
|
+
|
|
404
|
+
if _local_preview_encoding_service is None:
|
|
405
|
+
_local_preview_encoding_service = LocalPreviewEncodingService(**kwargs)
|
|
406
|
+
|
|
407
|
+
return _local_preview_encoding_service
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LyricsTranscriber cache synchronization with GCS.
|
|
3
|
+
|
|
4
|
+
This service persists LyricsTranscriber's cache files to GCS so that
|
|
5
|
+
cloud workers (Cloud Run instances) can share cache across containers.
|
|
6
|
+
|
|
7
|
+
Cache files are stored flat in GCS under lyrics-transcriber-cache/ prefix:
|
|
8
|
+
- Transcription: {provider}_{audio_hash}_raw.json, {provider}_{audio_hash}_converted.json
|
|
9
|
+
- Lyrics: {provider}_{artist_title_hash}_raw.json, {provider}_{artist_title_hash}_converted.json
|
|
10
|
+
|
|
11
|
+
Hash computation matches LyricsTranscriber's implementation exactly:
|
|
12
|
+
- Audio hash: MD5 of audio file bytes
|
|
13
|
+
- Lyrics hash: MD5 of "{artist.lower()}_{title.lower()}"
|
|
14
|
+
"""
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from backend.services.storage_service import StorageService
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Providers that use audio file hash as cache key
|
|
26
|
+
TRANSCRIPTION_PROVIDERS = ["audioshake", "whisper", "localwhisper"]
|
|
27
|
+
|
|
28
|
+
# Providers that use artist+title hash as cache key
|
|
29
|
+
LYRICS_PROVIDERS = ["genius", "spotify", "lrclib", "musixmatch"]
|
|
30
|
+
|
|
31
|
+
# Cache file suffixes
|
|
32
|
+
CACHE_SUFFIXES = ["raw", "converted"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LyricsCacheService:
|
|
36
|
+
"""Service to sync LyricsTranscriber cache with GCS."""
|
|
37
|
+
|
|
38
|
+
GCS_CACHE_PREFIX = "lyrics-transcriber-cache/"
|
|
39
|
+
|
|
40
|
+
def __init__(self, storage: Optional[StorageService] = None):
|
|
41
|
+
"""Initialize the cache service.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
storage: StorageService instance. If None, creates a new one.
|
|
45
|
+
"""
|
|
46
|
+
self.storage = storage or StorageService()
|
|
47
|
+
|
|
48
|
+
def compute_audio_hash(self, audio_path: str) -> str:
|
|
49
|
+
"""Compute MD5 hash of audio file bytes.
|
|
50
|
+
|
|
51
|
+
This matches LyricsTranscriber's _get_file_hash() method exactly.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
audio_path: Path to the audio file.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
MD5 hex digest of the file contents.
|
|
58
|
+
"""
|
|
59
|
+
md5_hash = hashlib.md5()
|
|
60
|
+
with open(audio_path, "rb") as f:
|
|
61
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
62
|
+
md5_hash.update(chunk)
|
|
63
|
+
return md5_hash.hexdigest()
|
|
64
|
+
|
|
65
|
+
def compute_lyrics_hash(self, artist: str, title: str) -> str:
|
|
66
|
+
"""Compute MD5 hash of artist and title.
|
|
67
|
+
|
|
68
|
+
This matches LyricsTranscriber's _get_artist_title_hash() method exactly.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
artist: Artist name.
|
|
72
|
+
title: Track title.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
MD5 hex digest of "{artist.lower()}_{title.lower()}".
|
|
76
|
+
"""
|
|
77
|
+
combined = f"{artist.lower()}_{title.lower()}"
|
|
78
|
+
return hashlib.md5(combined.encode()).hexdigest()
|
|
79
|
+
|
|
80
|
+
def _get_cache_filenames(
|
|
81
|
+
self, providers: List[str], hash_value: str
|
|
82
|
+
) -> List[str]:
|
|
83
|
+
"""Generate list of possible cache filenames for given providers and hash.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
providers: List of provider names (e.g., ["audioshake", "whisper"]).
|
|
87
|
+
hash_value: The hash to use in filenames.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of filenames like ["audioshake_abc123_raw.json", ...].
|
|
91
|
+
"""
|
|
92
|
+
filenames = []
|
|
93
|
+
for provider in providers:
|
|
94
|
+
for suffix in CACHE_SUFFIXES:
|
|
95
|
+
filenames.append(f"{provider}_{hash_value}_{suffix}.json")
|
|
96
|
+
return filenames
|
|
97
|
+
|
|
98
|
+
def sync_cache_from_gcs(
|
|
99
|
+
self,
|
|
100
|
+
local_cache_dir: str,
|
|
101
|
+
audio_hash: str,
|
|
102
|
+
lyrics_hash: str,
|
|
103
|
+
) -> Dict[str, int]:
|
|
104
|
+
"""Download relevant cache files from GCS to local directory.
|
|
105
|
+
|
|
106
|
+
Downloads cache files for both transcription (audio hash) and
|
|
107
|
+
lyrics (artist+title hash) providers.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
local_cache_dir: Local directory to download cache files to.
|
|
111
|
+
audio_hash: MD5 hash of audio file.
|
|
112
|
+
lyrics_hash: MD5 hash of artist+title.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Dict with counts: {"downloaded": N, "not_found": M, "errors": E}
|
|
116
|
+
"""
|
|
117
|
+
os.makedirs(local_cache_dir, exist_ok=True)
|
|
118
|
+
|
|
119
|
+
stats = {"downloaded": 0, "not_found": 0, "errors": 0}
|
|
120
|
+
|
|
121
|
+
# Get all possible cache filenames
|
|
122
|
+
transcription_files = self._get_cache_filenames(
|
|
123
|
+
TRANSCRIPTION_PROVIDERS, audio_hash
|
|
124
|
+
)
|
|
125
|
+
lyrics_files = self._get_cache_filenames(LYRICS_PROVIDERS, lyrics_hash)
|
|
126
|
+
all_files = transcription_files + lyrics_files
|
|
127
|
+
|
|
128
|
+
for filename in all_files:
|
|
129
|
+
gcs_path = f"{self.GCS_CACHE_PREFIX}{filename}"
|
|
130
|
+
local_path = os.path.join(local_cache_dir, filename)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
if self.storage.file_exists(gcs_path):
|
|
134
|
+
self.storage.download_file(gcs_path, local_path)
|
|
135
|
+
logger.info(f"Cache hit: downloaded {filename}")
|
|
136
|
+
stats["downloaded"] += 1
|
|
137
|
+
else:
|
|
138
|
+
logger.debug(f"Cache miss: {filename} not in GCS")
|
|
139
|
+
stats["not_found"] += 1
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.warning(f"Error downloading cache file {filename}: {e}")
|
|
142
|
+
stats["errors"] += 1
|
|
143
|
+
|
|
144
|
+
logger.info(
|
|
145
|
+
f"Cache sync from GCS complete: "
|
|
146
|
+
f"{stats['downloaded']} downloaded, "
|
|
147
|
+
f"{stats['not_found']} not found, "
|
|
148
|
+
f"{stats['errors']} errors"
|
|
149
|
+
)
|
|
150
|
+
return stats
|
|
151
|
+
|
|
152
|
+
def sync_cache_to_gcs(
|
|
153
|
+
self,
|
|
154
|
+
local_cache_dir: str,
|
|
155
|
+
audio_hash: str,
|
|
156
|
+
lyrics_hash: str,
|
|
157
|
+
) -> Dict[str, int]:
|
|
158
|
+
"""Upload new cache files from local directory to GCS.
|
|
159
|
+
|
|
160
|
+
Only uploads files that match expected cache patterns for the given
|
|
161
|
+
hashes and don't already exist in GCS.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
local_cache_dir: Local directory with cache files.
|
|
165
|
+
audio_hash: MD5 hash of audio file.
|
|
166
|
+
lyrics_hash: MD5 hash of artist+title.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Dict with counts: {"uploaded": N, "skipped": M, "errors": E}
|
|
170
|
+
"""
|
|
171
|
+
stats = {"uploaded": 0, "skipped": 0, "errors": 0}
|
|
172
|
+
|
|
173
|
+
if not os.path.exists(local_cache_dir):
|
|
174
|
+
logger.warning(f"Local cache dir does not exist: {local_cache_dir}")
|
|
175
|
+
return stats
|
|
176
|
+
|
|
177
|
+
# Get all possible cache filenames we're interested in
|
|
178
|
+
transcription_files = self._get_cache_filenames(
|
|
179
|
+
TRANSCRIPTION_PROVIDERS, audio_hash
|
|
180
|
+
)
|
|
181
|
+
lyrics_files = self._get_cache_filenames(LYRICS_PROVIDERS, lyrics_hash)
|
|
182
|
+
expected_files = set(transcription_files + lyrics_files)
|
|
183
|
+
|
|
184
|
+
# Check each file in local cache dir
|
|
185
|
+
for filename in os.listdir(local_cache_dir):
|
|
186
|
+
# Only process files we expect (matching our hash patterns)
|
|
187
|
+
if filename not in expected_files:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
local_path = os.path.join(local_cache_dir, filename)
|
|
191
|
+
if not os.path.isfile(local_path):
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
gcs_path = f"{self.GCS_CACHE_PREFIX}{filename}"
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
# Skip if already exists in GCS (same hash = same content)
|
|
198
|
+
if self.storage.file_exists(gcs_path):
|
|
199
|
+
logger.debug(f"Cache file already in GCS: {filename}")
|
|
200
|
+
stats["skipped"] += 1
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
self.storage.upload_file(local_path, gcs_path)
|
|
204
|
+
logger.info(f"Uploaded cache file: {filename}")
|
|
205
|
+
stats["uploaded"] += 1
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.warning(f"Error uploading cache file {filename}: {e}")
|
|
208
|
+
stats["errors"] += 1
|
|
209
|
+
|
|
210
|
+
logger.info(
|
|
211
|
+
f"Cache sync to GCS complete: "
|
|
212
|
+
f"{stats['uploaded']} uploaded, "
|
|
213
|
+
f"{stats['skipped']} skipped (already exist), "
|
|
214
|
+
f"{stats['errors']} errors"
|
|
215
|
+
)
|
|
216
|
+
return stats
|