sampleacc-3003 commited on
Commit
125cea8
Β·
verified Β·
1 Parent(s): bd51402

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +251 -64
app.py CHANGED
@@ -4,8 +4,8 @@ import static_ffmpeg
4
  import os
5
  import tempfile
6
  import requests
 
7
  from datetime import datetime
8
- from pathlib import Path
9
 
10
  # Add static ffmpeg to PATH
11
  static_ffmpeg.add_paths()
@@ -26,47 +26,27 @@ def download_file_from_url(url, output_dir, filename):
26
  raise Exception(f"Failed to download file from URL: {str(e)}")
27
 
28
  def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
29
- """
30
- Validate that only one input method is used and return the file path.
31
-
32
- Args:
33
- uploaded_file: Uploaded file object (or None)
34
- url_string: URL string (or empty)
35
- file_type: Type of file (for naming downloaded files)
36
- temp_dir: Temporary directory to store downloaded files
37
-
38
- Returns:
39
- tuple: (file_path, error_message)
40
- """
41
  has_upload = uploaded_file is not None
42
  has_url = url_string and url_string.strip()
43
 
44
- # Validation: Must have exactly one input method
45
  if not has_upload and not has_url:
46
  return None, f"❌ Please provide {file_type} either by upload or URL"
47
 
48
  if has_upload and has_url:
49
  return None, f"❌ Please use only ONE method for {file_type}: either upload OR URL (not both)"
50
 
51
- # Handle uploaded file
52
  if has_upload:
53
  file_path = uploaded_file.name if hasattr(uploaded_file, 'name') else uploaded_file
54
  return file_path, None
55
 
56
- # Handle URL download
57
  if has_url:
58
  try:
59
- # Get file extension from URL or use default
60
  url_parts = url_string.strip().split('/')
61
  original_filename = url_parts[-1] if url_parts else f"{file_type}_file"
62
 
63
- # Add extension if missing
64
  if '.' not in original_filename:
65
- ext_map = {
66
- 'video': '.mp4',
67
- 'audio': '.wav',
68
- 'subtitle': '.srt'
69
- }
70
  original_filename += ext_map.get(file_type, '.tmp')
71
 
72
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@@ -80,15 +60,158 @@ def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
80
 
81
  return None, f"❌ Unknown error processing {file_type}"
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def stitch_media(
84
- video_file, video_url,
85
- audio_file, audio_url,
86
- subtitle_file, subtitle_url,
87
- crf_quality=23
88
- ):
 
 
89
  """
90
  Stitch video, audio, and subtitle files together using ffmpeg.
91
- Files can be uploaded or fetched from URLs.
92
  """
93
  temp_dir = tempfile.mkdtemp()
94
 
@@ -123,6 +246,18 @@ crf_quality=23
123
  status_msg += f" β€’ Video: {'Downloaded from URL' if video_url else 'Uploaded file'}\n"
124
  status_msg += f" β€’ Audio: {'Downloaded from URL' if audio_url else 'Uploaded file'}\n"
125
  status_msg += f" β€’ Subtitle: {'Downloaded from URL' if subtitle_url else 'Uploaded file'}\n"
 
 
 
 
 
 
 
 
 
 
 
 
126
  status_msg += "\n🎬 Stitching video..."
127
 
128
  # FFmpeg command
@@ -130,7 +265,7 @@ crf_quality=23
130
  "ffmpeg",
131
  "-i", video_path,
132
  "-i", audio_path,
133
- "-vf", f"subtitles={subtitle_path}",
134
  "-map", "0:v",
135
  "-map", "1:a",
136
  "-c:v", "libx264",
@@ -150,11 +285,14 @@ crf_quality=23
150
 
151
  # Check if output file was created
152
  if os.path.exists(output_path):
153
- file_size = os.path.getsize(output_path) / (1024 * 1024) # Size in MB
154
  success_msg = f"βœ… Video stitched successfully!\n\n"
155
  success_msg += f"πŸ“Š Output size: {file_size:.2f} MB\n"
156
  success_msg += f"🎨 Quality: CRF {crf_quality}\n"
157
- success_msg += status_msg.split("🎬")[0] # Include source info
 
 
 
158
  return output_path, success_msg
159
  else:
160
  return None, "❌ Output file was not created"
@@ -165,13 +303,15 @@ crf_quality=23
165
  except Exception as e:
166
  return None, f"❌ Error: {str(e)}"
167
 
168
- # Create Gradio interface
169
- with gr.Blocks(title="Video Audio Subtitle Stitcher", theme=gr.themes.Soft()) as app:
170
  gr.Markdown(
171
  """
172
- # 🎬 Video Audio Subtitle Stitcher
 
 
173
 
174
- **Stitch video, audio, and subtitles together with FFmpeg**
175
 
176
  πŸ“€ Upload files directly **OR** 🌐 provide URLs to download them
177
 
@@ -223,7 +363,21 @@ with gr.Blocks(title="Video Audio Subtitle Stitcher", theme=gr.themes.Soft()) as
223
  lines=1
224
  )
225
 
226
- gr.Markdown("### βš™οΈ Settings")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  crf_input = gr.Slider(
228
  minimum=18,
229
  maximum=28,
@@ -241,7 +395,7 @@ with gr.Blocks(title="Video Audio Subtitle Stitcher", theme=gr.themes.Soft()) as
241
  status_output = gr.Textbox(
242
  label="Status",
243
  placeholder="Provide inputs and click 'Stitch Video' to begin...",
244
- lines=8
245
  )
246
  video_output = gr.Video(
247
  label="Result Video",
@@ -251,7 +405,6 @@ with gr.Blocks(title="Video Audio Subtitle Stitcher", theme=gr.themes.Soft()) as
251
  gr.Markdown(
252
  """
253
  **πŸ’Ύ Download:** Click the download button on the video player
254
- or right-click and "Save video as..."
255
  """
256
  )
257
 
@@ -259,46 +412,78 @@ with gr.Blocks(title="Video Audio Subtitle Stitcher", theme=gr.themes.Soft()) as
259
  with gr.Column():
260
  gr.Markdown(
261
  """
262
- ### πŸ“– Instructions:
 
 
 
 
 
 
 
 
263
 
264
- **For each file (video, audio, subtitle):**
265
- 1. **Either** upload a file from your computer
266
- 2. **OR** paste a URL to download it
267
- 3. ⚠️ **Don't use both!** Choose one method per file
268
 
269
- ### πŸ’‘ Quality Settings:
270
- - **CRF 18-20:** Excellent quality (larger files)
271
- - **CRF 23:** Balanced (recommended) ⭐
272
- - **CRF 26-28:** Good quality (smaller files)
 
 
 
 
273
  """
274
  )
275
 
276
  with gr.Column():
277
  gr.Markdown(
278
  """
279
- ### 🎯 Example URLs:
280
- You can use direct links to files:
281
- - Video: `.mp4`, `.mov`, `.avi`, `.mkv`
282
- - Audio: `.wav`, `.mp3`, `.aac`, `.m4a`
283
- - Subtitle: `.srt`
 
 
284
 
285
  ### πŸ”§ Technical Details:
286
- - **Video codec:** H.264 (libx264)
287
- - **Audio codec:** AAC
288
- - **Output length:** Matches shortest stream
289
- - **Subtitles:** Burned into video
 
 
 
 
 
 
 
 
290
  """
291
  )
292
 
293
  gr.Markdown(
294
  """
295
  ---
296
- ### ⚑ Features:
297
- βœ… Upload files or fetch from URLs
298
- βœ… Automatic validation (no mixing methods)
299
- βœ… Progress tracking
300
- βœ… Instant preview
301
- βœ… Cloud deployment ready
 
 
 
 
 
 
 
 
 
 
302
  """
303
  )
304
 
@@ -309,11 +494,13 @@ with gr.Blocks(title="Video Audio Subtitle Stitcher", theme=gr.themes.Soft()) as
309
  video_input, video_url_input,
310
  audio_input, audio_url_input,
311
  subtitle_input, subtitle_url_input,
 
 
312
  crf_input
313
  ],
314
  outputs=[video_output, status_output]
315
  )
316
 
317
- # Launch the app
318
  if __name__ == "__main__":
319
  app.launch()
 
4
  import os
5
  import tempfile
6
  import requests
7
+ import re
8
  from datetime import datetime
 
9
 
10
  # Add static ffmpeg to PATH
11
  static_ffmpeg.add_paths()
 
26
  raise Exception(f"Failed to download file from URL: {str(e)}")
27
 
28
  def validate_and_get_file(uploaded_file, url_string, file_type, temp_dir):
29
+ """Validate that only one input method is used and return the file path."""
 
 
 
 
 
 
 
 
 
 
 
30
  has_upload = uploaded_file is not None
31
  has_url = url_string and url_string.strip()
32
 
 
33
  if not has_upload and not has_url:
34
  return None, f"❌ Please provide {file_type} either by upload or URL"
35
 
36
  if has_upload and has_url:
37
  return None, f"❌ Please use only ONE method for {file_type}: either upload OR URL (not both)"
38
 
 
39
  if has_upload:
40
  file_path = uploaded_file.name if hasattr(uploaded_file, 'name') else uploaded_file
41
  return file_path, None
42
 
 
43
  if has_url:
44
  try:
 
45
  url_parts = url_string.strip().split('/')
46
  original_filename = url_parts[-1] if url_parts else f"{file_type}_file"
47
 
 
48
  if '.' not in original_filename:
49
+ ext_map = {'video': '.mp4', 'audio': '.wav', 'subtitle': '.srt'}
 
 
 
 
50
  original_filename += ext_map.get(file_type, '.tmp')
51
 
52
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
 
60
 
61
  return None, f"❌ Unknown error processing {file_type}"
62
 
63
+ def srt_time_to_ms(time_str):
64
+ """Convert SRT timestamp to milliseconds."""
65
+ time_str = time_str.strip()
66
+ hours, minutes, seconds = time_str.split(':')
67
+ seconds, milliseconds = seconds.split(',')
68
+
69
+ total_ms = (
70
+ int(hours) * 3600000 +
71
+ int(minutes) * 60000 +
72
+ int(seconds) * 1000 +
73
+ int(milliseconds)
74
+ )
75
+ return total_ms
76
+
77
+ def ms_to_ass_time(ms):
78
+ """Convert milliseconds to ASS timestamp format."""
79
+ hours = ms // 3600000
80
+ ms %= 3600000
81
+ minutes = ms // 60000
82
+ ms %= 60000
83
+ seconds = ms // 1000
84
+ centiseconds = (ms % 1000) // 10
85
+
86
+ return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
87
+
88
+ def create_word_by_word_highlight_ass(srt_path, output_dir, highlight_color='yellow'):
89
+ """
90
+ Convert SRT to ASS with word-by-word highlighting.
91
+ Uses NON-OVERLAPPING time windows to ensure only one word is highlighted at a time.
92
+
93
+ Example:
94
+ Text: "How are you?" (duration 1.5s)
95
+
96
+ Event 1: 0.000s - 0.500s β†’ [How] are you? (only "How" highlighted)
97
+ Event 2: 0.500s - 1.000s β†’ How [are] you? (only "are" highlighted)
98
+ Event 3: 1.000s - 1.500s β†’ How are [you?] (only "you?" highlighted)
99
+
100
+ NO OVERLAP = Clean highlighting!
101
+ """
102
+
103
+ # Color mapping for highlight background (ASS format: AABBGGRR)
104
+ color_map = {
105
+ 'yellow': ('&H0000FFFF', '&H00000000'), # Yellow bg, black text
106
+ 'orange': ('&H0000A5FF', '&H00000000'), # Orange bg, black text
107
+ 'green': ('&H0000FF00', '&H00000000'), # Green bg, black text
108
+ 'cyan': ('&H00FFFF00', '&H00000000'), # Cyan bg, black text
109
+ 'pink': ('&H00FF69B4', '&H00000000'), # Pink bg, black text
110
+ 'red': ('&H000000FF', '&H00FFFFFF'), # Red bg, white text
111
+ 'blue': ('&H00FF0000', '&H00FFFFFF'), # Blue bg, white text
112
+ }
113
+
114
+ highlight_bg, highlight_text = color_map.get(highlight_color.lower(), ('&H0000FFFF', '&H00000000'))
115
+
116
+ # Read SRT file
117
+ with open(srt_path, 'r', encoding='utf-8') as f:
118
+ srt_content = f.read()
119
+
120
+ # ASS file path
121
+ ass_path = os.path.join(output_dir, 'word_highlight_subtitles.ass')
122
+
123
+ # ASS header with styles
124
+ ass_header = f"""[Script Info]
125
+ Title: Word-by-Word Highlight Subtitles
126
+ ScriptType: v4.00+
127
+ Collisions: Normal
128
+ PlayDepth: 0
129
+
130
+ [V4+ Styles]
131
+ Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
132
+ Style: Default,Arial,28,&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,1,2,1,2,30,30,40,1
133
+
134
+ [Events]
135
+ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
136
+ """
137
+
138
+ # Parse SRT blocks
139
+ srt_blocks = re.split(r'\n\s*\n', srt_content.strip())
140
+ ass_events = []
141
+
142
+ for block in srt_blocks:
143
+ lines = block.strip().split('\n')
144
+ if len(lines) >= 3:
145
+ # Parse timestamp
146
+ timestamp_line = lines[1]
147
+ times = timestamp_line.split(' --> ')
148
+ if len(times) == 2:
149
+ start_ms = srt_time_to_ms(times[0])
150
+ end_ms = srt_time_to_ms(times[1])
151
+
152
+ # Get subtitle text
153
+ text = ' '.join(lines[2:])
154
+
155
+ # Split text into words
156
+ words = text.split()
157
+
158
+ if not words:
159
+ continue
160
+
161
+ # Calculate time per word with precise boundaries
162
+ total_duration = end_ms - start_ms
163
+ time_per_word = total_duration / len(words)
164
+
165
+ # Create NON-OVERLAPPING events for each word
166
+ for i, word in enumerate(words):
167
+ # Calculate precise, non-overlapping time boundaries
168
+ word_start_ms = start_ms + int(i * time_per_word)
169
+ word_end_ms = start_ms + int((i + 1) * time_per_word)
170
+
171
+ # Ensure last word ends exactly at subtitle end time
172
+ if i == len(words) - 1:
173
+ word_end_ms = end_ms
174
+
175
+ # Build the full text with inline color override for current word
176
+ text_parts = []
177
+ for j, w in enumerate(words):
178
+ if j == i:
179
+ # Current word: Apply highlight with inline override tags
180
+ # {\3c} = outline color (used as background in BorderStyle 3)
181
+ # {\c} = primary color (text color)
182
+ text_parts.append(f"{{\\c{highlight_text}\\3c{highlight_bg}\\bord5}}{w}{{\\r}}")
183
+ else:
184
+ # Other words: Normal style
185
+ text_parts.append(w)
186
+
187
+ styled_text = ' '.join(text_parts)
188
+
189
+ # Convert times to ASS format
190
+ start_time = ms_to_ass_time(word_start_ms)
191
+ end_time = ms_to_ass_time(word_end_ms)
192
+
193
+ # Create ASS dialogue line with strict timing
194
+ ass_line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{styled_text}"
195
+ ass_events.append(ass_line)
196
+
197
+ # Write ASS file
198
+ with open(ass_path, 'w', encoding='utf-8') as f:
199
+ f.write(ass_header)
200
+ f.write('\n'.join(ass_events))
201
+
202
+ return ass_path
203
+
204
  def stitch_media(
205
+ video_file, video_url,
206
+ audio_file, audio_url,
207
+ subtitle_file, subtitle_url,
208
+ enable_highlight,
209
+ highlight_color,
210
+ crf_quality=23
211
+ ):
212
  """
213
  Stitch video, audio, and subtitle files together using ffmpeg.
214
+ Optionally applies word-by-word highlighting with non-overlapping timing.
215
  """
216
  temp_dir = tempfile.mkdtemp()
217
 
 
246
  status_msg += f" β€’ Video: {'Downloaded from URL' if video_url else 'Uploaded file'}\n"
247
  status_msg += f" β€’ Audio: {'Downloaded from URL' if audio_url else 'Uploaded file'}\n"
248
  status_msg += f" β€’ Subtitle: {'Downloaded from URL' if subtitle_url else 'Uploaded file'}\n"
249
+
250
+ # Process subtitles with word highlighting if enabled
251
+ if enable_highlight:
252
+ status_msg += f"\n✨ Creating word-by-word highlights ({highlight_color})...\n"
253
+ status_msg += "⏱️ Using non-overlapping timing for clean highlighting...\n"
254
+ subtitle_to_use = create_word_by_word_highlight_ass(subtitle_path, temp_dir, highlight_color)
255
+ subtitle_filter = f"ass={subtitle_to_use.replace(':', '\\:').replace('\\\\', '/').replace('\\', '/')}"
256
+ else:
257
+ status_msg += "\nπŸ“ Using standard subtitles...\n"
258
+ subtitle_to_use = subtitle_path
259
+ subtitle_filter = f"subtitles={subtitle_to_use.replace(':', '\\:')}"
260
+
261
  status_msg += "\n🎬 Stitching video..."
262
 
263
  # FFmpeg command
 
265
  "ffmpeg",
266
  "-i", video_path,
267
  "-i", audio_path,
268
+ "-vf", subtitle_filter,
269
  "-map", "0:v",
270
  "-map", "1:a",
271
  "-c:v", "libx264",
 
285
 
286
  # Check if output file was created
287
  if os.path.exists(output_path):
288
+ file_size = os.path.getsize(output_path) / (1024 * 1024)
289
  success_msg = f"βœ… Video stitched successfully!\n\n"
290
  success_msg += f"πŸ“Š Output size: {file_size:.2f} MB\n"
291
  success_msg += f"🎨 Quality: CRF {crf_quality}\n"
292
+ if enable_highlight:
293
+ success_msg += f"✨ Word-by-word highlighting: {highlight_color}\n"
294
+ success_msg += f"⏱️ Non-overlapping timing: Clean highlights!\n"
295
+ success_msg += "\n" + status_msg.split("🎬")[0]
296
  return output_path, success_msg
297
  else:
298
  return None, "❌ Output file was not created"
 
303
  except Exception as e:
304
  return None, f"❌ Error: {str(e)}"
305
 
306
+ # Create Gradio interface
307
+ with gr.Blocks(title="Video Stitcher - Word Highlighting", theme=gr.themes.Soft()) as app:
308
  gr.Markdown(
309
  """
310
+ # 🎬 Video Audio Subtitle Stitcher with Word-by-Word Highlighting ✨
311
+
312
+ **Precise word-by-word highlighting with non-overlapping timing!**
313
 
314
+ Only ONE word is highlighted at a time - clean and professional!
315
 
316
  πŸ“€ Upload files directly **OR** 🌐 provide URLs to download them
317
 
 
363
  lines=1
364
  )
365
 
366
+ gr.Markdown("### ✨ Word Highlighting Settings")
367
+ with gr.Group():
368
+ enable_highlight = gr.Checkbox(
369
+ label="Enable Word-by-Word Highlighting",
370
+ value=True,
371
+ info="Only ONE word highlighted at a time (no overlap)"
372
+ )
373
+ highlight_color = gr.Dropdown(
374
+ choices=['yellow', 'orange', 'green', 'cyan', 'pink', 'red', 'blue'],
375
+ value='yellow',
376
+ label="Highlight Color",
377
+ info="Background color for the active word"
378
+ )
379
+
380
+ gr.Markdown("### βš™οΈ Video Settings")
381
  crf_input = gr.Slider(
382
  minimum=18,
383
  maximum=28,
 
395
  status_output = gr.Textbox(
396
  label="Status",
397
  placeholder="Provide inputs and click 'Stitch Video' to begin...",
398
+ lines=10
399
  )
400
  video_output = gr.Video(
401
  label="Result Video",
 
405
  gr.Markdown(
406
  """
407
  **πŸ’Ύ Download:** Click the download button on the video player
 
408
  """
409
  )
410
 
 
412
  with gr.Column():
413
  gr.Markdown(
414
  """
415
+ ### πŸ“– How It Works (Non-Overlapping Timing):
416
+
417
+ **Example:** "How are you?" (1.5s duration)
418
+
419
+ ```
420
+ Event 1: 0.000s - 0.500s β†’ [How] are you?
421
+ Event 2: 0.500s - 1.000s β†’ How [are] you?
422
+ Event 3: 1.000s - 1.500s β†’ How are [you?]
423
+ ```
424
 
425
+ βœ… **No time overlap** between events
426
+ βœ… **Only one event active** at any moment
427
+ βœ… **Clean highlighting** - one word at a time
428
+ βœ… **Full context** - all words visible
429
 
430
+ ### 🎨 Available Colors:
431
+ - 🟑 **Yellow** - Classic (recommended)
432
+ - 🟠 **Orange** - Warm & visible
433
+ - 🟒 **Green** - Easy on eyes
434
+ - 🩡 **Cyan** - Modern
435
+ - 🩷 **Pink** - Playful
436
+ - πŸ”΄ **Red** - Bold
437
+ - πŸ”΅ **Blue** - Professional
438
  """
439
  )
440
 
441
  with gr.Column():
442
  gr.Markdown(
443
  """
444
+ ### πŸ’‘ Best For:
445
+
446
+ - πŸ“š **Tutorial videos** - Emphasize key terms
447
+ - πŸŽ“ **Educational content** - Focus attention
448
+ - 🌍 **Language learning** - Word-by-word reading
449
+ - πŸ“± **Social media** - Engaging captions
450
+ - 🎀 **Presentations** - Highlight key points
451
 
452
  ### πŸ”§ Technical Details:
453
+
454
+ **Timing Strategy:**
455
+ - Calculates duration per word
456
+ - Creates strict time boundaries
457
+ - Prevents event overlap
458
+ - Ensures clean rendering
459
+
460
+ **Implementation:**
461
+ - Python: SRT β†’ ASS conversion
462
+ - ASS: Inline color override tags
463
+ - FFmpeg: libass rendering
464
+ - Result: Perfect highlighting!
465
  """
466
  )
467
 
468
  gr.Markdown(
469
  """
470
  ---
471
+ ### ⚑ Key Features:
472
+
473
+ βœ… **Non-overlapping timing** - No visual conflicts
474
+ βœ… **One word at a time** - Clean, professional look
475
+ βœ… **Full subtitle visible** - Context maintained
476
+ βœ… **Multiple colors** - 7 highlight options
477
+ βœ… **Upload or URL** - Flexible input
478
+ βœ… **Automatic timing** - Smart word distribution
479
+
480
+ ### πŸ”„ Process Flow:
481
+
482
+ 1. **Parse SRT** β†’ Extract text & timing
483
+ 2. **Split words** β†’ Calculate per-word duration
484
+ 3. **Create ASS** β†’ Non-overlapping events
485
+ 4. **FFmpeg render** β†’ Clean word highlighting
486
+ 5. **Output video** β†’ Professional result!
487
  """
488
  )
489
 
 
494
  video_input, video_url_input,
495
  audio_input, audio_url_input,
496
  subtitle_input, subtitle_url_input,
497
+ enable_highlight,
498
+ highlight_color,
499
  crf_input
500
  ],
501
  outputs=[video_output, status_output]
502
  )
503
 
504
+ # Launch the app
505
  if __name__ == "__main__":
506
  app.launch()