sampleacc-3003 commited on
Commit
ec3dae5
Β·
verified Β·
1 Parent(s): 1ac11fd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +582 -606
app.py CHANGED
@@ -5,625 +5,601 @@ import os
5
  import tempfile
6
  import requests
7
  import re
8
- import textwrap
9
- import shutil
10
- import time
11
  from datetime import datetime
12
- from PIL import Image, ImageDraw, ImageFont
13
- from functools import lru_cache
14
-
15
- # ========================================
16
- # CONFIGURATION SECTION - CUSTOMIZE HERE
17
- # ========================================
18
-
19
- REDDIT_CONFIG = {
20
- 'template_file': 'reddit_template.png',
21
- 'font_file': 'Roboto_Condensed-Bold.ttf',
22
- 'font_size_max': 120,
23
- 'font_size_min': 16,
24
- 'text_wrap_width': 50,
25
- 'text_color': 'black',
26
- 'line_spacing': 10,
27
- 'text_box_width_percent': 0.8,
28
- 'text_box_height_percent': 0.5,
29
- 'y_offset': 20,
30
- }
31
-
32
- SUBTITLE_CONFIG = {
33
- 'font_file': 'komiko_axis.ttf',
34
- 'font_name': 'Komika Axis',
35
- 'font_size_default': 18,
36
- 'position_alignment': 5,
37
- 'margin_left': 10,
38
- 'margin_right': 10,
39
- 'margin_vertical': 0,
40
- }
41
-
42
- VIDEO_CONFIG = {
43
- 'reddit_scale_percent': 0.90,
44
- 'fade_start_percent': 0.6,
45
- 'fade_end_percent': 0.75,
46
- 'promo_percent': 0.1,
47
- 'fade_color_rgb': (218, 207, 195),
48
- 'fade_color_hex': '#DACFC3',
49
- 'book_fade_in_duration': 2,
50
- # Performance settings
51
- 'encoding_preset': 'faster', # Options: ultrafast, superfast, veryfast, faster, fast, medium
52
- 'threads': 0, # 0 = auto-detect
53
- }
54
-
55
- # ========================================
56
- # END CONFIGURATION
57
- # ========================================
58
 
 
59
  static_ffmpeg.add_paths()
60
 
61
- # Utility Functions
62
- def load_font(font_paths, font_size, fallback='Verdana'):
63
- """Load font from multiple locations with fallback."""
64
- for path in font_paths:
65
- if os.path.exists(path):
66
- try:
67
- return ImageFont.truetype(path, font_size)
68
- except:
69
- pass
70
- try:
71
- return ImageFont.truetype(fallback, font_size)
72
- except:
73
- return ImageFont.load_default()
74
-
75
- def time_to_seconds(time_str):
76
- """Convert SRT time to seconds."""
77
- h, m, s = time_str.split(':')
78
- s, ms = s.split(',')
79
- return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
80
-
81
- def format_elapsed_time(seconds):
82
- """Format elapsed time as MM:SS."""
83
- mins = int(seconds // 60)
84
- secs = int(seconds % 60)
85
- return f"{mins}:{secs:02d}"
86
-
87
- def run_ffmpeg_cmd(cmd, env, description="", start_time=None):
88
- """Execute FFmpeg command with error handling and timing."""
89
- step_start = time.time()
90
- try:
91
- subprocess.run(cmd, check=True, capture_output=True, text=True, env=env)
92
- elapsed = time.time() - step_start
93
- total_elapsed = time.time() - start_time if start_time else elapsed
94
- return True, None, f"βœ… {description} ({elapsed:.1f}s) | Total: {format_elapsed_time(total_elapsed)}"
95
- except subprocess.CalledProcessError as e:
96
- error_msg = e.stderr[-1000:] if e.stderr else str(e)
97
- return False, f"{description} failed: {error_msg}", None
98
-
99
- # Font Setup
100
- def setup_custom_fonts_hf(temp_dir):
101
- """Setup fonts for HF Spaces compatibility."""
102
- try:
103
- fonts_dir = os.path.join(temp_dir, 'fonts')
104
- os.makedirs(fonts_dir, exist_ok=True)
105
-
106
- script_dir = os.path.dirname(os.path.abspath(__file__))
107
- fonts_to_copy = []
108
-
109
- # Check fonts/ subdirectory
110
- repo_fonts_dir = os.path.join(script_dir, 'fonts')
111
- if os.path.exists(repo_fonts_dir):
112
- fonts_to_copy.extend([
113
- os.path.join(repo_fonts_dir, f)
114
- for f in os.listdir(repo_fonts_dir)
115
- if f.lower().endswith(('.ttf', '.otf'))
116
- ])
117
-
118
- # Check root directory
119
- for font_file in [REDDIT_CONFIG['font_file'], SUBTITLE_CONFIG['font_file']]:
120
- font_path = os.path.join(script_dir, font_file)
121
- if os.path.exists(font_path) and font_path not in fonts_to_copy:
122
- fonts_to_copy.append(font_path)
123
-
124
- # Copy fonts
125
- for src in fonts_to_copy:
126
- shutil.copy(src, os.path.join(fonts_dir, os.path.basename(src)))
127
-
128
- if fonts_to_copy:
129
- with open(os.path.join(temp_dir, 'fonts.conf'), 'w') as f:
130
- f.write(f"""<?xml version="1.0"?>
131
- <fontconfig>
132
- <dir>{fonts_dir}</dir>
133
- <cachedir>{temp_dir}/cache</cachedir>
134
- </fontconfig>""")
135
-
136
- env = os.environ.copy()
137
- env['FONTCONFIG_FILE'] = os.path.join(temp_dir, 'fonts.conf')
138
- env['FONTCONFIG_PATH'] = temp_dir
139
- return env
140
-
141
- return os.environ.copy()
142
- except:
143
- return os.environ.copy()
144
-
145
- # File Handling
146
  def download_file_from_url(url, output_dir, filename):
147
- """Download file from URL."""
148
- response = requests.get(url, stream=True, timeout=30)
149
- response.raise_for_status()
150
-
151
- file_path = os.path.join(output_dir, filename)
152
- with open(file_path, 'wb') as f:
153
- for chunk in response.iter_content(chunk_size=8192):
154
- f.write(chunk)
155
- return file_path
 
 
 
 
156
 
157
  def download_book_cover(book_id, output_dir):
158
- """Download book cover from Google Books."""
159
- url = f"https://books.google.com/books/publisher/content/images/frontcover/{book_id}?fife=w720-h1280&source=gbs_api"
160
- response = requests.get(url, timeout=30)
161
- response.raise_for_status()
162
-
163
- path = os.path.join(output_dir, 'book_cover.png')
164
- with open(path, 'wb') as f:
165
- f.write(response.content)
166
-
167
- Image.open(path).verify()
168
- return path
 
 
 
 
 
 
169
 
170
- def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
171
- """Validate file input and return path."""
172
- has_upload = uploaded_file is not None
173
- has_url = url_string and url_string.strip()
174
-
175
- if not has_upload and not has_url:
176
- return None, f"❌ Provide {file_type} via upload or URL"
177
- if has_upload and has_url:
178
- return None, f"❌ Use only ONE method for {file_type}"
179
-
180
- if has_upload:
181
- return (uploaded_file.name if hasattr(uploaded_file, 'name') else uploaded_file), None
182
-
183
- try:
184
- url = url_string.strip()
185
- filename = url.split('/')[-1] or f"{file_type}_file"
186
-
187
- if '.' not in filename:
188
- ext_map = {'video': '.mp4', 'audio': '.wav', 'subtitle': '.srt'}
189
- filename += ext_map.get(file_type, '.tmp')
190
-
191
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
192
- return download_file_from_url(url, temp_dir, f"{file_type}_{timestamp}_{filename}"), None
193
- except Exception as e:
194
- return None, f"❌ Download error: {str(e)}"
195
-
196
- # Media Info (Cached)
197
- @lru_cache(maxsize=32)
198
  def get_video_info(video_path):
199
- """Get video resolution and frame rate (cached)."""
200
- result = subprocess.run([
201
- "ffprobe", "-v", "error", "-select_streams", "v:0",
202
- "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0", video_path
203
- ], capture_output=True, text=True, check=True)
204
- width, height = map(int, result.stdout.strip().split('x'))
205
-
206
- result = subprocess.run([
207
- "ffprobe", "-v", "error", "-select_streams", "v:0",
208
- "-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", video_path
209
- ], capture_output=True, text=True, check=True)
210
- fps_str = result.stdout.strip()
211
-
212
- fps = eval(fps_str) if '/' in fps_str else float(fps_str)
213
- return width, height, fps
214
-
215
- @lru_cache(maxsize=32)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  def get_audio_duration(audio_path):
217
- """Get audio duration (cached)."""
218
- result = subprocess.run([
219
- "ffprobe", "-v", "error", "-show_entries", "format=duration",
220
- "-of", "default=noprint_wrappers=1:nokey=1", audio_path
221
- ], capture_output=True, text=True, check=True)
222
- return float(result.stdout.strip())
223
-
224
- # Subtitle Processing
225
- def extract_first_subtitle(srt_path):
226
- """Extract first subtitle entry."""
227
- with open(srt_path, 'r', encoding='utf-8') as f:
228
- blocks = re.split(r'\n\s*\n', f.read().strip())
229
-
230
- if not blocks:
231
- return "No subtitle", 0.0, 3.0
232
-
233
- lines = blocks[0].strip().split('\n')
234
- if len(lines) >= 3:
235
- times = lines[1].split(' --> ')
236
- return ' '.join(lines[2:]).strip(), time_to_seconds(times[0].strip()), time_to_seconds(times[1].strip())
237
-
238
- return "No subtitle", 0.0, 3.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
  def srt_time_to_ms(time_str):
241
- """Convert SRT timestamp to milliseconds."""
242
- h, m, s = time_str.strip().split(':')
243
- s, ms = s.split(',')
244
- return int(h) * 3600000 + int(m) * 60000 + int(s) * 1000 + int(ms)
 
 
 
 
 
 
 
 
245
 
246
  def ms_to_ass_time(ms):
247
- """Convert milliseconds to ASS timestamp."""
248
- h = ms // 3600000
249
- ms %= 3600000
250
- m = ms // 60000
251
- ms %= 60000
252
- s = ms // 1000
253
- cs = (ms % 1000) // 10
254
- return f"{h}:{m:02d}:{s:02d}.{cs:02d}"
255
-
256
- def create_reddit_card_with_text(template_path, hook_text, output_dir, config=REDDIT_CONFIG):
257
- """Create Reddit card with text using PIL."""
258
- template = Image.open(template_path).convert('RGBA')
259
- tw, th = template.size
260
-
261
- text_box_w = int(tw * config['text_box_width_percent'])
262
- text_box_h = int(th * config['text_box_height_percent'])
263
-
264
- script_dir = os.path.dirname(os.path.abspath(__file__))
265
- font_paths = [
266
- os.path.join(script_dir, 'fonts', config['font_file']),
267
- os.path.join(script_dir, config['font_file'])
268
- ]
269
-
270
- # Find best font size
271
- best_size = config['font_size_max']
272
- best_wrapped = hook_text
273
-
274
- for size in range(config['font_size_max'], config['font_size_min'] - 1, -2):
275
- font = load_font(font_paths, size)
276
- wrapped = textwrap.fill(hook_text, width=config['text_wrap_width'])
277
-
278
- draw = ImageDraw.Draw(template)
279
- bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, spacing=config['line_spacing'])
280
-
281
- if bbox[2] <= text_box_w and bbox[3] <= text_box_h:
282
- best_size = size
283
- best_wrapped = wrapped
284
- break
285
-
286
- # Draw text
287
- font = load_font(font_paths, best_size)
288
- draw = ImageDraw.Draw(template)
289
- bbox = draw.multiline_textbbox((0, 0), best_wrapped, font=font, spacing=config['line_spacing'])
290
-
291
- x = (tw - bbox[2]) / 2
292
- y = (th - bbox[3]) / 2 + config['y_offset']
293
-
294
- draw.multiline_text((x, y), best_wrapped, fill=config['text_color'],
295
- font=font, spacing=config['line_spacing'], align='left')
296
-
297
- output_path = os.path.join(output_dir, 'reddit_card.png')
298
- template.save(output_path, 'PNG')
299
- return output_path
300
-
301
- def create_word_by_word_highlight_ass(srt_path, output_dir, highlight_color='yellow',
302
- font_size=None, skip_first=False, config=SUBTITLE_CONFIG):
303
- """Convert SRT to ASS with word highlighting."""
304
- font_size = font_size or config['font_size_default']
305
-
306
- color_map = {
307
- 'yellow': ('&H0000FFFF', '&H00000000'), 'orange': ('&H0000A5FF', '&H00000000'),
308
- 'green': ('&H0000FF00', '&H00000000'), 'cyan': ('&H00FFFF00', '&H00000000'),
309
- 'pink': ('&H00FF69B4', '&H00000000'), 'red': ('&H000000FF', '&H00FFFFFF'),
310
- 'blue': ('&H00FF0000', '&H00FFFFFF'),
311
- }
312
- highlight_bg, highlight_text = color_map.get(highlight_color.lower(), ('&H0000FFFF', '&H00000000'))
313
-
314
- with open(srt_path, 'r', encoding='utf-8') as f:
315
- srt_content = f.read()
316
-
317
- ass_header = f"""[Script Info]
318
- Title: Word Highlight
319
- ScriptType: v4.00+
320
- [V4+ Styles]
321
- Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
322
- Style: Default,{config['font_name']},{font_size},&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,{config['position_alignment']},{config['margin_left']},{config['margin_right']},{config['margin_vertical']},1
323
- [Events]
324
- Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
325
- """
326
-
327
- ass_events = []
328
- srt_blocks = re.split(r'\n\s*\n', srt_content.strip())
329
- start_idx = 1 if skip_first else 0
330
-
331
- for block in srt_blocks[start_idx:]:
332
- lines = block.strip().split('\n')
333
- if len(lines) < 3:
334
- continue
335
-
336
- times = lines[1].split(' --> ')
337
- if len(times) != 2:
338
- continue
339
-
340
- start_ms = srt_time_to_ms(times[0])
341
- end_ms = srt_time_to_ms(times[1])
342
- words = ' '.join(lines[2:]).split()
343
-
344
- if not words:
345
- continue
346
-
347
- time_per_word = (end_ms - start_ms) / len(words)
348
-
349
- for i, word in enumerate(words):
350
- word_start = start_ms + int(i * time_per_word)
351
- word_end = end_ms if i == len(words) - 1 else start_ms + int((i + 1) * time_per_word)
352
-
353
- styled_words = [
354
- f"{{\\c{highlight_text}\\3c{highlight_bg}\\bord5}}{w}{{\\r}}" if j == i else w
355
- for j, w in enumerate(words)
356
- ]
357
-
358
- ass_events.append(
359
- f"Dialogue: 0,{ms_to_ass_time(word_start)},{ms_to_ass_time(word_end)},Default,,0,0,0,,{' '.join(styled_words)}"
360
- )
361
-
362
- ass_path = os.path.join(output_dir, 'word_highlight.ass')
363
- with open(ass_path, 'w') as f:
364
- f.write(ass_header + '\n'.join(ass_events))
365
-
366
- return ass_path
367
-
368
- # Main Processing
369
- def stitch_media(video_file, video_url, audio_file, audio_url, subtitle_file, subtitle_url,
370
- book_id, enable_highlight, highlight_color, font_size, crf_quality=23):
371
- """Main stitching function - OPTIMIZED with timing."""
372
- # START TIMER
373
- start_time = time.time()
374
- temp_dir = tempfile.mkdtemp()
375
-
376
- try:
377
- ffmpeg_env = setup_custom_fonts_hf(temp_dir)
378
-
379
- # Validate inputs
380
- video_path, err = validate_and_get_file(video_file, video_url, 'video', temp_dir)
381
- if err: return None, err
382
-
383
- audio_path, err = validate_and_get_file(audio_file, audio_url, 'audio', temp_dir)
384
- if err: return None, err
385
-
386
- subtitle_path, err = validate_and_get_file(subtitle_file, subtitle_url, 'subtitle', temp_dir)
387
- if err: return None, err
388
-
389
- setup_time = time.time() - start_time
390
-
391
- # Get media info
392
- video_width, video_height, video_fps = get_video_info(video_path)
393
- audio_duration = get_audio_duration(audio_path)
394
-
395
- status = f"⏱️ Setup: {setup_time:.1f}s\n"
396
- status += f"πŸ“₯ {video_width}x{video_height}@{video_fps:.0f}fps | {audio_duration:.1f}s\n\n"
397
-
398
- # Reddit overlay
399
- script_dir = os.path.dirname(os.path.abspath(__file__))
400
- reddit_template_path = os.path.join(script_dir, REDDIT_CONFIG['template_file'])
401
- has_reddit = os.path.exists(reddit_template_path)
402
-
403
- if has_reddit:
404
- reddit_start = time.time()
405
- first_text, first_start, first_end = extract_first_subtitle(subtitle_path)
406
- reddit_card_path = create_reddit_card_with_text(reddit_template_path, first_text, temp_dir)
407
- reddit_time = time.time() - reddit_start
408
- status += f"πŸ“± Reddit card: βœ… ({reddit_time:.1f}s)\n"
409
-
410
- # Generate subtitles
411
- sub_start = time.time()
412
- subtitle_ass = create_word_by_word_highlight_ass(
413
- subtitle_path, temp_dir, highlight_color, font_size,
414
- skip_first=has_reddit, config=SUBTITLE_CONFIG
415
- ) if enable_highlight else subtitle_path
416
- sub_time = time.time() - sub_start
417
- status += f"πŸ“ Subtitles: βœ… ({sub_time:.1f}s)\n\n"
418
-
419
- subtitle_escaped = subtitle_ass.replace('\\', '/').replace(':', '\\:')
420
-
421
- # Output setup
422
- timestamp = datetime.now().strftime("%H%M%S")
423
- output_path = os.path.join(temp_dir, f"final_{timestamp}.mp4")
424
- has_book = book_id and book_id.strip()
425
-
426
- # Calculate timings
427
- fade_start = audio_duration * VIDEO_CONFIG['fade_start_percent']
428
- fade_end = audio_duration * VIDEO_CONFIG['fade_end_percent']
429
- fade_duration = fade_end - fade_start
430
- promo_duration = audio_duration * VIDEO_CONFIG['promo_percent']
431
- book_start = audio_duration - promo_duration
432
- solid_duration = book_start - fade_end
433
-
434
- # Common encoding flags (OPTIMIZED!)
435
- common_encode_flags = [
436
- "-c:v", "libx264",
437
- "-preset", VIDEO_CONFIG['encoding_preset'],
438
- "-crf", str(crf_quality),
439
- "-pix_fmt", "yuv420p",
440
- "-threads", str(VIDEO_CONFIG['threads'])
441
- ]
442
-
443
- if has_book:
444
- status += "🎬 Encoding with book cover:\n\n"
445
- book_cover_path = download_book_cover(book_id.strip(), temp_dir)
446
-
447
- segments = []
448
-
449
- # STEP 1: Main video
450
- main_path = os.path.join(temp_dir, f"main_{timestamp}.mp4")
451
- success, error, timing = run_ffmpeg_cmd([
452
- "ffmpeg", "-hwaccel", "auto",
453
- "-stream_loop", "-1", "-i", video_path, "-t", str(fade_end),
454
- "-vf", f"fps={video_fps},scale={video_width}:{video_height},fade=t=out:st={fade_start}:d={fade_duration}:c={VIDEO_CONFIG['fade_color_hex']}",
455
- *common_encode_flags, "-an", "-y", main_path
456
- ], ffmpeg_env, "Step 1/4: Main video", start_time)
457
- if not success: return None, error
458
- status += f"{timing}\n"
459
- segments.append(main_path)
460
-
461
- # STEP 2: Solid color
462
- if solid_duration > 0:
463
- solid_path = os.path.join(temp_dir, f"solid_{timestamp}.mp4")
464
- success, error, timing = run_ffmpeg_cmd([
465
- "ffmpeg", "-f", "lavfi",
466
- "-i", f"color=c={VIDEO_CONFIG['fade_color_hex']}:s={video_width}x{video_height}:d={solid_duration}:r={video_fps}",
467
- *common_encode_flags, "-y", solid_path
468
- ], ffmpeg_env, "Step 2/4: Solid color", start_time)
469
- if not success: return None, error
470
- status += f"{timing}\n"
471
- segments.append(solid_path)
472
-
473
- # STEP 3: Book cover
474
- cover_path = os.path.join(temp_dir, f"cover_{timestamp}.mp4")
475
- success, error, timing = run_ffmpeg_cmd([
476
- "ffmpeg", "-hwaccel", "auto",
477
- "-loop", "1", "-i", book_cover_path, "-t", str(promo_duration),
478
- "-vf", f"scale={video_width}:{video_height}:force_original_aspect_ratio=decrease,pad={video_width}:{video_height}:(ow-iw)/2:(oh-ih)/2:color={VIDEO_CONFIG['fade_color_hex']},setsar=1,fps={video_fps},fade=t=in:st=0:d={VIDEO_CONFIG['book_fade_in_duration']}:c={VIDEO_CONFIG['fade_color_hex']}",
479
- *common_encode_flags, "-an", "-y", cover_path
480
- ], ffmpeg_env, "Step 3/4: Book cover", start_time)
481
- if not success: return None, error
482
- status += f"{timing}\n"
483
- segments.append(cover_path)
484
-
485
- # STEP 4: Final assembly
486
- concat_list = os.path.join(temp_dir, f"concat_{timestamp}.txt")
487
- with open(concat_list, 'w') as f:
488
- f.write('\n'.join(f"file '{s}'" for s in segments))
489
-
490
- if has_reddit:
491
- filter_complex = (
492
- f"[0:v]ass={subtitle_escaped}[bg];"
493
- f"[1:v]scale={video_width}*{VIDEO_CONFIG['reddit_scale_percent']}:-1[reddit];"
494
- f"[bg][reddit]overlay=(W-w)/2:(H-h)/2:enable='between(t,{first_start},{first_end})'[v]"
495
- )
496
- cmd = [
497
- "ffmpeg", "-hwaccel", "auto",
498
- "-f", "concat", "-safe", "0", "-i", concat_list,
499
- "-loop", "1", "-i", reddit_card_path, "-i", audio_path,
500
- "-filter_complex", filter_complex, "-map", "[v]", "-map", "2:a",
501
- *common_encode_flags, "-c:a", "aac", "-shortest", "-y", output_path
502
- ]
503
- else:
504
- cmd = [
505
- "ffmpeg", "-hwaccel", "auto",
506
- "-f", "concat", "-safe", "0", "-i", concat_list, "-i", audio_path,
507
- "-vf", f"ass={subtitle_escaped}", "-map", "0:v", "-map", "1:a",
508
- *common_encode_flags, "-c:a", "aac", "-shortest", "-y", output_path
509
- ]
510
-
511
- success, error, timing = run_ffmpeg_cmd(cmd, ffmpeg_env, "Step 4/4: Final", start_time)
512
- if not success: return None, error
513
- status += f"{timing}\n"
514
-
515
- else:
516
- # Simple loop (no book)
517
- status += "🎬 Encoding:\n\n"
518
-
519
- if has_reddit:
520
- filter_complex = (
521
- f"[0:v]ass={subtitle_escaped}[bg];"
522
- f"[1:v]scale={video_width}*{VIDEO_CONFIG['reddit_scale_percent']}:-1[reddit];"
523
- f"[bg][reddit]overlay=(W-w)/2:(H-h)/2:enable='between(t,{first_start},{first_end})'[v]"
524
- )
525
- cmd = [
526
- "ffmpeg", "-hwaccel", "auto",
527
- "-stream_loop", "-1", "-i", video_path,
528
- "-loop", "1", "-i", reddit_card_path, "-i", audio_path,
529
- "-filter_complex", filter_complex, "-map", "[v]", "-map", "2:a",
530
- *common_encode_flags, "-c:a", "aac", "-shortest", "-y", output_path
531
- ]
532
- else:
533
- cmd = [
534
- "ffmpeg", "-hwaccel", "auto",
535
- "-stream_loop", "-1", "-i", video_path, "-i", audio_path,
536
- "-vf", f"ass={subtitle_escaped}", "-map", "0:v", "-map", "1:a",
537
- *common_encode_flags, "-c:a", "aac", "-shortest", "-y", output_path
538
- ]
539
-
540
- success, error, timing = run_ffmpeg_cmd(cmd, ffmpeg_env, "Video encoding", start_time)
541
- if not success: return None, error
542
- status += f"{timing}\n"
543
-
544
- # Success - Calculate total time
545
- total_time = time.time() - start_time
546
-
547
- if os.path.exists(output_path):
548
- size_mb = os.path.getsize(output_path) / (1024 * 1024)
549
- success_msg = f"""βœ… VIDEO COMPLETE!
550
-
551
- πŸ“Š File: {size_mb:.1f}MB | Duration: {audio_duration:.1f}s
552
- ⏱️ TOTAL TIME: {format_elapsed_time(total_time)} ({total_time:.1f}s)
553
- ⚑ Preset: {VIDEO_CONFIG['encoding_preset']} | Threads: {VIDEO_CONFIG['threads']}
554
-
555
- ──────────────────────────
556
- {status}"""
557
- return output_path, success_msg
558
-
559
- return None, "❌ Output not created"
560
-
561
- except Exception as e:
562
- total_time = time.time() - start_time
563
- return None, f"❌ Error after {format_elapsed_time(total_time)}: {str(e)}"
564
-
565
- # Gradio UI
566
- with gr.Blocks(title="Video Stitcher", theme=gr.themes.Soft()) as app:
567
- gr.Markdown(f"""
568
- # 🎬 Video Stitcher ⚑ OPTIMIZED
569
-
570
- **Performance:** Hardware accel + {VIDEO_CONFIG['encoding_preset']} preset + multi-threading
571
- **Config:** Reddit={REDDIT_CONFIG['font_file']} | Subtitle={SUBTITLE_CONFIG['font_name']}
572
-
573
- **Expected:** 3-4 minutes (was 6 minutes) - 30-50% faster! πŸš€
574
- """)
575
-
576
- with gr.Row():
577
- with gr.Column():
578
- with gr.Group():
579
- gr.Markdown("**πŸ“Ή Video**")
580
- video_input = gr.File(label="Upload", file_types=[".mp4", ".mov", ".avi", ".mkv"], type="filepath")
581
- video_url_input = gr.Textbox(label="OR URL", placeholder="https://...")
582
-
583
- with gr.Group():
584
- gr.Markdown("**🎡 Audio**")
585
- audio_input = gr.File(label="Upload", file_types=[".wav", ".mp3", ".aac"], type="filepath")
586
- audio_url_input = gr.Textbox(label="OR URL", placeholder="https://...")
587
-
588
- with gr.Group():
589
- gr.Markdown("**πŸ“ Subtitle**")
590
- subtitle_input = gr.File(label="Upload (.srt)", file_types=[".srt"], type="filepath")
591
- subtitle_url_input = gr.Textbox(label="OR URL", placeholder="https://...")
592
-
593
- book_id_input = gr.Textbox(label="πŸ“š Book ID (Optional)", placeholder="wyaEDwAAQBAJ")
594
-
595
- with gr.Row():
596
- enable_highlight = gr.Checkbox(label="Highlight", value=True)
597
- highlight_color = gr.Dropdown(choices=['yellow', 'orange', 'green', 'cyan', 'pink', 'red', 'blue'],
598
- value='yellow', label="Color")
599
- with gr.Row():
600
- font_size = gr.Slider(12, 32, 18, step=2, label="Font Size")
601
- crf_input = gr.Slider(18, 28, 23, step=1, label="Quality")
602
-
603
- stitch_btn = gr.Button("🎬 Stitch Video", variant="primary", size="lg")
604
-
605
- with gr.Column():
606
- status_output = gr.Textbox(label="Status", lines=12)
607
- video_output = gr.Video(label="Result")
608
-
609
- gr.Markdown("""
610
- ### ⚑ Optimizations Applied:
611
- - βœ… Hardware acceleration (`-hwaccel auto`)
612
- - βœ… Faster encoding preset
613
- - βœ… Multi-threading (auto CPU cores)
614
- - βœ… Cached media info
615
- - βœ… **Real-time execution tracking**
616
-
617
- **Timeline shown for each step + total time!**
618
- """)
619
-
620
- stitch_btn.click(
621
- fn=stitch_media,
622
- inputs=[video_input, video_url_input, audio_input, audio_url_input,
623
- subtitle_input, subtitle_url_input, book_id_input,
624
- enable_highlight, highlight_color, font_size, crf_input],
625
- outputs=[video_output, status_output]
626
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
 
628
  if __name__ == "__main__":
629
- app.launch(show_error=True)
 
5
  import tempfile
6
  import requests
7
  import re
 
 
 
8
  from datetime import datetime
9
+ from PIL import Image
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ # Add static ffmpeg to PATH
12
  static_ffmpeg.add_paths()
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  def download_file_from_url(url, output_dir, filename):
15
+ """Download a file from URL and save it to output directory."""
16
+ try:
17
+ response = requests.get(url, stream=True, timeout=30)
18
+ response.raise_for_status()
19
+
20
+ file_path = os.path.join(output_dir, filename)
21
+ with open(file_path, 'wb') as f:
22
+ for chunk in response.iter_content(chunk_size=8192):
23
+ f.write(chunk)
24
+
25
+ return file_path
26
+ except Exception as e:
27
+ raise Exception(f"Failed to download file from URL: {str(e)}")
28
 
29
  def download_book_cover(book_id, output_dir):
30
+ """Download book cover from Google Books API using Book ID."""
31
+ try:
32
+ image_url = f"https://books.google.com/books/publisher/content/images/frontcover/{book_id}?fife=w400-h600&source=gbs_api"
33
+
34
+ response = requests.get(image_url, timeout=30)
35
+ response.raise_for_status()
36
+
37
+ image_path = os.path.join(output_dir, 'book_cover.png')
38
+ with open(image_path, 'wb') as f:
39
+ f.write(response.content)
40
+
41
+ img = Image.open(image_path)
42
+ img.verify()
43
+
44
+ return image_path
45
+ except Exception as e:
46
+ raise Exception(f"Failed to download book cover: {str(e)}")
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def get_video_info(video_path):
49
+ """Get video resolution and frame rate using ffprobe."""
50
+ try:
51
+ # Get resolution
52
+ cmd_res = [
53
+ "ffprobe",
54
+ "-v", "error",
55
+ "-select_streams", "v:0",
56
+ "-show_entries", "stream=width,height",
57
+ "-of", "csv=s=x:p=0",
58
+ video_path
59
+ ]
60
+ result = subprocess.run(cmd_res, capture_output=True, text=True, check=True)
61
+ width, height = result.stdout.strip().split('x')
62
+
63
+ # Get frame rate
64
+ cmd_fps = [
65
+ "ffprobe",
66
+ "-v", "error",
67
+ "-select_streams", "v:0",
68
+ "-show_entries", "stream=r_frame_rate",
69
+ "-of", "default=noprint_wrappers=1:nokey=1",
70
+ video_path
71
+ ]
72
+ result = subprocess.run(cmd_fps, capture_output=True, text=True, check=True)
73
+ fps_str = result.stdout.strip()
74
+
75
+ # Parse frame rate (can be "60/1" or "30000/1001")
76
+ if '/' in fps_str:
77
+ num, den = fps_str.split('/')
78
+ fps = float(num) / float(den)
79
+ else:
80
+ fps = float(fps_str)
81
+
82
+ return int(width), int(height), fps
83
+ except Exception as e:
84
+ raise Exception(f"Failed to get video info: {str(e)}")
85
+
86
  def get_audio_duration(audio_path):
87
+ """Get audio duration in seconds using ffprobe."""
88
+ try:
89
+ cmd = [
90
+ "ffprobe",
91
+ "-v", "error",
92
+ "-show_entries", "format=duration",
93
+ "-of", "default=noprint_wrappers=1:nokey=1",
94
+ audio_path
95
+ ]
96
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
97
+ duration = float(result.stdout.strip())
98
+ return duration
99
+ except Exception as e:
100
+ raise Exception(f"Failed to get audio duration: {str(e)}")
101
+
102
+ def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
103
+ """Validate that only one input method is used and return the file path."""
104
+ has_upload = uploaded_file is not None
105
+ has_url = url_string and url_string.strip()
106
+
107
+ if not has_upload and not has_url:
108
+ return None, f"❌ Please provide {file_type} either by upload or URL"
109
+
110
+ if has_upload and has_url:
111
+ return None, f"❌ Please use only ONE method for {file_type}: either upload OR URL (not both)"
112
+
113
+ if has_upload:
114
+ file_path = uploaded_file.name if hasattr(uploaded_file, 'name') else uploaded_file
115
+ return file_path, None
116
+
117
+ if has_url:
118
+ try:
119
+ url_parts = url_string.strip().split('/')
120
+ original_filename = url_parts[-1] if url_parts else f"{file_type}_file"
121
+
122
+ if '.' not in original_filename:
123
+ ext_map = {'video': '.mp4', 'audio': '.wav', 'subtitle': '.srt'}
124
+ original_filename += ext_map.get(file_type, '.tmp')
125
+
126
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
127
+ filename = f"{file_type}_{timestamp}_{original_filename}"
128
+
129
+ file_path = download_file_from_url(url_string.strip(), temp_dir, filename)
130
+ return file_path, None
131
+
132
+ except Exception as e:
133
+ return None, f"❌ Error downloading {file_type} from URL: {str(e)}"
134
+
135
+ return None, f"❌ Unknown error processing {file_type}"
136
 
137
  def srt_time_to_ms(time_str):
138
+ """Convert SRT timestamp to milliseconds."""
139
+ time_str = time_str.strip()
140
+ hours, minutes, seconds = time_str.split(':')
141
+ seconds, milliseconds = seconds.split(',')
142
+
143
+ total_ms = (
144
+ int(hours) * 3600000 +
145
+ int(minutes) * 60000 +
146
+ int(seconds) * 1000 +
147
+ int(milliseconds)
148
+ )
149
+ return total_ms
150
 
151
  def ms_to_ass_time(ms):
152
+ """Convert milliseconds to ASS timestamp format."""
153
+ hours = ms // 3600000
154
+ ms %= 3600000
155
+ minutes = ms // 60000
156
+ ms %= 60000
157
+ seconds = ms // 1000
158
+ centiseconds = (ms % 1000) // 10
159
+
160
+ return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
161
+
162
+ def create_word_by_word_highlight_ass(srt_path, output_dir, highlight_color='yellow', font_size=18):
163
+ """Convert SRT to ASS with word-by-word highlighting."""
164
+ color_map = {
165
+ 'yellow': ('&H0000FFFF', '&H00000000'),
166
+ 'orange': ('&H0000A5FF', '&H00000000'),
167
+ 'green': ('&H0000FF00', '&H00000000'),
168
+ 'cyan': ('&H00FFFF00', '&H00000000'),
169
+ 'pink': ('&H00FF69B4', '&H00000000'),
170
+ 'red': ('&H000000FF', '&H00FFFFFF'),
171
+ 'blue': ('&H00FF0000', '&H00FFFFFF'),
172
+ }
173
+
174
+ highlight_bg, highlight_text = color_map.get(highlight_color.lower(), ('&H0000FFFF', '&H00000000'))
175
+
176
+ with open(srt_path, 'r', encoding='utf-8') as f:
177
+ srt_content = f.read()
178
+
179
+ ass_path = os.path.join(output_dir, 'word_highlight_subtitles.ass')
180
+
181
+ ass_header = f"""[Script Info]
182
+ Title: Word-by-Word Highlight Subtitles
183
+ ScriptType: v4.00+
184
+ Collisions: Normal
185
+ PlayDepth: 0
186
+
187
+ [V4+ Styles]
188
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
189
+ Style: Default,Arial Black,{font_size},&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,5,10,10,0,1
190
+
191
+ [Events]
192
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
193
+ """
194
+
195
+ srt_blocks = re.split(r'\n\s*\n', srt_content.strip())
196
+ ass_events = []
197
+
198
+ for block in srt_blocks:
199
+ lines = block.strip().split('\n')
200
+ if len(lines) >= 3:
201
+ timestamp_line = lines[1]
202
+ times = timestamp_line.split(' --> ')
203
+ if len(times) == 2:
204
+ start_ms = srt_time_to_ms(times[0])
205
+ end_ms = srt_time_to_ms(times[1])
206
+
207
+ text = ' '.join(lines[2:])
208
+ words = text.split()
209
+
210
+ if not words:
211
+ continue
212
+
213
+ total_duration = end_ms - start_ms
214
+ time_per_word = total_duration / len(words)
215
+
216
+ for i, word in enumerate(words):
217
+ word_start_ms = start_ms + int(i * time_per_word)
218
+ word_end_ms = start_ms + int((i + 1) * time_per_word)
219
+
220
+ if i == len(words) - 1:
221
+ word_end_ms = end_ms
222
+
223
+ text_parts = []
224
+ for j, w in enumerate(words):
225
+ if j == i:
226
+ text_parts.append(f"{{\\c{highlight_text}\\3c{highlight_bg}\\bord5}}{w}{{\\r}}")
227
+ else:
228
+ text_parts.append(w)
229
+
230
+ styled_text = ' '.join(text_parts)
231
+ start_time = ms_to_ass_time(word_start_ms)
232
+ end_time = ms_to_ass_time(word_end_ms)
233
+
234
+ ass_line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{styled_text}"
235
+ ass_events.append(ass_line)
236
+
237
+ with open(ass_path, 'w', encoding='utf-8') as f:
238
+ f.write(ass_header)
239
+ f.write('\n'.join(ass_events))
240
+
241
+ return ass_path
242
+
243
+ def stitch_media(
244
+ video_file, video_url,
245
+ audio_file, audio_url,
246
+ subtitle_file, subtitle_url,
247
+ book_id,
248
+ enable_highlight,
249
+ highlight_color,
250
+ font_size,
251
+ crf_quality=23
252
+ ):
253
+ """
254
+ Stitch video, audio, and subtitle files together using ffmpeg.
255
+ OPTIMIZED: 4-segment concat with solid color hold.
256
+ """
257
+ temp_dir = tempfile.mkdtemp()
258
+
259
+ try:
260
+ # Validate and get files
261
+ video_path, video_error = validate_and_get_file(
262
+ video_file, video_url, 'video', temp_dir
263
+ )
264
+ if video_error:
265
+ return None, video_error
266
+
267
+ audio_path, audio_error = validate_and_get_file(
268
+ audio_file, audio_url, 'audio', temp_dir
269
+ )
270
+ if audio_error:
271
+ return None, audio_error
272
+
273
+ subtitle_path, subtitle_error = validate_and_get_file(
274
+ subtitle_file, subtitle_url, 'subtitle', temp_dir
275
+ )
276
+ if subtitle_error:
277
+ return None, subtitle_error
278
+
279
+ # Get video info and audio duration
280
+ video_width, video_height, video_fps = get_video_info(video_path)
281
+ audio_duration = get_audio_duration(audio_path)
282
+
283
+ # Build status message
284
+ status_msg = "πŸ“₯ Processing files:\n"
285
+ status_msg += f" β€’ Video: {'URL' if video_url else 'Upload'} ({video_width}x{video_height} @ {video_fps:.2f}fps)\n"
286
+ status_msg += f" β€’ Audio: {'URL' if audio_url else 'Upload'} ({audio_duration:.2f}s)\n"
287
+ status_msg += f" β€’ Subtitle: {'URL' if subtitle_url else 'Upload'}\n"
288
+
289
+ # Process subtitles
290
+ if enable_highlight:
291
+ status_msg += f"\n✨ Word highlighting: {highlight_color} ({font_size}px)\n"
292
+ subtitle_to_use = create_word_by_word_highlight_ass(subtitle_path, temp_dir, highlight_color, font_size)
293
+ else:
294
+ subtitle_to_use = subtitle_path
295
+
296
+ # Escape subtitle path for filter
297
+ subtitle_escaped = subtitle_to_use.replace('\\', '/').replace(':', '\\:')
298
+
299
+ # Check if book cover is provided
300
+ has_book_cover = book_id and book_id.strip()
301
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
302
+ output_path = os.path.join(temp_dir, f"final_{timestamp}.mp4")
303
+
304
+ if has_book_cover:
305
+ status_msg += f"\nπŸ“š Downloading book cover (ID: {book_id})...\n"
306
+ try:
307
+ book_cover_path = download_book_cover(book_id.strip(), temp_dir)
308
+ status_msg += "βœ… Book cover downloaded\n"
309
+
310
+ # Calculate timing
311
+ fade_start_percent = 0.6 # Fade starts at 60%
312
+ fade_end_percent = 0.75 # Fully faded at 75%
313
+ promo_percent = 0.1 # Last 10% for book cover
314
+
315
+ fade_starts_at = audio_duration * fade_start_percent # 60% point
316
+ fade_ends_at = audio_duration * fade_end_percent # 75% point
317
+ fade_out_duration = fade_ends_at - fade_starts_at # 15% duration
318
+
319
+ promo_duration = audio_duration * promo_percent # 10%
320
+ book_appears_at = audio_duration - promo_duration # 90% point
321
+ solid_color_duration = book_appears_at - fade_ends_at # 75% to 90%
322
+
323
+ fade_in_duration = 2 # Cover fades in over 2 seconds
324
+
325
+ # Convert RGB to format FFmpeg fade filter understands
326
+ # RGB (218, 207, 195) = #DACFC3
327
+ fade_color_hex = "0xDACFC3" # FFmpeg uses BGR order! (reverse of RGB)
328
+
329
+ # Segment durations
330
+ main_video_duration = fade_ends_at # Video until 75%
331
+ cover_segment_duration = promo_duration # Last 10%
332
+
333
+ # Debug: Print timing values
334
+ status_msg += f"\n⏱️ Timing: Fade {fade_starts_at:.1f}sβ†’{fade_ends_at:.1f}s, Hold {solid_color_duration:.1f}s\n"
335
+
336
+ # STEP 1: Create main video segment with fade-out to color
337
+ status_msg += "🎬 Step 1/4: Creating main video with fade-out...\n"
338
+ main_segment_path = os.path.join(temp_dir, f"main_{timestamp}.mp4")
339
+
340
+ cmd_main = [
341
+ "ffmpeg",
342
+ "-stream_loop", "-1",
343
+ "-i", video_path,
344
+ "-t", str(main_video_duration),
345
+ "-vf", f"fps={video_fps},scale={video_width}:{video_height},fade=t=out:st={fade_starts_at}:d={fade_out_duration}:c={fade_color_hex}",
346
+ "-c:v", "libx264",
347
+ "-crf", str(crf_quality),
348
+ "-pix_fmt", "yuv420p",
349
+ "-an",
350
+ "-y",
351
+ main_segment_path
352
+ ]
353
+ subprocess.run(cmd_main, check=True, capture_output=True, text=True)
354
+
355
+ # STEP 2: Create solid color segment
356
+ status_msg += "βœ… Main segment ready\n"
357
+ status_msg += "🎬 Step 2/4: Creating solid color segment...\n"
358
+ solid_color_path = os.path.join(temp_dir, f"solid_{timestamp}.mp4")
359
+
360
+ # Use RGB format for color source
361
+ cmd_solid = [
362
+ "ffmpeg",
363
+ "-f", "lavfi",
364
+ "-i", f"color=c=0xDACFC3:s={video_width}x{video_height}:d={solid_color_duration}:r={video_fps}",
365
+ "-c:v", "libx264",
366
+ "-crf", str(crf_quality),
367
+ "-pix_fmt", "yuv420p",
368
+ "-y",
369
+ solid_color_path
370
+ ]
371
+ subprocess.run(cmd_solid, check=True, capture_output=True, text=True)
372
+
373
+ # STEP 3: Create cover video segment with fade-in from color
374
+ status_msg += "βœ… Solid color segment ready\n"
375
+ status_msg += "🎬 Step 3/4: Creating cover segment with fade-in...\n"
376
+ cover_segment_path = os.path.join(temp_dir, f"cover_seg_{timestamp}.mp4")
377
+
378
+ cmd_cover = [
379
+ "ffmpeg",
380
+ "-loop", "1",
381
+ "-i", book_cover_path,
382
+ "-t", str(cover_segment_duration),
383
+ "-vf", f"scale={video_width}:{video_height}:force_original_aspect_ratio=decrease,pad={video_width}:{video_height}:(ow-iw)/2:(oh-ih)/2:color={fade_color_hex},setsar=1,fps={video_fps},fade=t=in:st=0:d={fade_in_duration}:c={fade_color_hex}",
384
+ "-c:v", "libx264",
385
+ "-crf", str(crf_quality),
386
+ "-pix_fmt", "yuv420p",
387
+ "-an",
388
+ "-y",
389
+ cover_segment_path
390
+ ]
391
+ subprocess.run(cmd_cover, check=True, capture_output=True, text=True)
392
+
393
+ # STEP 4: Concat all segments + add audio & subtitles
394
+ status_msg += "βœ… Cover segment ready\n"
395
+ status_msg += "🎬 Step 4/4: Concatenating segments & adding audio/subtitles...\n"
396
+
397
+ # Create concat file list
398
+ concat_list_path = os.path.join(temp_dir, f"concat_{timestamp}.txt")
399
+ with open(concat_list_path, 'w') as f:
400
+ f.write(f"file '{main_segment_path}'\n")
401
+ f.write(f"file '{solid_color_path}'\n")
402
+ f.write(f"file '{cover_segment_path}'\n")
403
+
404
+ # Concat segments and add audio + subtitles
405
+ cmd_final = [
406
+ "ffmpeg",
407
+ "-f", "concat",
408
+ "-safe", "0",
409
+ "-i", concat_list_path,
410
+ "-i", audio_path,
411
+ "-vf", f"ass={subtitle_escaped}",
412
+ "-map", "0:v",
413
+ "-map", "1:a",
414
+ "-c:v", "libx264",
415
+ "-crf", str(crf_quality),
416
+ "-c:a", "aac",
417
+ "-pix_fmt", "yuv420p",
418
+ "-shortest",
419
+ "-y",
420
+ output_path
421
+ ]
422
+
423
+ subprocess.run(cmd_final, check=True, capture_output=True, text=True)
424
+
425
+ except subprocess.CalledProcessError as e:
426
+ error_detail = e.stderr if e.stderr else str(e)
427
+ return None, f"❌ FFmpeg error:\n{error_detail[-1000:]}"
428
+ except Exception as e:
429
+ return None, f"❌ Error processing book cover: {str(e)}"
430
+
431
+ else:
432
+ # No book cover - simple looping with subtitles
433
+ status_msg += "\n🎬 Creating video with looping & subtitles...\n"
434
+
435
+ cmd = [
436
+ "ffmpeg",
437
+ "-stream_loop", "-1",
438
+ "-i", video_path,
439
+ "-i", audio_path,
440
+ "-vf", f"ass={subtitle_escaped}",
441
+ "-map", "0:v",
442
+ "-map", "1:a",
443
+ "-c:v", "libx264",
444
+ "-crf", str(crf_quality),
445
+ "-c:a", "aac",
446
+ "-shortest",
447
+ "-y",
448
+ output_path
449
+ ]
450
+
451
+ subprocess.run(cmd, check=True, capture_output=True, text=True)
452
+
453
+ # Check output
454
+ if os.path.exists(output_path):
455
+ file_size = os.path.getsize(output_path) / (1024 * 1024)
456
+ success_msg = f"βœ… Video created successfully!\n\n"
457
+ success_msg += f"πŸ“Š Size: {file_size:.2f} MB | Duration: {audio_duration:.2f}s\n"
458
+ success_msg += f"🎨 Quality: CRF {crf_quality} | FPS: {video_fps:.2f}\n"
459
+ if has_book_cover:
460
+ success_msg += f"πŸ“š Timeline: Fade {fade_starts_at:.1f}β†’{fade_ends_at:.1f}s, Hold {solid_color_duration:.1f}s, Book {book_appears_at:.1f}β†’{audio_duration:.1f}s\n"
461
+ success_msg += f"⚑ Processing: 4-segment concat method (reliable!)\n"
462
+ else:
463
+ success_msg += f"⚑ Processing: Single-pass optimized\n"
464
+ success_msg += "\n" + status_msg
465
+ return output_path, success_msg
466
+ else:
467
+ return None, "❌ Output file was not created"
468
+
469
+ except Exception as e:
470
+ return None, f"❌ Error: {str(e)}"
471
+
472
+ # Gradio interface
473
+ with gr.Blocks(title="Video Stitcher - Word Highlighting", theme=gr.themes.Soft()) as app:
474
+ gr.Markdown(
475
+ """
476
+ # 🎬 Video Audio Subtitle Stitcher with Book Cover Promo ✨
477
+
478
+ **Features:**
479
+ - ⚑ **4-segment concat method** (reliable with solid color hold!)
480
+ - Word-by-word subtitle highlighting (Arial Black font)
481
+ - Looping background video (matches original FPS)
482
+ - Book cover with fade to beige/cream color (#DACFC3)
483
+
484
+ πŸ“€ Upload files or provide URLs | πŸ“š Add book cover from Google Books API
485
+ """
486
+ )
487
+
488
+ with gr.Row():
489
+ with gr.Column():
490
+ gr.Markdown("### πŸ“Ή Video Input")
491
+ with gr.Group():
492
+ video_input = gr.File(
493
+ label="Upload Video File",
494
+ file_types=[".mp4", ".mov", ".avi", ".mkv"],
495
+ type="filepath"
496
+ )
497
+ gr.Markdown("**OR**")
498
+ video_url_input = gr.Textbox(
499
+ label="Video URL",
500
+ placeholder="https://example.com/video.mp4",
501
+ lines=1
502
+ )
503
+
504
+ gr.Markdown("### 🎡 Audio Input")
505
+ with gr.Group():
506
+ audio_input = gr.File(
507
+ label="Upload Audio File",
508
+ file_types=[".wav", ".mp3", ".aac", ".m4a"],
509
+ type="filepath"
510
+ )
511
+ gr.Markdown("**OR**")
512
+ audio_url_input = gr.Textbox(
513
+ label="Audio URL",
514
+ placeholder="https://example.com/audio.wav",
515
+ lines=1
516
+ )
517
+
518
+ gr.Markdown("### πŸ“ Subtitle Input")
519
+ with gr.Group():
520
+ subtitle_input = gr.File(
521
+ label="Upload Subtitle File (.srt)",
522
+ file_types=[".srt"],
523
+ type="filepath"
524
+ )
525
+ gr.Markdown("**OR**")
526
+ subtitle_url_input = gr.Textbox(
527
+ label="Subtitle URL",
528
+ placeholder="https://example.com/subtitles.srt",
529
+ lines=1
530
+ )
531
+
532
+ gr.Markdown("### πŸ“š Book Cover (Optional)")
533
+ book_id_input = gr.Textbox(
534
+ label="Google Books ID",
535
+ placeholder="wyaEDwAAQBAJ",
536
+ lines=1,
537
+ info="Fade: 60β†’75%, Hold: 75β†’90%, Book: 90β†’100%"
538
+ )
539
+
540
+ gr.Markdown("### ✨ Word Highlighting")
541
+ with gr.Group():
542
+ enable_highlight = gr.Checkbox(
543
+ label="Enable Word-by-Word Highlighting",
544
+ value=True
545
+ )
546
+ highlight_color = gr.Dropdown(
547
+ choices=['yellow', 'orange', 'green', 'cyan', 'pink', 'red', 'blue'],
548
+ value='yellow',
549
+ label="Color"
550
+ )
551
+ font_size = gr.Slider(12, 32, 18, step=2, label="Font Size")
552
+
553
+ gr.Markdown("### βš™οΈ Quality")
554
+ crf_input = gr.Slider(18, 28, 23, step=1, label="CRF (lower=better)")
555
+
556
+ stitch_btn = gr.Button("🎬 Stitch Video", variant="primary", size="lg")
557
+
558
+ with gr.Column():
559
+ gr.Markdown("### πŸ“Š Status & Output")
560
+ status_output = gr.Textbox(label="Status", lines=12)
561
+ video_output = gr.Video(label="Result")
562
+
563
+ with gr.Row():
564
+ gr.Markdown(
565
+ """
566
+ ### 🎬 Timeline (30s example):
567
+ ```
568
+ 0-18s (60%): Normal video + subtitles
569
+ 18-22.5s (60-75%): FADE OUT to beige (#DACFC3)
570
+ 22.5-27s (75-90%): SOLID beige color
571
+ 27-29s (90-97%): Book cover FADES IN
572
+ 29-30s (97-100%): Book cover visible
573
+ ```
574
+
575
+ ### 🎨 Customization (in code):
576
+ ```python
577
+ fade_start_percent = 0.6
578
+ fade_end_percent = 0.75
579
+ promo_percent = 0.1
580
+ fade_in_duration = 2
581
+ fade_color_hex = "0xC3CFDA" # BGR order!
582
+ ```
583
+
584
+ ### πŸ“š Find Book ID:
585
+ `books.google.com/books?id=**wyaEDwAAQBAJ**`
586
+ """
587
+ )
588
+
589
+ stitch_btn.click(
590
+ fn=stitch_media,
591
+ inputs=[
592
+ video_input, video_url_input,
593
+ audio_input, audio_url_input,
594
+ subtitle_input, subtitle_url_input,
595
+ book_id_input,
596
+ enable_highlight,
597
+ highlight_color,
598
+ font_size,
599
+ crf_input
600
+ ],
601
+ outputs=[video_output, status_output]
602
+ )
603
 
604
  if __name__ == "__main__":
605
+ app.launch()