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,454 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Encoding Interface.
|
|
3
|
+
|
|
4
|
+
Defines the abstract interface for video encoding backends, allowing
|
|
5
|
+
the video worker orchestrator to use either GCE or local encoding
|
|
6
|
+
interchangeably.
|
|
7
|
+
|
|
8
|
+
This follows the Strategy pattern - different encoding implementations
|
|
9
|
+
can be swapped without changing the orchestration logic.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Dict, List, Optional, Any
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EncodingInput:
|
|
22
|
+
"""
|
|
23
|
+
Input configuration for video encoding.
|
|
24
|
+
|
|
25
|
+
Contains all the paths and metadata needed to encode a karaoke video.
|
|
26
|
+
"""
|
|
27
|
+
# Required input files
|
|
28
|
+
title_video_path: str # Title card video (MOV)
|
|
29
|
+
karaoke_video_path: str # Main karaoke video with vocals (MOV/MKV)
|
|
30
|
+
instrumental_audio_path: str # Instrumental audio track (FLAC)
|
|
31
|
+
|
|
32
|
+
# Optional input files
|
|
33
|
+
end_video_path: Optional[str] = None # End credits video (MOV)
|
|
34
|
+
|
|
35
|
+
# Metadata for output naming
|
|
36
|
+
artist: str = ""
|
|
37
|
+
title: str = ""
|
|
38
|
+
brand_code: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
# Output directory
|
|
41
|
+
output_dir: str = ""
|
|
42
|
+
|
|
43
|
+
# Additional options
|
|
44
|
+
options: Dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class EncodingOutput:
|
|
49
|
+
"""
|
|
50
|
+
Output from video encoding.
|
|
51
|
+
|
|
52
|
+
Contains paths to all generated video files and status information.
|
|
53
|
+
"""
|
|
54
|
+
success: bool
|
|
55
|
+
error_message: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
# Output file paths (relative to output_dir or absolute)
|
|
58
|
+
karaoke_mp4_path: Optional[str] = None # Karaoke video with instrumental audio
|
|
59
|
+
with_vocals_mp4_path: Optional[str] = None # With vocals MP4 version
|
|
60
|
+
lossless_4k_mp4_path: Optional[str] = None # Final lossless 4K MP4
|
|
61
|
+
lossy_4k_mp4_path: Optional[str] = None # Final lossy 4K MP4
|
|
62
|
+
lossless_mkv_path: Optional[str] = None # MKV with FLAC (for YouTube)
|
|
63
|
+
lossy_720p_mp4_path: Optional[str] = None # 720p web version
|
|
64
|
+
|
|
65
|
+
# All output files as a dict for convenience
|
|
66
|
+
output_files: Dict[str, str] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
# Encoding metadata
|
|
69
|
+
encoding_time_seconds: Optional[float] = None
|
|
70
|
+
encoding_backend: Optional[str] = None # "gce" or "local"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EncodingBackend(ABC):
|
|
74
|
+
"""
|
|
75
|
+
Abstract base class for video encoding backends.
|
|
76
|
+
|
|
77
|
+
Implementations:
|
|
78
|
+
- GCEEncodingBackend: Cloud-based encoding using Google Compute Engine
|
|
79
|
+
- LocalEncodingBackend: Local FFmpeg-based encoding
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def name(self) -> str:
|
|
85
|
+
"""Return the name of this encoding backend."""
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
@abstractmethod
|
|
89
|
+
async def encode(self, input_config: EncodingInput) -> EncodingOutput:
|
|
90
|
+
"""
|
|
91
|
+
Encode video files according to the input configuration.
|
|
92
|
+
|
|
93
|
+
This method should:
|
|
94
|
+
1. Create karaoke video with instrumental audio
|
|
95
|
+
2. Convert with-vocals video to MP4 if needed
|
|
96
|
+
3. Encode lossless 4K MP4 (concatenating title + karaoke + end)
|
|
97
|
+
4. Encode lossy 4K MP4 with AAC audio
|
|
98
|
+
5. Create MKV with FLAC audio for YouTube
|
|
99
|
+
6. Encode 720p version for web
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
input_config: EncodingInput with all required paths and options
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
EncodingOutput with paths to generated files and status
|
|
106
|
+
"""
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
@abstractmethod
|
|
110
|
+
async def is_available(self) -> bool:
|
|
111
|
+
"""
|
|
112
|
+
Check if this encoding backend is available and configured.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if the backend can be used, False otherwise
|
|
116
|
+
"""
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
async def get_status(self) -> Dict[str, Any]:
|
|
121
|
+
"""
|
|
122
|
+
Get the current status of the encoding backend.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dict with status information (availability, queue length, etc.)
|
|
126
|
+
"""
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class LocalEncodingBackend(EncodingBackend):
|
|
131
|
+
"""
|
|
132
|
+
Local FFmpeg-based encoding backend.
|
|
133
|
+
|
|
134
|
+
Wraps the LocalEncodingService to implement the EncodingBackend interface.
|
|
135
|
+
Uses asyncio.to_thread() to run synchronous FFmpeg operations asynchronously.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
dry_run: bool = False,
|
|
141
|
+
logger: Optional[logging.Logger] = None,
|
|
142
|
+
):
|
|
143
|
+
"""
|
|
144
|
+
Initialize the local encoding backend.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
dry_run: If True, log operations without executing
|
|
148
|
+
logger: Optional logger instance
|
|
149
|
+
"""
|
|
150
|
+
self.dry_run = dry_run
|
|
151
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
152
|
+
self._service = None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def name(self) -> str:
|
|
156
|
+
return "local"
|
|
157
|
+
|
|
158
|
+
def _get_service(self):
|
|
159
|
+
"""Lazy-load the local encoding service."""
|
|
160
|
+
if self._service is None:
|
|
161
|
+
from backend.services.local_encoding_service import LocalEncodingService
|
|
162
|
+
self._service = LocalEncodingService(
|
|
163
|
+
dry_run=self.dry_run,
|
|
164
|
+
logger=self.logger
|
|
165
|
+
)
|
|
166
|
+
return self._service
|
|
167
|
+
|
|
168
|
+
async def encode(self, input_config: EncodingInput) -> EncodingOutput:
|
|
169
|
+
"""
|
|
170
|
+
Encode video using local FFmpeg.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
input_config: Encoding input configuration
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
EncodingOutput with results
|
|
177
|
+
"""
|
|
178
|
+
import asyncio
|
|
179
|
+
import time
|
|
180
|
+
from backend.services.local_encoding_service import EncodingConfig
|
|
181
|
+
|
|
182
|
+
start_time = time.time()
|
|
183
|
+
|
|
184
|
+
# Build output file paths
|
|
185
|
+
base_name = f"{input_config.artist} - {input_config.title}"
|
|
186
|
+
output_dir = input_config.output_dir or "."
|
|
187
|
+
|
|
188
|
+
import os
|
|
189
|
+
config = EncodingConfig(
|
|
190
|
+
title_video=input_config.title_video_path,
|
|
191
|
+
karaoke_video=input_config.karaoke_video_path,
|
|
192
|
+
instrumental_audio=input_config.instrumental_audio_path,
|
|
193
|
+
end_video=input_config.end_video_path,
|
|
194
|
+
output_karaoke_mp4=os.path.join(output_dir, f"{base_name} (Karaoke).mp4"),
|
|
195
|
+
output_with_vocals_mp4=os.path.join(output_dir, f"{base_name} (With Vocals).mp4"),
|
|
196
|
+
output_lossless_4k_mp4=os.path.join(output_dir, f"{base_name} (Final Karaoke Lossless 4k).mp4"),
|
|
197
|
+
output_lossy_4k_mp4=os.path.join(output_dir, f"{base_name} (Final Karaoke Lossy 4k).mp4"),
|
|
198
|
+
output_lossless_mkv=os.path.join(output_dir, f"{base_name} (Final Karaoke Lossless 4k).mkv"),
|
|
199
|
+
output_720p_mp4=os.path.join(output_dir, f"{base_name} (Final Karaoke Lossy 720p).mp4"),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Run encoding in thread pool to avoid blocking
|
|
203
|
+
service = self._get_service()
|
|
204
|
+
result = await asyncio.to_thread(service.encode_all_formats, config)
|
|
205
|
+
|
|
206
|
+
encoding_time = time.time() - start_time
|
|
207
|
+
|
|
208
|
+
if result.success:
|
|
209
|
+
return EncodingOutput(
|
|
210
|
+
success=True,
|
|
211
|
+
karaoke_mp4_path=config.output_karaoke_mp4,
|
|
212
|
+
with_vocals_mp4_path=config.output_with_vocals_mp4,
|
|
213
|
+
lossless_4k_mp4_path=config.output_lossless_4k_mp4,
|
|
214
|
+
lossy_4k_mp4_path=config.output_lossy_4k_mp4,
|
|
215
|
+
lossless_mkv_path=config.output_lossless_mkv,
|
|
216
|
+
lossy_720p_mp4_path=config.output_720p_mp4,
|
|
217
|
+
output_files=result.output_files,
|
|
218
|
+
encoding_time_seconds=encoding_time,
|
|
219
|
+
encoding_backend=self.name
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
return EncodingOutput(
|
|
223
|
+
success=False,
|
|
224
|
+
error_message=result.error,
|
|
225
|
+
output_files=result.output_files,
|
|
226
|
+
encoding_time_seconds=encoding_time,
|
|
227
|
+
encoding_backend=self.name
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def is_available(self) -> bool:
|
|
231
|
+
"""Check if local encoding is available (FFmpeg installed)."""
|
|
232
|
+
import subprocess
|
|
233
|
+
import asyncio
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
result = await asyncio.to_thread(
|
|
237
|
+
subprocess.run,
|
|
238
|
+
["ffmpeg", "-version"],
|
|
239
|
+
capture_output=True,
|
|
240
|
+
timeout=10
|
|
241
|
+
)
|
|
242
|
+
return result.returncode == 0
|
|
243
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
async def get_status(self) -> Dict[str, Any]:
|
|
247
|
+
"""Get local encoding status."""
|
|
248
|
+
available = await self.is_available()
|
|
249
|
+
service = self._get_service()
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"backend": self.name,
|
|
253
|
+
"available": available,
|
|
254
|
+
"hwaccel_available": service.hwaccel_available if available else False,
|
|
255
|
+
"video_encoder": service.video_encoder if available else None,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class GCEEncodingBackend(EncodingBackend):
|
|
260
|
+
"""
|
|
261
|
+
GCE-based encoding backend.
|
|
262
|
+
|
|
263
|
+
Wraps the existing EncodingService to implement the EncodingBackend interface.
|
|
264
|
+
Submits jobs to a remote GCE worker for high-performance encoding.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
def __init__(
|
|
268
|
+
self,
|
|
269
|
+
dry_run: bool = False,
|
|
270
|
+
logger: Optional[logging.Logger] = None,
|
|
271
|
+
):
|
|
272
|
+
"""
|
|
273
|
+
Initialize the GCE encoding backend.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
dry_run: Ignored for GCE backend (remote service doesn't support dry run)
|
|
277
|
+
logger: Optional logger instance
|
|
278
|
+
"""
|
|
279
|
+
self.dry_run = dry_run # Stored but not used (GCE is remote)
|
|
280
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
281
|
+
self._service = None
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def name(self) -> str:
|
|
285
|
+
return "gce"
|
|
286
|
+
|
|
287
|
+
def _get_service(self):
|
|
288
|
+
"""Lazy-load the GCE encoding service."""
|
|
289
|
+
if self._service is None:
|
|
290
|
+
from backend.services.encoding_service import get_encoding_service
|
|
291
|
+
self._service = get_encoding_service()
|
|
292
|
+
return self._service
|
|
293
|
+
|
|
294
|
+
async def encode(self, input_config: EncodingInput) -> EncodingOutput:
|
|
295
|
+
"""
|
|
296
|
+
Encode video using GCE worker.
|
|
297
|
+
|
|
298
|
+
Note: GCE encoding requires files to be in GCS, so this method
|
|
299
|
+
expects input_config.options to contain 'input_gcs_path' and
|
|
300
|
+
'output_gcs_path'.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
input_config: Encoding input configuration
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
EncodingOutput with results
|
|
307
|
+
"""
|
|
308
|
+
import time
|
|
309
|
+
|
|
310
|
+
start_time = time.time()
|
|
311
|
+
service = self._get_service()
|
|
312
|
+
|
|
313
|
+
# GCE requires GCS paths
|
|
314
|
+
input_gcs_path = input_config.options.get("input_gcs_path")
|
|
315
|
+
output_gcs_path = input_config.options.get("output_gcs_path")
|
|
316
|
+
job_id = input_config.options.get("job_id", input_config.brand_code or "unknown")
|
|
317
|
+
|
|
318
|
+
if not input_gcs_path or not output_gcs_path:
|
|
319
|
+
return EncodingOutput(
|
|
320
|
+
success=False,
|
|
321
|
+
error_message="GCE encoding requires input_gcs_path and output_gcs_path in options",
|
|
322
|
+
encoding_backend=self.name
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
# Build encoding config
|
|
327
|
+
encoding_config = {
|
|
328
|
+
"formats": ["mp4_4k_lossless", "mp4_4k_lossy", "mkv_4k", "mp4_720p"],
|
|
329
|
+
"artist": input_config.artist,
|
|
330
|
+
"title": input_config.title,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
# Submit and wait for completion
|
|
334
|
+
result = await service.encode_videos(
|
|
335
|
+
job_id=job_id,
|
|
336
|
+
input_gcs_path=input_gcs_path,
|
|
337
|
+
output_gcs_path=output_gcs_path,
|
|
338
|
+
encoding_config=encoding_config,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
encoding_time = time.time() - start_time
|
|
342
|
+
|
|
343
|
+
# Extract output file paths from result
|
|
344
|
+
# Handle case where GCE worker returns a list or unexpected format
|
|
345
|
+
if isinstance(result, list):
|
|
346
|
+
# If result is a list, try to find the output_files in the first dict
|
|
347
|
+
self.logger.warning(f"GCE returned list instead of dict: {result}")
|
|
348
|
+
result = result[0] if result and isinstance(result[0], dict) else {}
|
|
349
|
+
if not isinstance(result, dict):
|
|
350
|
+
self.logger.error(f"Unexpected GCE result type: {type(result)}")
|
|
351
|
+
result = {}
|
|
352
|
+
raw_output_files = result.get("output_files", {})
|
|
353
|
+
|
|
354
|
+
# Convert output_files from list of paths to dict
|
|
355
|
+
# GCE worker returns list like: ["path/Artist - Title (Final Karaoke Lossless 4k).mp4", ...]
|
|
356
|
+
# We need dict like: {"mp4_4k_lossless": "path/...", "mp4_720p": "path/..."}
|
|
357
|
+
if isinstance(raw_output_files, list):
|
|
358
|
+
output_files = {}
|
|
359
|
+
for path in raw_output_files:
|
|
360
|
+
if not isinstance(path, str):
|
|
361
|
+
continue
|
|
362
|
+
filename = path.split("/")[-1] if "/" in path else path
|
|
363
|
+
filename_lower = filename.lower()
|
|
364
|
+
# Map filename patterns to output format keys
|
|
365
|
+
# Files are named like "Artist - Title (Final Karaoke Lossless 4k).mp4"
|
|
366
|
+
if "lossless 4k" in filename_lower:
|
|
367
|
+
if filename.endswith(".mkv"):
|
|
368
|
+
output_files["mkv_4k"] = path
|
|
369
|
+
else:
|
|
370
|
+
output_files["mp4_4k_lossless"] = path
|
|
371
|
+
elif "lossy 4k" in filename_lower:
|
|
372
|
+
output_files["mp4_4k_lossy"] = path
|
|
373
|
+
elif "720p" in filename_lower:
|
|
374
|
+
output_files["mp4_720p"] = path
|
|
375
|
+
self.logger.info(f"Converted output_files list to dict: {output_files}")
|
|
376
|
+
else:
|
|
377
|
+
output_files = raw_output_files if isinstance(raw_output_files, dict) else {}
|
|
378
|
+
|
|
379
|
+
return EncodingOutput(
|
|
380
|
+
success=True,
|
|
381
|
+
lossless_4k_mp4_path=output_files.get("mp4_4k_lossless"),
|
|
382
|
+
lossy_4k_mp4_path=output_files.get("mp4_4k_lossy"),
|
|
383
|
+
lossless_mkv_path=output_files.get("mkv_4k"),
|
|
384
|
+
lossy_720p_mp4_path=output_files.get("mp4_720p"),
|
|
385
|
+
output_files=output_files,
|
|
386
|
+
encoding_time_seconds=encoding_time,
|
|
387
|
+
encoding_backend=self.name
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
except Exception as e:
|
|
391
|
+
encoding_time = time.time() - start_time
|
|
392
|
+
self.logger.error(f"GCE encoding failed: {e}")
|
|
393
|
+
return EncodingOutput(
|
|
394
|
+
success=False,
|
|
395
|
+
error_message=str(e),
|
|
396
|
+
encoding_time_seconds=encoding_time,
|
|
397
|
+
encoding_backend=self.name
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
async def is_available(self) -> bool:
|
|
401
|
+
"""Check if GCE encoding is available and configured."""
|
|
402
|
+
service = self._get_service()
|
|
403
|
+
return service.is_enabled
|
|
404
|
+
|
|
405
|
+
async def get_status(self) -> Dict[str, Any]:
|
|
406
|
+
"""Get GCE encoding status."""
|
|
407
|
+
service = self._get_service()
|
|
408
|
+
|
|
409
|
+
status = {
|
|
410
|
+
"backend": self.name,
|
|
411
|
+
"available": service.is_enabled,
|
|
412
|
+
"configured": service.is_configured,
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if service.is_configured:
|
|
416
|
+
try:
|
|
417
|
+
health = await service.health_check()
|
|
418
|
+
status["health"] = health
|
|
419
|
+
except Exception as e:
|
|
420
|
+
status["health_error"] = str(e)
|
|
421
|
+
|
|
422
|
+
return status
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# Factory function to get an encoding backend
|
|
426
|
+
def get_encoding_backend(
|
|
427
|
+
backend_type: str = "auto",
|
|
428
|
+
**kwargs
|
|
429
|
+
) -> EncodingBackend:
|
|
430
|
+
"""
|
|
431
|
+
Get an encoding backend instance.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
backend_type: Type of backend - "local", "gce", or "auto"
|
|
435
|
+
**kwargs: Additional arguments for the backend
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
EncodingBackend instance
|
|
439
|
+
|
|
440
|
+
Raises:
|
|
441
|
+
ValueError: If backend_type is unknown
|
|
442
|
+
"""
|
|
443
|
+
if backend_type == "local":
|
|
444
|
+
return LocalEncodingBackend(**kwargs)
|
|
445
|
+
elif backend_type == "gce":
|
|
446
|
+
return GCEEncodingBackend(**kwargs)
|
|
447
|
+
elif backend_type == "auto":
|
|
448
|
+
# Check if GCE is available, otherwise use local
|
|
449
|
+
gce_backend = GCEEncodingBackend(**kwargs)
|
|
450
|
+
if gce_backend._get_service().is_enabled:
|
|
451
|
+
return gce_backend
|
|
452
|
+
return LocalEncodingBackend(**kwargs)
|
|
453
|
+
else:
|
|
454
|
+
raise ValueError(f"Unknown encoding backend type: {backend_type}")
|