Pepguy commited on
Commit
fa37cef
Β·
verified Β·
1 Parent(s): f87054d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +118 -31
app.py CHANGED
@@ -1,14 +1,16 @@
1
- from fastapi import FastAPI, HTTPException
 
2
  from pydantic import BaseModel
3
  import subprocess
4
  import base64
5
  import os
6
  import uuid
7
  import shutil
8
- from pytube import YouTube
9
  import socket
10
 
11
- # Force IPv4 to prevent DNS errors on cloud hosting
 
12
  orig_getaddrinfo = socket.getaddrinfo
13
  def hooked_getaddrinfo(*args, **kwargs):
14
  res = orig_getaddrinfo(*args, **kwargs)
@@ -17,40 +19,128 @@ socket.getaddrinfo = hooked_getaddrinfo
17
 
18
  app = FastAPI()
19
 
20
- @app.get("/")
21
- def greet_json():
22
- return {"status": "online", "engine": "PyTube + FFmpeg"}
23
-
24
  class VideoRequest(BaseModel):
25
  url: str
26
- platform: str # Note: PyTube will ignore this and assume YouTube
27
  is_pro: bool = False
28
  fps: int = 2
29
 
30
  def file_to_base64(filepath):
 
31
  with open(filepath, "rb") as f:
32
  return base64.b64encode(f.read()).decode('utf-8')
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  @app.post("/process-video")
35
  def process_video(req: VideoRequest):
36
  job_id = str(uuid.uuid4())
37
  work_dir = f"/tmp/viralcat_{job_id}"
38
  os.makedirs(work_dir, exist_ok=True)
39
 
40
- video_filename = "video.mp4"
41
- video_path = os.path.join(work_dir, video_filename)
42
  audio_path = os.path.join(work_dir, "audio.mp3")
43
 
44
  try:
45
- # 1. Download using PyTube
46
- print(f"[PYTUBE] Downloading: {req.url}")
47
- yt = YouTube(req.url)
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
- # Select the lowest resolution progressive stream to save bandwidth and time
50
- stream = yt.streams.filter(progressive=True, file_extension='mp4').order_by('resolution').first()
51
- stream.download(output_path=work_dir, filename=video_filename)
52
 
53
- # 2. Extract Duration via ffprobe
 
 
 
 
 
 
54
  probe = subprocess.run([
55
  "ffprobe", "-v", "error", "-show_entries",
56
  "format=duration", "-of", "default=noprint_wrappers=1:nokey=1",
@@ -58,33 +148,30 @@ def process_video(req: VideoRequest):
58
  ], capture_output=True, text=True, check=True)
59
 
60
  duration = float(probe.stdout.strip() or 0)
61
-
62
  if duration > 120 and not req.is_pro:
63
- raise ValueError(f"Video is {duration:.1f}s. Max 120s for free users.")
64
 
65
- # 3. Extract Frames (FPS based)
66
- # Using -q:v 2 for high quality frames so AI sees clearly
67
  subprocess.run([
68
  "ffmpeg", "-y", "-i", video_path,
69
  "-vf", f"fps={req.fps}",
70
- "-q:v", "2", f"{work_dir}/frame_%04d.jpg"
71
  ], check=True, capture_output=True)
72
 
73
- # 4. Extract Audio (64k mono mp3 for small payload)
74
  subprocess.run([
75
  "ffmpeg", "-y", "-i", video_path,
76
  "-q:a", "0", "-map", "a", "-ac", "1", "-b:a", "64k", audio_path
77
  ], check=True, capture_output=True)
78
 
79
- # 5. Convert to Base64
80
  frame_files = sorted([f for f in os.listdir(work_dir) if f.startswith("frame_") and f.endswith(".jpg")])
81
 
82
- # Safeguard: Limit to 60 frames max to prevent Node.js payload crash
83
- if len(frame_files) > 60:
84
- frame_files = frame_files[:60]
85
 
86
  frames_b64 = [file_to_base64(os.path.join(work_dir, f)) for f in frame_files]
87
- audio_b64 = file_to_base64(audio_path) if os.path.exists(audio_path) else None
88
 
89
  return {
90
  "success": True,
@@ -95,10 +182,10 @@ def process_video(req: VideoRequest):
95
  }
96
 
97
  except Exception as e:
98
- print(f"[ERROR] {str(e)}")
99
- raise HTTPException(status_code=500, detail=str(e))
100
 
101
  finally:
102
- # CLEANUP
103
  if os.path.exists(work_dir):
104
  shutil.rmtree(work_dir, ignore_errors=True)
 
1
+ from fastapi import FastAPI, HTTPException, Request
2
+ from fastapi.responses import HTMLResponse
3
  from pydantic import BaseModel
4
  import subprocess
5
  import base64
6
  import os
7
  import uuid
8
  import shutil
9
+ import yt_dlp
10
  import socket
11
 
12
+ # ── 1. SOCKET HACK: FORCE IPv4 ──
13
+ # Prevents "[Errno -5] No address associated with hostname" on cloud hosts
14
  orig_getaddrinfo = socket.getaddrinfo
15
  def hooked_getaddrinfo(*args, **kwargs):
16
  res = orig_getaddrinfo(*args, **kwargs)
 
19
 
20
  app = FastAPI()
21
 
 
 
 
 
22
  class VideoRequest(BaseModel):
23
  url: str
24
+ platform: str # 'tiktok' or 'instagram'
25
  is_pro: bool = False
26
  fps: int = 2
27
 
28
  def file_to_base64(filepath):
29
+ if not os.path.exists(filepath): return None
30
  with open(filepath, "rb") as f:
31
  return base64.b64encode(f.read()).decode('utf-8')
32
 
33
+ # ── 2. TEST UI ROUTE ──
34
+ @app.get("/", response_class=HTMLResponse)
35
+ def index():
36
+ return """
37
+ <!DOCTYPE html>
38
+ <html>
39
+ <head>
40
+ <title>Viral Cat | Media Worker</title>
41
+ <script src="https://cdn.tailwindcss.com"></script>
42
+ <style>
43
+ body { background: #090A0F; color: white; font-family: sans-serif; }
44
+ .neon { color: #12D8C3; text-shadow: 0 0 10px rgba(18,216,195,0.5); }
45
+ input { background: #16181F; border: 1px solid #2D3748; color: white; }
46
+ </style>
47
+ </head>
48
+ <body class="p-10">
49
+ <div class="max-w-2xl mx-auto bg-[#16181F] p-8 rounded-3xl border border-[#2D3748]">
50
+ <h1 class="text-3xl font-bold mb-2">Viral Cat <span class="neon">Media Worker</span></h1>
51
+ <p class="text-gray-400 mb-6">Test TikTok or Instagram links below.</p>
52
+
53
+ <input type="text" id="url" placeholder="Paste link here..." class="w-full p-4 rounded-xl mb-4">
54
+
55
+ <select id="platform" class="w-full p-4 rounded-xl mb-4 bg-[#090A0F] border border-[#2D3748]">
56
+ <option value="tiktok">TikTok</option>
57
+ <option value="instagram">Instagram Reel</option>
58
+ </select>
59
+
60
+ <button onclick="process()" id="btn" class="w-full bg-[#12D8C3] text-black font-bold p-4 rounded-xl">Process Video</button>
61
+
62
+ <div id="status" class="mt-6 text-sm text-gray-400"></div>
63
+ <div id="results" class="mt-6 grid grid-cols-4 gap-2"></div>
64
+ </div>
65
+
66
+ <script>
67
+ async function process() {
68
+ const btn = document.getElementById('btn');
69
+ const status = document.getElementById('status');
70
+ const results = document.getElementById('results');
71
+
72
+ btn.disabled = true;
73
+ btn.innerText = "Processing... (may take 20s)";
74
+ status.innerText = "Downloading and extracting frames...";
75
+ results.innerHTML = "";
76
+
77
+ try {
78
+ const res = await fetch('/process-video', {
79
+ method: 'POST',
80
+ headers: {'Content-Type': 'application/json'},
81
+ body: JSON.stringify({
82
+ url: document.getElementById('url').value,
83
+ platform: document.getElementById('platform').value,
84
+ fps: 2
85
+ })
86
+ });
87
+ const data = await res.json();
88
+ if(data.success) {
89
+ status.innerText = "Success! Extracted " + data.total_frames + " frames.";
90
+ data.frames.slice(0, 8).forEach(img => {
91
+ results.innerHTML += `<img src="data:image/jpeg;base64,${img}" class="rounded-lg">`;
92
+ });
93
+ } else {
94
+ status.innerText = "Error: " + data.detail;
95
+ }
96
+ } catch(e) {
97
+ status.innerText = "Network Error: " + e.message;
98
+ }
99
+ btn.disabled = false;
100
+ btn.innerText = "Process Video";
101
+ }
102
+ </script>
103
+ </body>
104
+ </html>
105
+ """
106
+
107
+ # ── 3. MEDIA PROCESSING ROUTE ──
108
  @app.post("/process-video")
109
  def process_video(req: VideoRequest):
110
  job_id = str(uuid.uuid4())
111
  work_dir = f"/tmp/viralcat_{job_id}"
112
  os.makedirs(work_dir, exist_ok=True)
113
 
114
+ video_path = os.path.join(work_dir, "video.mp4")
 
115
  audio_path = os.path.join(work_dir, "audio.mp3")
116
 
117
  try:
118
+ # TikTok/IG require specific headers to not get 403 Forbidden
119
+ ydl_opts = {
120
+ 'format': 'bestvideo[height<=720]+bestaudio/best[height<=720]',
121
+ 'outtmpl': video_path,
122
+ 'quiet': True,
123
+ 'no_warnings': True,
124
+ 'nocheckcertificate': True,
125
+ # Disguise as a standard mobile browser
126
+ 'user_agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1',
127
+ 'add_header': [
128
+ 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
129
+ 'Accept-Language: en-us',
130
+ 'Connection: keep-alive',
131
+ ]
132
+ }
133
 
134
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
135
+ ydl.download([req.url])
 
136
 
137
+ if not os.path.exists(video_path):
138
+ # Find the actual file if extension differed
139
+ files = os.listdir(work_dir)
140
+ if files: video_path = os.path.join(work_dir, files[0])
141
+ else: raise ValueError("Download failed - No file saved.")
142
+
143
+ # PROBE DURATION
144
  probe = subprocess.run([
145
  "ffprobe", "-v", "error", "-show_entries",
146
  "format=duration", "-of", "default=noprint_wrappers=1:nokey=1",
 
148
  ], capture_output=True, text=True, check=True)
149
 
150
  duration = float(probe.stdout.strip() or 0)
 
151
  if duration > 120 and not req.is_pro:
152
+ raise ValueError(f"Video ({duration:.1f}s) exceeds 120s limit.")
153
 
154
+ # EXTRACT FRAMES (2 per second)
 
155
  subprocess.run([
156
  "ffmpeg", "-y", "-i", video_path,
157
  "-vf", f"fps={req.fps}",
158
+ "-q:v", "4", f"{work_dir}/frame_%04d.jpg"
159
  ], check=True, capture_output=True)
160
 
161
+ # EXTRACT AUDIO
162
  subprocess.run([
163
  "ffmpeg", "-y", "-i", video_path,
164
  "-q:a", "0", "-map", "a", "-ac", "1", "-b:a", "64k", audio_path
165
  ], check=True, capture_output=True)
166
 
167
+ # COLLECT DATA
168
  frame_files = sorted([f for f in os.listdir(work_dir) if f.startswith("frame_") and f.endswith(".jpg")])
169
 
170
+ # Max limit for AI context windows
171
+ if len(frame_files) > 80: frame_files = frame_files[:80]
 
172
 
173
  frames_b64 = [file_to_base64(os.path.join(work_dir, f)) for f in frame_files]
174
+ audio_b64 = file_to_base64(audio_path)
175
 
176
  return {
177
  "success": True,
 
182
  }
183
 
184
  except Exception as e:
185
+ print(f"ERROR: {str(e)}")
186
+ return {"success": False, "error": str(e)}
187
 
188
  finally:
189
+ # 🚨 GARBAGE COLLECTION
190
  if os.path.exists(work_dir):
191
  shutil.rmtree(work_dir, ignore_errors=True)