factorstudios commited on
Commit
cb2ab9a
·
verified ·
1 Parent(s): a0dce99

Create server.py

Browse files
Files changed (1) hide show
  1. server.py +542 -0
server.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ import os
3
+ import json
4
+ import re
5
+ import asyncio
6
+ import tempfile
7
+ import subprocess
8
+ from pathlib import Path
9
+ from datetime import datetime
10
+ from dotenv import load_dotenv
11
+ from typing import List, Dict, Optional
12
+
13
+ from fastapi import FastAPI, HTTPException
14
+ from fastapi.responses import JSONResponse
15
+ import uvicorn
16
+
17
+ try:
18
+ from huggingface_hub import list_repo_files, hf_hub_download, upload_file
19
+ import cv2
20
+ import numpy as np
21
+ from PIL import Image, ImageDraw, ImageFont
22
+ except ImportError as e:
23
+ print(f"Missing dependency: {e}")
24
+ exit(1)
25
+
26
+ # Load environment variables
27
+ load_dotenv()
28
+ HF_TOKEN = os.getenv("HF_TOKEN")
29
+ if not HF_TOKEN:
30
+ print("Error: Missing HF_TOKEN in .env")
31
+ exit(1)
32
+
33
+ app = FastAPI(title="Video Processing Service")
34
+
35
+ # Global state
36
+ processing_state = {
37
+ "is_running": False,
38
+ "total_processed": 0,
39
+ "current_file": None,
40
+ "error_count": 0,
41
+ "last_error": None,
42
+ "processed_files": []
43
+ }
44
+
45
+ HF_DATASET_REPO = "factorstudios/movs"
46
+ HOOKS_FOLDER = "hooks"
47
+ READY_VIDEOS_FOLDER = "ready_videos"
48
+ TRANSCRIPTION_FOLDER = "transcriptions"
49
+
50
+ def timestamp_to_seconds(timestamp: str) -> float:
51
+ """Convert HH:MM:SS to seconds."""
52
+ try:
53
+ parts = timestamp.split(":")
54
+ hours = int(parts[0])
55
+ minutes = int(parts[1])
56
+ seconds = int(parts[2])
57
+ return hours * 3600 + minutes * 60 + seconds
58
+ except Exception as e:
59
+ print(f"Error converting timestamp {timestamp}: {e}")
60
+ return 0.0
61
+
62
+ def extract_captions_for_segment(transcript_content: str, start_time: str, end_time: str) -> List[tuple]:
63
+ """Extract captions from transcript that fall within segment timeframe.
64
+ Returns list of (timestamp, text) tuples."""
65
+ captions = []
66
+ start_seconds = timestamp_to_seconds(start_time)
67
+ end_seconds = timestamp_to_seconds(end_time)
68
+
69
+ # Parse transcript lines in format: [HH:MM:SS] text
70
+ lines = transcript_content.strip().split('\n')
71
+ for line in lines:
72
+ match = re.match(r'\[(\d{2}):(\d{2}):(\d{2})\]\s+(.*)', line)
73
+ if match:
74
+ h, m, s, text = match.groups()
75
+ line_seconds = int(h) * 3600 + int(m) * 60 + int(s)
76
+
77
+ if start_seconds <= line_seconds <= end_seconds:
78
+ relative_time = line_seconds - start_seconds
79
+ captions.append((relative_time, text.strip()))
80
+
81
+ return captions
82
+
83
+ def apply_color_grading_wedding_retro(frame: np.ndarray) -> np.ndarray:
84
+ """Apply cinematic wedding LUT + retro style with high sharpening."""
85
+ # Convert BGR to LAB for better color manipulation
86
+ lab = cv2.cvtColor(frame, cv2.COLOR_BGR2LAB)
87
+
88
+ # Split LAB channels
89
+ l_channel, a_channel, b_channel = cv2.split(lab)
90
+
91
+ # 1. VINTAGE/RETRO EFFECT: Add warm tones
92
+ # Increase yellows and reduce blues (warm vintage look)
93
+ a_channel = cv2.add(a_channel, 5) # Shift towards magenta/red slightly
94
+ b_channel = cv2.add(b_channel, 8) # Shift towards yellow/warm
95
+
96
+ # 2. WEDDING LOOK: Soft highlights, skin tone enhancement
97
+ # Boost highlights on L channel
98
+ clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
99
+ l_channel = clahe.apply(l_channel)
100
+
101
+ # Merge back
102
+ lab_enhanced = cv2.merge([l_channel, a_channel, b_channel])
103
+ frame = cv2.cvtColor(lab_enhanced, cv2.COLOR_LAB2BGR)
104
+
105
+ # 3. SATURATION BOOST (wedding cinematics are vibrant)
106
+ hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV).astype(np.float32)
107
+ hsv[:, :, 1] = hsv[:, :, 1] * 1.3 # Boost saturation by 30%
108
+ hsv[:, :, 1] = np.clip(hsv[:, :, 1], 0, 255)
109
+ frame = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR)
110
+
111
+ # 4. CONTRAST ENHANCEMENT (cinematic look)
112
+ frame = cv2.convertScaleAbs(frame, alpha=1.15, beta=10)
113
+
114
+ # 5. HIGH SHARPENING (professional quality)
115
+ kernel = np.array([[-1, -1, -1],
116
+ [-1, 9, -1],
117
+ [-1, -1, -1]]) / 1.2
118
+ sharpened = cv2.filter2D(frame, -1, kernel)
119
+ # Blend original with sharpened for natural look
120
+ frame = cv2.addWeighted(frame, 0.4, sharpened, 0.6, 0)
121
+
122
+ # 6. SLIGHT VIGNETTE (cinematic framing)
123
+ rows, cols = frame.shape[:2]
124
+ X_resultant_kernel = cv2.getGaussianKernel(cols, cols/2)
125
+ Y_resultant_kernel = cv2.getGaussianKernel(rows, rows/2)
126
+ kernel = Y_resultant_kernel * X_resultant_kernel.T
127
+ mask = kernel / kernel.max()
128
+ mask = mask ** 0.4 # Adjust intensity
129
+
130
+ for i in range(3): # Apply to each channel
131
+ frame[:, :, i] = frame[:, :, i] * mask
132
+
133
+ return np.clip(frame, 0, 255).astype(np.uint8)
134
+
135
+ def burn_captions_to_frame(frame: np.ndarray, text: str, font_size: int = 32) -> np.ndarray:
136
+ """Burn caption text onto frame with semi-transparent background (centered)."""
137
+ height, width = frame.shape[:2]
138
+
139
+ # Convert frame to PIL for easier text rendering
140
+ frame_pil = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
141
+ draw = ImageDraw.Draw(frame_pil, 'RGBA')
142
+
143
+ # Try to use a nice font, fall back to default
144
+ try:
145
+ font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
146
+ except:
147
+ font = ImageFont.load_default()
148
+
149
+ # Wrap text for width
150
+ max_width = width - 60
151
+ wrapped_lines = []
152
+ words = text.split()
153
+ current_line = []
154
+
155
+ for word in words:
156
+ test_line = ' '.join(current_line + [word])
157
+ bbox = draw.textbbox((0, 0), test_line, font=font)
158
+ if bbox[2] - bbox[0] > max_width:
159
+ if current_line:
160
+ wrapped_lines.append(' '.join(current_line))
161
+ current_line = [word]
162
+ else:
163
+ current_line.append(word)
164
+ if current_line:
165
+ wrapped_lines.append(' '.join(current_line))
166
+
167
+ # Calculate dimensions for background
168
+ line_height = font_size + 10
169
+ text_height = len(wrapped_lines) * line_height + 20
170
+ bg_y_start = max(height // 2 - text_height // 2 - 10, 20)
171
+ bg_y_end = min(bg_y_start + text_height, height - 20)
172
+
173
+ # Draw semi-transparent background
174
+ overlay = Image.new('RGBA', frame_pil.size, (0, 0, 0, 0))
175
+ overlay_draw = ImageDraw.Draw(overlay, 'RGBA')
176
+ overlay_draw.rectangle(
177
+ [(20, bg_y_start), (width - 20, bg_y_end)],
178
+ fill=(0, 0, 0, 180) # Semi-transparent black
179
+ )
180
+ frame_pil = Image.alpha_composite(frame_pil.convert('RGBA'), overlay).convert('RGB')
181
+ draw = ImageDraw.Draw(frame_pil)
182
+
183
+ # Draw text centered
184
+ y_position = bg_y_start + 10
185
+ for line in wrapped_lines:
186
+ bbox = draw.textbbox((0, 0), line, font=font)
187
+ line_width = bbox[2] - bbox[0]
188
+ x_position = (width - line_width) // 2
189
+ draw.text((x_position, y_position), line, font=font, fill=(255, 255, 255, 255))
190
+ y_position += line_height
191
+
192
+ # Convert back to OpenCV format
193
+ frame = cv2.cvtColor(np.array(frame_pil), cv2.COLOR_RGB2BGR)
194
+ return frame
195
+
196
+ def process_video_segment(
197
+ video_path: str,
198
+ output_path: str,
199
+ start_time: str,
200
+ end_time: str,
201
+ captions: List[tuple],
202
+ target_width: int = 1080,
203
+ target_height: int = 1350
204
+ ) -> bool:
205
+ """Process video segment: resize, cut, add captions, apply color grading."""
206
+ try:
207
+ print(f"Opening video: {video_path}")
208
+ cap = cv2.VideoCapture(video_path)
209
+
210
+ if not cap.isOpened():
211
+ print(f"Error: Could not open video {video_path}")
212
+ return False
213
+
214
+ # Get video properties
215
+ fps = cap.get(cv2.CAP_PROP_FPS)
216
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
217
+ original_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
218
+ original_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
219
+
220
+ start_seconds = timestamp_to_seconds(start_time)
221
+ end_seconds = timestamp_to_seconds(end_time)
222
+ duration = end_seconds - start_seconds
223
+
224
+ print(f"Video info: {fps} fps, {original_width}x{original_height}")
225
+ print(f"Extracting segment: {start_time} to {end_time} ({duration} seconds)")
226
+
227
+ # Setup video writer
228
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
229
+ out = cv2.VideoWriter(output_path, fourcc, fps, (target_width, target_height))
230
+
231
+ if not out.isOpened():
232
+ print(f"Error: Could not create video writer for {output_path}")
233
+ return False
234
+
235
+ # Seek to start time
236
+ start_frame = int(start_seconds * fps)
237
+ cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
238
+
239
+ # Create a mapping of frame numbers to captions
240
+ caption_map = {}
241
+ for rel_time, caption_text in captions:
242
+ frame_num = int(rel_time * fps)
243
+ caption_map[frame_num] = caption_text
244
+
245
+ current_caption = ""
246
+ processed_frames = 0
247
+ target_frames = int(duration * fps)
248
+
249
+ print(f"Processing {target_frames} frames...")
250
+
251
+ while processed_frames < target_frames:
252
+ ret, frame = cap.read()
253
+ if not ret:
254
+ print(f"Warning: Could not read frame at position {processed_frames}")
255
+ break
256
+
257
+ # Resize frame to target aspect ratio
258
+ # Calculate dimensions maintaining aspect ratio
259
+ aspect_ratio = target_width / target_height
260
+ if original_width / original_height > aspect_ratio:
261
+ # Width is too large
262
+ new_height = original_height
263
+ new_width = int(new_height * aspect_ratio)
264
+ x_offset = (original_width - new_width) // 2
265
+ frame = frame[:, x_offset:x_offset + new_width]
266
+ else:
267
+ # Height is too large
268
+ new_width = original_width
269
+ new_height = int(new_width / aspect_ratio)
270
+ y_offset = (original_height - new_height) // 2
271
+ frame = frame[y_offset:y_offset + new_height, :]
272
+
273
+ frame = cv2.resize(frame, (target_width, target_height), interpolation=cv2.INTER_LANCZOS4)
274
+
275
+ # Apply color grading
276
+ frame = apply_color_grading_wedding_retro(frame)
277
+
278
+ # Update caption if needed
279
+ if processed_frames in caption_map:
280
+ current_caption = caption_map[processed_frames]
281
+
282
+ # Burn caption
283
+ if current_caption:
284
+ frame = burn_captions_to_frame(frame, current_caption)
285
+
286
+ out.write(frame)
287
+ processed_frames += 1
288
+
289
+ if processed_frames % max(1, target_frames // 10) == 0:
290
+ progress = (processed_frames / target_frames) * 100
291
+ print(f"Progress: {progress:.1f}%")
292
+
293
+ cap.release()
294
+ out.release()
295
+
296
+ print(f"✓ Video segment saved: {output_path}")
297
+ return True
298
+
299
+ except Exception as e:
300
+ print(f"✗ Error processing video segment: {e}")
301
+ return False
302
+
303
+ async def process_movie_segments(movie_name: str) -> bool:
304
+ """Process all segments for a movie."""
305
+ try:
306
+ processing_state["current_file"] = movie_name
307
+ print(f"\n{'='*80}")
308
+ print(f"Processing movie: {movie_name}")
309
+ print(f"{'='*80}")
310
+
311
+ # Download transcript
312
+ transcript_file = f"{TRANSCRIPTION_FOLDER}/{movie_name}.transcript.txt"
313
+ print(f"Downloading transcript: {transcript_file}")
314
+
315
+ try:
316
+ transcript_path = hf_hub_download(
317
+ repo_id=HF_DATASET_REPO,
318
+ filename=transcript_file,
319
+ repo_type="dataset",
320
+ token=HF_TOKEN,
321
+ cache_dir="/tmp/video_processor_cache"
322
+ )
323
+ with open(transcript_path, 'r', encoding='utf-8') as f:
324
+ transcript_content = f.read()
325
+ except Exception as e:
326
+ print(f"Warning: Could not download transcript: {e}")
327
+ transcript_content = ""
328
+
329
+ # Download original video
330
+ video_file = f"{movie_name}.mkv"
331
+ print(f"Downloading video: {video_file}")
332
+
333
+ try:
334
+ video_path = hf_hub_download(
335
+ repo_id=HF_DATASET_REPO,
336
+ filename=video_file,
337
+ repo_type="dataset",
338
+ token=HF_TOKEN,
339
+ cache_dir="/tmp/video_processor_cache"
340
+ )
341
+ # Resolve symlink if needed
342
+ if os.path.islink(video_path):
343
+ video_path = os.path.realpath(video_path)
344
+ except Exception as e:
345
+ print(f"Error: Could not download video: {e}")
346
+ return False
347
+
348
+ # List segment JSON files
349
+ hooks_folder = f"{HOOKS_FOLDER}/{movie_name}"
350
+ print(f"Listing segments from: {hooks_folder}")
351
+
352
+ files = list_repo_files(
353
+ repo_id=HF_DATASET_REPO,
354
+ repo_type="dataset",
355
+ token=HF_TOKEN
356
+ )
357
+
358
+ segment_files = sorted([
359
+ f for f in files
360
+ if f.startswith(f"{hooks_folder}/") and f.endswith(".json")
361
+ ])
362
+
363
+ if not segment_files:
364
+ print(f"No segment JSON files found for {movie_name}")
365
+ return False
366
+
367
+ print(f"Found {len(segment_files)} segments")
368
+
369
+ # Process each segment
370
+ temp_dir = tempfile.mkdtemp()
371
+
372
+ try:
373
+ for segment_file in segment_files:
374
+ try:
375
+ # Download segment JSON
376
+ segment_path = hf_hub_download(
377
+ repo_id=HF_DATASET_REPO,
378
+ filename=segment_file,
379
+ repo_type="dataset",
380
+ token=HF_TOKEN,
381
+ cache_dir="/tmp/video_processor_cache"
382
+ )
383
+
384
+ with open(segment_path, 'r', encoding='utf-8') as f:
385
+ segment_data = json.load(f)
386
+
387
+ segment_number = segment_data.get("segment_number", 1)
388
+ start_time = segment_data.get("start_time", "00:00:00")
389
+ end_time = segment_data.get("end_time", "00:10:00")
390
+
391
+ print(f"\nProcessing segment {segment_number}: {start_time} to {end_time}")
392
+
393
+ # Extract captions for this segment
394
+ captions = extract_captions_for_segment(transcript_content, start_time, end_time)
395
+ print(f"Found {len(captions)} caption lines for this segment")
396
+
397
+ # Process video
398
+ output_filename = f"segment-{segment_number:02d}.mp4"
399
+ output_path = os.path.join(temp_dir, output_filename)
400
+
401
+ success = process_video_segment(
402
+ video_path,
403
+ output_path,
404
+ start_time,
405
+ end_time,
406
+ captions
407
+ )
408
+
409
+ if not success:
410
+ print(f"Failed to process segment {segment_number}")
411
+ continue
412
+
413
+ # Upload to dataset
414
+ upload_path = f"{READY_VIDEOS_FOLDER}/{movie_name}/{output_filename}"
415
+ print(f"Uploading to: {upload_path}")
416
+
417
+ upload_file(
418
+ path_or_fileobj=output_path,
419
+ path_in_repo=upload_path,
420
+ repo_id=HF_DATASET_REPO,
421
+ repo_type="dataset",
422
+ token=HF_TOKEN,
423
+ commit_message=f"Add processed video segment {segment_number} for {movie_name}"
424
+ )
425
+ print(f"✓ Segment {segment_number} uploaded successfully")
426
+
427
+ except Exception as e:
428
+ print(f"✗ Error processing segment: {e}")
429
+ processing_state["error_count"] += 1
430
+ continue
431
+
432
+ finally:
433
+ import shutil
434
+ shutil.rmtree(temp_dir, ignore_errors=True)
435
+
436
+ processing_state["processed_files"].append(movie_name)
437
+ processing_state["total_processed"] += 1
438
+ print(f"\n✓ Successfully processed all segments for {movie_name}")
439
+ return True
440
+
441
+ except Exception as e:
442
+ processing_state["error_count"] += 1
443
+ processing_state["last_error"] = str(e)
444
+ print(f"✗ Error: {e}")
445
+ return False
446
+
447
+ async def scan_and_process_videos():
448
+ """Scan hooks folder and process all movies."""
449
+ if processing_state["is_running"]:
450
+ print("Video processing already running, skipping...")
451
+ return
452
+
453
+ processing_state["is_running"] = True
454
+ print("\n" + "="*80)
455
+ print("STARTING VIDEO PROCESSING SERVICE")
456
+ print("="*80)
457
+
458
+ try:
459
+ files = list_repo_files(
460
+ repo_id=HF_DATASET_REPO,
461
+ repo_type="dataset",
462
+ token=HF_TOKEN
463
+ )
464
+
465
+ # Find all movie folders in hooks/
466
+ movie_folders = set()
467
+ for f in files:
468
+ if f.startswith(f"{HOOKS_FOLDER}/") and f.endswith(".json"):
469
+ # Extract movie name
470
+ parts = f.split("/")
471
+ if len(parts) >= 2:
472
+ movie_name = parts[1]
473
+ movie_folders.add(movie_name)
474
+
475
+ print(f"Found {len(movie_folders)} movies to process")
476
+
477
+ for movie_name in sorted(movie_folders):
478
+ await process_movie_segments(movie_name)
479
+ await asyncio.sleep(2)
480
+
481
+ print("\n" + "="*80)
482
+ print("VIDEO PROCESSING COMPLETE")
483
+ print(f"Processed: {processing_state['total_processed']}")
484
+ print(f"Errors: {processing_state['error_count']}")
485
+ print("="*80 + "\n")
486
+
487
+ except Exception as e:
488
+ print(f"Critical error: {e}")
489
+ processing_state["last_error"] = str(e)
490
+ finally:
491
+ processing_state["is_running"] = False
492
+
493
+ @app.on_event("startup")
494
+ async def startup_event():
495
+ """Start video processing on server startup."""
496
+ asyncio.create_task(scan_and_process_videos())
497
+
498
+ @app.get("/")
499
+ async def health():
500
+ """Health check endpoint."""
501
+ return JSONResponse({
502
+ "status": "running",
503
+ "service": "Video Processing Service",
504
+ "is_processing": processing_state["is_running"],
505
+ "total_processed": processing_state["total_processed"],
506
+ "error_count": processing_state["error_count"],
507
+ "current_file": processing_state["current_file"],
508
+ "last_error": processing_state["last_error"],
509
+ "processed_files": processing_state["processed_files"]
510
+ })
511
+
512
+ @app.get("/status")
513
+ async def get_status():
514
+ """Get current processing status."""
515
+ return JSONResponse({
516
+ "is_running": processing_state["is_running"],
517
+ "total_processed": processing_state["total_processed"],
518
+ "error_count": processing_state["error_count"],
519
+ "current_file": processing_state["current_file"],
520
+ "last_error": processing_state["last_error"],
521
+ "processed_files": processing_state["processed_files"]
522
+ })
523
+
524
+ @app.post("/trigger-processing")
525
+ async def trigger_processing():
526
+ """Manually trigger video processing."""
527
+ if processing_state["is_running"]:
528
+ return JSONResponse({
529
+ "status": "already_running",
530
+ "message": "Video processing is already in progress"
531
+ })
532
+
533
+ asyncio.create_task(scan_and_process_videos())
534
+ return JSONResponse({
535
+ "status": "started",
536
+ "message": "Video processing scan started"
537
+ })
538
+
539
+ if __name__ == "__main__":
540
+ print("Starting Video Processing Service on port 7862...")
541
+ print("Will automatically scan and process videos on startup")
542
+ uvicorn.run(app, host="0.0.0.0", port=7860)