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,590 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local Encoding Service.
|
|
3
|
+
|
|
4
|
+
Provides local FFmpeg-based video encoding functionality, extracted from KaraokeFinalise
|
|
5
|
+
for use by both the cloud backend (video_worker as a fallback) and local CLI.
|
|
6
|
+
|
|
7
|
+
This service handles:
|
|
8
|
+
- Concatenating title, karaoke, and end videos
|
|
9
|
+
- Encoding to multiple output formats (4K lossless, 4K lossy, 720p, MKV)
|
|
10
|
+
- Hardware acceleration detection and fallback to software encoding
|
|
11
|
+
- Remuxing video with instrumental audio
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import shlex
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class EncodingConfig:
|
|
27
|
+
"""Configuration for video encoding."""
|
|
28
|
+
title_video: str # Path to title video
|
|
29
|
+
karaoke_video: str # Path to karaoke video (with vocals)
|
|
30
|
+
instrumental_audio: str # Path to instrumental audio
|
|
31
|
+
end_video: Optional[str] = None # Optional path to end credits video
|
|
32
|
+
|
|
33
|
+
# Output paths
|
|
34
|
+
output_karaoke_mp4: Optional[str] = None
|
|
35
|
+
output_with_vocals_mp4: Optional[str] = None
|
|
36
|
+
output_lossless_4k_mp4: Optional[str] = None
|
|
37
|
+
output_lossy_4k_mp4: Optional[str] = None
|
|
38
|
+
output_lossless_mkv: Optional[str] = None
|
|
39
|
+
output_720p_mp4: Optional[str] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class EncodingResult:
|
|
44
|
+
"""Result of video encoding operation."""
|
|
45
|
+
success: bool
|
|
46
|
+
output_files: Dict[str, str]
|
|
47
|
+
error: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class LocalEncodingService:
|
|
51
|
+
"""
|
|
52
|
+
Service for local FFmpeg-based video encoding.
|
|
53
|
+
|
|
54
|
+
Supports hardware acceleration (NVENC) with automatic fallback
|
|
55
|
+
to software encoding (libx264) when unavailable.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
# MP4 flags for better compatibility
|
|
59
|
+
MP4_FLAGS = "-movflags +faststart"
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
dry_run: bool = False,
|
|
64
|
+
log_level: int = logging.INFO,
|
|
65
|
+
logger: Optional[logging.Logger] = None,
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
Initialize the local encoding service.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
dry_run: If True, log commands without executing them
|
|
72
|
+
log_level: Logging level (affects FFmpeg verbosity)
|
|
73
|
+
logger: Optional logger instance
|
|
74
|
+
"""
|
|
75
|
+
self.dry_run = dry_run
|
|
76
|
+
self.log_level = log_level
|
|
77
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
# Hardware acceleration settings (detected on first use)
|
|
80
|
+
self._hwaccel_available: Optional[bool] = None
|
|
81
|
+
self._video_encoder: Optional[str] = None
|
|
82
|
+
self._scale_filter: Optional[str] = None
|
|
83
|
+
self._hwaccel_decode_flags: Optional[str] = None
|
|
84
|
+
self._aac_codec: Optional[str] = None
|
|
85
|
+
|
|
86
|
+
# Build FFmpeg base command
|
|
87
|
+
self._ffmpeg_base_command = self._build_ffmpeg_base_command()
|
|
88
|
+
|
|
89
|
+
def _build_ffmpeg_base_command(self) -> str:
|
|
90
|
+
"""Build the FFmpeg base command with appropriate flags."""
|
|
91
|
+
# Use bundled FFmpeg for frozen builds
|
|
92
|
+
ffmpeg_path = os.path.join(sys._MEIPASS, "ffmpeg.exe") if getattr(sys, "frozen", False) else "ffmpeg"
|
|
93
|
+
|
|
94
|
+
base_cmd = f"{ffmpeg_path} -hide_banner -nostats -y"
|
|
95
|
+
|
|
96
|
+
if self.log_level == logging.DEBUG:
|
|
97
|
+
base_cmd += " -loglevel verbose"
|
|
98
|
+
else:
|
|
99
|
+
base_cmd += " -loglevel fatal"
|
|
100
|
+
|
|
101
|
+
return base_cmd
|
|
102
|
+
|
|
103
|
+
def _detect_hardware_acceleration(self) -> Tuple[bool, str, str, str, str]:
|
|
104
|
+
"""
|
|
105
|
+
Detect available hardware acceleration.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Tuple of (hwaccel_available, video_encoder, scale_filter,
|
|
109
|
+
hwaccel_decode_flags, aac_codec)
|
|
110
|
+
"""
|
|
111
|
+
self.logger.info("Detecting hardware acceleration capabilities...")
|
|
112
|
+
|
|
113
|
+
# Try NVENC (NVIDIA)
|
|
114
|
+
try:
|
|
115
|
+
test_cmd = f"{self._ffmpeg_base_command} -hide_banner -loglevel error " \
|
|
116
|
+
f"-f lavfi -i testsrc=duration=1:size=320x240:rate=1 " \
|
|
117
|
+
f"-c:v h264_nvenc -f null -"
|
|
118
|
+
subprocess.run(
|
|
119
|
+
test_cmd, shell=True, check=True,
|
|
120
|
+
capture_output=True, timeout=30
|
|
121
|
+
)
|
|
122
|
+
self.logger.info("NVIDIA NVENC hardware acceleration available")
|
|
123
|
+
return (
|
|
124
|
+
True,
|
|
125
|
+
"h264_nvenc",
|
|
126
|
+
"scale_cuda",
|
|
127
|
+
"-hwaccel cuda -hwaccel_output_format cuda",
|
|
128
|
+
"aac"
|
|
129
|
+
)
|
|
130
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
# No hardware acceleration available
|
|
134
|
+
self.logger.info("No hardware acceleration available, using software encoding")
|
|
135
|
+
return (False, "libx264", "scale", "", "aac")
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def hwaccel_available(self) -> bool:
|
|
139
|
+
"""Check if hardware acceleration is available."""
|
|
140
|
+
if self._hwaccel_available is None:
|
|
141
|
+
self._detect_and_set_hwaccel()
|
|
142
|
+
return self._hwaccel_available
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def video_encoder(self) -> str:
|
|
146
|
+
"""Get the video encoder to use."""
|
|
147
|
+
if self._video_encoder is None:
|
|
148
|
+
self._detect_and_set_hwaccel()
|
|
149
|
+
return self._video_encoder
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def scale_filter(self) -> str:
|
|
153
|
+
"""Get the scale filter to use."""
|
|
154
|
+
if self._scale_filter is None:
|
|
155
|
+
self._detect_and_set_hwaccel()
|
|
156
|
+
return self._scale_filter
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def hwaccel_decode_flags(self) -> str:
|
|
160
|
+
"""Get hardware acceleration decode flags."""
|
|
161
|
+
if self._hwaccel_decode_flags is None:
|
|
162
|
+
self._detect_and_set_hwaccel()
|
|
163
|
+
return self._hwaccel_decode_flags
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def aac_codec(self) -> str:
|
|
167
|
+
"""Get the AAC codec to use."""
|
|
168
|
+
if self._aac_codec is None:
|
|
169
|
+
self._detect_and_set_hwaccel()
|
|
170
|
+
return self._aac_codec
|
|
171
|
+
|
|
172
|
+
def _detect_and_set_hwaccel(self) -> None:
|
|
173
|
+
"""Detect and set hardware acceleration settings."""
|
|
174
|
+
(
|
|
175
|
+
self._hwaccel_available,
|
|
176
|
+
self._video_encoder,
|
|
177
|
+
self._scale_filter,
|
|
178
|
+
self._hwaccel_decode_flags,
|
|
179
|
+
self._aac_codec
|
|
180
|
+
) = self._detect_hardware_acceleration()
|
|
181
|
+
|
|
182
|
+
def _get_nvenc_quality_settings(self, preset: str = "medium") -> str:
|
|
183
|
+
"""Get NVENC quality settings for different presets."""
|
|
184
|
+
if not self.hwaccel_available:
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
settings = {
|
|
188
|
+
"lossless": "-preset p7 -tune hq -rc vbr -cq 0 -qmin 0 -qmax 0",
|
|
189
|
+
"high": "-preset p7 -tune hq -rc vbr -cq 19 -b:v 0",
|
|
190
|
+
"medium": "-preset p4 -tune hq -rc vbr -cq 23 -b:v 0",
|
|
191
|
+
"fast": "-preset p2 -tune ll -rc vbr -cq 28 -b:v 0",
|
|
192
|
+
}
|
|
193
|
+
return settings.get(preset, settings["medium"])
|
|
194
|
+
|
|
195
|
+
def _execute_command(
|
|
196
|
+
self,
|
|
197
|
+
command: str,
|
|
198
|
+
description: str,
|
|
199
|
+
timeout: int = 3600, # 1 hour default
|
|
200
|
+
) -> bool:
|
|
201
|
+
"""
|
|
202
|
+
Execute an FFmpeg command.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
command: The FFmpeg command to execute
|
|
206
|
+
description: Human-readable description of the operation
|
|
207
|
+
timeout: Command timeout in seconds
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
True if successful, False otherwise
|
|
211
|
+
"""
|
|
212
|
+
self.logger.info(f"Executing: {description}")
|
|
213
|
+
self.logger.debug(f"Command: {command}")
|
|
214
|
+
|
|
215
|
+
if self.dry_run:
|
|
216
|
+
self.logger.info(f"DRY RUN: Would execute: {command}")
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
result = subprocess.run(
|
|
221
|
+
command,
|
|
222
|
+
shell=True,
|
|
223
|
+
check=True,
|
|
224
|
+
capture_output=True,
|
|
225
|
+
text=True,
|
|
226
|
+
timeout=timeout
|
|
227
|
+
)
|
|
228
|
+
self.logger.info(f"Completed: {description}")
|
|
229
|
+
return True
|
|
230
|
+
except subprocess.CalledProcessError as e:
|
|
231
|
+
self.logger.error(f"Failed: {description}")
|
|
232
|
+
self.logger.error(f"Error: {e.stderr}")
|
|
233
|
+
return False
|
|
234
|
+
except subprocess.TimeoutExpired:
|
|
235
|
+
self.logger.error(f"Timeout: {description}")
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
def _execute_command_with_fallback(
|
|
239
|
+
self,
|
|
240
|
+
gpu_command: str,
|
|
241
|
+
cpu_command: str,
|
|
242
|
+
description: str,
|
|
243
|
+
) -> bool:
|
|
244
|
+
"""
|
|
245
|
+
Execute a command with GPU, falling back to CPU if it fails.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
gpu_command: Hardware-accelerated command
|
|
249
|
+
cpu_command: Software fallback command
|
|
250
|
+
description: Human-readable description
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
True if successful, False otherwise
|
|
254
|
+
"""
|
|
255
|
+
if self.hwaccel_available:
|
|
256
|
+
self.logger.info(f"Trying hardware-accelerated encoding for: {description}")
|
|
257
|
+
if self._execute_command(gpu_command, description):
|
|
258
|
+
return True
|
|
259
|
+
self.logger.warning(f"Hardware encoding failed, falling back to software")
|
|
260
|
+
|
|
261
|
+
return self._execute_command(cpu_command, description)
|
|
262
|
+
|
|
263
|
+
def remux_with_instrumental(
|
|
264
|
+
self,
|
|
265
|
+
input_video: str,
|
|
266
|
+
instrumental_audio: str,
|
|
267
|
+
output_file: str,
|
|
268
|
+
) -> bool:
|
|
269
|
+
"""
|
|
270
|
+
Remux video with instrumental audio track.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
input_video: Path to input video file
|
|
274
|
+
instrumental_audio: Path to instrumental audio file
|
|
275
|
+
output_file: Path for output file
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
True if successful, False otherwise
|
|
279
|
+
"""
|
|
280
|
+
command = (
|
|
281
|
+
f'{self._ffmpeg_base_command} -i "{input_video}" '
|
|
282
|
+
f'-i "{instrumental_audio}" -map 0:v -map 1:a -c copy '
|
|
283
|
+
f'{self.MP4_FLAGS} "{output_file}"'
|
|
284
|
+
)
|
|
285
|
+
return self._execute_command(command, "Remuxing with instrumental audio")
|
|
286
|
+
|
|
287
|
+
def convert_mov_to_mp4(
|
|
288
|
+
self,
|
|
289
|
+
input_file: str,
|
|
290
|
+
output_file: str,
|
|
291
|
+
) -> bool:
|
|
292
|
+
"""
|
|
293
|
+
Convert MOV to MP4 format.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
input_file: Path to input MOV file
|
|
297
|
+
output_file: Path for output MP4 file
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
True if successful, False otherwise
|
|
301
|
+
"""
|
|
302
|
+
gpu_command = (
|
|
303
|
+
f'{self._ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
|
|
304
|
+
f'-c:v {self.video_encoder} -c:a copy {self.MP4_FLAGS} "{output_file}"'
|
|
305
|
+
)
|
|
306
|
+
cpu_command = (
|
|
307
|
+
f'{self._ffmpeg_base_command} -i "{input_file}" '
|
|
308
|
+
f'-c:v libx264 -c:a copy {self.MP4_FLAGS} "{output_file}"'
|
|
309
|
+
)
|
|
310
|
+
return self._execute_command_with_fallback(
|
|
311
|
+
gpu_command, cpu_command, "Converting MOV to MP4"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def encode_lossless_mp4(
|
|
315
|
+
self,
|
|
316
|
+
title_video: str,
|
|
317
|
+
karaoke_video: str,
|
|
318
|
+
output_file: str,
|
|
319
|
+
end_video: Optional[str] = None,
|
|
320
|
+
) -> bool:
|
|
321
|
+
"""
|
|
322
|
+
Create lossless 4K MP4 by concatenating title, karaoke, and optionally end videos.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
title_video: Path to title video
|
|
326
|
+
karaoke_video: Path to karaoke video
|
|
327
|
+
output_file: Path for output file
|
|
328
|
+
end_video: Optional path to end credits video
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
True if successful, False otherwise
|
|
332
|
+
"""
|
|
333
|
+
# Quote file paths
|
|
334
|
+
title_quoted = shlex.quote(os.path.abspath(title_video))
|
|
335
|
+
karaoke_quoted = shlex.quote(os.path.abspath(karaoke_video))
|
|
336
|
+
|
|
337
|
+
# Build filter and inputs for concatenation
|
|
338
|
+
if end_video and os.path.isfile(end_video):
|
|
339
|
+
end_quoted = shlex.quote(os.path.abspath(end_video))
|
|
340
|
+
extra_input = f"-i {end_quoted}"
|
|
341
|
+
concat_filter = (
|
|
342
|
+
'-filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0][2:v:0][2:a:0]'
|
|
343
|
+
'concat=n=3:v=1:a=1[outv][outa]"'
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
extra_input = ""
|
|
347
|
+
concat_filter = (
|
|
348
|
+
'-filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0]'
|
|
349
|
+
'concat=n=2:v=1:a=1[outv][outa]"'
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
gpu_command = (
|
|
353
|
+
f"{self._ffmpeg_base_command} {self.hwaccel_decode_flags} -i {title_quoted} "
|
|
354
|
+
f"{self.hwaccel_decode_flags} -i {karaoke_quoted} {extra_input} "
|
|
355
|
+
f'{concat_filter} -map "[outv]" -map "[outa]" -c:v {self.video_encoder} '
|
|
356
|
+
f'{self._get_nvenc_quality_settings("lossless")} -c:a pcm_s16le '
|
|
357
|
+
f'{self.MP4_FLAGS} "{output_file}"'
|
|
358
|
+
)
|
|
359
|
+
cpu_command = (
|
|
360
|
+
f"{self._ffmpeg_base_command} -i {title_quoted} -i {karaoke_quoted} {extra_input} "
|
|
361
|
+
f'{concat_filter} -map "[outv]" -map "[outa]" -c:v libx264 -c:a pcm_s16le '
|
|
362
|
+
f'{self.MP4_FLAGS} "{output_file}"'
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
return self._execute_command_with_fallback(
|
|
366
|
+
gpu_command, cpu_command, "Encoding lossless 4K MP4"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def encode_lossy_mp4(
|
|
370
|
+
self,
|
|
371
|
+
input_file: str,
|
|
372
|
+
output_file: str,
|
|
373
|
+
) -> bool:
|
|
374
|
+
"""
|
|
375
|
+
Create lossy 4K MP4 with AAC audio.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
input_file: Path to input file (typically lossless MP4)
|
|
379
|
+
output_file: Path for output file
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
True if successful, False otherwise
|
|
383
|
+
"""
|
|
384
|
+
command = (
|
|
385
|
+
f'{self._ffmpeg_base_command} -i "{input_file}" '
|
|
386
|
+
f'-c:v copy -c:a {self.aac_codec} -ar 48000 -b:a 320k '
|
|
387
|
+
f'{self.MP4_FLAGS} "{output_file}"'
|
|
388
|
+
)
|
|
389
|
+
return self._execute_command(command, "Encoding lossy 4K MP4 with AAC")
|
|
390
|
+
|
|
391
|
+
def encode_lossless_mkv(
|
|
392
|
+
self,
|
|
393
|
+
input_file: str,
|
|
394
|
+
output_file: str,
|
|
395
|
+
) -> bool:
|
|
396
|
+
"""
|
|
397
|
+
Create MKV with FLAC audio (for YouTube upload).
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
input_file: Path to input file
|
|
401
|
+
output_file: Path for output file
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
True if successful, False otherwise
|
|
405
|
+
"""
|
|
406
|
+
command = (
|
|
407
|
+
f'{self._ffmpeg_base_command} -i "{input_file}" '
|
|
408
|
+
f'-c:v copy -c:a flac "{output_file}"'
|
|
409
|
+
)
|
|
410
|
+
return self._execute_command(command, "Creating MKV with FLAC for YouTube")
|
|
411
|
+
|
|
412
|
+
def encode_720p(
|
|
413
|
+
self,
|
|
414
|
+
input_file: str,
|
|
415
|
+
output_file: str,
|
|
416
|
+
) -> bool:
|
|
417
|
+
"""
|
|
418
|
+
Create 720p MP4 with AAC audio.
|
|
419
|
+
|
|
420
|
+
Args:
|
|
421
|
+
input_file: Path to input file
|
|
422
|
+
output_file: Path for output file
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
True if successful, False otherwise
|
|
426
|
+
"""
|
|
427
|
+
gpu_command = (
|
|
428
|
+
f'{self._ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
|
|
429
|
+
f'-c:v {self.video_encoder} -vf "{self.scale_filter}=1280:720" '
|
|
430
|
+
f'{self._get_nvenc_quality_settings("medium")} -b:v 2000k '
|
|
431
|
+
f'-c:a {self.aac_codec} -ar 48000 -b:a 128k '
|
|
432
|
+
f'{self.MP4_FLAGS} "{output_file}"'
|
|
433
|
+
)
|
|
434
|
+
cpu_command = (
|
|
435
|
+
f'{self._ffmpeg_base_command} -i "{input_file}" '
|
|
436
|
+
f'-c:v libx264 -vf "scale=1280:720" -b:v 2000k -preset medium -tune animation '
|
|
437
|
+
f'-c:a {self.aac_codec} -ar 48000 -b:a 128k '
|
|
438
|
+
f'{self.MP4_FLAGS} "{output_file}"'
|
|
439
|
+
)
|
|
440
|
+
return self._execute_command_with_fallback(
|
|
441
|
+
gpu_command, cpu_command, "Encoding 720p MP4"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def encode_all_formats(
|
|
445
|
+
self,
|
|
446
|
+
config: EncodingConfig,
|
|
447
|
+
) -> EncodingResult:
|
|
448
|
+
"""
|
|
449
|
+
Encode video to all output formats.
|
|
450
|
+
|
|
451
|
+
This performs the full encoding pipeline:
|
|
452
|
+
1. Remux with instrumental audio
|
|
453
|
+
2. Convert to MP4 if needed
|
|
454
|
+
3. Encode lossless 4K MP4 (concatenated)
|
|
455
|
+
4. Encode lossy 4K MP4
|
|
456
|
+
5. Encode lossless MKV (for YouTube)
|
|
457
|
+
6. Encode 720p MP4
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
config: Encoding configuration with input/output paths
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
EncodingResult with success status and output file paths
|
|
464
|
+
"""
|
|
465
|
+
output_files = {}
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
# Step 1: Remux with instrumental audio
|
|
469
|
+
if config.output_karaoke_mp4:
|
|
470
|
+
self.logger.info("[Step 1/6] Remuxing video with instrumental audio...")
|
|
471
|
+
if not self.remux_with_instrumental(
|
|
472
|
+
config.karaoke_video,
|
|
473
|
+
config.instrumental_audio,
|
|
474
|
+
config.output_karaoke_mp4
|
|
475
|
+
):
|
|
476
|
+
return EncodingResult(
|
|
477
|
+
success=False,
|
|
478
|
+
output_files=output_files,
|
|
479
|
+
error="Failed to remux with instrumental audio"
|
|
480
|
+
)
|
|
481
|
+
output_files["karaoke_mp4"] = config.output_karaoke_mp4
|
|
482
|
+
|
|
483
|
+
# Step 2: Convert to MP4 if needed
|
|
484
|
+
if config.output_with_vocals_mp4:
|
|
485
|
+
if not config.karaoke_video.endswith(".mp4"):
|
|
486
|
+
self.logger.info("[Step 2/6] Converting karaoke video to MP4...")
|
|
487
|
+
if not self.convert_mov_to_mp4(
|
|
488
|
+
config.karaoke_video,
|
|
489
|
+
config.output_with_vocals_mp4
|
|
490
|
+
):
|
|
491
|
+
return EncodingResult(
|
|
492
|
+
success=False,
|
|
493
|
+
output_files=output_files,
|
|
494
|
+
error="Failed to convert to MP4"
|
|
495
|
+
)
|
|
496
|
+
output_files["with_vocals_mp4"] = config.output_with_vocals_mp4
|
|
497
|
+
else:
|
|
498
|
+
self.logger.info("[Step 2/6] Skipped - video already MP4")
|
|
499
|
+
|
|
500
|
+
# Step 3: Encode lossless 4K MP4
|
|
501
|
+
if config.output_lossless_4k_mp4:
|
|
502
|
+
self.logger.info("[Step 3/6] Encoding lossless 4K MP4...")
|
|
503
|
+
karaoke_for_concat = config.output_karaoke_mp4 or config.karaoke_video
|
|
504
|
+
if not self.encode_lossless_mp4(
|
|
505
|
+
config.title_video,
|
|
506
|
+
karaoke_for_concat,
|
|
507
|
+
config.output_lossless_4k_mp4,
|
|
508
|
+
config.end_video
|
|
509
|
+
):
|
|
510
|
+
return EncodingResult(
|
|
511
|
+
success=False,
|
|
512
|
+
output_files=output_files,
|
|
513
|
+
error="Failed to encode lossless 4K MP4"
|
|
514
|
+
)
|
|
515
|
+
output_files["lossless_4k_mp4"] = config.output_lossless_4k_mp4
|
|
516
|
+
|
|
517
|
+
# Step 4: Encode lossy 4K MP4
|
|
518
|
+
if config.output_lossy_4k_mp4 and config.output_lossless_4k_mp4:
|
|
519
|
+
self.logger.info("[Step 4/6] Encoding lossy 4K MP4...")
|
|
520
|
+
if not self.encode_lossy_mp4(
|
|
521
|
+
config.output_lossless_4k_mp4,
|
|
522
|
+
config.output_lossy_4k_mp4
|
|
523
|
+
):
|
|
524
|
+
return EncodingResult(
|
|
525
|
+
success=False,
|
|
526
|
+
output_files=output_files,
|
|
527
|
+
error="Failed to encode lossy 4K MP4"
|
|
528
|
+
)
|
|
529
|
+
output_files["lossy_4k_mp4"] = config.output_lossy_4k_mp4
|
|
530
|
+
|
|
531
|
+
# Step 5: Create MKV with FLAC audio
|
|
532
|
+
if config.output_lossless_mkv and config.output_lossless_4k_mp4:
|
|
533
|
+
self.logger.info("[Step 5/6] Creating MKV with FLAC audio...")
|
|
534
|
+
if not self.encode_lossless_mkv(
|
|
535
|
+
config.output_lossless_4k_mp4,
|
|
536
|
+
config.output_lossless_mkv
|
|
537
|
+
):
|
|
538
|
+
return EncodingResult(
|
|
539
|
+
success=False,
|
|
540
|
+
output_files=output_files,
|
|
541
|
+
error="Failed to create MKV"
|
|
542
|
+
)
|
|
543
|
+
output_files["lossless_mkv"] = config.output_lossless_mkv
|
|
544
|
+
|
|
545
|
+
# Step 6: Encode 720p version
|
|
546
|
+
if config.output_720p_mp4 and config.output_lossless_4k_mp4:
|
|
547
|
+
self.logger.info("[Step 6/6] Encoding 720p MP4...")
|
|
548
|
+
if not self.encode_720p(
|
|
549
|
+
config.output_lossless_4k_mp4,
|
|
550
|
+
config.output_720p_mp4
|
|
551
|
+
):
|
|
552
|
+
return EncodingResult(
|
|
553
|
+
success=False,
|
|
554
|
+
output_files=output_files,
|
|
555
|
+
error="Failed to encode 720p"
|
|
556
|
+
)
|
|
557
|
+
output_files["720p_mp4"] = config.output_720p_mp4
|
|
558
|
+
|
|
559
|
+
self.logger.info("All encoding steps completed successfully")
|
|
560
|
+
return EncodingResult(success=True, output_files=output_files)
|
|
561
|
+
|
|
562
|
+
except Exception as e:
|
|
563
|
+
self.logger.error(f"Encoding failed with exception: {e}")
|
|
564
|
+
return EncodingResult(
|
|
565
|
+
success=False,
|
|
566
|
+
output_files=output_files,
|
|
567
|
+
error=str(e)
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# Singleton instance and factory function (following existing service pattern)
|
|
572
|
+
_local_encoding_service: Optional[LocalEncodingService] = None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def get_local_encoding_service(**kwargs) -> LocalEncodingService:
|
|
576
|
+
"""
|
|
577
|
+
Get a local encoding service instance.
|
|
578
|
+
|
|
579
|
+
Args:
|
|
580
|
+
**kwargs: Arguments passed to LocalEncodingService
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
LocalEncodingService instance
|
|
584
|
+
"""
|
|
585
|
+
global _local_encoding_service
|
|
586
|
+
|
|
587
|
+
if _local_encoding_service is None:
|
|
588
|
+
_local_encoding_service = LocalEncodingService(**kwargs)
|
|
589
|
+
|
|
590
|
+
return _local_encoding_service
|