lattifai 0.4.4__py3-none-any.whl → 0.4.6__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.
- lattifai/__init__.py +26 -27
- lattifai/base_client.py +7 -7
- lattifai/bin/agent.py +94 -91
- lattifai/bin/align.py +110 -111
- lattifai/bin/cli_base.py +3 -3
- lattifai/bin/subtitle.py +45 -45
- lattifai/client.py +56 -56
- lattifai/errors.py +73 -73
- lattifai/io/__init__.py +12 -11
- lattifai/io/gemini_reader.py +30 -30
- lattifai/io/gemini_writer.py +17 -17
- lattifai/io/reader.py +13 -12
- lattifai/io/supervision.py +3 -3
- lattifai/io/text_parser.py +43 -16
- lattifai/io/utils.py +4 -4
- lattifai/io/writer.py +31 -19
- lattifai/tokenizer/__init__.py +1 -1
- lattifai/tokenizer/phonemizer.py +3 -3
- lattifai/tokenizer/tokenizer.py +84 -83
- lattifai/utils.py +15 -15
- lattifai/workers/__init__.py +1 -1
- lattifai/workers/lattice1_alpha.py +103 -63
- lattifai/workflows/__init__.py +11 -11
- lattifai/workflows/agents.py +2 -0
- lattifai/workflows/base.py +22 -22
- lattifai/workflows/file_manager.py +182 -182
- lattifai/workflows/gemini.py +29 -29
- lattifai/workflows/prompts/__init__.py +4 -4
- lattifai/workflows/youtube.py +233 -233
- {lattifai-0.4.4.dist-info → lattifai-0.4.6.dist-info}/METADATA +7 -10
- lattifai-0.4.6.dist-info/RECORD +39 -0
- {lattifai-0.4.4.dist-info → lattifai-0.4.6.dist-info}/licenses/LICENSE +1 -1
- lattifai-0.4.4.dist-info/RECORD +0 -39
- {lattifai-0.4.4.dist-info → lattifai-0.4.6.dist-info}/WHEEL +0 -0
- {lattifai-0.4.4.dist-info → lattifai-0.4.6.dist-info}/entry_points.txt +0 -0
- {lattifai-0.4.4.dist-info → lattifai-0.4.6.dist-info}/top_level.txt +0 -0
lattifai/workflows/youtube.py
CHANGED
|
@@ -11,7 +11,7 @@ from pathlib import Path
|
|
|
11
11
|
from typing import Any, Dict, List, Optional
|
|
12
12
|
|
|
13
13
|
from ..client import AsyncLattifAI
|
|
14
|
-
from ..io import SUBTITLE_FORMATS,
|
|
14
|
+
from ..io import SUBTITLE_FORMATS, GeminiWriter, SubtitleIO
|
|
15
15
|
from .base import WorkflowAgent, WorkflowStep, setup_workflow_logger
|
|
16
16
|
from .file_manager import FileExistenceManager
|
|
17
17
|
from .gemini import GeminiTranscriber
|
|
@@ -31,7 +31,7 @@ class YouTubeDownloader:
|
|
|
31
31
|
"""
|
|
32
32
|
|
|
33
33
|
def __init__(self):
|
|
34
|
-
self.logger = setup_workflow_logger(
|
|
34
|
+
self.logger = setup_workflow_logger("youtube")
|
|
35
35
|
# Check if yt-dlp is available
|
|
36
36
|
self._check_ytdlp()
|
|
37
37
|
|
|
@@ -50,32 +50,32 @@ class YouTubeDownloader:
|
|
|
50
50
|
Video ID (e.g., 'cprOj8PWepY')
|
|
51
51
|
"""
|
|
52
52
|
patterns = [
|
|
53
|
-
r
|
|
54
|
-
r
|
|
55
|
-
r
|
|
53
|
+
r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([a-zA-Z0-9_-]{11})",
|
|
54
|
+
r"youtube\.com/embed/([a-zA-Z0-9_-]{11})",
|
|
55
|
+
r"youtube\.com/v/([a-zA-Z0-9_-]{11})",
|
|
56
56
|
]
|
|
57
57
|
|
|
58
58
|
for pattern in patterns:
|
|
59
59
|
match = re.search(pattern, url)
|
|
60
60
|
if match:
|
|
61
61
|
return match.group(1)
|
|
62
|
-
return
|
|
62
|
+
return "youtube_media"
|
|
63
63
|
|
|
64
64
|
def _check_ytdlp(self):
|
|
65
65
|
"""Check if yt-dlp is installed"""
|
|
66
66
|
try:
|
|
67
|
-
result = subprocess.run([
|
|
68
|
-
self.logger.info(f
|
|
67
|
+
result = subprocess.run(["yt-dlp", "--version"], capture_output=True, text=True, check=True)
|
|
68
|
+
self.logger.info(f"yt-dlp version: {result.stdout.strip()}")
|
|
69
69
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
70
70
|
raise RuntimeError(
|
|
71
|
-
|
|
71
|
+
"yt-dlp is not installed or not found in PATH. Please install it with: pip install yt-dlp"
|
|
72
72
|
)
|
|
73
73
|
|
|
74
74
|
async def get_video_info(self, url: str) -> Dict[str, Any]:
|
|
75
75
|
"""Get video metadata without downloading"""
|
|
76
|
-
self.logger.info(f
|
|
76
|
+
self.logger.info(f"🔍 Extracting video info for: {url}")
|
|
77
77
|
|
|
78
|
-
cmd = [
|
|
78
|
+
cmd = ["yt-dlp", "--dump-json", "--no-download", url]
|
|
79
79
|
|
|
80
80
|
try:
|
|
81
81
|
# Run in thread pool to avoid blocking
|
|
@@ -90,25 +90,25 @@ class YouTubeDownloader:
|
|
|
90
90
|
|
|
91
91
|
# Extract relevant info
|
|
92
92
|
info = {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
93
|
+
"title": metadata.get("title", "Unknown"),
|
|
94
|
+
"duration": metadata.get("duration", 0),
|
|
95
|
+
"uploader": metadata.get("uploader", "Unknown"),
|
|
96
|
+
"upload_date": metadata.get("upload_date", "Unknown"),
|
|
97
|
+
"view_count": metadata.get("view_count", 0),
|
|
98
|
+
"description": metadata.get("description", ""),
|
|
99
|
+
"thumbnail": metadata.get("thumbnail", ""),
|
|
100
|
+
"webpage_url": metadata.get("webpage_url", url),
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
self.logger.info(f'✅ Video info extracted: {info["title"]}')
|
|
104
104
|
return info
|
|
105
105
|
|
|
106
106
|
except subprocess.CalledProcessError as e:
|
|
107
|
-
self.logger.error(f
|
|
108
|
-
raise RuntimeError(f
|
|
107
|
+
self.logger.error(f"Failed to extract video info: {e.stderr}")
|
|
108
|
+
raise RuntimeError(f"Failed to extract video info: {e.stderr}")
|
|
109
109
|
except json.JSONDecodeError as e:
|
|
110
|
-
self.logger.error(f
|
|
111
|
-
raise RuntimeError(f
|
|
110
|
+
self.logger.error(f"Failed to parse video metadata: {e}")
|
|
111
|
+
raise RuntimeError(f"Failed to parse video metadata: {e}")
|
|
112
112
|
|
|
113
113
|
async def download_media(
|
|
114
114
|
self,
|
|
@@ -136,16 +136,16 @@ class YouTubeDownloader:
|
|
|
136
136
|
media_format = media_format or self.media_format
|
|
137
137
|
|
|
138
138
|
# Determine if format is audio or video
|
|
139
|
-
audio_formats = [
|
|
139
|
+
audio_formats = ["mp3", "wav", "m4a", "aac", "opus", "ogg", "flac", "aiff"]
|
|
140
140
|
is_audio = media_format.lower() in audio_formats
|
|
141
141
|
|
|
142
142
|
if is_audio:
|
|
143
|
-
self.logger.info(f
|
|
143
|
+
self.logger.info(f"🎵 Detected audio format: {media_format}")
|
|
144
144
|
return await self.download_audio(
|
|
145
145
|
url=url, output_dir=output_dir, media_format=media_format, force_overwrite=force_overwrite
|
|
146
146
|
)
|
|
147
147
|
else:
|
|
148
|
-
self.logger.info(f
|
|
148
|
+
self.logger.info(f"🎬 Detected video format: {media_format}")
|
|
149
149
|
return await self.download_video(
|
|
150
150
|
url=url, output_dir=output_dir, video_format=media_format, force_overwrite=force_overwrite
|
|
151
151
|
)
|
|
@@ -172,11 +172,11 @@ class YouTubeDownloader:
|
|
|
172
172
|
Path to downloaded media file
|
|
173
173
|
"""
|
|
174
174
|
target_dir = Path(output_dir).expanduser()
|
|
175
|
-
media_type =
|
|
176
|
-
emoji =
|
|
175
|
+
media_type = "audio" if is_audio else "video"
|
|
176
|
+
emoji = "🎵" if is_audio else "🎬"
|
|
177
177
|
|
|
178
|
-
self.logger.info(f
|
|
179
|
-
self.logger.info(f
|
|
178
|
+
self.logger.info(f"{emoji} Downloading {media_type} from: {url}")
|
|
179
|
+
self.logger.info(f"📁 Output directory: {target_dir}")
|
|
180
180
|
self.logger.info(f'{"🎶" if is_audio else "🎥"} Media format: {media_format}')
|
|
181
181
|
|
|
182
182
|
# Create output directory if it doesn't exist
|
|
@@ -187,57 +187,57 @@ class YouTubeDownloader:
|
|
|
187
187
|
existing_files = FileExistenceManager.check_existing_files(video_id, str(target_dir), [media_format])
|
|
188
188
|
|
|
189
189
|
# Handle existing files
|
|
190
|
-
if existing_files[
|
|
190
|
+
if existing_files["media"] and not force_overwrite:
|
|
191
191
|
if FileExistenceManager.is_interactive_mode():
|
|
192
192
|
user_choice = FileExistenceManager.prompt_user_confirmation(
|
|
193
|
-
{
|
|
193
|
+
{"media": existing_files["media"]}, "media download"
|
|
194
194
|
)
|
|
195
195
|
|
|
196
|
-
if user_choice ==
|
|
197
|
-
raise RuntimeError(
|
|
198
|
-
elif user_choice ==
|
|
196
|
+
if user_choice == "cancel":
|
|
197
|
+
raise RuntimeError("Media download cancelled by user")
|
|
198
|
+
elif user_choice == "overwrite":
|
|
199
199
|
# Continue with download
|
|
200
200
|
pass
|
|
201
|
-
elif user_choice in existing_files[
|
|
201
|
+
elif user_choice in existing_files["media"]:
|
|
202
202
|
# User selected a specific file
|
|
203
|
-
self.logger.info(f
|
|
203
|
+
self.logger.info(f"✅ Using selected media file: {user_choice}")
|
|
204
204
|
return user_choice
|
|
205
205
|
else:
|
|
206
206
|
# Fallback: use first file
|
|
207
207
|
self.logger.info(f'✅ Using existing media file: {existing_files["media"][0]}')
|
|
208
|
-
return existing_files[
|
|
208
|
+
return existing_files["media"][0]
|
|
209
209
|
else:
|
|
210
210
|
# Non-interactive mode: use existing file
|
|
211
211
|
self.logger.info(f'✅ Using existing media file: {existing_files["media"][0]}')
|
|
212
|
-
return existing_files[
|
|
212
|
+
return existing_files["media"][0]
|
|
213
213
|
|
|
214
214
|
# Generate output filename template
|
|
215
|
-
output_template = str(target_dir / f
|
|
215
|
+
output_template = str(target_dir / f"{video_id}.%(ext)s")
|
|
216
216
|
|
|
217
217
|
# Build yt-dlp command based on media type
|
|
218
218
|
if is_audio:
|
|
219
219
|
cmd = [
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
"yt-dlp",
|
|
221
|
+
"--extract-audio",
|
|
222
|
+
"--audio-format",
|
|
223
223
|
media_format,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
"--audio-quality",
|
|
225
|
+
"0", # Best quality
|
|
226
|
+
"--output",
|
|
227
227
|
output_template,
|
|
228
|
-
|
|
228
|
+
"--no-playlist",
|
|
229
229
|
url,
|
|
230
230
|
]
|
|
231
231
|
else:
|
|
232
232
|
cmd = [
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
"yt-dlp",
|
|
234
|
+
"--format",
|
|
235
|
+
"bestvideo*+bestaudio/best",
|
|
236
|
+
"--merge-output-format",
|
|
237
237
|
media_format,
|
|
238
|
-
|
|
238
|
+
"--output",
|
|
239
239
|
output_template,
|
|
240
|
-
|
|
240
|
+
"--no-playlist",
|
|
241
241
|
url,
|
|
242
242
|
]
|
|
243
243
|
|
|
@@ -248,45 +248,45 @@ class YouTubeDownloader:
|
|
|
248
248
|
None, lambda: subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
249
249
|
)
|
|
250
250
|
|
|
251
|
-
self.logger.info(f
|
|
251
|
+
self.logger.info(f"✅ {media_type.capitalize()} download completed")
|
|
252
252
|
|
|
253
253
|
# Find the downloaded file
|
|
254
254
|
# Try to parse from yt-dlp output first
|
|
255
255
|
if is_audio:
|
|
256
|
-
output_lines = result.stderr.strip().split(
|
|
256
|
+
output_lines = result.stderr.strip().split("\n")
|
|
257
257
|
for line in reversed(output_lines):
|
|
258
|
-
if
|
|
258
|
+
if "Destination:" in line or "has already been downloaded" in line:
|
|
259
259
|
parts = line.split()
|
|
260
|
-
filename =
|
|
260
|
+
filename = " ".join(parts[1:]) if "Destination:" in line else parts[0]
|
|
261
261
|
file_path = target_dir / filename
|
|
262
262
|
if file_path.exists():
|
|
263
|
-
self.logger.info(f
|
|
263
|
+
self.logger.info(f"{emoji} Downloaded {media_type} file: {file_path}")
|
|
264
264
|
return str(file_path)
|
|
265
265
|
|
|
266
266
|
# Check for expected file format
|
|
267
|
-
expected_file = target_dir / f
|
|
267
|
+
expected_file = target_dir / f"{video_id}.{media_format}"
|
|
268
268
|
if expected_file.exists():
|
|
269
|
-
self.logger.info(f
|
|
269
|
+
self.logger.info(f"{emoji} Downloaded {media_type}: {expected_file}")
|
|
270
270
|
return str(expected_file)
|
|
271
271
|
|
|
272
272
|
# Fallback: search for media files with this video_id
|
|
273
273
|
if is_audio:
|
|
274
|
-
fallback_extensions = [media_format,
|
|
274
|
+
fallback_extensions = [media_format, "mp3", "wav", "m4a", "aac"]
|
|
275
275
|
else:
|
|
276
|
-
fallback_extensions = [media_format,
|
|
276
|
+
fallback_extensions = [media_format, "mp4", "webm", "mkv"]
|
|
277
277
|
|
|
278
278
|
for ext in fallback_extensions:
|
|
279
|
-
files = list(target_dir.glob(f
|
|
279
|
+
files = list(target_dir.glob(f"{video_id}*.{ext}"))
|
|
280
280
|
if files:
|
|
281
281
|
latest_file = max(files, key=os.path.getctime)
|
|
282
|
-
self.logger.info(f
|
|
282
|
+
self.logger.info(f"{emoji} Found {media_type} file: {latest_file}")
|
|
283
283
|
return str(latest_file)
|
|
284
284
|
|
|
285
|
-
raise RuntimeError(f
|
|
285
|
+
raise RuntimeError(f"Downloaded {media_type} file not found")
|
|
286
286
|
|
|
287
287
|
except subprocess.CalledProcessError as e:
|
|
288
|
-
self.logger.error(f
|
|
289
|
-
raise RuntimeError(f
|
|
288
|
+
self.logger.error(f"Failed to download {media_type}: {e.stderr}")
|
|
289
|
+
raise RuntimeError(f"Failed to download {media_type}: {e.stderr}")
|
|
290
290
|
|
|
291
291
|
async def download_audio(
|
|
292
292
|
self,
|
|
@@ -314,7 +314,7 @@ class YouTubeDownloader:
|
|
|
314
314
|
)
|
|
315
315
|
|
|
316
316
|
async def download_video(
|
|
317
|
-
self, url: str, output_dir: Optional[str] = None, video_format: str =
|
|
317
|
+
self, url: str, output_dir: Optional[str] = None, video_format: str = "mp4", force_overwrite: bool = False
|
|
318
318
|
) -> str:
|
|
319
319
|
"""
|
|
320
320
|
Download video from YouTube URL
|
|
@@ -368,54 +368,54 @@ class YouTubeDownloader:
|
|
|
368
368
|
)
|
|
369
369
|
|
|
370
370
|
# Handle existing subtitle files
|
|
371
|
-
if existing_files[
|
|
371
|
+
if existing_files["subtitle"] and not force_overwrite:
|
|
372
372
|
if FileExistenceManager.is_interactive_mode():
|
|
373
373
|
user_choice = FileExistenceManager.prompt_user_confirmation(
|
|
374
|
-
{
|
|
374
|
+
{"subtitle": existing_files["subtitle"]}, "subtitle download"
|
|
375
375
|
)
|
|
376
376
|
|
|
377
|
-
if user_choice ==
|
|
378
|
-
raise RuntimeError(
|
|
379
|
-
elif user_choice ==
|
|
377
|
+
if user_choice == "cancel":
|
|
378
|
+
raise RuntimeError("Subtitle download cancelled by user")
|
|
379
|
+
elif user_choice == "overwrite":
|
|
380
380
|
# Continue with download
|
|
381
381
|
pass
|
|
382
|
-
elif user_choice in existing_files[
|
|
382
|
+
elif user_choice in existing_files["subtitle"]:
|
|
383
383
|
# User selected a specific file
|
|
384
384
|
subtitle_file = Path(user_choice)
|
|
385
|
-
self.logger.info(f
|
|
385
|
+
self.logger.info(f"✅ Using selected subtitle file: {subtitle_file}")
|
|
386
386
|
return str(subtitle_file)
|
|
387
387
|
else:
|
|
388
388
|
# Fallback: use first file
|
|
389
|
-
subtitle_file = Path(existing_files[
|
|
390
|
-
self.logger.info(f
|
|
389
|
+
subtitle_file = Path(existing_files["subtitle"][0])
|
|
390
|
+
self.logger.info(f"✅ Using existing subtitle file: {subtitle_file}")
|
|
391
391
|
return str(subtitle_file)
|
|
392
392
|
else:
|
|
393
|
-
subtitle_file = Path(existing_files[
|
|
394
|
-
self.logger.info(f
|
|
393
|
+
subtitle_file = Path(existing_files["subtitle"][0])
|
|
394
|
+
self.logger.info(f"🔍 Found existing subtitle: {subtitle_file}")
|
|
395
395
|
return str(subtitle_file)
|
|
396
396
|
|
|
397
|
-
self.logger.info(f
|
|
397
|
+
self.logger.info(f"📥 Downloading subtitle for: {url}")
|
|
398
398
|
if subtitle_lang:
|
|
399
|
-
self.logger.info(f
|
|
399
|
+
self.logger.info(f"🎯 Targeting specific subtitle track: {subtitle_lang}")
|
|
400
400
|
|
|
401
|
-
output_template = str(target_dir / f
|
|
401
|
+
output_template = str(target_dir / f"{video_id}.%(ext)s")
|
|
402
402
|
|
|
403
403
|
# Configure yt-dlp options for subtitle download
|
|
404
404
|
ytdlp_options = [
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
405
|
+
"yt-dlp",
|
|
406
|
+
"--skip-download", # Don't download video/audio
|
|
407
|
+
"--output",
|
|
408
408
|
output_template,
|
|
409
|
-
|
|
410
|
-
|
|
409
|
+
"--sub-format",
|
|
410
|
+
"best", # Prefer best available format
|
|
411
411
|
]
|
|
412
412
|
|
|
413
413
|
# Add subtitle language selection if specified
|
|
414
414
|
if subtitle_lang:
|
|
415
|
-
ytdlp_options.extend([
|
|
415
|
+
ytdlp_options.extend(["--write-sub", "--write-auto-sub", "--sub-langs", f"{subtitle_lang}.*"])
|
|
416
416
|
else:
|
|
417
417
|
# Download only manual subtitles (not auto-generated) in English to avoid rate limiting
|
|
418
|
-
ytdlp_options.extend([
|
|
418
|
+
ytdlp_options.extend(["--write-sub", "--write-auto-sub"])
|
|
419
419
|
|
|
420
420
|
ytdlp_options.append(url)
|
|
421
421
|
|
|
@@ -426,71 +426,71 @@ class YouTubeDownloader:
|
|
|
426
426
|
None, lambda: subprocess.run(ytdlp_options, capture_output=True, text=True, check=True)
|
|
427
427
|
)
|
|
428
428
|
|
|
429
|
-
self.logger.info(f
|
|
429
|
+
self.logger.info(f"yt-dlp transcript output: {result.stdout.strip()}")
|
|
430
430
|
|
|
431
431
|
# Find the downloaded transcript file
|
|
432
432
|
subtitle_patterns = [
|
|
433
|
-
f
|
|
434
|
-
f
|
|
435
|
-
f
|
|
436
|
-
f
|
|
437
|
-
f
|
|
438
|
-
f
|
|
433
|
+
f"{video_id}.*vtt",
|
|
434
|
+
f"{video_id}.*srt",
|
|
435
|
+
f"{video_id}.*sub",
|
|
436
|
+
f"{video_id}.*sbv",
|
|
437
|
+
f"{video_id}.*ssa",
|
|
438
|
+
f"{video_id}.*ass",
|
|
439
439
|
]
|
|
440
440
|
|
|
441
441
|
subtitle_files = []
|
|
442
442
|
for pattern in subtitle_patterns:
|
|
443
443
|
_subtitle_files = list(target_dir.glob(pattern))
|
|
444
444
|
for subtitle_file in _subtitle_files:
|
|
445
|
-
self.logger.info(f
|
|
445
|
+
self.logger.info(f"📥 Downloaded subtitle: {subtitle_file}")
|
|
446
446
|
subtitle_files.extend(_subtitle_files)
|
|
447
447
|
|
|
448
448
|
if not subtitle_files:
|
|
449
|
-
self.logger.warning(
|
|
449
|
+
self.logger.warning("No subtitle available for this video")
|
|
450
450
|
return None
|
|
451
451
|
|
|
452
452
|
# If only one subtitle file, return it directly
|
|
453
453
|
if len(subtitle_files) == 1:
|
|
454
|
-
self.logger.info(f
|
|
454
|
+
self.logger.info(f"✅ Using subtitle: {subtitle_files[0]}")
|
|
455
455
|
return str(subtitle_files[0])
|
|
456
456
|
|
|
457
457
|
# Multiple subtitle files found, let user choose
|
|
458
458
|
if FileExistenceManager.is_interactive_mode():
|
|
459
|
-
self.logger.info(f
|
|
459
|
+
self.logger.info(f"📋 Found {len(subtitle_files)} subtitle files")
|
|
460
460
|
# Use the enable_gemini_option parameter passed by caller
|
|
461
461
|
subtitle_choice = FileExistenceManager.prompt_file_selection(
|
|
462
|
-
file_type=
|
|
462
|
+
file_type="subtitle",
|
|
463
463
|
files=[str(f) for f in subtitle_files],
|
|
464
|
-
operation=
|
|
464
|
+
operation="use",
|
|
465
465
|
enable_gemini=enable_gemini_option,
|
|
466
466
|
)
|
|
467
467
|
|
|
468
|
-
if subtitle_choice ==
|
|
469
|
-
raise RuntimeError(
|
|
470
|
-
elif subtitle_choice ==
|
|
468
|
+
if subtitle_choice == "cancel":
|
|
469
|
+
raise RuntimeError("Subtitle selection cancelled by user")
|
|
470
|
+
elif subtitle_choice == "gemini":
|
|
471
471
|
# User chose to transcribe with Gemini instead of using downloaded subtitles
|
|
472
|
-
self.logger.info(
|
|
473
|
-
return
|
|
472
|
+
self.logger.info("✨ User selected Gemini transcription")
|
|
473
|
+
return "gemini" # Return special value to indicate Gemini transcription
|
|
474
474
|
elif subtitle_choice:
|
|
475
|
-
self.logger.info(f
|
|
475
|
+
self.logger.info(f"✅ Selected subtitle: {subtitle_choice}")
|
|
476
476
|
return subtitle_choice
|
|
477
477
|
else:
|
|
478
478
|
# Fallback to first file
|
|
479
|
-
self.logger.info(f
|
|
479
|
+
self.logger.info(f"✅ Using first subtitle: {subtitle_files[0]}")
|
|
480
480
|
return str(subtitle_files[0])
|
|
481
481
|
else:
|
|
482
482
|
# Non-interactive mode: use first file
|
|
483
|
-
self.logger.info(f
|
|
483
|
+
self.logger.info(f"✅ Using first subtitle: {subtitle_files[0]}")
|
|
484
484
|
return str(subtitle_files[0])
|
|
485
485
|
|
|
486
486
|
except subprocess.CalledProcessError as e:
|
|
487
487
|
error_msg = e.stderr.strip() if e.stderr else str(e)
|
|
488
|
-
if
|
|
489
|
-
self.logger.warning(
|
|
488
|
+
if "No automatic or manual subtitles found" in error_msg:
|
|
489
|
+
self.logger.warning("No subtitles available for this video")
|
|
490
490
|
return None
|
|
491
491
|
else:
|
|
492
|
-
self.logger.error(f
|
|
493
|
-
raise RuntimeError(f
|
|
492
|
+
self.logger.error(f"Failed to download transcript: {error_msg}")
|
|
493
|
+
raise RuntimeError(f"Failed to download transcript: {error_msg}")
|
|
494
494
|
|
|
495
495
|
async def list_available_subtitles(self, url: str) -> List[Dict[str, Any]]:
|
|
496
496
|
"""
|
|
@@ -502,9 +502,9 @@ class YouTubeDownloader:
|
|
|
502
502
|
Returns:
|
|
503
503
|
List of subtitle track information dictionaries
|
|
504
504
|
"""
|
|
505
|
-
self.logger.info(f
|
|
505
|
+
self.logger.info(f"📋 Listing available subtitles for: {url}")
|
|
506
506
|
|
|
507
|
-
cmd = [
|
|
507
|
+
cmd = ["yt-dlp", "--list-subs", "--no-download", url]
|
|
508
508
|
|
|
509
509
|
try:
|
|
510
510
|
# Run in thread pool to avoid blocking
|
|
@@ -515,31 +515,31 @@ class YouTubeDownloader:
|
|
|
515
515
|
|
|
516
516
|
# Parse the subtitle list output
|
|
517
517
|
subtitle_info = []
|
|
518
|
-
lines = result.stdout.strip().split(
|
|
518
|
+
lines = result.stdout.strip().split("\n")
|
|
519
519
|
|
|
520
520
|
# Look for the subtitle section (not automatic captions)
|
|
521
521
|
in_subtitle_section = False
|
|
522
522
|
for line in lines:
|
|
523
|
-
if
|
|
523
|
+
if "Available subtitles for" in line:
|
|
524
524
|
in_subtitle_section = True
|
|
525
525
|
continue
|
|
526
|
-
elif
|
|
526
|
+
elif "Available automatic captions for" in line:
|
|
527
527
|
in_subtitle_section = False
|
|
528
528
|
continue
|
|
529
529
|
elif in_subtitle_section and line.strip():
|
|
530
530
|
# Skip header lines
|
|
531
|
-
if
|
|
531
|
+
if "Language" in line and "Name" in line and "Formats" in line:
|
|
532
532
|
continue
|
|
533
533
|
|
|
534
534
|
# Parse subtitle information
|
|
535
535
|
# Format: "Language Name Formats" where formats are comma-separated
|
|
536
536
|
# Example: "en-uYU-mmqFLq8 English - CC1 vtt, srt, ttml, srv3, srv2, srv1, json3"
|
|
537
537
|
|
|
538
|
-
if line.strip() and not line.startswith(
|
|
538
|
+
if line.strip() and not line.startswith("["):
|
|
539
539
|
# Split by multiple spaces to separate language, name, and formats
|
|
540
540
|
import re
|
|
541
541
|
|
|
542
|
-
parts = re.split(r
|
|
542
|
+
parts = re.split(r"\s{2,}", line.strip())
|
|
543
543
|
|
|
544
544
|
if len(parts) >= 2:
|
|
545
545
|
# First part is language, last part is formats
|
|
@@ -547,25 +547,25 @@ class YouTubeDownloader:
|
|
|
547
547
|
formats_str = parts[-1]
|
|
548
548
|
|
|
549
549
|
# Split language and name - language is first word
|
|
550
|
-
lang_name_parts = language_and_name.split(
|
|
550
|
+
lang_name_parts = language_and_name.split(" ", 1)
|
|
551
551
|
language = lang_name_parts[0]
|
|
552
|
-
name = lang_name_parts[1] if len(lang_name_parts) > 1 else
|
|
552
|
+
name = lang_name_parts[1] if len(lang_name_parts) > 1 else ""
|
|
553
553
|
|
|
554
554
|
# If there are more than 2 parts, middle parts are also part of name
|
|
555
555
|
if len(parts) > 2:
|
|
556
|
-
name =
|
|
556
|
+
name = " ".join([name] + parts[1:-1]).strip()
|
|
557
557
|
|
|
558
558
|
# Parse formats - they are comma-separated
|
|
559
|
-
formats = [f.strip() for f in formats_str.split(
|
|
559
|
+
formats = [f.strip() for f in formats_str.split(",") if f.strip()]
|
|
560
560
|
|
|
561
|
-
subtitle_info.append({
|
|
561
|
+
subtitle_info.append({"language": language, "name": name, "formats": formats})
|
|
562
562
|
|
|
563
|
-
self.logger.info(f
|
|
563
|
+
self.logger.info(f"✅ Found {len(subtitle_info)} subtitle tracks")
|
|
564
564
|
return subtitle_info
|
|
565
565
|
|
|
566
566
|
except subprocess.CalledProcessError as e:
|
|
567
|
-
self.logger.error(f
|
|
568
|
-
raise RuntimeError(f
|
|
567
|
+
self.logger.error(f"Failed to list subtitles: {e.stderr}")
|
|
568
|
+
raise RuntimeError(f"Failed to list subtitles: {e.stderr}")
|
|
569
569
|
|
|
570
570
|
|
|
571
571
|
class YouTubeSubtitleAgent(WorkflowAgent):
|
|
@@ -592,7 +592,7 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
592
592
|
aligner: AsyncLattifAI,
|
|
593
593
|
max_retries: int = 0,
|
|
594
594
|
):
|
|
595
|
-
super().__init__(
|
|
595
|
+
super().__init__("YouTube Subtitle Agent", max_retries)
|
|
596
596
|
|
|
597
597
|
# Components (injected)
|
|
598
598
|
self.downloader = downloader
|
|
@@ -603,52 +603,52 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
603
603
|
"""Define the workflow steps"""
|
|
604
604
|
return [
|
|
605
605
|
WorkflowStep(
|
|
606
|
-
name=
|
|
606
|
+
name="Process YouTube URL", description="Extract video info and download video/audio", required=True
|
|
607
607
|
),
|
|
608
608
|
WorkflowStep(
|
|
609
|
-
name=
|
|
610
|
-
description=
|
|
609
|
+
name="Transcribe Media",
|
|
610
|
+
description="Download subtitle if available or transcribe the media file",
|
|
611
611
|
required=True,
|
|
612
612
|
),
|
|
613
|
-
WorkflowStep(name=
|
|
613
|
+
WorkflowStep(name="Align Subtitle", description="Align Subtitle with media using LattifAI", required=True),
|
|
614
614
|
WorkflowStep(
|
|
615
|
-
name=
|
|
615
|
+
name="Export Results", description="Export aligned subtitles in specified formats", required=True
|
|
616
616
|
),
|
|
617
617
|
]
|
|
618
618
|
|
|
619
619
|
async def execute_step(self, step: WorkflowStep, context: Dict[str, Any]) -> Any:
|
|
620
620
|
"""Execute a single workflow step"""
|
|
621
621
|
|
|
622
|
-
if step.name ==
|
|
622
|
+
if step.name == "Process YouTube URL":
|
|
623
623
|
return await self._process_youtube_url(context)
|
|
624
624
|
|
|
625
|
-
elif step.name ==
|
|
625
|
+
elif step.name == "Transcribe Media":
|
|
626
626
|
return await self._transcribe_media(context)
|
|
627
627
|
|
|
628
|
-
elif step.name ==
|
|
628
|
+
elif step.name == "Align Subtitle":
|
|
629
629
|
return await self._align_subtitle(context)
|
|
630
630
|
|
|
631
|
-
elif step.name ==
|
|
631
|
+
elif step.name == "Export Results":
|
|
632
632
|
return await self._export_results(context)
|
|
633
633
|
|
|
634
634
|
else:
|
|
635
|
-
raise ValueError(f
|
|
635
|
+
raise ValueError(f"Unknown step: {step.name}")
|
|
636
636
|
|
|
637
637
|
async def _process_youtube_url(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
638
638
|
"""Step 1: Process YouTube URL and download video"""
|
|
639
|
-
url = context.get(
|
|
639
|
+
url = context.get("url")
|
|
640
640
|
if not url:
|
|
641
|
-
raise ValueError(
|
|
641
|
+
raise ValueError("YouTube URL is required")
|
|
642
642
|
|
|
643
|
-
output_dir = context.get(
|
|
643
|
+
output_dir = context.get("output_dir") or tempfile.gettempdir()
|
|
644
644
|
output_dir = Path(output_dir).expanduser()
|
|
645
645
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
646
646
|
|
|
647
|
-
media_format = context.get(
|
|
648
|
-
force_overwrite = context.get(
|
|
647
|
+
media_format = context.get("media_format", "mp4")
|
|
648
|
+
force_overwrite = context.get("force_overwrite", False)
|
|
649
649
|
|
|
650
|
-
self.logger.info(f
|
|
651
|
-
self.logger.info(f
|
|
650
|
+
self.logger.info(f"🎥 Processing YouTube URL: {url}")
|
|
651
|
+
self.logger.info(f"📦 Media format: {media_format}")
|
|
652
652
|
|
|
653
653
|
# Download media (audio or video) with runtime parameters
|
|
654
654
|
media_path = await self.downloader.download_media(
|
|
@@ -668,51 +668,51 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
668
668
|
enable_gemini_option=bool(self.transcriber.api_key),
|
|
669
669
|
)
|
|
670
670
|
if subtitle_path:
|
|
671
|
-
self.logger.info(f
|
|
671
|
+
self.logger.info(f"✅ Subtitle downloaded: {subtitle_path}")
|
|
672
672
|
else:
|
|
673
|
-
self.logger.info(
|
|
673
|
+
self.logger.info("ℹ️ No subtitles available for this video")
|
|
674
674
|
except Exception as e:
|
|
675
|
-
self.logger.warning(f
|
|
675
|
+
self.logger.warning(f"⚠️ Failed to download subtitles: {e}")
|
|
676
676
|
# Continue without subtitles - will transcribe later if needed
|
|
677
677
|
|
|
678
678
|
# Get video metadata
|
|
679
679
|
metadata = await self.downloader.get_video_info(url)
|
|
680
680
|
|
|
681
681
|
result = {
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
682
|
+
"url": url,
|
|
683
|
+
"video_path": media_path, # Keep 'video_path' key for backward compatibility
|
|
684
|
+
"audio_path": media_path, # Also add 'audio_path' for clarity
|
|
685
|
+
"metadata": metadata,
|
|
686
|
+
"video_format": media_format,
|
|
687
|
+
"output_dir": output_dir,
|
|
688
|
+
"force_overwrite": force_overwrite,
|
|
689
|
+
"downloaded_subtitle_path": subtitle_path, # Store downloaded subtitle path
|
|
690
690
|
}
|
|
691
691
|
|
|
692
|
-
self.logger.info(f
|
|
692
|
+
self.logger.info(f"✅ Media downloaded: {media_path}")
|
|
693
693
|
return result
|
|
694
694
|
|
|
695
695
|
async def _transcribe_media(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
696
696
|
"""Step 2: Transcribe video using Gemini 2.5 Pro or use downloaded subtitle"""
|
|
697
|
-
url = context.get(
|
|
698
|
-
result = context.get(
|
|
699
|
-
video_path = result.get(
|
|
700
|
-
output_dir = result.get(
|
|
701
|
-
force_overwrite = result.get(
|
|
702
|
-
downloaded_subtitle_path = result.get(
|
|
697
|
+
url = context.get("url")
|
|
698
|
+
result = context.get("process_youtube_url_result", {})
|
|
699
|
+
video_path = result.get("video_path")
|
|
700
|
+
output_dir = result.get("output_dir")
|
|
701
|
+
force_overwrite = result.get("force_overwrite", False)
|
|
702
|
+
downloaded_subtitle_path = result.get("downloaded_subtitle_path")
|
|
703
703
|
|
|
704
704
|
if not url or not video_path:
|
|
705
|
-
raise ValueError(
|
|
705
|
+
raise ValueError("URL and video path not found in context")
|
|
706
706
|
|
|
707
707
|
video_id = self.downloader.extract_video_id(url)
|
|
708
708
|
|
|
709
709
|
# If subtitle was already downloaded in step 1 and user selected it, use it directly
|
|
710
|
-
if downloaded_subtitle_path and downloaded_subtitle_path !=
|
|
711
|
-
self.logger.info(f
|
|
712
|
-
return {
|
|
710
|
+
if downloaded_subtitle_path and downloaded_subtitle_path != "gemini":
|
|
711
|
+
self.logger.info(f"📥 Using subtitle: {downloaded_subtitle_path}")
|
|
712
|
+
return {"subtitle_path": downloaded_subtitle_path}
|
|
713
713
|
|
|
714
714
|
# Check for existing subtitles if subtitle was not downloaded yet
|
|
715
|
-
self.logger.info(
|
|
715
|
+
self.logger.info("📥 Checking for existing subtitles...")
|
|
716
716
|
|
|
717
717
|
# Check for existing subtitle files (all formats including Gemini transcripts)
|
|
718
718
|
existing_files = FileExistenceManager.check_existing_files(
|
|
@@ -722,57 +722,57 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
722
722
|
)
|
|
723
723
|
|
|
724
724
|
# Prompt user if subtitle exists and force_overwrite is not set
|
|
725
|
-
if existing_files[
|
|
725
|
+
if existing_files["subtitle"] and not force_overwrite:
|
|
726
726
|
# Let user choose which subtitle file to use
|
|
727
727
|
# Enable Gemini option if API key is available (check transcriber's api_key)
|
|
728
728
|
has_gemini_key = bool(self.transcriber.api_key)
|
|
729
729
|
subtitle_choice = FileExistenceManager.prompt_file_selection(
|
|
730
|
-
file_type=
|
|
731
|
-
files=existing_files[
|
|
732
|
-
operation=
|
|
730
|
+
file_type="subtitle",
|
|
731
|
+
files=existing_files["subtitle"],
|
|
732
|
+
operation="transcribe",
|
|
733
733
|
enable_gemini=has_gemini_key,
|
|
734
734
|
)
|
|
735
735
|
|
|
736
|
-
if subtitle_choice ==
|
|
737
|
-
raise RuntimeError(
|
|
738
|
-
elif subtitle_choice in (
|
|
736
|
+
if subtitle_choice == "cancel":
|
|
737
|
+
raise RuntimeError("Transcription cancelled by user")
|
|
738
|
+
elif subtitle_choice in ("overwrite", "gemini"):
|
|
739
739
|
# Continue to transcription below
|
|
740
740
|
# For 'gemini', user explicitly chose to transcribe with Gemini
|
|
741
741
|
pass
|
|
742
|
-
elif subtitle_choice ==
|
|
742
|
+
elif subtitle_choice == "use":
|
|
743
743
|
# User chose to use existing subtitle files (use first one)
|
|
744
|
-
subtitle_path = Path(existing_files[
|
|
745
|
-
self.logger.info(f
|
|
746
|
-
return {
|
|
744
|
+
subtitle_path = Path(existing_files["subtitle"][0])
|
|
745
|
+
self.logger.info(f"🔁 Using existing subtitle: {subtitle_path}")
|
|
746
|
+
return {"subtitle_path": str(subtitle_path)}
|
|
747
747
|
elif subtitle_choice: # User selected a specific file path
|
|
748
748
|
# Use selected subtitle
|
|
749
749
|
subtitle_path = Path(subtitle_choice)
|
|
750
|
-
self.logger.info(f
|
|
751
|
-
return {
|
|
750
|
+
self.logger.info(f"🔁 Using existing subtitle: {subtitle_path}")
|
|
751
|
+
return {"subtitle_path": str(subtitle_path)}
|
|
752
752
|
# If user_choice == 'overwrite' or 'gemini', continue to transcription below
|
|
753
753
|
|
|
754
754
|
# TODO: support other Transcriber options
|
|
755
|
-
self.logger.info(
|
|
755
|
+
self.logger.info("✨ Transcribing URL with Gemini 2.5 Pro...")
|
|
756
756
|
transcript = await self.transcriber.transcribe_url(url)
|
|
757
|
-
subtitle_path = output_dir / f
|
|
758
|
-
with open(subtitle_path,
|
|
757
|
+
subtitle_path = output_dir / f"{video_id}_Gemini.md"
|
|
758
|
+
with open(subtitle_path, "w", encoding="utf-8") as f:
|
|
759
759
|
f.write(transcript)
|
|
760
|
-
result = {
|
|
761
|
-
self.logger.info(f
|
|
760
|
+
result = {"subtitle_path": str(subtitle_path)}
|
|
761
|
+
self.logger.info(f"✅ Transcript generated: {len(transcript)} characters")
|
|
762
762
|
return result
|
|
763
763
|
|
|
764
764
|
async def _align_subtitle(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
765
765
|
"""Step 3: Align transcript with video using LattifAI"""
|
|
766
|
-
result = context[
|
|
767
|
-
media_path = result.get(
|
|
768
|
-
subtitle_path = context.get(
|
|
766
|
+
result = context["process_youtube_url_result"]
|
|
767
|
+
media_path = result.get("video_path", result.get("audio_path"))
|
|
768
|
+
subtitle_path = context.get("transcribe_media_result", {}).get("subtitle_path")
|
|
769
769
|
|
|
770
770
|
if not media_path or not subtitle_path:
|
|
771
|
-
raise ValueError(
|
|
771
|
+
raise ValueError("Video path and subtitle path are required")
|
|
772
772
|
|
|
773
|
-
self.logger.info(
|
|
773
|
+
self.logger.info("🎯 Aligning subtitle with video...")
|
|
774
774
|
|
|
775
|
-
if subtitle_path.endswith(
|
|
775
|
+
if subtitle_path.endswith("_Gemini.md"):
|
|
776
776
|
is_gemini_format = True
|
|
777
777
|
else:
|
|
778
778
|
is_gemini_format = False
|
|
@@ -781,57 +781,57 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
781
781
|
self.logger.info(f'📄 Subtitle format: {"Gemini" if is_gemini_format else f"{subtitle_path.suffix}"}')
|
|
782
782
|
|
|
783
783
|
original_subtitle_path = subtitle_path
|
|
784
|
-
output_dir = result.get(
|
|
785
|
-
split_sentence = context.get(
|
|
786
|
-
word_level = context.get(
|
|
787
|
-
output_path = output_dir / f
|
|
784
|
+
output_dir = result.get("output_dir")
|
|
785
|
+
split_sentence = context.get("split_sentence", False)
|
|
786
|
+
word_level = context.get("word_level", False)
|
|
787
|
+
output_path = output_dir / f"{Path(media_path).stem}_aligned.ass"
|
|
788
788
|
|
|
789
789
|
# Perform alignment with LattifAI (split_sentence and word_level passed as function parameters)
|
|
790
790
|
aligned_result = await self.aligner.alignment(
|
|
791
791
|
audio=media_path,
|
|
792
792
|
subtitle=str(subtitle_path), # Use dialogue text for YouTube format, original for plain text
|
|
793
|
-
format=
|
|
793
|
+
format="gemini" if is_gemini_format else "auto",
|
|
794
794
|
split_sentence=split_sentence,
|
|
795
795
|
return_details=word_level,
|
|
796
796
|
output_subtitle_path=str(output_path),
|
|
797
797
|
)
|
|
798
798
|
|
|
799
799
|
result = {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
800
|
+
"aligned_path": output_path,
|
|
801
|
+
"alignment_result": aligned_result,
|
|
802
|
+
"original_subtitle_path": original_subtitle_path,
|
|
803
|
+
"is_gemini_format": is_gemini_format,
|
|
804
804
|
}
|
|
805
805
|
|
|
806
|
-
self.logger.info(
|
|
806
|
+
self.logger.info("✅ Alignment completed")
|
|
807
807
|
return result
|
|
808
808
|
|
|
809
809
|
async def _export_results(self, context: Dict[str, Any]) -> Dict[str, Any]:
|
|
810
810
|
"""Step 4: Export results in specified format and update subtitle file"""
|
|
811
|
-
align_result = context.get(
|
|
812
|
-
aligned_path = align_result.get(
|
|
813
|
-
original_subtitle_path = align_result.get(
|
|
814
|
-
is_gemini_format = align_result.get(
|
|
815
|
-
metadata = context.get(
|
|
811
|
+
align_result = context.get("align_subtitle_result", {})
|
|
812
|
+
aligned_path = align_result.get("aligned_path")
|
|
813
|
+
original_subtitle_path = align_result.get("original_subtitle_path")
|
|
814
|
+
is_gemini_format = align_result.get("is_gemini_format", False)
|
|
815
|
+
metadata = context.get("process_youtube_url_result", {}).get("metadata", {})
|
|
816
816
|
|
|
817
817
|
if not aligned_path:
|
|
818
|
-
raise ValueError(
|
|
818
|
+
raise ValueError("Aligned subtitle path not found")
|
|
819
819
|
|
|
820
|
-
output_format = context.get(
|
|
821
|
-
self.logger.info(f
|
|
820
|
+
output_format = context.get("output_format", "srt")
|
|
821
|
+
self.logger.info(f"📤 Exporting results in format: {output_format}")
|
|
822
822
|
|
|
823
|
-
supervisions = SubtitleIO.read(aligned_path, format=
|
|
823
|
+
supervisions = SubtitleIO.read(aligned_path, format="ass")
|
|
824
824
|
exported_files = {}
|
|
825
825
|
|
|
826
826
|
# Update original transcript file with aligned timestamps if YouTube format
|
|
827
827
|
if is_gemini_format:
|
|
828
|
-
assert Path(original_subtitle_path).exists(),
|
|
829
|
-
self.logger.info(
|
|
828
|
+
assert Path(original_subtitle_path).exists(), "Original subtitle path not found"
|
|
829
|
+
self.logger.info("📝 Updating original transcript with aligned timestamps...")
|
|
830
830
|
|
|
831
831
|
try:
|
|
832
832
|
# Generate updated transcript file path
|
|
833
833
|
original_path = Path(original_subtitle_path)
|
|
834
|
-
updated_subtitle_path = original_path.parent / f
|
|
834
|
+
updated_subtitle_path = original_path.parent / f"{original_path.stem}_LattifAI.md"
|
|
835
835
|
|
|
836
836
|
# Update timestamps in original transcript
|
|
837
837
|
GeminiWriter.update_timestamps(
|
|
@@ -840,26 +840,26 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
840
840
|
output_path=str(updated_subtitle_path),
|
|
841
841
|
)
|
|
842
842
|
|
|
843
|
-
exported_files[
|
|
844
|
-
self.logger.info(f
|
|
843
|
+
exported_files["updated_transcript"] = str(updated_subtitle_path)
|
|
844
|
+
self.logger.info(f"✅ Updated transcript: {updated_subtitle_path}")
|
|
845
845
|
|
|
846
846
|
except Exception as e:
|
|
847
|
-
self.logger.warning(f
|
|
847
|
+
self.logger.warning(f"⚠️ Failed to update transcript timestamps: {e}")
|
|
848
848
|
|
|
849
849
|
# Export to requested subtitle format
|
|
850
850
|
output_path = str(aligned_path).replace(
|
|
851
|
-
|
|
851
|
+
"_aligned.ass", f'{"_Gemini" if is_gemini_format else ""}_LattifAI.{output_format}'
|
|
852
852
|
)
|
|
853
853
|
SubtitleIO.write(supervisions, output_path=output_path)
|
|
854
854
|
exported_files[output_format] = output_path
|
|
855
|
-
self.logger.info(f
|
|
855
|
+
self.logger.info(f"✅ Exported {output_format.upper()}: {output_path}")
|
|
856
856
|
|
|
857
857
|
result = {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
858
|
+
"exported_files": exported_files,
|
|
859
|
+
"metadata": metadata,
|
|
860
|
+
"subtitle_count": len(supervisions),
|
|
861
|
+
"is_gemini_format": is_gemini_format,
|
|
862
|
+
"original_subtitle_path": original_subtitle_path,
|
|
863
863
|
}
|
|
864
864
|
|
|
865
865
|
return result
|
|
@@ -868,9 +868,9 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
868
868
|
self,
|
|
869
869
|
url: str,
|
|
870
870
|
output_dir: Optional[str] = None,
|
|
871
|
-
media_format: str =
|
|
871
|
+
media_format: str = "mp4",
|
|
872
872
|
force_overwrite: bool = False,
|
|
873
|
-
output_format: str =
|
|
873
|
+
output_format: str = "srt",
|
|
874
874
|
split_sentence: bool = False,
|
|
875
875
|
word_level: bool = False,
|
|
876
876
|
) -> Dict[str, Any]:
|
|
@@ -889,9 +889,9 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
889
889
|
self,
|
|
890
890
|
url: str,
|
|
891
891
|
output_dir: Optional[str] = None,
|
|
892
|
-
media_format: str =
|
|
892
|
+
media_format: str = "mp4",
|
|
893
893
|
force_overwrite: bool = False,
|
|
894
|
-
output_format: str =
|
|
894
|
+
output_format: str = "srt",
|
|
895
895
|
split_sentence: bool = False,
|
|
896
896
|
word_level: bool = False,
|
|
897
897
|
) -> Dict[str, Any]:
|
|
@@ -922,10 +922,10 @@ class YouTubeSubtitleAgent(WorkflowAgent):
|
|
|
922
922
|
)
|
|
923
923
|
|
|
924
924
|
if result.is_success:
|
|
925
|
-
return result.data.get(
|
|
925
|
+
return result.data.get("export_results_result", {})
|
|
926
926
|
else:
|
|
927
927
|
# Re-raise the original exception if available to preserve error type and context
|
|
928
928
|
if result.exception:
|
|
929
929
|
raise result.exception
|
|
930
930
|
else:
|
|
931
|
-
raise Exception(f
|
|
931
|
+
raise Exception(f"Workflow failed: {result.error}")
|