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,483 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Render Video Worker
|
|
3
|
+
|
|
4
|
+
Generates the karaoke video with synchronized lyrics AFTER human review.
|
|
5
|
+
|
|
6
|
+
This worker:
|
|
7
|
+
1. Downloads the corrected lyrics data from GCS
|
|
8
|
+
2. Downloads the audio file
|
|
9
|
+
3. Downloads style assets from GCS
|
|
10
|
+
4. Uses LyricsTranscriber's OutputGenerator to render video
|
|
11
|
+
5. Uploads the with_vocals.mkv to GCS
|
|
12
|
+
6. Transitions to AWAITING_INSTRUMENTAL_SELECTION
|
|
13
|
+
|
|
14
|
+
Key insight: We use OutputGenerator from lyrics_transcriber library
|
|
15
|
+
WITHOUT using its blocking ReviewServer. This allows async operation
|
|
16
|
+
in Cloud Run.
|
|
17
|
+
|
|
18
|
+
Observability:
|
|
19
|
+
- All operations wrapped in tracing spans for Cloud Trace visibility
|
|
20
|
+
- Logs include [job:ID] prefix for easy filtering in Cloud Logging
|
|
21
|
+
- Worker start/end timing logged with WORKER_START/WORKER_END markers
|
|
22
|
+
"""
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
import json
|
|
28
|
+
from typing import Optional, Dict, Any
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from backend.models.job import JobStatus
|
|
32
|
+
from backend.services.job_manager import JobManager
|
|
33
|
+
from backend.services.storage_service import StorageService
|
|
34
|
+
from backend.config import get_settings
|
|
35
|
+
from backend.workers.worker_logging import create_job_logger, setup_job_logging, job_logging_context
|
|
36
|
+
from backend.services.tracing import job_span, add_span_event, add_span_attribute
|
|
37
|
+
|
|
38
|
+
# Import from lyrics_transcriber (submodule)
|
|
39
|
+
from lyrics_transcriber.output.generator import OutputGenerator
|
|
40
|
+
from lyrics_transcriber.output.countdown_processor import CountdownProcessor
|
|
41
|
+
from lyrics_transcriber.types import CorrectionResult
|
|
42
|
+
from lyrics_transcriber.core.config import OutputConfig
|
|
43
|
+
|
|
44
|
+
# Import from the unified style loader
|
|
45
|
+
from karaoke_gen.style_loader import load_styles_from_gcs
|
|
46
|
+
from karaoke_gen.utils import sanitize_filename
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Loggers to capture for render video worker
|
|
53
|
+
RENDER_VIDEO_WORKER_LOGGERS = [
|
|
54
|
+
"lyrics_transcriber.output",
|
|
55
|
+
"lyrics_transcriber.output.generator",
|
|
56
|
+
"lyrics_transcriber.output.video",
|
|
57
|
+
"lyrics_transcriber.output.ass",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def process_render_video(job_id: str) -> bool:
|
|
62
|
+
"""
|
|
63
|
+
Render karaoke video with corrected lyrics.
|
|
64
|
+
|
|
65
|
+
Called after human review is complete (REVIEW_COMPLETE state).
|
|
66
|
+
Uses OutputGenerator from lyrics_transcriber to generate the video.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
job_id: Job ID to process
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
True if successful, False otherwise
|
|
73
|
+
"""
|
|
74
|
+
start_time = time.time()
|
|
75
|
+
job_manager = JobManager()
|
|
76
|
+
storage = StorageService()
|
|
77
|
+
settings = get_settings()
|
|
78
|
+
|
|
79
|
+
# Create job logger for remote debugging FIRST
|
|
80
|
+
job_log = create_job_logger(job_id, "render_video")
|
|
81
|
+
|
|
82
|
+
# Log with structured markers for easy Cloud Logging queries
|
|
83
|
+
logger.info(f"[job:{job_id}] WORKER_START worker=render-video")
|
|
84
|
+
job_log.info("=== RENDER VIDEO WORKER STARTED ===")
|
|
85
|
+
job_log.info(f"Job ID: {job_id}")
|
|
86
|
+
|
|
87
|
+
# Set up log capture for OutputGenerator
|
|
88
|
+
log_handler = setup_job_logging(job_id, "render_video", *RENDER_VIDEO_WORKER_LOGGERS)
|
|
89
|
+
job_log.info(f"Log handler attached for {len(RENDER_VIDEO_WORKER_LOGGERS)} loggers")
|
|
90
|
+
|
|
91
|
+
job = job_manager.get_job(job_id)
|
|
92
|
+
if not job:
|
|
93
|
+
logger.error(f"[job:{job_id}] Job not found in Firestore")
|
|
94
|
+
job_log.error(f"Job {job_id} not found in Firestore!")
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
job_log.info(f"Starting video render for {job.artist} - {job.title}")
|
|
98
|
+
logger.info(f"[job:{job_id}] Starting video render (post-review)")
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
# Wrap entire worker in a tracing span
|
|
102
|
+
with job_span("render-video-worker", job_id, {"artist": job.artist, "title": job.title}) as root_span:
|
|
103
|
+
# Use job_logging_context for proper log isolation when multiple jobs run concurrently
|
|
104
|
+
# This ensures logs from third-party libraries (lyrics_transcriber.output) are only
|
|
105
|
+
# captured by this job's handler, not handlers from other concurrent jobs
|
|
106
|
+
with job_logging_context(job_id):
|
|
107
|
+
# Transition to RENDERING_VIDEO
|
|
108
|
+
job_manager.transition_to_state(
|
|
109
|
+
job_id=job_id,
|
|
110
|
+
new_status=JobStatus.RENDERING_VIDEO,
|
|
111
|
+
progress=75,
|
|
112
|
+
message="Rendering karaoke video with corrected lyrics"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
116
|
+
job_log.info(f"Created temp directory: {temp_dir}")
|
|
117
|
+
|
|
118
|
+
# Try updated corrections first (from human review), fall back to original
|
|
119
|
+
corrections_gcs_updated = f"jobs/{job_id}/lyrics/corrections_updated.json"
|
|
120
|
+
corrections_gcs_original = f"jobs/{job_id}/lyrics/corrections.json"
|
|
121
|
+
|
|
122
|
+
# Get GCS path from file_urls
|
|
123
|
+
corrections_url = job.file_urls.get('lyrics', {}).get('corrections_updated')
|
|
124
|
+
if not corrections_url:
|
|
125
|
+
corrections_url = job.file_urls.get('lyrics', {}).get('corrections')
|
|
126
|
+
|
|
127
|
+
if not corrections_url:
|
|
128
|
+
# Try direct GCS paths
|
|
129
|
+
if storage.file_exists(corrections_gcs_updated):
|
|
130
|
+
corrections_gcs = corrections_gcs_updated
|
|
131
|
+
elif storage.file_exists(corrections_gcs_original):
|
|
132
|
+
corrections_gcs = corrections_gcs_original
|
|
133
|
+
else:
|
|
134
|
+
raise FileNotFoundError(f"No corrections file found for job {job_id}")
|
|
135
|
+
else:
|
|
136
|
+
# Extract GCS path from URL
|
|
137
|
+
corrections_gcs = _extract_gcs_path(corrections_url)
|
|
138
|
+
|
|
139
|
+
# 2. Load the ORIGINAL corrections (has full structure)
|
|
140
|
+
original_corrections_gcs = f"jobs/{job_id}/lyrics/corrections.json"
|
|
141
|
+
original_corrections_path = os.path.join(temp_dir, "corrections_original.json")
|
|
142
|
+
|
|
143
|
+
job_log.info(f"Downloading original corrections from {original_corrections_gcs}")
|
|
144
|
+
logger.info(f"Job {job_id}: Downloading original corrections from {original_corrections_gcs}")
|
|
145
|
+
storage.download_file(original_corrections_gcs, original_corrections_path)
|
|
146
|
+
|
|
147
|
+
with open(original_corrections_path, 'r', encoding='utf-8') as f:
|
|
148
|
+
original_data = json.load(f)
|
|
149
|
+
|
|
150
|
+
# 3. Check if there are updated corrections (from review UI)
|
|
151
|
+
# The frontend sends only partial data: {corrections, corrected_segments}
|
|
152
|
+
updated_corrections_gcs = f"jobs/{job_id}/lyrics/corrections_updated.json"
|
|
153
|
+
|
|
154
|
+
if storage.file_exists(updated_corrections_gcs):
|
|
155
|
+
job_log.info("Found updated corrections from review, merging")
|
|
156
|
+
logger.info(f"Job {job_id}: Found updated corrections, merging")
|
|
157
|
+
updated_path = os.path.join(temp_dir, "corrections_updated.json")
|
|
158
|
+
storage.download_file(updated_corrections_gcs, updated_path)
|
|
159
|
+
|
|
160
|
+
with open(updated_path, 'r', encoding='utf-8') as f:
|
|
161
|
+
updated_data = json.load(f)
|
|
162
|
+
|
|
163
|
+
# Merge: update the original with the user's corrections
|
|
164
|
+
if 'corrections' in updated_data:
|
|
165
|
+
original_data['corrections'] = updated_data['corrections']
|
|
166
|
+
if 'corrected_segments' in updated_data:
|
|
167
|
+
original_data['corrected_segments'] = updated_data['corrected_segments']
|
|
168
|
+
|
|
169
|
+
job_log.info("Merged user corrections into original data")
|
|
170
|
+
logger.info(f"Job {job_id}: Merged user corrections into original data")
|
|
171
|
+
|
|
172
|
+
# 4. Convert to CorrectionResult
|
|
173
|
+
correction_result = CorrectionResult.from_dict(original_data)
|
|
174
|
+
job_log.info(f"Loaded CorrectionResult with {len(correction_result.corrected_segments)} segments")
|
|
175
|
+
logger.info(f"Job {job_id}: Loaded CorrectionResult with {len(correction_result.corrected_segments)} segments")
|
|
176
|
+
|
|
177
|
+
# 5. Download audio file
|
|
178
|
+
audio_path = os.path.join(temp_dir, "audio.flac")
|
|
179
|
+
audio_gcs_path = job.input_media_gcs_path
|
|
180
|
+
|
|
181
|
+
if not audio_gcs_path:
|
|
182
|
+
raise FileNotFoundError(f"No input audio path for job {job_id}")
|
|
183
|
+
|
|
184
|
+
job_log.info(f"Downloading audio from {audio_gcs_path}")
|
|
185
|
+
logger.info(f"Job {job_id}: Downloading audio from {audio_gcs_path}")
|
|
186
|
+
storage.download_file(audio_gcs_path, audio_path)
|
|
187
|
+
job_log.info(f"Audio downloaded: {os.path.getsize(audio_path)} bytes")
|
|
188
|
+
|
|
189
|
+
# Process countdown intro if needed (for songs that start too quickly)
|
|
190
|
+
# This adds "3... 2... 1..." segment and pads audio with 3s silence
|
|
191
|
+
# Note: Countdown is deferred to this stage (not during lyrics transcription)
|
|
192
|
+
# so the review UI can show the original timing without the 3s shift
|
|
193
|
+
countdown_processor = CountdownProcessor(cache_dir=temp_dir, logger=logger)
|
|
194
|
+
correction_result, audio_path, padding_added, padding_seconds = countdown_processor.process(
|
|
195
|
+
correction_result=correction_result,
|
|
196
|
+
audio_filepath=audio_path,
|
|
197
|
+
)
|
|
198
|
+
if padding_added:
|
|
199
|
+
job_log.info(f"Countdown added: {padding_seconds}s padding applied to audio and timestamps shifted")
|
|
200
|
+
else:
|
|
201
|
+
job_log.info("No countdown needed - song starts after 3 seconds")
|
|
202
|
+
|
|
203
|
+
# 6. Get or create styles using the unified style loader
|
|
204
|
+
job_log.info("Loading style configuration...")
|
|
205
|
+
job_log.info(f" job.style_params_gcs_path: {job.style_params_gcs_path}")
|
|
206
|
+
job_log.info(f" job.style_assets: {list(job.style_assets.keys()) if job.style_assets else 'None'}")
|
|
207
|
+
|
|
208
|
+
styles_path, style_data = load_styles_from_gcs(
|
|
209
|
+
style_params_gcs_path=job.style_params_gcs_path,
|
|
210
|
+
style_assets=job.style_assets,
|
|
211
|
+
temp_dir=temp_dir,
|
|
212
|
+
download_func=storage.download_file,
|
|
213
|
+
logger=job_log,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# 7. Configure OutputGenerator
|
|
217
|
+
output_dir = os.path.join(temp_dir, "output")
|
|
218
|
+
cache_dir = os.path.join(temp_dir, "cache")
|
|
219
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
220
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
221
|
+
|
|
222
|
+
job_log.info(f"Using styles from: {styles_path}")
|
|
223
|
+
|
|
224
|
+
# Get subtitle offset from job (user-specified timing adjustment)
|
|
225
|
+
subtitle_offset = getattr(job, 'subtitle_offset_ms', 0) or 0
|
|
226
|
+
if subtitle_offset != 0:
|
|
227
|
+
job_log.info(f"Applying subtitle offset: {subtitle_offset}ms")
|
|
228
|
+
|
|
229
|
+
config = OutputConfig(
|
|
230
|
+
output_dir=output_dir,
|
|
231
|
+
cache_dir=cache_dir,
|
|
232
|
+
output_styles_json=styles_path,
|
|
233
|
+
render_video=True,
|
|
234
|
+
generate_cdg=False, # CDG optional, generated separately
|
|
235
|
+
generate_plain_text=True,
|
|
236
|
+
generate_lrc=True,
|
|
237
|
+
video_resolution="4k",
|
|
238
|
+
subtitle_offset_ms=subtitle_offset
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
job_log.info(f"OutputConfig: output_styles_json={config.output_styles_json}, render_video={config.render_video}")
|
|
242
|
+
|
|
243
|
+
output_generator = OutputGenerator(config, logger)
|
|
244
|
+
|
|
245
|
+
# 8. Generate outputs (video, LRC, ASS, etc.)
|
|
246
|
+
# Sanitize artist/title to handle Unicode characters (curly quotes, em dashes, etc.)
|
|
247
|
+
safe_artist = sanitize_filename(job.artist) if job.artist else "Unknown"
|
|
248
|
+
safe_title = sanitize_filename(job.title) if job.title else "Unknown"
|
|
249
|
+
output_prefix = f"{safe_artist} - {safe_title}"
|
|
250
|
+
job_log.info(f"Generating outputs with prefix '{output_prefix}'")
|
|
251
|
+
logger.info(f"Job {job_id}: Generating outputs with prefix '{output_prefix}'")
|
|
252
|
+
|
|
253
|
+
outputs = output_generator.generate_outputs(
|
|
254
|
+
transcription_corrected=correction_result,
|
|
255
|
+
lyrics_results={}, # Reference lyrics already in correction_result
|
|
256
|
+
output_prefix=output_prefix,
|
|
257
|
+
audio_filepath=audio_path,
|
|
258
|
+
artist=job.artist,
|
|
259
|
+
title=job.title
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# 9. Upload video to GCS
|
|
263
|
+
if outputs.video and os.path.exists(outputs.video):
|
|
264
|
+
video_size = os.path.getsize(outputs.video)
|
|
265
|
+
job_log.info(f"Video generated: {video_size} bytes")
|
|
266
|
+
video_gcs_path = f"jobs/{job_id}/videos/with_vocals.mkv"
|
|
267
|
+
video_url = storage.upload_file(outputs.video, video_gcs_path)
|
|
268
|
+
job_manager.update_file_url(job_id, 'videos', 'with_vocals', video_url)
|
|
269
|
+
job_log.info(f"Uploaded with_vocals.mkv to GCS")
|
|
270
|
+
logger.info(f"Job {job_id}: Uploaded with_vocals.mkv ({video_size} bytes)")
|
|
271
|
+
else:
|
|
272
|
+
job_log.error("Video generation failed - no output file produced!")
|
|
273
|
+
raise Exception("Video generation failed - no output file produced")
|
|
274
|
+
|
|
275
|
+
# 10. Upload LRC file
|
|
276
|
+
if outputs.lrc and os.path.exists(outputs.lrc):
|
|
277
|
+
lrc_gcs_path = f"jobs/{job_id}/lyrics/karaoke.lrc"
|
|
278
|
+
lrc_url = storage.upload_file(outputs.lrc, lrc_gcs_path)
|
|
279
|
+
job_manager.update_file_url(job_id, 'lyrics', 'lrc', lrc_url)
|
|
280
|
+
job_log.info("Uploaded karaoke.lrc")
|
|
281
|
+
logger.info(f"Job {job_id}: Uploaded karaoke.lrc")
|
|
282
|
+
|
|
283
|
+
# 11. Upload ASS subtitle file
|
|
284
|
+
if outputs.ass and os.path.exists(outputs.ass):
|
|
285
|
+
ass_gcs_path = f"jobs/{job_id}/lyrics/karaoke.ass"
|
|
286
|
+
ass_url = storage.upload_file(outputs.ass, ass_gcs_path)
|
|
287
|
+
job_manager.update_file_url(job_id, 'lyrics', 'ass', ass_url)
|
|
288
|
+
job_log.info("Uploaded karaoke.ass")
|
|
289
|
+
logger.info(f"Job {job_id}: Uploaded karaoke.ass")
|
|
290
|
+
|
|
291
|
+
# 12. Upload corrected text files
|
|
292
|
+
if outputs.corrected_txt and os.path.exists(outputs.corrected_txt):
|
|
293
|
+
txt_gcs_path = f"jobs/{job_id}/lyrics/corrected.txt"
|
|
294
|
+
txt_url = storage.upload_file(outputs.corrected_txt, txt_gcs_path)
|
|
295
|
+
job_manager.update_file_url(job_id, 'lyrics', 'corrected_txt', txt_url)
|
|
296
|
+
job_log.info("Uploaded corrected.txt")
|
|
297
|
+
logger.info(f"Job {job_id}: Uploaded corrected.txt")
|
|
298
|
+
|
|
299
|
+
# 13. Analyze backing vocals for intelligent instrumental selection
|
|
300
|
+
job_log.info("Analyzing backing vocals for instrumental selection...")
|
|
301
|
+
await _analyze_backing_vocals(job_id, job_manager, storage, job_log)
|
|
302
|
+
|
|
303
|
+
# 14. Transition based on prep_only flag and existing instrumental
|
|
304
|
+
if getattr(job, 'prep_only', False):
|
|
305
|
+
# Prep-only mode: stop here and mark as prep complete
|
|
306
|
+
job_manager.transition_to_state(
|
|
307
|
+
job_id=job_id,
|
|
308
|
+
new_status=JobStatus.PREP_COMPLETE,
|
|
309
|
+
progress=100,
|
|
310
|
+
message="Prep phase complete - download outputs to continue locally"
|
|
311
|
+
)
|
|
312
|
+
job_log.info("=== RENDER VIDEO WORKER COMPLETE (PREP ONLY) ===")
|
|
313
|
+
duration = time.time() - start_time
|
|
314
|
+
root_span.set_attribute("duration_seconds", duration)
|
|
315
|
+
root_span.set_attribute("prep_only", True)
|
|
316
|
+
logger.info(f"[job:{job_id}] WORKER_END worker=render-video status=success duration={duration:.1f}s prep_only=true")
|
|
317
|
+
elif getattr(job, 'existing_instrumental_gcs_path', None):
|
|
318
|
+
# Existing instrumental provided - skip selection, auto-use it
|
|
319
|
+
# Store selection as 'custom' to indicate user-provided instrumental
|
|
320
|
+
job_manager.update_state_data(job_id, 'instrumental_selection', 'custom')
|
|
321
|
+
job_log.info("Existing instrumental provided - skipping selection, using user-provided file")
|
|
322
|
+
|
|
323
|
+
job_manager.transition_to_state(
|
|
324
|
+
job_id=job_id,
|
|
325
|
+
new_status=JobStatus.INSTRUMENTAL_SELECTED,
|
|
326
|
+
progress=82,
|
|
327
|
+
message="Using user-provided instrumental"
|
|
328
|
+
)
|
|
329
|
+
job_log.info("=== RENDER VIDEO WORKER COMPLETE (EXISTING INSTRUMENTAL) ===")
|
|
330
|
+
duration = time.time() - start_time
|
|
331
|
+
root_span.set_attribute("duration_seconds", duration)
|
|
332
|
+
root_span.set_attribute("existing_instrumental", True)
|
|
333
|
+
logger.info(f"[job:{job_id}] WORKER_END worker=render-video status=success duration={duration:.1f}s existing_instrumental=true")
|
|
334
|
+
|
|
335
|
+
# Trigger video worker directly since no user selection needed
|
|
336
|
+
from backend.workers.video_worker import run_video_worker
|
|
337
|
+
job_log.info("Triggering video worker for final encoding...")
|
|
338
|
+
await run_video_worker(job_id, job_manager, storage)
|
|
339
|
+
else:
|
|
340
|
+
# Normal mode: proceed to instrumental selection
|
|
341
|
+
job_manager.transition_to_state(
|
|
342
|
+
job_id=job_id,
|
|
343
|
+
new_status=JobStatus.AWAITING_INSTRUMENTAL_SELECTION,
|
|
344
|
+
progress=80,
|
|
345
|
+
message="Video rendered - select your instrumental"
|
|
346
|
+
)
|
|
347
|
+
job_log.info("=== RENDER VIDEO WORKER COMPLETE ===")
|
|
348
|
+
duration = time.time() - start_time
|
|
349
|
+
root_span.set_attribute("duration_seconds", duration)
|
|
350
|
+
logger.info(f"[job:{job_id}] WORKER_END worker=render-video status=success duration={duration:.1f}s")
|
|
351
|
+
return True
|
|
352
|
+
|
|
353
|
+
except Exception as e:
|
|
354
|
+
duration = time.time() - start_time
|
|
355
|
+
job_log.error(f"Video render failed: {e}")
|
|
356
|
+
logger.error(f"[job:{job_id}] WORKER_END worker=render-video status=error duration={duration:.1f}s error={e}")
|
|
357
|
+
job_manager.fail_job(job_id, f"Video render failed: {str(e)}")
|
|
358
|
+
return False
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _extract_gcs_path(url: str) -> str:
|
|
362
|
+
"""Extract GCS path from a URL or return as-is if already a path."""
|
|
363
|
+
if url.startswith('gs://'):
|
|
364
|
+
# Already a GCS path
|
|
365
|
+
return url.replace('gs://', '').split('/', 1)[1] if '/' in url else url
|
|
366
|
+
if url.startswith('https://storage.googleapis.com/'):
|
|
367
|
+
# Signed URL - extract path
|
|
368
|
+
path = url.replace('https://storage.googleapis.com/', '')
|
|
369
|
+
# Remove query params
|
|
370
|
+
if '?' in path:
|
|
371
|
+
path = path.split('?')[0]
|
|
372
|
+
# Skip bucket name
|
|
373
|
+
parts = path.split('/', 1)
|
|
374
|
+
return parts[1] if len(parts) > 1 else path
|
|
375
|
+
return url
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def _analyze_backing_vocals(
|
|
379
|
+
job_id: str,
|
|
380
|
+
job_manager: JobManager,
|
|
381
|
+
storage: StorageService,
|
|
382
|
+
job_log: logging.Logger,
|
|
383
|
+
) -> None:
|
|
384
|
+
"""
|
|
385
|
+
Analyze backing vocals to help with intelligent instrumental selection.
|
|
386
|
+
|
|
387
|
+
This function:
|
|
388
|
+
1. Downloads the backing vocals stem from GCS
|
|
389
|
+
2. Runs audio analysis to detect audible content
|
|
390
|
+
3. Generates a waveform visualization image
|
|
391
|
+
4. Stores analysis results and waveform URL in job state
|
|
392
|
+
|
|
393
|
+
The analysis data is then used by the frontend to provide an
|
|
394
|
+
intelligent instrumental selection experience.
|
|
395
|
+
"""
|
|
396
|
+
from backend.services.audio_analysis_service import AudioAnalysisService
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
# Get the job to access file URLs
|
|
400
|
+
job = job_manager.get_job(job_id)
|
|
401
|
+
if not job:
|
|
402
|
+
job_log.warning(f"Could not get job {job_id} for backing vocals analysis")
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
# Get backing vocals path
|
|
406
|
+
backing_vocals_path = job.file_urls.get('stems', {}).get('backing_vocals')
|
|
407
|
+
if not backing_vocals_path:
|
|
408
|
+
job_log.warning("No backing vocals file found - skipping analysis")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
job_log.info(f"Analyzing backing vocals: {backing_vocals_path}")
|
|
412
|
+
|
|
413
|
+
# Create analysis service and run analysis
|
|
414
|
+
analysis_service = AudioAnalysisService()
|
|
415
|
+
|
|
416
|
+
# Define output path for waveform
|
|
417
|
+
waveform_gcs_path = f"jobs/{job_id}/analysis/backing_vocals_waveform.png"
|
|
418
|
+
|
|
419
|
+
# Run analysis and generate waveform
|
|
420
|
+
result, waveform_path = analysis_service.analyze_and_generate_waveform(
|
|
421
|
+
gcs_audio_path=backing_vocals_path,
|
|
422
|
+
job_id=job_id,
|
|
423
|
+
gcs_waveform_destination=waveform_gcs_path,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
# Store analysis results in job state_data
|
|
427
|
+
analysis_data = {
|
|
428
|
+
'has_audible_content': result.has_audible_content,
|
|
429
|
+
'total_duration_seconds': result.total_duration_seconds,
|
|
430
|
+
'audible_segments': [
|
|
431
|
+
{
|
|
432
|
+
'start_seconds': seg.start_seconds,
|
|
433
|
+
'end_seconds': seg.end_seconds,
|
|
434
|
+
'duration_seconds': seg.duration_seconds,
|
|
435
|
+
'avg_amplitude_db': seg.avg_amplitude_db,
|
|
436
|
+
'peak_amplitude_db': seg.peak_amplitude_db,
|
|
437
|
+
}
|
|
438
|
+
for seg in result.audible_segments
|
|
439
|
+
],
|
|
440
|
+
'recommended_selection': result.recommended_selection.value,
|
|
441
|
+
'total_audible_duration_seconds': result.total_audible_duration_seconds,
|
|
442
|
+
'audible_percentage': result.audible_percentage,
|
|
443
|
+
'silence_threshold_db': result.silence_threshold_db,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
job_manager.update_state_data(job_id, 'backing_vocals_analysis', analysis_data)
|
|
447
|
+
|
|
448
|
+
# Store waveform URL
|
|
449
|
+
job_manager.update_file_url(job_id, 'analysis', 'backing_vocals_waveform', waveform_path)
|
|
450
|
+
|
|
451
|
+
job_log.info(
|
|
452
|
+
f"Backing vocals analysis complete: "
|
|
453
|
+
f"has_audible={result.has_audible_content}, "
|
|
454
|
+
f"segments={result.segment_count}, "
|
|
455
|
+
f"recommendation={result.recommended_selection.value}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Log segment details if there are any
|
|
459
|
+
if result.audible_segments:
|
|
460
|
+
job_log.info(f"Audible segments ({len(result.audible_segments)}):")
|
|
461
|
+
for i, seg in enumerate(result.audible_segments[:5]): # Log first 5
|
|
462
|
+
job_log.info(
|
|
463
|
+
f" [{i+1}] {seg.start_seconds:.1f}s - {seg.end_seconds:.1f}s "
|
|
464
|
+
f"({seg.duration_seconds:.1f}s, avg: {seg.avg_amplitude_db:.1f}dB)"
|
|
465
|
+
)
|
|
466
|
+
if len(result.audible_segments) > 5:
|
|
467
|
+
job_log.info(f" ... and {len(result.audible_segments) - 5} more")
|
|
468
|
+
|
|
469
|
+
except Exception as e:
|
|
470
|
+
# Log the error but don't fail the job - analysis is a nice-to-have
|
|
471
|
+
job_log.warning(f"Backing vocals analysis failed (non-fatal): {e}")
|
|
472
|
+
logger.warning(f"Job {job_id}: Backing vocals analysis failed: {e}")
|
|
473
|
+
|
|
474
|
+
# Store empty analysis so the frontend knows analysis was attempted
|
|
475
|
+
job_manager.update_state_data(job_id, 'backing_vocals_analysis', {
|
|
476
|
+
'has_audible_content': None,
|
|
477
|
+
'analysis_error': str(e),
|
|
478
|
+
'recommended_selection': 'review_needed',
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# For compatibility with worker service
|
|
483
|
+
render_video_worker = process_render_video
|