ADXabhi commited on
Commit
af817ca
·
verified ·
1 Parent(s): 687eabe

Upload 5 files

Browse files
Files changed (3) hide show
  1. README.md +25 -5
  2. app.py +43 -33
  3. client-v2.html +339 -0
README.md CHANGED
@@ -1,10 +1,30 @@
1
  ---
2
- title: V3
3
- emoji: 🏢
4
- colorFrom: gray
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Async Video Backend
3
+ emoji:
4
+ colorFrom: green
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
+ app_port: 7860
9
  ---
10
 
11
+ # Async Video Backend (V2)
12
+
13
+ This backend uses a **Job Queue** architecture to support large files (2GB+) and long processing times without timeouts.
14
+
15
+ ## 🚀 How to Upgrade your Space
16
+
17
+ 1. **Go to your existing Space** (e.g., `clo-up`).
18
+ 2. **Go to "Files"**.
19
+ 3. **Delete** the old `app.py`, `Dockerfile`, and `requirements.txt`.
20
+ 4. **Upload the NEW files** from this `async_v2` folder:
21
+ * `app.py`
22
+ * `Dockerfile`
23
+ * `requirements.txt`
24
+ 5. Wait for it to Rebuild and Run.
25
+
26
+ ## ⚡ Client Usage
27
+
28
+ Use the **`client-v2.html`** file in this folder.
29
+ - It handles the new "Submit -> Poll -> Wait" flow.
30
+ - Compatible with the new V2 backend ONLY.
app.py CHANGED
@@ -84,45 +84,56 @@ def process_video_background(job_id: str, video_url: str):
84
  except:
85
  raise Exception("Failed to get duration.")
86
 
87
- # 3. SPLIT (5-MINUTE CHUNKS)
88
- JOBS[job_id]["progress"] = "Splitting video into 5-minute chunks..."
 
 
89
 
90
- CHUNK_DURATION = 300 # 5 minutes in seconds
91
- parts = []
92
 
 
93
  current_start = 0.0
94
  chunk_idx = 0
95
 
96
  while current_start < total_duration:
97
- chunk_name = os.path.join(work_dir, f"part_{chunk_idx:03d}.mp4")
98
-
99
- # Simple time-based cut
100
- # -ss before -i is faster seeking
101
- # -t duration
102
- # -c copy is fast (stream copy), no re-encoding
103
- # -avoid_negative_ts make_zero ensures timestamps start at 0
104
-
105
- cmd = f"ffmpeg -y -hide_banner -loglevel error -ss {current_start} -t {CHUNK_DURATION} -i {filename} -c copy -avoid_negative_ts make_zero {chunk_name}"
106
- subprocess.run(cmd, shell=True, check=True)
107
-
108
- if os.path.exists(chunk_name) and os.path.getsize(chunk_name) > 0:
109
- parts.append(chunk_name)
110
- chunk_idx += 1
111
-
112
- current_start += CHUNK_DURATION
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  # 4. UPLOAD
115
  JOBS[job_id]["progress"] = f"Uploading {len(parts)} chunks to Cloudinary..."
116
- uploaded_urls = []
117
 
118
- # Helper to maintain order if needed, but here simple append is okay?
119
- # Actually parallel upload might scramble order in list.
120
- # Let's map results back to order.
121
-
122
- def upload_chunk_worker(part_file):
123
  try:
124
- # Get the chunk index from filename for ordering if needed
125
- # But map() preserves order of results iterator
126
  response = cloudinary.uploader.unsigned_upload(
127
  part_file,
128
  UPLOAD_PRESET,
@@ -131,17 +142,16 @@ def process_video_background(job_id: str, video_url: str):
131
  )
132
  url = response['secure_url']
133
  if os.path.exists(part_file): os.remove(part_file)
134
- return url
135
  except Exception as e:
136
  print(f"Upload Error: {e}")
137
  return None
138
 
139
  # Parallel Upload
140
  with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
141
- # map returns iterator in order of inputs
142
  results = executor.map(upload_chunk_worker, parts)
143
  for res in results:
144
- if res: uploaded_urls.append(res)
145
 
146
  # 5. CLEANUP & FINISH
147
  if os.path.exists(filename): os.remove(filename)
@@ -151,7 +161,7 @@ def process_video_background(job_id: str, video_url: str):
151
 
152
  JOBS[job_id]["status"] = "completed"
153
  JOBS[job_id]["progress"] = "Done"
154
- JOBS[job_id]["result"] = uploaded_urls
155
  print(f"[{job_id}] Completed.")
156
 
157
  except Exception as e:
@@ -191,4 +201,4 @@ def get_job_status(job_id: str):
191
 
192
  @app.get("/")
193
  def home():
194
- return {"message": "Async Video Processor V3 (5-min chunks) is Running"}
 
84
  except:
85
  raise Exception("Failed to get duration.")
86
 
87
+ # 3. SPLIT
88
+ JOBS[job_id]["progress"] = "Splitting video into chunks..."
89
+ CHUNK_LIMIT_MB = 95
90
+ TARGET_BYTES = CHUNK_LIMIT_MB * 1024 * 1024
91
 
92
+ file_size = os.path.getsize(filename)
93
+ avg_bytes_per_sec = file_size / total_duration
94
 
95
+ parts = [] # Will store tuples of (chunk_path, duration_seconds)
96
  current_start = 0.0
97
  chunk_idx = 0
98
 
99
  while current_start < total_duration:
100
+ est_duration = TARGET_BYTES / avg_bytes_per_sec
101
+ success = False
102
+ while not success:
103
+ current_end = current_start + est_duration
104
+ if current_end > total_duration: current_end = total_duration
105
+
106
+ chunk_name = os.path.join(work_dir, f"part_{chunk_idx:03d}.mp4")
107
+
108
+ # Cut command
109
+ cmd = f"ffmpeg -y -hide_banner -loglevel error -ss {current_start} -to {current_end} -i {filename} -c copy -avoid_negative_ts make_zero {chunk_name}"
110
+ subprocess.run(cmd, shell=True)
111
+
112
+ if not os.path.exists(chunk_name):
113
+ success = True
114
+ break
115
+
116
+ chunk_size = os.path.getsize(chunk_name)
117
+ chunk_size_mb = chunk_size / (1024*1024)
118
+
119
+ if chunk_size_mb > 99.0:
120
+ est_duration = est_duration * 0.9
121
+ os.remove(chunk_name)
122
+ else:
123
+ # Calculate actual duration of this chunk
124
+ chunk_duration = current_end - current_start
125
+ parts.append((chunk_name, chunk_duration))
126
+ current_start = current_end
127
+ chunk_idx += 1
128
+ success = True
129
 
130
  # 4. UPLOAD
131
  JOBS[job_id]["progress"] = f"Uploading {len(parts)} chunks to Cloudinary..."
132
+ uploaded_results = [] # Will store {url, duration}
133
 
134
+ def upload_chunk_worker(part_info):
135
+ part_file, duration = part_info
 
 
 
136
  try:
 
 
137
  response = cloudinary.uploader.unsigned_upload(
138
  part_file,
139
  UPLOAD_PRESET,
 
142
  )
143
  url = response['secure_url']
144
  if os.path.exists(part_file): os.remove(part_file)
145
+ return {"url": url, "duration": round(duration, 2)}
146
  except Exception as e:
147
  print(f"Upload Error: {e}")
148
  return None
149
 
150
  # Parallel Upload
151
  with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
 
152
  results = executor.map(upload_chunk_worker, parts)
153
  for res in results:
154
+ if res: uploaded_results.append(res)
155
 
156
  # 5. CLEANUP & FINISH
157
  if os.path.exists(filename): os.remove(filename)
 
161
 
162
  JOBS[job_id]["status"] = "completed"
163
  JOBS[job_id]["progress"] = "Done"
164
+ JOBS[job_id]["result"] = uploaded_results
165
  print(f"[{job_id}] Completed.")
166
 
167
  except Exception as e:
 
201
 
202
  @app.get("/")
203
  def home():
204
+ return {"message": "Async Video Processor V2 is Running"}
client-v2.html ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Hugging Face Video Uploader (Async V2)</title>
8
+ <style>
9
+ :root {
10
+ --primary: #10b981;
11
+ /* Emerald Green */
12
+ --bg: #111827;
13
+ --surface: #1f2937;
14
+ --text: #f3f4f6;
15
+ }
16
+
17
+ body {
18
+ font-family: 'Inter', system-ui, sans-serif;
19
+ background: var(--bg);
20
+ color: var(--text);
21
+ display: flex;
22
+ justify-content: center;
23
+ align-items: center;
24
+ min-height: 100vh;
25
+ margin: 0;
26
+ }
27
+
28
+ .container {
29
+ background: var(--surface);
30
+ padding: 2rem;
31
+ border-radius: 12px;
32
+ width: 100%;
33
+ max-width: 550px;
34
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
35
+ border: 1px solid #374151;
36
+ }
37
+
38
+ h2 {
39
+ margin-top: 0;
40
+ text-align: center;
41
+ color: var(--primary);
42
+ }
43
+
44
+ h4 {
45
+ margin: 0;
46
+ color: #9ca3af;
47
+ text-align: center;
48
+ font-weight: 400;
49
+ margin-bottom: 1.5rem;
50
+ }
51
+
52
+ .form-group {
53
+ margin-bottom: 1.5rem;
54
+ }
55
+
56
+ label {
57
+ display: block;
58
+ margin-bottom: 0.5rem;
59
+ font-size: 0.9rem;
60
+ color: #d1d5db;
61
+ }
62
+
63
+ input {
64
+ width: 100%;
65
+ padding: 0.75rem;
66
+ background: #111827;
67
+ border: 1px solid #374151;
68
+ border-radius: 6px;
69
+ color: white;
70
+ box-sizing: border-box;
71
+ }
72
+
73
+ input:focus {
74
+ outline: 2px solid var(--primary);
75
+ border-color: transparent;
76
+ }
77
+
78
+ button {
79
+ width: 100%;
80
+ padding: 0.75rem;
81
+ background: var(--primary);
82
+ color: #064e3b;
83
+ border: none;
84
+ border-radius: 6px;
85
+ font-weight: 700;
86
+ cursor: pointer;
87
+ transition: transform 0.1s;
88
+ }
89
+
90
+ button:hover {
91
+ opacity: 0.9;
92
+ transform: scale(1.02);
93
+ }
94
+
95
+ button:disabled {
96
+ opacity: 0.5;
97
+ cursor: not-allowed;
98
+ transform: none;
99
+ }
100
+
101
+ #statusBox {
102
+ margin-top: 2rem;
103
+ display: none;
104
+ background: #111827;
105
+ padding: 1.5rem;
106
+ border-radius: 8px;
107
+ border: 1px solid #374151;
108
+ text-align: center;
109
+ }
110
+
111
+ .status-badge {
112
+ display: inline-block;
113
+ padding: 4px 12px;
114
+ border-radius: 99px;
115
+ font-size: 0.8rem;
116
+ font-weight: 600;
117
+ background: #374151;
118
+ color: white;
119
+ margin-bottom: 1rem;
120
+ }
121
+
122
+ .status-badge.queued {
123
+ background: #f59e0b;
124
+ color: black;
125
+ }
126
+
127
+ .status-badge.processing {
128
+ background: #3b82f6;
129
+ color: white;
130
+ }
131
+
132
+ .status-badge.completed {
133
+ background: #10b981;
134
+ color: black;
135
+ }
136
+
137
+ .status-badge.failed {
138
+ background: #ef4444;
139
+ color: white;
140
+ }
141
+
142
+ #progressText {
143
+ color: #d1d5db;
144
+ margin-bottom: 1rem;
145
+ font-size: 0.95rem;
146
+ }
147
+
148
+ .result-item {
149
+ background: #374151;
150
+ padding: 0.5rem;
151
+ border-radius: 6px;
152
+ margin-bottom: 0.5rem;
153
+ word-break: break-all;
154
+ font-size: 0.85rem;
155
+ text-align: left;
156
+ display: flex;
157
+ justify-content: space-between;
158
+ align-items: center;
159
+ }
160
+
161
+ .copy-btn {
162
+ background: #1f2937;
163
+ border: none;
164
+ color: white;
165
+ padding: 4px 8px;
166
+ border-radius: 4px;
167
+ font-size: 0.75rem;
168
+ cursor: pointer;
169
+ width: auto;
170
+ margin-left: 10px;
171
+ }
172
+
173
+ /* Spinner */
174
+ .spinner {
175
+ border: 4px solid #374151;
176
+ border-top: 4px solid var(--primary);
177
+ border-radius: 50%;
178
+ width: 30px;
179
+ height: 30px;
180
+ animation: spin 1s linear infinite;
181
+ margin: 0 auto 1rem auto;
182
+ display: none;
183
+ }
184
+
185
+ @keyframes spin {
186
+ 0% {
187
+ transform: rotate(0deg);
188
+ }
189
+
190
+ 100% {
191
+ transform: rotate(360deg);
192
+ }
193
+ }
194
+ </style>
195
+ </head>
196
+
197
+ <body>
198
+
199
+ <div class="container">
200
+ <h2>⚡ Async Video Uploader</h2>
201
+ <h4>Support for Huge Files (2GB+)</h4>
202
+
203
+ <div class="form-group">
204
+ <label>1. Your Space URL (Direct link)</label>
205
+ <input type="text" id="serverUrl" value="https://adxabhi-v3.hf.space"
206
+ placeholder="https://username-space-name.hf.space" required>
207
+ <small style="color: #6b7280; display: block; margin-top: 4px;">Found in Space > Embed this Space > Direct
208
+ URL</small>
209
+ </div>
210
+
211
+ <div class="form-group">
212
+ <label>2. Video URL (Direct link)</label>
213
+ <input type="text" id="videoUrl" placeholder="https://example.com/huge_video.mp4" required>
214
+ </div>
215
+
216
+ <button id="processBtn" onclick="submitJob()">Start Background Job</button>
217
+
218
+ <div id="statusBox">
219
+ <div id="spinner" class="spinner"></div>
220
+ <span id="statusBadge" class="status-badge">Waiting</span>
221
+ <div id="progressText">Initializing...</div>
222
+ <div id="linksList"></div>
223
+ </div>
224
+ </div>
225
+
226
+ <script>
227
+ let pollInterval = null;
228
+
229
+ async function submitJob() {
230
+ let serverUrl = document.getElementById('serverUrl').value.trim();
231
+ serverUrl = serverUrl.replace(/\/$/, "");
232
+
233
+ const videoUrl = document.getElementById('videoUrl').value.trim();
234
+ const btn = document.getElementById('processBtn');
235
+ const statusBox = document.getElementById('statusBox');
236
+
237
+ if (!serverUrl || !videoUrl) { alert("Please fill in both fields"); return; }
238
+
239
+ btn.disabled = true;
240
+ statusBox.style.display = 'block';
241
+ updateStatus("queued", "Submitting job...");
242
+
243
+ try {
244
+ // 1. SUBMIT JOB
245
+ const response = await fetch(`${serverUrl}/jobs`, {
246
+ method: 'POST',
247
+ headers: { 'Content-Type': 'application/json' },
248
+ body: JSON.stringify({ video_url: videoUrl })
249
+ });
250
+
251
+ const data = await response.json();
252
+
253
+ if (data.job_id) {
254
+ console.log("Job Submitted:", data.job_id);
255
+ startPolling(serverUrl, data.job_id);
256
+ } else {
257
+ updateStatus("failed", "Failed to get Job ID");
258
+ btn.disabled = false;
259
+ }
260
+
261
+ } catch (error) {
262
+ console.error(error);
263
+ updateStatus("failed", "Connection Error. Check URL.");
264
+ btn.disabled = false;
265
+ }
266
+ }
267
+
268
+ function startPolling(serverUrl, jobId) {
269
+ if (pollInterval) clearInterval(pollInterval);
270
+
271
+ pollInterval = setInterval(async () => {
272
+ try {
273
+ const res = await fetch(`${serverUrl}/jobs/${jobId}`);
274
+ const job = await res.json();
275
+
276
+ updateStatus(job.status, job.progress);
277
+
278
+ if (job.status === 'completed') {
279
+ clearInterval(pollInterval);
280
+ showResults(job.result);
281
+ document.getElementById('processBtn').disabled = false;
282
+ }
283
+
284
+ if (job.status === 'failed') {
285
+ clearInterval(pollInterval);
286
+ document.getElementById('progressText').innerText = "Error: " + job.error;
287
+ document.getElementById('processBtn').disabled = false;
288
+ }
289
+
290
+ } catch (e) {
291
+ console.error("Polling error", e);
292
+ }
293
+ }, 3000); // Check every 3 seconds
294
+ }
295
+
296
+ function updateStatus(status, message) {
297
+ const badge = document.getElementById('statusBadge');
298
+ const spinner = document.getElementById('spinner');
299
+ const text = document.getElementById('progressText');
300
+
301
+ badge.className = `status-badge ${status}`;
302
+ badge.innerText = status.toUpperCase();
303
+ text.innerText = message || "Processing...";
304
+
305
+ if (status === 'processing' || status === 'queued') {
306
+ spinner.style.display = 'block';
307
+ } else {
308
+ spinner.style.display = 'none';
309
+ }
310
+ }
311
+
312
+ function formatDuration(seconds) {
313
+ const mins = Math.floor(seconds / 60);
314
+ const secs = Math.floor(seconds % 60);
315
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
316
+ }
317
+
318
+ function showResults(results) {
319
+ const list = document.getElementById('linksList');
320
+ list.innerHTML = '';
321
+ results.forEach((item, index) => {
322
+ const div = document.createElement('div');
323
+ div.className = 'result-item';
324
+ // Handle both old format (just url string) and new format ({url, duration})
325
+ const url = typeof item === 'string' ? item : item.url;
326
+ const duration = typeof item === 'object' && item.duration ? formatDuration(item.duration) : '';
327
+ const durationBadge = duration ? `<span style="color:#f59e0b; margin-left:8px;">[${duration}]</span>` : '';
328
+ div.innerHTML = `
329
+ <span>Part ${index + 1}${durationBadge}: <a href="${url}" target="_blank" style="color:#10b981">${url.substring(0, 30)}...</a></span>
330
+ <button class="copy-btn" onclick="navigator.clipboard.writeText('${url}')">Copy</button>
331
+ `;
332
+ list.appendChild(div);
333
+ });
334
+ }
335
+ </script>
336
+
337
+ </body>
338
+
339
+ </html>