videopix commited on
Commit
fd45fc3
·
verified ·
1 Parent(s): dd25719

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +705 -0
app.py ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import cv2
3
+ import numpy as np
4
+ import requests
5
+ import insightface
6
+
7
+ from tempfile import NamedTemporaryFile
8
+ from insightface.app import FaceAnalysis
9
+
10
+ from fastapi import FastAPI, UploadFile, File, HTTPException
11
+ from fastapi.responses import StreamingResponse, HTMLResponse, Response
12
+ from fastapi.concurrency import run_in_threadpool
13
+
14
+ # -----------------------------------------------------------
15
+ # GLOBALS
16
+ # -----------------------------------------------------------
17
+ face_app = None
18
+ swapper = None
19
+
20
+ # -----------------------------------------------------------
21
+ # DOWNLOAD INSIGHTFACE MODEL
22
+ # -----------------------------------------------------------
23
+ def download_model():
24
+ url = "https://cdn.adikhanofficial.com/python/insightface/models/inswapper_128.onnx"
25
+ filename = os.path.basename(url)
26
+ save_path = os.path.join(os.path.dirname(__file__), filename)
27
+
28
+ if not os.path.exists(save_path):
29
+ print("Downloading model:", filename)
30
+ r = requests.get(url, timeout=60)
31
+ r.raise_for_status()
32
+ with open(save_path, "wb") as f:
33
+ f.write(r.content)
34
+ else:
35
+ print("Model already exists:", filename)
36
+
37
+
38
+ # -----------------------------------------------------------
39
+ # FACE SWAP HELPER
40
+ # -----------------------------------------------------------
41
+ def swap_faces(target_img, target_face, source_face):
42
+ return swapper.get(target_img, target_face, source_face, paste_back=True)
43
+
44
+
45
+ # -----------------------------------------------------------
46
+ # FASTAPI APP
47
+ # -----------------------------------------------------------
48
+ app = FastAPI(title="FaceSwap API")
49
+
50
+ # -----------------------------------------------------------
51
+ # HTML UI (Home Page) - stylish modern UI
52
+ # -----------------------------------------------------------
53
+ @app.get("/", response_class=HTMLResponse)
54
+ def home():
55
+ return """
56
+ <!doctype html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="utf-8" />
60
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
61
+ <title>FaceSwap — Tester UI</title>
62
+ <style>
63
+ :root{
64
+ --bg:#0f1724;
65
+ --card:#0b1220;
66
+ --muted:#9aa4b2;
67
+ --accent1:#7c3aed;
68
+ --accent2:#06b6d4;
69
+ --glass: rgba(255,255,255,0.04);
70
+ --glass-2: rgba(255,255,255,0.03);
71
+ --success:#22c55e;
72
+ --danger:#ef4444;
73
+ --radius:12px;
74
+ }
75
+ *{box-sizing:border-box}
76
+ body{
77
+ margin:0;
78
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
79
+ background: linear-gradient(180deg, #071023 0%, #071b2c 60%);
80
+ color: #e6eef6;
81
+ -webkit-font-smoothing:antialiased;
82
+ -moz-osx-font-smoothing:grayscale;
83
+ padding:28px;
84
+ }
85
+
86
+ .wrap{
87
+ max-width:1100px;
88
+ margin:0 auto;
89
+ }
90
+
91
+ header{
92
+ display:flex;
93
+ align-items:center;
94
+ gap:18px;
95
+ margin-bottom:18px;
96
+ }
97
+ .logo {
98
+ width:64px;
99
+ height:64px;
100
+ border-radius:14px;
101
+ background: linear-gradient(135deg,var(--accent1), var(--accent2));
102
+ display:grid;
103
+ place-items:center;
104
+ box-shadow: 0 6px 22px rgba(12,18,32,0.6), inset 0 -8px 20px rgba(255,255,255,0.03);
105
+ font-weight:700;
106
+ font-size:20px;
107
+ color:white;
108
+ }
109
+ h1{font-size:20px;margin:0}
110
+ p.lead{margin:0;color:var(--muted);font-size:13px}
111
+
112
+ .grid{
113
+ display:grid;
114
+ grid-template-columns: 1fr 420px;
115
+ gap:20px;
116
+ margin-top:20px;
117
+ }
118
+
119
+ /* main card */
120
+ .card{
121
+ background: linear-gradient(180deg,var(--glass), var(--glass-2));
122
+ border-radius:var(--radius);
123
+ padding:18px;
124
+ border: 1px solid rgba(255,255,255,0.04);
125
+ box-shadow: 0 6px 30px rgba(2,6,23,0.6);
126
+ }
127
+
128
+ .section-title {
129
+ display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px;
130
+ }
131
+ .section-title h2{font-size:16px;margin:0;color:#fff}
132
+ .muted{color:var(--muted);font-size:13px;margin-top:6px}
133
+
134
+ .upload {
135
+ display:grid;
136
+ grid-template-columns: 1fr 1fr;
137
+ gap:12px;
138
+ }
139
+
140
+ .drop {
141
+ background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
142
+ border-radius:10px;
143
+ padding:12px;
144
+ border:1px dashed rgba(255,255,255,0.04);
145
+ min-height:140px;
146
+ display:flex;
147
+ flex-direction:column;
148
+ gap:8px;
149
+ align-items:center;
150
+ justify-content:center;
151
+ text-align:center;
152
+ cursor:pointer;
153
+ transition:transform .12s ease, border-color .12s ease, background .12s ease;
154
+ }
155
+ .drop.dragover{ transform: translateY(-4px); border-color: rgba(255,255,255,0.12); background: rgba(255,255,255,0.015); }
156
+ .drop small{ color:var(--muted); font-size:13px}
157
+
158
+ input[type="file"]{ display:none; }
159
+
160
+ .controls { display:flex; gap:10px; align-items:center; margin-top:12px;}
161
+ .btn {
162
+ background: linear-gradient(90deg,var(--accent1), var(--accent2));
163
+ color:#fff;padding:10px 14px;border-radius:10px;border:none;font-weight:600;cursor:pointer;
164
+ box-shadow: 0 6px 18px rgba(124,58,237,0.18);
165
+ }
166
+ .btn.secondary {
167
+ background: transparent;
168
+ border:1px solid rgba(255,255,255,0.06);
169
+ color:var(--muted);
170
+ box-shadow:none;
171
+ }
172
+ .btn[disabled]{ opacity:0.5; cursor:not-allowed; transform:none; box-shadow:none;}
173
+
174
+ .preview {
175
+ display:flex;flex-direction:column;gap:8px;align-items:center;
176
+ }
177
+ .preview img, .preview video {
178
+ max-width:100%;
179
+ max-height:320px;
180
+ border-radius:8px;
181
+ border: 1px solid rgba(255,255,255,0.04);
182
+ background: #031223;
183
+ }
184
+
185
+ .status {
186
+ margin-top:10px;
187
+ font-size:13px;
188
+ color:var(--muted);
189
+ display:flex;
190
+ gap:10px;
191
+ align-items:center;
192
+ }
193
+ .spinner{
194
+ width:18px;height:18px;border-radius:50%;border:2px solid rgba(255,255,255,0.08);border-top-color:var(--accent2);animation:spin .9s linear infinite;
195
+ }
196
+ @keyframes spin{ to{ transform:rotate(360deg) } }
197
+
198
+ /* right column */
199
+ .info {
200
+ display:flex;
201
+ flex-direction:column;
202
+ gap:12px;
203
+ }
204
+ .info .mini {
205
+ background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));
206
+ padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,0.03);
207
+ color:var(--muted);
208
+ font-size:13px;
209
+ }
210
+ .copy {
211
+ display:flex;gap:8px;align-items:center;justify-content:space-between;margin-top:8px;
212
+ }
213
+ .link {
214
+ background: rgba(255,255,255,0.03);
215
+ padding:8px 10px;border-radius:8px;font-size:13px;color:#dfe8f8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
216
+ }
217
+ .danger { color:var(--danger); }
218
+
219
+ footer { margin-top:18px;text-align:center;color:var(--muted);font-size:13px}
220
+ @media (max-width:980px){
221
+ .grid{ grid-template-columns: 1fr; }
222
+ .upload{ grid-template-columns: 1fr; }
223
+ }
224
+ </style>
225
+ </head>
226
+ <body>
227
+ <div class="wrap">
228
+ <header>
229
+ <div class="logo">FP</div>
230
+ <div>
231
+ <h1>FaceSwap — Tester UI</h1>
232
+ <p class="lead">Upload a source face and a target (image or video). Results appear below.</p>
233
+ </div>
234
+ </header>
235
+
236
+ <div class="grid">
237
+ <main class="card">
238
+ <div class="section-title">
239
+ <h2>Image Face Swap</h2>
240
+ <div class="muted">Endpoint: <code>/swap-image</code></div>
241
+ </div>
242
+
243
+ <div class="upload" style="margin-bottom:14px;">
244
+ <label id="dropSource" class="drop" for="sourceInput">
245
+ <input id="sourceInput" type="file" accept="image/*">
246
+ <div>
247
+ <strong id="sourceLabel">Drop source image</strong>
248
+ <div style="height:6px"></div>
249
+ <small>Drag a photo here or click to choose</small>
250
+ </div>
251
+ </label>
252
+
253
+ <label id="dropTarget" class="drop" for="targetInput">
254
+ <input id="targetInput" type="file" accept="image/*">
255
+ <div>
256
+ <strong id="targetLabel">Drop target image</strong>
257
+ <div style="height:6px"></div>
258
+ <small>Where face will be placed</small>
259
+ </div>
260
+ </label>
261
+ </div>
262
+
263
+ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">
264
+ <button id="swapImageBtn" class="btn">Swap Image</button>
265
+ <button id="resetImgBtn" class="btn secondary">Reset</button>
266
+ <div style="flex:1"></div>
267
+ <div class="status" id="imgStatus"></div>
268
+ </div>
269
+
270
+ <div style="margin-top:16px; display:flex; gap:12px; flex-wrap:wrap;">
271
+ <div style="flex:1" class="preview">
272
+ <div style="font-size:13px;color:var(--muted)">Preview (target)</div>
273
+ <img id="previewTarget" alt="target preview" />
274
+ </div>
275
+ <div style="flex:1" class="preview">
276
+ <div style="font-size:13px;color:var(--muted)">Result</div>
277
+ <img id="resultImage" alt="result preview" />
278
+ </div>
279
+ </div>
280
+ </main>
281
+
282
+ <aside class="info">
283
+ <div class="mini">
284
+ <strong>Quick tips</strong>
285
+ <ul style="margin:8px 0 0 16px;padding:0;color:var(--muted)">
286
+ <li>Best results with clear faces, frontal views.</li>
287
+ <li>Use JPG/PNG for images, MP4 for video.</li>
288
+ <li>Video processing may take longer — watch the status.</li>
289
+ </ul>
290
+ </div>
291
+
292
+ <div class="mini">
293
+ <strong>API URL</strong>
294
+ <div class="copy">
295
+ <div class="link" id="apiUrl">/swap-image</div>
296
+ <button class="btn secondary" id="copyBtn">Copy</button>
297
+ </div>
298
+ </div>
299
+
300
+ <div class="mini">
301
+ <strong>Console</strong>
302
+ <div id="log" style="margin-top:8px;color:var(--muted);font-size:13px;max-height:120px;overflow:auto"></div>
303
+ </div>
304
+ </aside>
305
+ </div>
306
+
307
+ <!-- Video section -->
308
+ <div style="margin-top:20px" class="card">
309
+ <div class="section-title">
310
+ <h2>Video Face Swap</h2>
311
+ <div class="muted">Endpoint: <code>/swap-video</code></div>
312
+ </div>
313
+
314
+ <div style="display:flex;gap:12px;flex-wrap:wrap;">
315
+ <label id="dropVSource" class="drop" for="vsourceInput" style="flex:0 0 240px;min-width:180px;">
316
+ <input id="vsourceInput" type="file" accept="image/*">
317
+ <div><strong id="vsourceLabel">Source image</strong><div style="height:6px"></div><small>Face to paste from</small></div>
318
+ </label>
319
+
320
+ <label id="dropVTarget" class="drop" for="vtargetInput" style="flex:1;min-width:200px;">
321
+ <input id="vtargetInput" type="file" accept="video/*">
322
+ <div><strong id="vtargetLabel">Target video</strong><div style="height:6px"></div><small>Video that will receive the face</small></div>
323
+ </label>
324
+
325
+ <div style="width:100%;display:flex;gap:10px;align-items:center;margin-top:12px;">
326
+ <button id="swapVideoBtn" class="btn">Swap Video</button>
327
+ <button id="resetVidBtn" class="btn secondary">Reset</button>
328
+ <div style="flex:1"></div>
329
+ <div class="status" id="vidStatus"></div>
330
+ </div>
331
+ </div>
332
+
333
+ <div style="margin-top:14px;display:flex;gap:12px;flex-wrap:wrap;">
334
+ <div style="flex:1" class="preview">
335
+ <div style="font-size:13px;color:var(--muted)">Original video (target)</div>
336
+ <video id="previewVideo" controls></video>
337
+ </div>
338
+ <div style="flex:1" class="preview">
339
+ <div style="font-size:13px;color:var(--muted)">Result video</div>
340
+ <video id="resultVideo" controls></video>
341
+ <a id="downloadVideo" style="display:block;margin-top:8px;color:var(--accent2);font-weight:600"></a>
342
+ </div>
343
+ </div>
344
+ </div>
345
+
346
+ <footer>
347
+ Built with FastAPI • Local model inference • Endpoint: <code>/swap-image</code> & <code>/swap-video</code>
348
+ </footer>
349
+ </div>
350
+
351
+ <script>
352
+ (() => {
353
+ // Helpers
354
+ const logEl = id => document.getElementById(id);
355
+ const el = id => document.getElementById(id);
356
+ const apiBase = '';
357
+
358
+ // Element refs
359
+ const sourceInput = el('sourceInput');
360
+ const targetInput = el('targetInput');
361
+ const dropSource = el('dropSource');
362
+ const dropTarget = el('dropTarget');
363
+ const previewTarget = el('previewTarget');
364
+ const resultImage = el('resultImage');
365
+ const swapImageBtn = el('swapImageBtn');
366
+ const resetImgBtn = el('resetImgBtn');
367
+ const imgStatus = el('imgStatus');
368
+ const apiUrlEl = el('apiUrl');
369
+ const copyBtn = el('copyBtn');
370
+ const logBox = el('log');
371
+
372
+ const vsourceInput = el('vsourceInput');
373
+ const vtargetInput = el('vtargetInput');
374
+ const previewVideo = el('previewVideo');
375
+ const resultVideo = el('resultVideo');
376
+ const swapVideoBtn = el('swapVideoBtn');
377
+ const resetVidBtn = el('resetVidBtn');
378
+ const vidStatus = el('vidStatus');
379
+ const downloadVideo = el('downloadVideo');
380
+
381
+ // drag & drop helpers
382
+ function prevent(e) { e.preventDefault(); e.stopPropagation(); }
383
+ function bindDrop(dropEl, inputEl, labelEl){
384
+ ['dragenter','dragover','dragleave','drop'].forEach(evt=>{
385
+ dropEl.addEventListener(evt, prevent);
386
+ });
387
+ dropEl.addEventListener('dragover', ()=> dropEl.classList.add('dragover'));
388
+ dropEl.addEventListener('dragleave', ()=> dropEl.classList.remove('dragover'));
389
+ dropEl.addEventListener('drop', (ev)=>{
390
+ dropEl.classList.remove('dragover');
391
+ const dt = ev.dataTransfer;
392
+ if (dt && dt.files && dt.files.length) {
393
+ inputEl.files = dt.files;
394
+ handleFileSelect(inputEl, labelEl);
395
+ }
396
+ });
397
+ dropEl.addEventListener('click', ()=> inputEl.click());
398
+ inputEl.addEventListener('change', ()=> handleFileSelect(inputEl, labelEl));
399
+ }
400
+
401
+ function handleFileSelect(inputEl, labelEl){
402
+ const f = inputEl.files && inputEl.files[0];
403
+ if (!f) return;
404
+ labelEl.textContent = f.name;
405
+ if (inputEl.accept && inputEl.accept.includes('image')) {
406
+ // preview
407
+ const url = URL.createObjectURL(f);
408
+ if (labelEl === document.getElementById('sourceLabel')) {
409
+ // source label (not previewed separately)
410
+ }
411
+ previewTarget.src = url;
412
+ }
413
+ if (inputEl.accept && inputEl.accept.includes('video')) {
414
+ previewVideo.src = URL.createObjectURL(f);
415
+ }
416
+ }
417
+
418
+ bindDrop(dropSource, sourceInput, document.getElementById('sourceLabel'));
419
+ bindDrop(dropTarget, targetInput, document.getElementById('targetLabel'));
420
+ bindDrop(el('dropVSource'), vsourceInput, document.getElementById('vsourceLabel'));
421
+ bindDrop(el('dropVTarget'), vtargetInput, document.getElementById('vtargetLabel'));
422
+
423
+ // copy button
424
+ copyBtn.addEventListener('click', async () => {
425
+ const text = apiUrlEl.textContent || '/swap-image';
426
+ try {
427
+ await navigator.clipboard.writeText(text);
428
+ appendLog('Copied API endpoint');
429
+ } catch (e) {
430
+ appendLog('Clipboard failed', true);
431
+ }
432
+ });
433
+
434
+ function appendLog(msg, isErr){
435
+ const div = document.createElement('div');
436
+ div.textContent = msg;
437
+ div.style.color = isErr ? 'var(--danger)' : 'var(--muted)';
438
+ logBox.prepend(div);
439
+ }
440
+
441
+ // Image swap
442
+ swapImageBtn.addEventListener('click', async () => {
443
+ const src = sourceInput.files[0];
444
+ const tgt = targetInput.files[0];
445
+ if (!src || !tgt) { alert('Select both source and target images.'); return; }
446
+
447
+ toggleImageButtons(true);
448
+ setImgStatus('Uploading...', true);
449
+
450
+ try {
451
+ const fd = new FormData();
452
+ fd.append('source', src);
453
+ fd.append('target', tgt);
454
+
455
+ const res = await fetch(apiBase + '/swap-image', { method:'POST', body: fd });
456
+ if (!res.ok) {
457
+ const text = await res.text().catch(()=>res.statusText);
458
+ setImgStatus('Error: ' + text, false, true);
459
+ appendLog('Image swap failed: ' + text, true);
460
+ toggleImageButtons(false);
461
+ return;
462
+ }
463
+
464
+ const blob = await res.blob();
465
+ const url = URL.createObjectURL(blob);
466
+ resultImage.src = url;
467
+ setImgStatus('Done', false, false);
468
+ appendLog('Image swapped successfully');
469
+ } catch (err) {
470
+ console.error(err);
471
+ setImgStatus('Network error', false, true);
472
+ appendLog('Network: ' + err.message, true);
473
+ } finally {
474
+ toggleImageButtons(false);
475
+ }
476
+ });
477
+
478
+ function toggleImageButtons(disable){
479
+ swapImageBtn.disabled = disable;
480
+ resetImgBtn.disabled = disable;
481
+ if (disable) swapImageBtn.style.opacity = 0.7;
482
+ else swapImageBtn.style.opacity = 1;
483
+ }
484
+ function setImgStatus(text, busy=false, isError=false){
485
+ imgStatus.innerHTML = '';
486
+ if (busy){
487
+ const s = document.createElement('div'); s.className='spinner';
488
+ imgStatus.appendChild(s);
489
+ }
490
+ const t = document.createElement('div'); t.textContent = text;
491
+ t.style.color = isError ? 'var(--danger)' : 'var(--muted)';
492
+ imgStatus.appendChild(t);
493
+ }
494
+
495
+ resetImgBtn.addEventListener('click', ()=>{
496
+ sourceInput.value = '';
497
+ targetInput.value = '';
498
+ previewTarget.src = '';
499
+ resultImage.src = '';
500
+ document.getElementById('sourceLabel').textContent = 'Drop source image';
501
+ document.getElementById('targetLabel').textContent = 'Drop target image';
502
+ imgStatus.innerHTML = '';
503
+ });
504
+
505
+ // Video swap
506
+ swapVideoBtn.addEventListener('click', async () => {
507
+ const src = vsourceInput.files[0];
508
+ const vid = vtargetInput.files[0];
509
+ if (!src || !vid) { alert('Select source image and target video.'); return; }
510
+
511
+ toggleVideoButtons(true);
512
+ setVidStatus('Uploading...', true);
513
+
514
+ try {
515
+ const fd = new FormData();
516
+ fd.append('source', src);
517
+ fd.append('video', vid);
518
+
519
+ const res = await fetch(apiBase + '/swap-video', { method:'POST', body: fd });
520
+ if (!res.ok) {
521
+ const text = await res.text().catch(()=>res.statusText);
522
+ setVidStatus('Error: ' + text, false, true);
523
+ appendLog('Video swap failed: ' + text, true);
524
+ toggleVideoButtons(false);
525
+ return;
526
+ }
527
+
528
+ const blob = await res.blob();
529
+ const url = URL.createObjectURL(blob);
530
+ resultVideo.src = url;
531
+ downloadVideo.href = url;
532
+ downloadVideo.download = 'swapped_out.mp4';
533
+ downloadVideo.textContent = 'Download result';
534
+ setVidStatus('Done', false, false);
535
+ appendLog('Video swapped successfully');
536
+ } catch (err) {
537
+ console.error(err);
538
+ setVidStatus('Network error', false, true);
539
+ appendLog('Network: ' + err.message, true);
540
+ } finally {
541
+ toggleVideoButtons(false);
542
+ }
543
+ });
544
+
545
+ function toggleVideoButtons(disable){
546
+ swapVideoBtn.disabled = disable;
547
+ resetVidBtn.disabled = disable;
548
+ if (disable) swapVideoBtn.style.opacity = 0.7;
549
+ else swapVideoBtn.style.opacity = 1;
550
+ }
551
+ function setVidStatus(text, busy=false, isError=false){
552
+ vidStatus.innerHTML = '';
553
+ if (busy){
554
+ const s = document.createElement('div'); s.className='spinner';
555
+ vidStatus.appendChild(s);
556
+ }
557
+ const t = document.createElement('div'); t.textContent = text;
558
+ t.style.color = isError ? 'var(--danger)' : 'var(--muted)';
559
+ vidStatus.appendChild(t);
560
+ }
561
+
562
+ resetVidBtn.addEventListener('click', ()=>{
563
+ vsourceInput.value = '';
564
+ vtargetInput.value = '';
565
+ previewVideo.src = '';
566
+ resultVideo.src = '';
567
+ downloadVideo.href = '';
568
+ document.getElementById('vsourceLabel').textContent = 'Source image';
569
+ document.getElementById('vtargetLabel').textContent = 'Target video';
570
+ vidStatus.innerHTML = '';
571
+ });
572
+
573
+ })();
574
+ </script>
575
+ </body>
576
+ </html>
577
+ """
578
+
579
+ # -----------------------------------------------------------
580
+ # IMAGE SWAP ENDPOINT
581
+ # -----------------------------------------------------------
582
+ @app.post("/swap-image")
583
+ async def swap_image(source: UploadFile = File(...), target: UploadFile = File(...)):
584
+ src_bytes = await source.read()
585
+ tgt_bytes = await target.read()
586
+
587
+ def process():
588
+ src = cv2.imdecode(np.frombuffer(src_bytes, np.uint8), cv2.IMREAD_COLOR)
589
+ tgt = cv2.imdecode(np.frombuffer(tgt_bytes, np.uint8), cv2.IMREAD_COLOR)
590
+
591
+ if src is None: raise ValueError("Invalid source image")
592
+ if tgt is None: raise ValueError("Invalid target image")
593
+
594
+ src_faces = face_app.get(src)
595
+ tgt_faces = face_app.get(tgt)
596
+
597
+ if not src_faces: raise ValueError("No face in source image")
598
+ if not tgt_faces: raise ValueError("No face in target image")
599
+
600
+ s_face = src_faces[0]
601
+ t_face = tgt_faces[0]
602
+
603
+ result = swap_faces(tgt, t_face, s_face)
604
+
605
+ ok, encoded = cv2.imencode(".jpg", result)
606
+ if not ok:
607
+ raise ValueError("Failed encoding result")
608
+ return encoded.tobytes()
609
+
610
+ try:
611
+ output = await run_in_threadpool(process)
612
+ except ValueError as e:
613
+ raise HTTPException(400, str(e))
614
+ except Exception as e:
615
+ raise HTTPException(500, str(e))
616
+
617
+ return Response(output, media_type="image/jpeg")
618
+
619
+
620
+ # -----------------------------------------------------------
621
+ # VIDEO SWAP ENDPOINT
622
+ # -----------------------------------------------------------
623
+ @app.post("/swap-video")
624
+ async def swap_video(source: UploadFile = File(...), video: UploadFile = File(...)):
625
+ src_bytes = await source.read()
626
+ vid_bytes = await video.read()
627
+
628
+ src_img = cv2.imdecode(np.frombuffer(src_bytes, np.uint8), cv2.IMREAD_COLOR)
629
+ if src_img is None:
630
+ raise HTTPException(400, "Invalid source image")
631
+
632
+ def process_video_job():
633
+ temp_in = NamedTemporaryFile(delete=False, suffix=".mp4")
634
+ temp_in.write(vid_bytes)
635
+ temp_in.close()
636
+
637
+ out_path = temp_in.name.replace(".mp4", "_out.mp4")
638
+
639
+ cap = cv2.VideoCapture(temp_in.name)
640
+ if not cap.isOpened():
641
+ raise ValueError("Cannot open input video")
642
+
643
+ fps = cap.get(cv2.CAP_PROP_FPS) or 24
644
+ w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
645
+ h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
646
+
647
+ writer = cv2.VideoWriter(out_path, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
648
+
649
+ src_faces = face_app.get(src_img)
650
+ if not src_faces:
651
+ raise ValueError("No face in source image")
652
+
653
+ src_face = src_faces[0]
654
+
655
+ while True:
656
+ ok, frame = cap.read()
657
+ if not ok:
658
+ break
659
+
660
+ tgt_faces = face_app.get(frame)
661
+ if tgt_faces:
662
+ try:
663
+ frame = swap_faces(frame, tgt_faces[0], src_face)
664
+ except Exception:
665
+ # keep original frame on failure
666
+ pass
667
+
668
+ writer.write(frame)
669
+
670
+ writer.release()
671
+ cap.release()
672
+
673
+ return temp_in.name, out_path
674
+
675
+ try:
676
+ in_path, out_path = await run_in_threadpool(process_video_job)
677
+ except ValueError as e:
678
+ raise HTTPException(400, str(e))
679
+ except Exception as e:
680
+ raise HTTPException(500, str(e))
681
+
682
+ def iterator(path, cleanup=()):
683
+ try:
684
+ with open(path, "rb") as f:
685
+ while chunk := f.read(65536):
686
+ yield chunk
687
+ finally:
688
+ for p in cleanup:
689
+ try:
690
+ os.unlink(p)
691
+ except:
692
+ pass
693
+
694
+ return StreamingResponse(iterator(out_path, cleanup=[in_path, out_path]), media_type="video/mp4")
695
+
696
+
697
+ # -----------------------------------------------------------
698
+ # MODEL INIT
699
+ # -----------------------------------------------------------
700
+ print("Loading models...")
701
+ face_app = FaceAnalysis(name="buffalo_l")
702
+ face_app.prepare(ctx_id=-1)
703
+ download_model()
704
+ swapper = insightface.model_zoo.get_model("inswapper_128.onnx", root=os.path.dirname(__file__))
705
+ print("Model loaded.")