sampleacc-3003 commited on
Commit
bd0074b
Β·
verified Β·
1 Parent(s): 5d4253b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +442 -228
app.py CHANGED
@@ -5,12 +5,123 @@ import os
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:
@@ -29,7 +140,7 @@ def download_file_from_url(url, output_dir, filename):
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()
@@ -48,31 +159,20 @@ def download_book_cover(book_id, output_dir):
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)
@@ -87,18 +187,136 @@ 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
@@ -128,7 +346,6 @@ def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
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
 
@@ -139,14 +356,8 @@ def srt_time_to_ms(time_str):
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."""
@@ -156,11 +367,17 @@ def ms_to_ass_time(ms):
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'),
@@ -186,7 +403,7 @@ 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
@@ -194,8 +411,9 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
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]
@@ -250,144 +468,199 @@ def stitch_media(
250
  font_size,
251
  crf_quality=23
252
  ):
253
- """
254
- Stitch video, audio, and subtitle files together using ffmpeg.
255
- OPTIMIZED: Single-pass encoding with filter_complex.
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
- status_msg += "🎬 Creating video with optimized single-pass encoding...\n"
310
 
311
- # Calculate timing
312
- fade_duration = 3.0
313
- promo_duration = 2.0
314
- fade_starts_at = audio_duration - 5.0
315
- cover_appears_at = audio_duration - 2.0
316
 
317
- # OPTIMIZED: Single filter_complex command
318
- # Build complex filter for book cover fade-in overlay
319
- filter_complex = (
320
- f"[0:v]fps={video_fps},scale={video_width}:{video_height}[main];"
321
- f"[1:v]loop=-1:size=1,fps={video_fps},"
322
- f"scale={video_width}:{video_height}:force_original_aspect_ratio=decrease,"
323
- f"pad={video_width}:{video_height}:(ow-iw)/2:(oh-ih)/2,"
324
- f"setsar=1[cover];"
325
- f"[cover]fade=t=in:st=0:d={fade_duration}:alpha=1[cover_fade];"
326
- f"[main][cover_fade]overlay=enable='gte(t,{fade_starts_at})':format=auto[video_raw];"
327
- f"[video_raw]ass={subtitle_escaped}[video]"
328
- )
329
 
330
- # Single FFmpeg command - OPTIMIZED!
331
- cmd = [
332
- "ffmpeg",
333
- "-stream_loop", "-1",
334
- "-i", video_path,
335
- "-loop", "1",
336
- "-i", book_cover_path,
337
- "-i", audio_path,
338
- "-filter_complex", filter_complex,
339
- "-map", "[video]",
340
- "-map", "2:a",
341
- "-c:v", "libx264",
342
- "-crf", str(crf_quality),
343
- "-c:a", "aac",
344
- "-pix_fmt", "yuv420p",
345
- "-shortest",
346
- "-y",
347
- output_path
348
  ]
 
349
 
350
- subprocess.run(cmd, check=True, capture_output=True, text=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
 
352
  except subprocess.CalledProcessError as e:
353
- error_detail = e.stderr if e.stderr else str(e)
354
- return None, f"❌ FFmpeg error:\n{error_detail[-1000:]}"
355
  except Exception as e:
356
- return None, f"❌ Error processing book cover: {str(e)}"
357
 
358
  else:
359
- # No book cover - simple looping with subtitles (already fast)
360
- status_msg += "\n🎬 Creating video (no book cover)...\n"
361
-
362
- filter_complex = f"[0:v]ass={subtitle_escaped}[v]"
363
 
364
- cmd = [
365
- "ffmpeg",
366
- "-stream_loop", "-1",
367
- "-i", video_path,
368
- "-i", audio_path,
369
- "-filter_complex", filter_complex,
370
- "-map", "[v]",
371
- "-map", "1:a",
372
- "-c:v", "libx264",
373
- "-crf", str(crf_quality),
374
- "-c:a", "aac",
375
- "-shortest",
376
- "-y",
377
- output_path
378
- ]
 
 
 
 
 
379
 
380
- subprocess.run(cmd, check=True, capture_output=True, text=True)
381
 
382
  # Check output
383
  if os.path.exists(output_path):
384
  file_size = os.path.getsize(output_path) / (1024 * 1024)
385
- success_msg = f"βœ… Video created successfully! (Optimized single-pass)\n\n"
386
  success_msg += f"πŸ“Š Size: {file_size:.2f} MB | Duration: {audio_duration:.2f}s\n"
387
  success_msg += f"🎨 Quality: CRF {crf_quality} | FPS: {video_fps:.2f}\n"
 
 
388
  if has_book_cover:
389
- success_msg += f"πŸ“š Book cover: βœ… (fade: {fade_duration}s, starts at {fade_starts_at:.1f}s)\n"
390
- success_msg += f"⚑ Processing: Single-pass encoding (optimized!)\n"
391
  success_msg += "\n" + status_msg
392
  return output_path, success_msg
393
  else:
@@ -396,137 +669,78 @@ def stitch_media(
396
  except Exception as e:
397
  return None, f"❌ Error: {str(e)}"
398
 
399
- # Gradio interface (UNCHANGED)
400
- with gr.Blocks(title="Video Stitcher - Word Highlighting", theme=gr.themes.Soft()) as app:
401
  gr.Markdown(
402
- """
403
- # 🎬 Video Audio Subtitle Stitcher with Book Cover Promo ✨
404
 
405
- **Features:**
406
- - ⚑ **Optimized single-pass encoding** (4-5x faster!)
407
- - Word-by-word subtitle highlighting (Arial Black font)
408
- - Looping background video (matches original FPS)
409
- - Optional book cover with 3s fade (starts 5s before end, visible last 2s)
410
 
411
- πŸ“€ Upload files or provide URLs | πŸ“š Add book cover from Google Books API
412
  """
413
  )
414
 
415
  with gr.Row():
416
  with gr.Column():
417
- gr.Markdown("### πŸ“Ή Video Input")
418
  with gr.Group():
419
- video_input = gr.File(
420
- label="Upload Video File",
421
- file_types=[".mp4", ".mov", ".avi", ".mkv"],
422
- type="filepath"
423
- )
424
  gr.Markdown("**OR**")
425
- video_url_input = gr.Textbox(
426
- label="Video URL",
427
- placeholder="https://example.com/video.mp4",
428
- lines=1
429
- )
430
 
431
- gr.Markdown("### 🎡 Audio Input")
432
  with gr.Group():
433
- audio_input = gr.File(
434
- label="Upload Audio File",
435
- file_types=[".wav", ".mp3", ".aac", ".m4a"],
436
- type="filepath"
437
- )
438
  gr.Markdown("**OR**")
439
- audio_url_input = gr.Textbox(
440
- label="Audio URL",
441
- placeholder="https://example.com/audio.wav",
442
- lines=1
443
- )
444
 
445
- gr.Markdown("### πŸ“ Subtitle Input")
446
  with gr.Group():
447
- subtitle_input = gr.File(
448
- label="Upload Subtitle File (.srt)",
449
- file_types=[".srt"],
450
- type="filepath"
451
- )
452
  gr.Markdown("**OR**")
453
- subtitle_url_input = gr.Textbox(
454
- label="Subtitle URL",
455
- placeholder="https://example.com/subtitles.srt",
456
- lines=1
457
- )
458
 
459
  gr.Markdown("### πŸ“š Book Cover (Optional)")
460
- book_id_input = gr.Textbox(
461
- label="Google Books ID",
462
- placeholder="wyaEDwAAQBAJ",
463
- lines=1,
464
- info="3s fade starts 5s before end, visible last 2s"
465
- )
466
 
467
- gr.Markdown("### ✨ Word Highlighting")
468
- with gr.Group():
469
- enable_highlight = gr.Checkbox(
470
- label="Enable Word-by-Word Highlighting",
471
- value=True
472
- )
473
- highlight_color = gr.Dropdown(
474
- choices=['yellow', 'orange', 'green', 'cyan', 'pink', 'red', 'blue'],
475
- value='yellow',
476
- label="Color"
477
- )
478
- font_size = gr.Slider(12, 32, 18, step=2, label="Font Size")
479
-
480
- gr.Markdown("### βš™οΈ Quality")
481
- crf_input = gr.Slider(18, 28, 23, step=1, label="CRF (lower=better)")
482
 
483
  stitch_btn = gr.Button("🎬 Stitch Video", variant="primary", size="lg")
484
 
485
  with gr.Column():
486
- gr.Markdown("### πŸ“Š Status & Output")
487
- status_output = gr.Textbox(label="Status", lines=12)
488
  video_output = gr.Video(label="Result")
489
 
490
- with gr.Row():
491
- gr.Markdown(
492
- """
493
- ### 🎬 Timeline (30s example):
494
- ```
495
- 0-25s: Looping video + audio + subtitles
496
- 25-28s: Crossfade (3s fade - cover fading in)
497
- 28-30s: Book cover fully visible (last 2s)
498
- ```
499
-
500
- ### ⚑ Performance:
501
- - **Old method**: 4-6 minutes (4 separate encodes)
502
- - **New method**: 30-90 seconds (single-pass!) ✨
503
- - **Speedup**: ~5x faster
504
-
505
- ### πŸ“š Find Book ID:
506
- Google Books URL: `books.google.com/books?id=**wyaEDwAAQBAJ**`
507
-
508
- ### ✨ Features:
509
- - Arial Black font for subtitles
510
- - Auto-detects video FPS and matches it
511
- - Optimized single-pass encoding
512
- - No intermediate files
513
- """
514
- )
515
 
516
  stitch_btn.click(
517
  fn=stitch_media,
518
- inputs=[
519
- video_input, video_url_input,
520
- audio_input, audio_url_input,
521
- subtitle_input, subtitle_url_input,
522
- book_id_input,
523
- enable_highlight,
524
- highlight_color,
525
- font_size,
526
- crf_input
527
- ],
528
  outputs=[video_output, status_output]
529
  )
530
 
531
  if __name__ == "__main__":
532
- app.launch()
 
5
  import tempfile
6
  import requests
7
  import re
8
+ import textwrap
9
+ import shutil
10
  from datetime import datetime
11
+ from PIL import Image, ImageDraw, ImageFont
12
+
13
+ # ========================================
14
+ # CONFIGURATION SECTION - CUSTOMIZE HERE
15
+ # ========================================
16
+
17
+ # Reddit Template Text Settings
18
+ REDDIT_CONFIG = {
19
+ 'template_file': 'reddit_template.png', # Template filename in script directory
20
+ 'font_file': 'Roboto_Condensed-Bold.ttf', # Font file for Reddit text
21
+ 'font_size_max': 120, # Maximum font size to try
22
+ 'font_size_min': 16, # Minimum font size (if text too long)
23
+ 'text_wrap_width': 50, # Characters per line for wrapping
24
+ 'text_color': 'black', # Text color
25
+ 'line_spacing': 10, # Spacing between lines
26
+ 'text_box_width_percent': 0.8, # 80% of template width
27
+ 'text_box_height_percent': 0.5, # 50% of template height
28
+ 'y_offset': 20, # Vertical offset from center
29
+ }
30
+
31
+ # Word-by-Word Subtitle Settings
32
+ SUBTITLE_CONFIG = {
33
+ 'font_file': 'komiko_axis.ttf', # Font file for subtitles (TTF or OTF)
34
+ 'font_name': 'Komika Axis', # Font name as it appears in system
35
+ 'font_size_default': 18, # Default subtitle font size
36
+ 'position_alignment': 5, # 5 = center (1-9 numpad layout)
37
+ 'margin_left': 10,
38
+ 'margin_right': 10,
39
+ 'margin_vertical': 0,
40
+ }
41
+
42
+ # Video Processing Settings
43
+ VIDEO_CONFIG = {
44
+ 'reddit_scale_percent': 0.75, # Reddit template size (0.75 = 75% of video width)
45
+ 'fade_start_percent': 0.6, # When fade to color starts (60%)
46
+ 'fade_end_percent': 0.75, # When fully faded to color (75%)
47
+ 'promo_percent': 0.1, # Last 10% for book cover
48
+ 'fade_color_rgb': (218, 207, 195), # Fade color RGB
49
+ 'book_fade_in_duration': 2, # Book cover fade-in duration (seconds)
50
+ }
51
+
52
+ # ========================================
53
+ # END CONFIGURATION SECTION
54
+ # ========================================
55
 
56
  # Add static ffmpeg to PATH
57
  static_ffmpeg.add_paths()
58
 
59
+ def setup_custom_fonts_hf(temp_dir):
60
+ """
61
+ Setup custom fonts for FFmpeg/libass - Hugging Face Spaces compatible.
62
+
63
+ File Structure Required:
64
+ project/
65
+ β”œβ”€β”€ app.py
66
+ β”œβ”€β”€ fonts/
67
+ β”‚ β”œβ”€β”€ komiko_axis.ttf (or your fonts)
68
+ β”‚ └── (other fonts...)
69
+ └── reddit_template.png
70
+
71
+ Returns: environment dict with FONTCONFIG configured
72
+ """
73
+ try:
74
+ fonts_dir = os.path.join(temp_dir, 'fonts')
75
+ os.makedirs(fonts_dir, exist_ok=True)
76
+
77
+ # Get script directory and check for fonts/ subdirectory
78
+ script_dir = os.path.dirname(os.path.abspath(__file__))
79
+ repo_fonts_dir = os.path.join(script_dir, 'fonts')
80
+
81
+ # Also check for fonts in script root (fallback)
82
+ fonts_to_copy = []
83
+
84
+ # Check fonts/ subdirectory first
85
+ if os.path.exists(repo_fonts_dir):
86
+ for font_file in os.listdir(repo_fonts_dir):
87
+ if font_file.endswith(('.ttf', '.otf', '.TTF', '.OTF')):
88
+ fonts_to_copy.append(os.path.join(repo_fonts_dir, font_file))
89
+
90
+ # Check script root directory for fonts
91
+ for item in [REDDIT_CONFIG['font_file'], SUBTITLE_CONFIG['font_file']]:
92
+ font_path = os.path.join(script_dir, item)
93
+ if os.path.exists(font_path) and font_path not in fonts_to_copy:
94
+ fonts_to_copy.append(font_path)
95
+
96
+ # Copy all found fonts
97
+ for src in fonts_to_copy:
98
+ dst = os.path.join(fonts_dir, os.path.basename(src))
99
+ shutil.copy(src, dst)
100
+
101
+ if fonts_to_copy:
102
+ # Create fonts.conf for fontconfig
103
+ fonts_conf = f"""<?xml version="1.0"?>
104
+ <fontconfig>
105
+ <dir>{fonts_dir}</dir>
106
+ <cachedir>{temp_dir}/cache</cachedir>
107
+ </fontconfig>
108
+ """
109
+ conf_path = os.path.join(temp_dir, 'fonts.conf')
110
+ with open(conf_path, 'w') as f:
111
+ f.write(fonts_conf)
112
+
113
+ # Set environment variables
114
+ env = os.environ.copy()
115
+ env['FONTCONFIG_FILE'] = conf_path
116
+ env['FONTCONFIG_PATH'] = temp_dir
117
+ return env
118
+
119
+ # Fallback to normal environment
120
+ return os.environ.copy()
121
+
122
+ except Exception as e:
123
+ return os.environ.copy()
124
+
125
  def download_file_from_url(url, output_dir, filename):
126
  """Download a file from URL and save it to output directory."""
127
  try:
 
140
  def download_book_cover(book_id, output_dir):
141
  """Download book cover from Google Books API using Book ID."""
142
  try:
143
+ image_url = f"https://books.google.com/books/publisher/content/images/frontcover/{book_id}?fife=w720-h1280&source=gbs_api"
144
 
145
  response = requests.get(image_url, timeout=30)
146
  response.raise_for_status()
 
159
  def get_video_info(video_path):
160
  """Get video resolution and frame rate using ffprobe."""
161
  try:
 
162
  cmd_res = [
163
+ "ffprobe", "-v", "error", "-select_streams", "v:0",
164
+ "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0", video_path
 
 
 
 
165
  ]
166
  result = subprocess.run(cmd_res, capture_output=True, text=True, check=True)
167
  width, height = result.stdout.strip().split('x')
168
 
 
169
  cmd_fps = [
170
+ "ffprobe", "-v", "error", "-select_streams", "v:0",
171
+ "-show_entries", "stream=r_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", video_path
 
 
 
 
172
  ]
173
  result = subprocess.run(cmd_fps, capture_output=True, text=True, check=True)
174
  fps_str = result.stdout.strip()
175
 
 
176
  if '/' in fps_str:
177
  num, den = fps_str.split('/')
178
  fps = float(num) / float(den)
 
187
  """Get audio duration in seconds using ffprobe."""
188
  try:
189
  cmd = [
190
+ "ffprobe", "-v", "error", "-show_entries", "format=duration",
191
+ "-of", "default=noprint_wrappers=1:nokey=1", audio_path
 
 
 
192
  ]
193
  result = subprocess.run(cmd, capture_output=True, text=True, check=True)
194
+ return float(result.stdout.strip())
 
195
  except Exception as e:
196
  raise Exception(f"Failed to get audio duration: {str(e)}")
197
 
198
+ def extract_first_subtitle(srt_path):
199
+ """Extract first subtitle entry. Returns: (text, start_sec, end_sec)"""
200
+ try:
201
+ with open(srt_path, 'r', encoding='utf-8') as f:
202
+ content = f.read()
203
+
204
+ blocks = re.split(r'\n\s*\n', content.strip())
205
+ if not blocks:
206
+ return "No subtitle found", 0.0, 3.0
207
+
208
+ first_block = blocks[0].strip().split('\n')
209
+ if len(first_block) >= 3:
210
+ times = first_block[1].split(' --> ')
211
+
212
+ def time_to_sec(t):
213
+ h, m, s = t.split(':')
214
+ s, ms = s.split(',')
215
+ return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
216
+
217
+ start_sec = time_to_sec(times[0].strip())
218
+ end_sec = time_to_sec(times[1].strip())
219
+ text = ' '.join(first_block[2:]).strip()
220
+
221
+ return text, start_sec, end_sec
222
+
223
+ return "No subtitle found", 0.0, 3.0
224
+ except Exception as e:
225
+ raise Exception(f"Failed to extract first subtitle: {str(e)}")
226
+
227
+ def create_reddit_card_with_text(template_path, hook_text, output_dir, config=REDDIT_CONFIG):
228
+ """
229
+ Create Reddit card with text using PIL.
230
+ Uses REDDIT_CONFIG for all styling settings.
231
+ """
232
+ try:
233
+ template = Image.open(template_path).convert('RGBA')
234
+ template_width, template_height = template.size
235
+
236
+ text_box_width = int(template_width * config['text_box_width_percent'])
237
+ text_box_height = int(template_height * config['text_box_height_percent'])
238
+
239
+ best_font_size = config['font_size_max']
240
+ best_wrapped_text = hook_text
241
+
242
+ # Get font path
243
+ script_dir = os.path.dirname(os.path.abspath(__file__))
244
+ font_paths = [
245
+ os.path.join(script_dir, 'fonts', config['font_file']),
246
+ os.path.join(script_dir, config['font_file'])
247
+ ]
248
+
249
+ # Try font sizes from max to min
250
+ for font_size in range(config['font_size_max'], config['font_size_min'] - 1, -2):
251
+ # Try loading font from multiple locations
252
+ font = None
253
+ for font_path in font_paths:
254
+ if os.path.exists(font_path):
255
+ try:
256
+ font = ImageFont.truetype(font_path, font_size)
257
+ break
258
+ except:
259
+ pass
260
+
261
+ # Fallback fonts
262
+ if font is None:
263
+ try:
264
+ font = ImageFont.truetype('Verdana', font_size)
265
+ except:
266
+ font = ImageFont.load_default()
267
+
268
+ # Wrap and measure text
269
+ wrapped = textwrap.fill(hook_text, width=config['text_wrap_width'])
270
+ draw = ImageDraw.Draw(template)
271
+ bbox = draw.multiline_textbbox((0, 0), wrapped, font=font, spacing=config['line_spacing'])
272
+ text_width = bbox[2] - bbox[0]
273
+ text_height = bbox[3] - bbox[1]
274
+
275
+ if text_width <= text_box_width and text_height <= text_box_height:
276
+ best_font_size = font_size
277
+ best_wrapped_text = wrapped
278
+ break
279
+
280
+ # Draw text with best size
281
+ font = None
282
+ for font_path in font_paths:
283
+ if os.path.exists(font_path):
284
+ try:
285
+ font = ImageFont.truetype(font_path, best_font_size)
286
+ break
287
+ except:
288
+ pass
289
+
290
+ if font is None:
291
+ try:
292
+ font = ImageFont.truetype('Verdana', best_font_size)
293
+ except:
294
+ font = ImageFont.load_default()
295
+
296
+ draw = ImageDraw.Draw(template)
297
+ bbox = draw.multiline_textbbox((0, 0), best_wrapped_text, font=font, spacing=config['line_spacing'])
298
+ text_width = bbox[2] - bbox[0]
299
+ text_height = bbox[3] - bbox[1]
300
+
301
+ x = (template_width - text_width) / 2
302
+ y = (template_height - text_height) / 2 + config['y_offset']
303
+
304
+ draw.multiline_text(
305
+ (x, y),
306
+ best_wrapped_text,
307
+ fill=config['text_color'],
308
+ font=font,
309
+ spacing=config['line_spacing'],
310
+ align='left'
311
+ )
312
+
313
+ output_path = os.path.join(output_dir, 'reddit_card_composite.png')
314
+ template.save(output_path, 'PNG')
315
+
316
+ return output_path
317
+ except Exception as e:
318
+ raise Exception(f"Failed to create Reddit card: {str(e)}")
319
+
320
  def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
321
  """Validate that only one input method is used and return the file path."""
322
  has_upload = uploaded_file is not None
 
346
 
347
  file_path = download_file_from_url(url_string.strip(), temp_dir, filename)
348
  return file_path, None
 
349
  except Exception as e:
350
  return None, f"❌ Error downloading {file_type} from URL: {str(e)}"
351
 
 
356
  time_str = time_str.strip()
357
  hours, minutes, seconds = time_str.split(':')
358
  seconds, milliseconds = seconds.split(',')
359
+ return (int(hours) * 3600000 + int(minutes) * 60000 +
360
+ int(seconds) * 1000 + int(milliseconds))
 
 
 
 
 
 
361
 
362
  def ms_to_ass_time(ms):
363
  """Convert milliseconds to ASS timestamp format."""
 
367
  ms %= 60000
368
  seconds = ms // 1000
369
  centiseconds = (ms % 1000) // 10
 
370
  return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
371
 
372
+ def create_word_by_word_highlight_ass(srt_path, output_dir, highlight_color='yellow',
373
+ font_size=None, skip_first=False, config=SUBTITLE_CONFIG):
374
+ """
375
+ Convert SRT to ASS with word-by-word highlighting.
376
+ Uses SUBTITLE_CONFIG for all font and styling settings.
377
+ """
378
+ if font_size is None:
379
+ font_size = config['font_size_default']
380
+
381
  color_map = {
382
  'yellow': ('&H0000FFFF', '&H00000000'),
383
  'orange': ('&H0000A5FF', '&H00000000'),
 
403
 
404
  [V4+ Styles]
405
  Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
406
+ 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
407
 
408
  [Events]
409
  Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
 
411
 
412
  srt_blocks = re.split(r'\n\s*\n', srt_content.strip())
413
  ass_events = []
414
+ start_index = 1 if skip_first else 0
415
 
416
+ for block in srt_blocks[start_index:]:
417
  lines = block.strip().split('\n')
418
  if len(lines) >= 3:
419
  timestamp_line = lines[1]
 
468
  font_size,
469
  crf_quality=23
470
  ):
471
+ """Main video stitching function with Reddit overlay and book cover."""
 
 
 
472
  temp_dir = tempfile.mkdtemp()
473
 
474
  try:
475
+ # Setup custom fonts environment
476
+ ffmpeg_env = setup_custom_fonts_hf(temp_dir)
 
 
 
 
477
 
478
+ # Validate files
479
+ video_path, video_error = validate_and_get_file(video_file, video_url, 'video', temp_dir)
480
+ if video_error: return None, video_error
 
 
481
 
482
+ audio_path, audio_error = validate_and_get_file(audio_file, audio_url, 'audio', temp_dir)
483
+ if audio_error: return None, audio_error
 
 
 
484
 
485
+ subtitle_path, subtitle_error = validate_and_get_file(subtitle_file, subtitle_url, 'subtitle', temp_dir)
486
+ if subtitle_error: return None, subtitle_error
487
+
488
+ # Get video info
489
  video_width, video_height, video_fps = get_video_info(video_path)
490
  audio_duration = get_audio_duration(audio_path)
491
 
 
492
  status_msg = "πŸ“₯ Processing files:\n"
493
  status_msg += f" β€’ Video: {'URL' if video_url else 'Upload'} ({video_width}x{video_height} @ {video_fps:.2f}fps)\n"
494
  status_msg += f" β€’ Audio: {'URL' if audio_url else 'Upload'} ({audio_duration:.2f}s)\n"
495
  status_msg += f" β€’ Subtitle: {'URL' if subtitle_url else 'Upload'}\n"
496
 
497
+ # Check for Reddit template
498
+ script_dir = os.path.dirname(os.path.abspath(__file__))
499
+ reddit_template_path = os.path.join(script_dir, REDDIT_CONFIG['template_file'])
500
+ has_reddit_template = os.path.exists(reddit_template_path)
501
+
502
+ if has_reddit_template:
503
+ status_msg += " β€’ Reddit template: βœ… Found\n"
504
+ try:
505
+ first_sub_text, first_sub_start, first_sub_end = extract_first_subtitle(subtitle_path)
506
+ status_msg += f"\nπŸ“± Reddit Overlay:\n"
507
+ status_msg += f" β€’ Text: '{first_sub_text[:40]}...'\n"
508
+ status_msg += f" β€’ Timing: {first_sub_start:.1f}s - {first_sub_end:.1f}s\n"
509
+
510
+ reddit_card_path = create_reddit_card_with_text(
511
+ reddit_template_path, first_sub_text, temp_dir, REDDIT_CONFIG
512
+ )
513
+ status_msg += " β€’ βœ… Reddit card ready\n"
514
+ except Exception as e:
515
+ status_msg += f" β€’ ⚠️ Reddit card failed: {str(e)}\n"
516
+ has_reddit_template = False
517
+ else:
518
+ status_msg += " β€’ Reddit template: ⚠️ Not found (skipping)\n"
519
+
520
  # Process subtitles
521
  if enable_highlight:
522
  status_msg += f"\n✨ Word highlighting: {highlight_color} ({font_size}px)\n"
523
+ subtitle_to_use = create_word_by_word_highlight_ass(
524
+ subtitle_path, temp_dir, highlight_color, font_size,
525
+ skip_first=has_reddit_template, config=SUBTITLE_CONFIG
526
+ )
527
  else:
528
  subtitle_to_use = subtitle_path
529
 
 
530
  subtitle_escaped = subtitle_to_use.replace('\\', '/').replace(':', '\\:')
531
 
532
+ # Check book cover
533
  has_book_cover = book_id and book_id.strip()
534
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
535
  output_path = os.path.join(temp_dir, f"final_{timestamp}.mp4")
536
 
537
+ # Convert RGB to BGR hex
538
+ r, g, b = VIDEO_CONFIG['fade_color_rgb']
539
+ fade_color_hex = f"#dacfc3"
540
+
541
  if has_book_cover:
542
  status_msg += f"\nπŸ“š Downloading book cover (ID: {book_id})...\n"
543
  try:
544
  book_cover_path = download_book_cover(book_id.strip(), temp_dir)
545
  status_msg += "βœ… Book cover downloaded\n"
 
546
 
547
+ # Calculate timing from config
548
+ fade_starts_at = audio_duration * VIDEO_CONFIG['fade_start_percent']
549
+ fade_ends_at = audio_duration * VIDEO_CONFIG['fade_end_percent']
550
+ fade_out_duration = fade_ends_at - fade_starts_at
 
551
 
552
+ promo_duration = audio_duration * VIDEO_CONFIG['promo_percent']
553
+ book_appears_at = audio_duration - promo_duration
554
+ solid_color_duration = book_appears_at - fade_ends_at
 
 
 
 
 
 
 
 
 
555
 
556
+ main_video_duration = fade_ends_at
557
+ cover_segment_duration = promo_duration
558
+
559
+ status_msg += f"\n⏱️ Timing: Fade {fade_starts_at:.1f}β†’{fade_ends_at:.1f}s, Hold {solid_color_duration:.1f}s\n"
560
+
561
+ # STEP 1: Main video with fade-out
562
+ status_msg += "🎬 Step 1/4: Main video with fade-out...\n"
563
+ main_segment_path = os.path.join(temp_dir, f"main_{timestamp}.mp4")
564
+ cmd_main = [
565
+ "ffmpeg", "-stream_loop", "-1", "-i", video_path, "-t", str(main_video_duration),
566
+ "-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}",
567
+ "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-an", "-y", main_segment_path
 
 
 
 
 
 
568
  ]
569
+ subprocess.run(cmd_main, check=True, capture_output=True, text=True, env=ffmpeg_env)
570
 
571
+ # STEP 2: Solid color
572
+ status_msg += "βœ… Step 1 done\n🎬 Step 2/4: Solid color...\n"
573
+ solid_color_path = os.path.join(temp_dir, f"solid_{timestamp}.mp4")
574
+ cmd_solid = [
575
+ "ffmpeg", "-f", "lavfi",
576
+ "-i", f"color=c={fade_color_hex}:s={video_width}x{video_height}:d={solid_color_duration}:r={video_fps}",
577
+ "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-y", solid_color_path
578
+ ]
579
+ subprocess.run(cmd_solid, check=True, capture_output=True, text=True, env=ffmpeg_env)
580
+
581
+ # STEP 3: Cover with fade-in
582
+ status_msg += "βœ… Step 2 done\n🎬 Step 3/4: Cover with fade-in...\n"
583
+ cover_segment_path = os.path.join(temp_dir, f"cover_{timestamp}.mp4")
584
+ cmd_cover = [
585
+ "ffmpeg", "-loop", "1", "-i", book_cover_path, "-t", str(cover_segment_duration),
586
+ "-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={VIDEO_CONFIG['book_fade_in_duration']}:c={fade_color_hex}",
587
+ "-c:v", "libx264", "-crf", str(crf_quality), "-pix_fmt", "yuv420p", "-an", "-y", cover_segment_path
588
+ ]
589
+ subprocess.run(cmd_cover, check=True, capture_output=True, text=True, env=ffmpeg_env)
590
+
591
+ # STEP 4: Concat + audio + subtitles + Reddit
592
+ status_msg += "βœ… Step 3 done\n🎬 Step 4/4: Final assembly...\n"
593
+ concat_list_path = os.path.join(temp_dir, f"concat_{timestamp}.txt")
594
+ with open(concat_list_path, 'w') as f:
595
+ f.write(f"file '{main_segment_path}'\n")
596
+ f.write(f"file '{solid_color_path}'\n")
597
+ f.write(f"file '{cover_segment_path}'\n")
598
+
599
+ if has_reddit_template:
600
+ filter_complex = (
601
+ f"[0:v]ass={subtitle_escaped}[bg];"
602
+ f"[1:v]scale={video_width}*{VIDEO_CONFIG['reddit_scale_percent']}:-1[reddit];"
603
+ f"[bg][reddit]overlay=(W-w)/2:(H-h)/2:enable='between(t,{first_sub_start},{first_sub_end})'[v]"
604
+ )
605
+ cmd_final = [
606
+ "ffmpeg", "-f", "concat", "-safe", "0", "-i", concat_list_path,
607
+ "-loop", "1", "-i", reddit_card_path, "-i", audio_path,
608
+ "-filter_complex", filter_complex, "-map", "[v]", "-map", "2:a",
609
+ "-c:v", "libx264", "-crf", str(crf_quality), "-c:a", "aac",
610
+ "-pix_fmt", "yuv420p", "-shortest", "-y", output_path
611
+ ]
612
+ else:
613
+ cmd_final = [
614
+ "ffmpeg", "-f", "concat", "-safe", "0", "-i", concat_list_path, "-i", audio_path,
615
+ "-vf", f"ass={subtitle_escaped}", "-map", "0:v", "-map", "1:a",
616
+ "-c:v", "libx264", "-crf", str(crf_quality), "-c:a", "aac",
617
+ "-pix_fmt", "yuv420p", "-shortest", "-y", output_path
618
+ ]
619
+
620
+ subprocess.run(cmd_final, check=True, capture_output=True, text=True, env=ffmpeg_env)
621
 
622
  except subprocess.CalledProcessError as e:
623
+ return None, f"❌ FFmpeg error:\n{e.stderr[-1000:] if e.stderr else str(e)}"
 
624
  except Exception as e:
625
+ return None, f"❌ Error: {str(e)}"
626
 
627
  else:
628
+ # No book cover - simple loop
629
+ status_msg += "\n🎬 Creating video...\n"
 
 
630
 
631
+ if has_reddit_template:
632
+ filter_complex = (
633
+ f"[0:v]ass={subtitle_escaped}[bg];"
634
+ f"[1:v]scale={video_width}*{VIDEO_CONFIG['reddit_scale_percent']}:-1[reddit];"
635
+ f"[bg][reddit]overlay=(W-w)/2:(H-h)/2:enable='between(t,{first_sub_start},{first_sub_end})'[v]"
636
+ )
637
+ cmd = [
638
+ "ffmpeg", "-stream_loop", "-1", "-i", video_path,
639
+ "-loop", "1", "-i", reddit_card_path, "-i", audio_path,
640
+ "-filter_complex", filter_complex, "-map", "[v]", "-map", "2:a",
641
+ "-c:v", "libx264", "-crf", str(crf_quality), "-c:a", "aac",
642
+ "-shortest", "-y", output_path
643
+ ]
644
+ else:
645
+ cmd = [
646
+ "ffmpeg", "-stream_loop", "-1", "-i", video_path, "-i", audio_path,
647
+ "-vf", f"ass={subtitle_escaped}", "-map", "0:v", "-map", "1:a",
648
+ "-c:v", "libx264", "-crf", str(crf_quality), "-c:a", "aac",
649
+ "-shortest", "-y", output_path
650
+ ]
651
 
652
+ subprocess.run(cmd, check=True, capture_output=True, text=True, env=ffmpeg_env)
653
 
654
  # Check output
655
  if os.path.exists(output_path):
656
  file_size = os.path.getsize(output_path) / (1024 * 1024)
657
+ success_msg = f"βœ… Video created successfully!\n\n"
658
  success_msg += f"πŸ“Š Size: {file_size:.2f} MB | Duration: {audio_duration:.2f}s\n"
659
  success_msg += f"🎨 Quality: CRF {crf_quality} | FPS: {video_fps:.2f}\n"
660
+ if has_reddit_template:
661
+ success_msg += f"πŸ“± Reddit: βœ… ({first_sub_start:.1f}-{first_sub_end:.1f}s)\n"
662
  if has_book_cover:
663
+ success_msg += f"πŸ“š Book: βœ… (Fade: 60β†’75%, Hold: 75β†’90%, Book: 90β†’100%)\n"
 
664
  success_msg += "\n" + status_msg
665
  return output_path, success_msg
666
  else:
 
669
  except Exception as e:
670
  return None, f"❌ Error: {str(e)}"
671
 
672
+ # Gradio UI
673
+ with gr.Blocks(title="Video Stitcher", theme=gr.themes.Soft()) as app:
674
  gr.Markdown(
675
+ f"""
676
+ # 🎬 Video Stitcher with Reddit Overlay & Book Promo ✨
677
 
678
+ **Current Configuration:**
679
+ - πŸ“± Reddit text: {REDDIT_CONFIG['font_file']} ({REDDIT_CONFIG['font_size_max']}-{REDDIT_CONFIG['font_size_min']}px)
680
+ - πŸ’¬ Subtitle: {SUBTITLE_CONFIG['font_name']} ({SUBTITLE_CONFIG['font_size_default']}px)
681
+ - 🎨 Fade color: RGB{VIDEO_CONFIG['fade_color_rgb']}
 
682
 
683
+ **To customize:** Edit CONFIG dictionaries at top of script
684
  """
685
  )
686
 
687
  with gr.Row():
688
  with gr.Column():
689
+ gr.Markdown("### πŸ“Ή Video")
690
  with gr.Group():
691
+ video_input = gr.File(label="Upload", file_types=[".mp4", ".mov", ".avi", ".mkv"], type="filepath")
 
 
 
 
692
  gr.Markdown("**OR**")
693
+ video_url_input = gr.Textbox(label="URL", placeholder="https://example.com/video.mp4")
 
 
 
 
694
 
695
+ gr.Markdown("### 🎡 Audio")
696
  with gr.Group():
697
+ audio_input = gr.File(label="Upload", file_types=[".wav", ".mp3", ".aac", ".m4a"], type="filepath")
 
 
 
 
698
  gr.Markdown("**OR**")
699
+ audio_url_input = gr.Textbox(label="URL", placeholder="https://example.com/audio.wav")
 
 
 
 
700
 
701
+ gr.Markdown("### πŸ“ Subtitle")
702
  with gr.Group():
703
+ subtitle_input = gr.File(label="Upload (.srt)", file_types=[".srt"], type="filepath")
 
 
 
 
704
  gr.Markdown("**OR**")
705
+ subtitle_url_input = gr.Textbox(label="URL", placeholder="https://example.com/subtitles.srt")
 
 
 
 
706
 
707
  gr.Markdown("### πŸ“š Book Cover (Optional)")
708
+ book_id_input = gr.Textbox(label="Google Books ID", placeholder="wyaEDwAAQBAJ")
 
 
 
 
 
709
 
710
+ gr.Markdown("### ✨ Settings")
711
+ enable_highlight = gr.Checkbox(label="Word Highlighting", value=True)
712
+ highlight_color = gr.Dropdown(choices=['yellow', 'orange', 'green', 'cyan', 'pink', 'red', 'blue'], value='yellow', label="Color")
713
+ font_size = gr.Slider(12, 32, 18, step=2, label="Font Size")
714
+ crf_input = gr.Slider(18, 28, 23, step=1, label="Quality (CRF)")
 
 
 
 
 
 
 
 
 
 
715
 
716
  stitch_btn = gr.Button("🎬 Stitch Video", variant="primary", size="lg")
717
 
718
  with gr.Column():
719
+ gr.Markdown("### πŸ“Š Output")
720
+ status_output = gr.Textbox(label="Status", lines=14)
721
  video_output = gr.Video(label="Result")
722
 
723
+ gr.Markdown(
724
+ """
725
+ ### πŸ“ File Structure:
726
+ ```
727
+ project/
728
+ β”œβ”€β”€ app.py
729
+ β”œβ”€β”€ fonts/ (optional - for HF deployment)
730
+ β”‚ └── komiko_axis.ttf
731
+ β”œβ”€β”€ reddit_template.png (optional)
732
+ └── komiko_axis.ttf (or in fonts/)
733
+ ```
734
+ """
735
+ )
 
 
 
 
 
 
 
 
 
 
 
 
736
 
737
  stitch_btn.click(
738
  fn=stitch_media,
739
+ inputs=[video_input, video_url_input, audio_input, audio_url_input,
740
+ subtitle_input, subtitle_url_input, book_id_input,
741
+ enable_highlight, highlight_color, font_size, crf_input],
 
 
 
 
 
 
 
742
  outputs=[video_output, status_output]
743
  )
744
 
745
  if __name__ == "__main__":
746
+ app.launch(show_error=True)