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,589 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import glob as glob_module
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, BackgroundTasks, HTTPException, Header, Depends
|
|
15
|
+
from google.cloud import storage
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
logging.basicConfig(level=logging.INFO)
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
app = FastAPI(title="Encoding Worker", version="1.0.0")
|
|
22
|
+
|
|
23
|
+
# API key authentication
|
|
24
|
+
API_KEY = os.environ.get("ENCODING_API_KEY", "")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def verify_api_key(x_api_key: str = Header(None)):
|
|
28
|
+
# Verify API key for authentication
|
|
29
|
+
if not API_KEY:
|
|
30
|
+
logger.warning("No API key configured - authentication disabled")
|
|
31
|
+
return True
|
|
32
|
+
if x_api_key != API_KEY:
|
|
33
|
+
raise HTTPException(status_code=401, detail="Invalid API key")
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
# Job tracking
|
|
37
|
+
jobs: dict[str, dict] = {}
|
|
38
|
+
executor = ThreadPoolExecutor(max_workers=4) # 4 parallel encoding jobs
|
|
39
|
+
|
|
40
|
+
# GCS client
|
|
41
|
+
storage_client = storage.Client()
|
|
42
|
+
|
|
43
|
+
class EncodeRequest(BaseModel):
|
|
44
|
+
job_id: str
|
|
45
|
+
input_gcs_path: str # gs://bucket/path/to/inputs/
|
|
46
|
+
output_gcs_path: str # gs://bucket/path/to/outputs/
|
|
47
|
+
encoding_config: dict # Video formats to generate
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class EncodePreviewRequest(BaseModel):
|
|
51
|
+
job_id: str
|
|
52
|
+
ass_gcs_path: str # gs://bucket/path/to/subtitles.ass
|
|
53
|
+
audio_gcs_path: str # gs://bucket/path/to/audio.flac
|
|
54
|
+
output_gcs_path: str # gs://bucket/path/to/output.mp4
|
|
55
|
+
background_color: str = "black"
|
|
56
|
+
background_image_gcs_path: Optional[str] = None
|
|
57
|
+
font_gcs_path: Optional[str] = None # gs://bucket/path/to/custom-font.ttf
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class JobStatus(BaseModel):
|
|
61
|
+
job_id: str
|
|
62
|
+
status: str # pending, running, complete, failed
|
|
63
|
+
progress: int # 0-100
|
|
64
|
+
error: Optional[str] = None
|
|
65
|
+
output_files: Optional[list[str]] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def download_from_gcs(gcs_uri: str, local_path: Path):
|
|
69
|
+
# Download a file or folder from GCS
|
|
70
|
+
# Parse gs://bucket/path
|
|
71
|
+
parts = gcs_uri.replace("gs://", "").split("/", 1)
|
|
72
|
+
bucket_name = parts[0]
|
|
73
|
+
prefix = parts[1] if len(parts) > 1 else ""
|
|
74
|
+
|
|
75
|
+
bucket = storage_client.bucket(bucket_name)
|
|
76
|
+
blobs = list(bucket.list_blobs(prefix=prefix))
|
|
77
|
+
|
|
78
|
+
for blob in blobs:
|
|
79
|
+
# Get relative path from prefix
|
|
80
|
+
rel_path = blob.name[len(prefix):].lstrip("/")
|
|
81
|
+
if not rel_path:
|
|
82
|
+
continue
|
|
83
|
+
dest = local_path / rel_path
|
|
84
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
blob.download_to_filename(str(dest))
|
|
86
|
+
logger.info(f"Downloaded: {blob.name} -> {dest}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def upload_to_gcs(local_path: Path, gcs_uri: str):
|
|
90
|
+
# Upload a file or folder to GCS
|
|
91
|
+
parts = gcs_uri.replace("gs://", "").split("/", 1)
|
|
92
|
+
bucket_name = parts[0]
|
|
93
|
+
prefix = parts[1].rstrip("/") if len(parts) > 1 else ""
|
|
94
|
+
|
|
95
|
+
bucket = storage_client.bucket(bucket_name)
|
|
96
|
+
|
|
97
|
+
if local_path.is_file():
|
|
98
|
+
blob_name = f"{prefix}/{local_path.name}" if prefix else local_path.name
|
|
99
|
+
blob = bucket.blob(blob_name)
|
|
100
|
+
blob.upload_from_filename(str(local_path))
|
|
101
|
+
logger.info(f"Uploaded: {local_path} -> gs://{bucket_name}/{blob_name}")
|
|
102
|
+
else:
|
|
103
|
+
for file in local_path.rglob("*"):
|
|
104
|
+
if file.is_file():
|
|
105
|
+
rel_path = file.relative_to(local_path)
|
|
106
|
+
blob_name = f"{prefix}/{rel_path}" if prefix else str(rel_path)
|
|
107
|
+
blob = bucket.blob(blob_name)
|
|
108
|
+
blob.upload_from_filename(str(file))
|
|
109
|
+
logger.info(f"Uploaded: {file} -> gs://{bucket_name}/{blob_name}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def download_single_file_from_gcs(gcs_uri: str, local_path: Path):
|
|
113
|
+
# Download a single file from GCS
|
|
114
|
+
parts = gcs_uri.replace("gs://", "").split("/", 1)
|
|
115
|
+
bucket_name = parts[0]
|
|
116
|
+
blob_name = parts[1] if len(parts) > 1 else ""
|
|
117
|
+
|
|
118
|
+
bucket = storage_client.bucket(bucket_name)
|
|
119
|
+
blob = bucket.blob(blob_name)
|
|
120
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
blob.download_to_filename(str(local_path))
|
|
122
|
+
logger.info(f"Downloaded: {gcs_uri} -> {local_path}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def upload_single_file_to_gcs(local_path: Path, gcs_uri: str):
|
|
126
|
+
# Upload a single file to a specific GCS path
|
|
127
|
+
parts = gcs_uri.replace("gs://", "").split("/", 1)
|
|
128
|
+
bucket_name = parts[0]
|
|
129
|
+
blob_name = parts[1] if len(parts) > 1 else ""
|
|
130
|
+
|
|
131
|
+
bucket = storage_client.bucket(bucket_name)
|
|
132
|
+
blob = bucket.blob(blob_name)
|
|
133
|
+
blob.upload_from_filename(str(local_path))
|
|
134
|
+
logger.info(f"Uploaded: {local_path} -> {gcs_uri}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_preview_encoding(job_id: str, work_dir: Path, request: "EncodePreviewRequest"):
|
|
138
|
+
# Run FFmpeg encoding for preview video (480x270, fast settings)
|
|
139
|
+
jobs[job_id]["status"] = "running"
|
|
140
|
+
jobs[job_id]["progress"] = 10
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
# Download input files
|
|
144
|
+
ass_path = work_dir / "subtitles.ass"
|
|
145
|
+
audio_path = work_dir / "audio.flac"
|
|
146
|
+
|
|
147
|
+
download_single_file_from_gcs(request.ass_gcs_path, ass_path)
|
|
148
|
+
jobs[job_id]["progress"] = 20
|
|
149
|
+
|
|
150
|
+
download_single_file_from_gcs(request.audio_gcs_path, audio_path)
|
|
151
|
+
jobs[job_id]["progress"] = 30
|
|
152
|
+
|
|
153
|
+
# Download background image if provided
|
|
154
|
+
bg_image_path = None
|
|
155
|
+
if request.background_image_gcs_path:
|
|
156
|
+
bg_image_path = work_dir / "background.png"
|
|
157
|
+
download_single_file_from_gcs(request.background_image_gcs_path, bg_image_path)
|
|
158
|
+
|
|
159
|
+
# Download custom font if provided and register with fontconfig
|
|
160
|
+
if request.font_gcs_path:
|
|
161
|
+
# Use standard fontconfig location that's already in the search path
|
|
162
|
+
fonts_dir = Path("/usr/local/share/fonts/custom")
|
|
163
|
+
fonts_dir.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
font_filename = request.font_gcs_path.split("/")[-1]
|
|
165
|
+
font_path = fonts_dir / font_filename
|
|
166
|
+
download_single_file_from_gcs(request.font_gcs_path, font_path)
|
|
167
|
+
logger.info(f"Downloaded custom font: {font_path}")
|
|
168
|
+
# Update fontconfig cache so libass can find the font
|
|
169
|
+
subprocess.run(["fc-cache", "-fv"], capture_output=True)
|
|
170
|
+
logger.info(f"Updated fontconfig cache with custom font: {font_filename}")
|
|
171
|
+
|
|
172
|
+
# Build FFmpeg command
|
|
173
|
+
output_path = work_dir / "preview.mp4"
|
|
174
|
+
|
|
175
|
+
# Escape special characters in path for FFmpeg filter syntax
|
|
176
|
+
# FFmpeg filter parsing requires escaping: \ : , [ ] ;
|
|
177
|
+
def escape_ffmpeg_filter_path(path: str) -> str:
|
|
178
|
+
# Note: Extra escaping needed since this is inside a triple-quoted string in Pulumi
|
|
179
|
+
return path.replace("\\", "\\\\").replace(":", "\\:").replace(",", "\\,").replace("[", "\\[").replace("]", "\\]").replace(";", "\\;")
|
|
180
|
+
|
|
181
|
+
escaped_ass_path = escape_ffmpeg_filter_path(str(ass_path))
|
|
182
|
+
|
|
183
|
+
# Base command with frame rate
|
|
184
|
+
cmd = ["ffmpeg", "-y", "-r", "24"]
|
|
185
|
+
|
|
186
|
+
# Video input: background image or solid color
|
|
187
|
+
if bg_image_path and bg_image_path.exists():
|
|
188
|
+
cmd.extend(["-loop", "1", "-i", str(bg_image_path)])
|
|
189
|
+
# Scale and pad background to 480x270
|
|
190
|
+
vf = f"scale=480:270:force_original_aspect_ratio=decrease,pad=480:270:(ow-iw)/2:(oh-ih)/2,ass={escaped_ass_path}"
|
|
191
|
+
else:
|
|
192
|
+
# Solid color background
|
|
193
|
+
color = request.background_color or "black"
|
|
194
|
+
cmd.extend(["-f", "lavfi", "-i", f"color=c={color}:s=480x270:r=24"])
|
|
195
|
+
vf = f"ass={escaped_ass_path}"
|
|
196
|
+
|
|
197
|
+
# Audio input
|
|
198
|
+
cmd.extend(["-i", str(audio_path)])
|
|
199
|
+
|
|
200
|
+
# Video filter and encoding settings
|
|
201
|
+
cmd.extend([
|
|
202
|
+
"-vf", vf,
|
|
203
|
+
"-c:a", "aac", "-b:a", "96k",
|
|
204
|
+
"-c:v", "libx264",
|
|
205
|
+
"-preset", "superfast",
|
|
206
|
+
"-crf", "28",
|
|
207
|
+
"-pix_fmt", "yuv420p",
|
|
208
|
+
"-movflags", "+faststart",
|
|
209
|
+
"-threads", "8",
|
|
210
|
+
"-shortest",
|
|
211
|
+
str(output_path)
|
|
212
|
+
])
|
|
213
|
+
|
|
214
|
+
jobs[job_id]["progress"] = 40
|
|
215
|
+
logger.info(f"Running preview encoding: {' '.join(cmd)}")
|
|
216
|
+
|
|
217
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
218
|
+
|
|
219
|
+
if result.returncode != 0:
|
|
220
|
+
logger.error(f"FFmpeg failed: {result.stderr}")
|
|
221
|
+
raise RuntimeError(f"FFmpeg preview encoding failed: {result.stderr[-500:]}")
|
|
222
|
+
|
|
223
|
+
jobs[job_id]["progress"] = 80
|
|
224
|
+
logger.info(f"Preview encoded: {output_path}")
|
|
225
|
+
|
|
226
|
+
# Upload output to GCS
|
|
227
|
+
upload_single_file_to_gcs(output_path, request.output_gcs_path)
|
|
228
|
+
jobs[job_id]["progress"] = 95
|
|
229
|
+
|
|
230
|
+
jobs[job_id]["output_files"] = [request.output_gcs_path]
|
|
231
|
+
return output_path
|
|
232
|
+
|
|
233
|
+
except Exception as e:
|
|
234
|
+
logger.error(f"Preview encoding failed: {e}")
|
|
235
|
+
jobs[job_id]["status"] = "failed"
|
|
236
|
+
jobs[job_id]["error"] = str(e)
|
|
237
|
+
raise
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def ensure_latest_wheel():
|
|
241
|
+
'''Download and install latest karaoke-gen wheel from GCS.
|
|
242
|
+
|
|
243
|
+
Called at the start of each job to enable hot code updates without restart.
|
|
244
|
+
In-progress jobs continue with their version, new jobs get latest code.
|
|
245
|
+
'''
|
|
246
|
+
try:
|
|
247
|
+
logger.info("Checking for latest karaoke-gen wheel in GCS...")
|
|
248
|
+
|
|
249
|
+
# Download latest wheel
|
|
250
|
+
result = subprocess.run(
|
|
251
|
+
["gsutil", "cp", "gs://karaoke-gen-storage-nomadkaraoke/wheels/karaoke_gen-*.whl", "/tmp/"],
|
|
252
|
+
capture_output=True, text=True, timeout=60
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Find the downloaded wheel (get the latest by version sorting)
|
|
256
|
+
wheels = glob_module.glob("/tmp/karaoke_gen-*.whl")
|
|
257
|
+
if not wheels:
|
|
258
|
+
logger.warning("No wheel found in GCS, using fallback encoding logic")
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
# Sort to get latest version
|
|
262
|
+
wheel_path = sorted(wheels)[-1]
|
|
263
|
+
logger.info(f"Installing wheel: {wheel_path}")
|
|
264
|
+
|
|
265
|
+
# Install (or upgrade) the wheel
|
|
266
|
+
# Use 5-minute timeout - first install at job start may need to resolve dependencies
|
|
267
|
+
# Subsequent installs are faster since dependencies are cached
|
|
268
|
+
install_result = subprocess.run(
|
|
269
|
+
[sys.executable, "-m", "pip", "install", "--upgrade", "--quiet", wheel_path],
|
|
270
|
+
capture_output=True, text=True, timeout=300
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if install_result.returncode != 0:
|
|
274
|
+
logger.warning(f"Wheel installation failed: {install_result.stderr}")
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
logger.info(f"Successfully installed wheel: {wheel_path}")
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
except subprocess.TimeoutExpired:
|
|
281
|
+
logger.warning("Wheel download/install timed out, using fallback")
|
|
282
|
+
return False
|
|
283
|
+
except Exception as e:
|
|
284
|
+
logger.warning(f"Failed to ensure latest wheel: {e}")
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def find_file(work_dir: Path, *patterns):
|
|
289
|
+
'''Find a file matching any of the given glob patterns.'''
|
|
290
|
+
for pattern in patterns:
|
|
291
|
+
matches = list(work_dir.glob(f"**/{pattern}"))
|
|
292
|
+
if matches:
|
|
293
|
+
return matches[0]
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def run_encoding(job_id: str, work_dir: Path, config: dict):
|
|
298
|
+
'''Run encoding using LocalEncodingService (single source of truth).
|
|
299
|
+
|
|
300
|
+
Uses LocalEncodingService from the installed karaoke-gen wheel to ensure
|
|
301
|
+
output files match local CLI exactly:
|
|
302
|
+
- Proper names like "Artist - Title (Final Karaoke Lossless 4k).mp4"
|
|
303
|
+
- Concatenated title + karaoke + end screens
|
|
304
|
+
- All formats: lossless 4K MP4, lossy 4K MP4, lossless MKV, 720p MP4
|
|
305
|
+
|
|
306
|
+
Requires the karaoke-gen wheel to be installed (done by ensure_latest_wheel).
|
|
307
|
+
'''
|
|
308
|
+
jobs[job_id]["status"] = "running"
|
|
309
|
+
jobs[job_id]["progress"] = 10
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
# Import LocalEncodingService from installed wheel (required, no fallback)
|
|
313
|
+
from backend.services.local_encoding_service import LocalEncodingService, EncodingConfig
|
|
314
|
+
logger.info("Using LocalEncodingService from installed wheel")
|
|
315
|
+
|
|
316
|
+
# Get artist/title from config for proper naming
|
|
317
|
+
artist = config.get("artist", "Unknown Artist")
|
|
318
|
+
title = config.get("title", "Unknown Title")
|
|
319
|
+
base_name = f"{artist} - {title}"
|
|
320
|
+
logger.info(f"Encoding for: {base_name}")
|
|
321
|
+
|
|
322
|
+
# Find input files in work_dir
|
|
323
|
+
# Title/end screens are in screens/ subdirectory
|
|
324
|
+
title_video = find_file(work_dir, "screens/title.mov", "*Title*.mov", "*title*.mov")
|
|
325
|
+
end_video = find_file(work_dir, "screens/end.mov", "*End*.mov", "*end*.mov")
|
|
326
|
+
|
|
327
|
+
# Karaoke video - search for With Vocals or main karaoke video
|
|
328
|
+
karaoke_video = find_file(
|
|
329
|
+
work_dir,
|
|
330
|
+
"*With Vocals*.mov", "*With Vocals*.mkv",
|
|
331
|
+
"*Vocals*.mov", "*Vocals*.mkv",
|
|
332
|
+
"*.mkv", "*.mov"
|
|
333
|
+
)
|
|
334
|
+
# Exclude title/end/output videos
|
|
335
|
+
if karaoke_video:
|
|
336
|
+
name_lower = karaoke_video.name.lower()
|
|
337
|
+
if "title" in name_lower or "end" in name_lower or "outputs" in str(karaoke_video):
|
|
338
|
+
# Search more specifically for karaoke video
|
|
339
|
+
karaoke_video = find_file(work_dir, "*Karaoke*.mkv", "*Karaoke*.mov", "*vocals*.mkv")
|
|
340
|
+
|
|
341
|
+
# Instrumental audio
|
|
342
|
+
instrumental = find_file(
|
|
343
|
+
work_dir,
|
|
344
|
+
"*instrumental_clean*.flac", "*Instrumental Clean*.flac",
|
|
345
|
+
"*instrumental*.flac", "*Instrumental*.flac",
|
|
346
|
+
"*instrumental*.wav"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
logger.info(f"Found files:")
|
|
350
|
+
logger.info(f" Title video: {title_video}")
|
|
351
|
+
logger.info(f" Karaoke video: {karaoke_video}")
|
|
352
|
+
logger.info(f" End video: {end_video}")
|
|
353
|
+
logger.info(f" Instrumental: {instrumental}")
|
|
354
|
+
|
|
355
|
+
# Validate required files
|
|
356
|
+
if not title_video:
|
|
357
|
+
raise ValueError(f"No title video found in {work_dir}. Check screens/ subdirectory.")
|
|
358
|
+
if not karaoke_video:
|
|
359
|
+
raise ValueError(f"No karaoke video found in {work_dir}")
|
|
360
|
+
if not instrumental:
|
|
361
|
+
raise ValueError(f"No instrumental audio found in {work_dir}")
|
|
362
|
+
|
|
363
|
+
output_dir = work_dir / "outputs"
|
|
364
|
+
output_dir.mkdir(exist_ok=True)
|
|
365
|
+
|
|
366
|
+
jobs[job_id]["progress"] = 20
|
|
367
|
+
|
|
368
|
+
# Build encoding config with proper file names
|
|
369
|
+
encoding_config = EncodingConfig(
|
|
370
|
+
title_video=str(title_video),
|
|
371
|
+
karaoke_video=str(karaoke_video),
|
|
372
|
+
instrumental_audio=str(instrumental),
|
|
373
|
+
end_video=str(end_video) if end_video else None,
|
|
374
|
+
output_karaoke_mp4=str(output_dir / f"{base_name} (Karaoke).mp4"),
|
|
375
|
+
output_with_vocals_mp4=str(output_dir / f"{base_name} (With Vocals).mp4"),
|
|
376
|
+
output_lossless_4k_mp4=str(output_dir / f"{base_name} (Final Karaoke Lossless 4k).mp4"),
|
|
377
|
+
output_lossy_4k_mp4=str(output_dir / f"{base_name} (Final Karaoke Lossy 4k).mp4"),
|
|
378
|
+
output_lossless_mkv=str(output_dir / f"{base_name} (Final Karaoke Lossless 4k).mkv"),
|
|
379
|
+
output_720p_mp4=str(output_dir / f"{base_name} (Final Karaoke Lossy 720p).mp4"),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Create service and run encoding
|
|
383
|
+
service = LocalEncodingService(logger=logger)
|
|
384
|
+
|
|
385
|
+
jobs[job_id]["progress"] = 30
|
|
386
|
+
logger.info("Starting LocalEncodingService.encode_all_formats()")
|
|
387
|
+
|
|
388
|
+
result = service.encode_all_formats(encoding_config)
|
|
389
|
+
|
|
390
|
+
if not result.success:
|
|
391
|
+
raise RuntimeError(f"Encoding failed: {result.error}")
|
|
392
|
+
|
|
393
|
+
jobs[job_id]["progress"] = 90
|
|
394
|
+
|
|
395
|
+
# Collect output files
|
|
396
|
+
output_files = [str(f) for f in output_dir.glob("*") if f.is_file()]
|
|
397
|
+
jobs[job_id]["output_files"] = output_files
|
|
398
|
+
|
|
399
|
+
logger.info(f"Encoding complete. Output files: {output_files}")
|
|
400
|
+
return output_dir
|
|
401
|
+
|
|
402
|
+
except ImportError as e:
|
|
403
|
+
# No fallback - wheel must be installed
|
|
404
|
+
error_msg = (
|
|
405
|
+
f"LocalEncodingService not available: {e}. "
|
|
406
|
+
"The karaoke-gen wheel must be installed. "
|
|
407
|
+
"Check that ensure_latest_wheel() succeeded and wheel exists in GCS."
|
|
408
|
+
)
|
|
409
|
+
logger.error(error_msg)
|
|
410
|
+
jobs[job_id]["status"] = "failed"
|
|
411
|
+
jobs[job_id]["error"] = error_msg
|
|
412
|
+
raise RuntimeError(error_msg) from e
|
|
413
|
+
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logger.error(f"Encoding failed: {e}")
|
|
416
|
+
jobs[job_id]["status"] = "failed"
|
|
417
|
+
jobs[job_id]["error"] = str(e)
|
|
418
|
+
raise
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def process_job(job_id: str, request: EncodeRequest):
|
|
422
|
+
# Process an encoding job asynchronously
|
|
423
|
+
try:
|
|
424
|
+
# Download and install latest wheel at job start (allows hot updates without restart)
|
|
425
|
+
# This means in-progress jobs continue with their version, new jobs get latest code
|
|
426
|
+
ensure_latest_wheel()
|
|
427
|
+
|
|
428
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
429
|
+
work_dir = Path(temp_dir) / "work"
|
|
430
|
+
work_dir.mkdir()
|
|
431
|
+
|
|
432
|
+
# Download input files
|
|
433
|
+
jobs[job_id]["progress"] = 5
|
|
434
|
+
logger.info(f"Downloading from {request.input_gcs_path}")
|
|
435
|
+
download_from_gcs(request.input_gcs_path, work_dir)
|
|
436
|
+
|
|
437
|
+
# Run encoding in thread pool (CPU-bound)
|
|
438
|
+
loop = asyncio.get_event_loop()
|
|
439
|
+
output_dir = await loop.run_in_executor(
|
|
440
|
+
executor,
|
|
441
|
+
run_encoding,
|
|
442
|
+
job_id,
|
|
443
|
+
work_dir,
|
|
444
|
+
request.encoding_config
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Upload outputs
|
|
448
|
+
jobs[job_id]["progress"] = 95
|
|
449
|
+
logger.info(f"Uploading to {request.output_gcs_path}")
|
|
450
|
+
upload_to_gcs(output_dir, request.output_gcs_path)
|
|
451
|
+
|
|
452
|
+
# Convert local paths to blob paths (backend expects blob paths, not full gs:// URIs)
|
|
453
|
+
# output_gcs_path is like "gs://bucket/jobs/id/encoded/"
|
|
454
|
+
# We need paths like "jobs/id/encoded/Artist - Title (Final Karaoke Lossless 4k).mp4"
|
|
455
|
+
gcs_path = request.output_gcs_path.replace("gs://", "")
|
|
456
|
+
parts = gcs_path.split("/", 1)
|
|
457
|
+
prefix = parts[1].rstrip("/") if len(parts) > 1 else ""
|
|
458
|
+
local_output_files = jobs[job_id].get("output_files", [])
|
|
459
|
+
blob_paths = []
|
|
460
|
+
for local_path in local_output_files:
|
|
461
|
+
filename = Path(local_path).name
|
|
462
|
+
blob_path = f"{prefix}/{filename}" if prefix else filename
|
|
463
|
+
blob_paths.append(blob_path)
|
|
464
|
+
jobs[job_id]["output_files"] = blob_paths
|
|
465
|
+
logger.info(f"Output files (blob paths): {blob_paths}")
|
|
466
|
+
|
|
467
|
+
jobs[job_id]["status"] = "complete"
|
|
468
|
+
jobs[job_id]["progress"] = 100
|
|
469
|
+
logger.info(f"Job {job_id} complete")
|
|
470
|
+
|
|
471
|
+
except Exception as e:
|
|
472
|
+
logger.error(f"Job {job_id} failed: {e}")
|
|
473
|
+
jobs[job_id]["status"] = "failed"
|
|
474
|
+
jobs[job_id]["error"] = str(e)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
async def process_preview_job(job_id: str, request: EncodePreviewRequest):
|
|
478
|
+
# Process a preview encoding job asynchronously
|
|
479
|
+
try:
|
|
480
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
481
|
+
work_dir = Path(temp_dir) / "work"
|
|
482
|
+
work_dir.mkdir()
|
|
483
|
+
|
|
484
|
+
# Run preview encoding in thread pool (CPU-bound)
|
|
485
|
+
# Note: run_preview_encoding handles download/upload internally
|
|
486
|
+
loop = asyncio.get_event_loop()
|
|
487
|
+
await loop.run_in_executor(
|
|
488
|
+
executor,
|
|
489
|
+
run_preview_encoding,
|
|
490
|
+
job_id,
|
|
491
|
+
work_dir,
|
|
492
|
+
request
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
jobs[job_id]["status"] = "complete"
|
|
496
|
+
jobs[job_id]["progress"] = 100
|
|
497
|
+
logger.info(f"Preview job {job_id} complete")
|
|
498
|
+
|
|
499
|
+
except Exception as e:
|
|
500
|
+
logger.error(f"Preview job {job_id} failed: {e}")
|
|
501
|
+
jobs[job_id]["status"] = "failed"
|
|
502
|
+
jobs[job_id]["error"] = str(e)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@app.post("/encode-preview")
|
|
506
|
+
async def submit_preview_encode_job(request: EncodePreviewRequest, background_tasks: BackgroundTasks, _auth: bool = Depends(verify_api_key)):
|
|
507
|
+
# Submit a preview encoding job
|
|
508
|
+
job_id = request.job_id
|
|
509
|
+
|
|
510
|
+
# If job already exists, return cached result or current status
|
|
511
|
+
if job_id in jobs:
|
|
512
|
+
existing_job = jobs[job_id]
|
|
513
|
+
if existing_job["status"] == "complete":
|
|
514
|
+
# Return cached result - preview already encoded
|
|
515
|
+
return {"status": "cached", "job_id": job_id, "output_path": existing_job.get("output_path")}
|
|
516
|
+
elif existing_job["status"] == "failed":
|
|
517
|
+
# Previous attempt failed, allow retry by replacing the job
|
|
518
|
+
pass
|
|
519
|
+
else:
|
|
520
|
+
# Job is still in progress
|
|
521
|
+
return {"status": "in_progress", "job_id": job_id}
|
|
522
|
+
|
|
523
|
+
jobs[job_id] = {
|
|
524
|
+
"job_id": job_id,
|
|
525
|
+
"status": "pending",
|
|
526
|
+
"progress": 0,
|
|
527
|
+
"error": None,
|
|
528
|
+
"output_files": None,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
background_tasks.add_task(process_preview_job, job_id, request)
|
|
532
|
+
|
|
533
|
+
return {"status": "accepted", "job_id": job_id}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
@app.post("/encode")
|
|
537
|
+
async def submit_encode_job(request: EncodeRequest, background_tasks: BackgroundTasks, _auth: bool = Depends(verify_api_key)):
|
|
538
|
+
# Submit an encoding job
|
|
539
|
+
job_id = request.job_id
|
|
540
|
+
|
|
541
|
+
if job_id in jobs:
|
|
542
|
+
raise HTTPException(status_code=409, detail=f"Job {job_id} already exists")
|
|
543
|
+
|
|
544
|
+
jobs[job_id] = {
|
|
545
|
+
"job_id": job_id,
|
|
546
|
+
"status": "pending",
|
|
547
|
+
"progress": 0,
|
|
548
|
+
"error": None,
|
|
549
|
+
"output_files": None,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
background_tasks.add_task(process_job, job_id, request)
|
|
553
|
+
|
|
554
|
+
return {"status": "accepted", "job_id": job_id}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@app.get("/status/{job_id}")
|
|
558
|
+
async def get_job_status(job_id: str, _auth: bool = Depends(verify_api_key)) -> JobStatus:
|
|
559
|
+
# Get the status of an encoding job
|
|
560
|
+
if job_id not in jobs:
|
|
561
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
562
|
+
return JobStatus(**jobs[job_id])
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@app.get("/health")
|
|
566
|
+
async def health_check():
|
|
567
|
+
# Health check endpoint
|
|
568
|
+
active_jobs = sum(1 for j in jobs.values() if j["status"] == "running")
|
|
569
|
+
|
|
570
|
+
# Get karaoke-gen wheel version if installed
|
|
571
|
+
wheel_version = None
|
|
572
|
+
try:
|
|
573
|
+
from importlib.metadata import version as get_version
|
|
574
|
+
wheel_version = get_version("karaoke-gen")
|
|
575
|
+
except Exception:
|
|
576
|
+
pass
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
"status": "ok",
|
|
580
|
+
"active_jobs": active_jobs,
|
|
581
|
+
"queue_length": sum(1 for j in jobs.values() if j["status"] == "pending"),
|
|
582
|
+
"ffmpeg_version": subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True).stdout.split("\n")[0],
|
|
583
|
+
"wheel_version": wheel_version,
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
if __name__ == "__main__":
|
|
588
|
+
import uvicorn
|
|
589
|
+
uvicorn.run(app, host="0.0.0.0", port=8080)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# GCE Encoding Worker dependencies
|
|
2
|
+
# These are installed in the VM's Python virtual environment
|
|
3
|
+
|
|
4
|
+
# Web framework
|
|
5
|
+
fastapi>=0.109.0
|
|
6
|
+
uvicorn>=0.27.0
|
|
7
|
+
|
|
8
|
+
# GCS client
|
|
9
|
+
google-cloud-storage>=2.14.0
|
|
10
|
+
|
|
11
|
+
# Async utilities (optional, for future improvements)
|
|
12
|
+
aiofiles>=23.2.0
|
|
13
|
+
aiohttp>=3.9.0
|
|
14
|
+
|
|
15
|
+
# Note: The karaoke-gen wheel is installed separately from GCS.
|
|
16
|
+
# It provides LocalEncodingService which does the actual encoding.
|