File size: 37,315 Bytes
7765e8f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
"""
server.py β€” μ–Όκ΅΄ 일괄 ꡐ체 (HF Space ↔ Comfy Cloud)

  POST /api/swap/jobs                 이미지 1μž₯ + 성별 β†’ μž‘μ—… 생성 (μ¦‰μ‹œ {jobId} λ°˜ν™˜)
  GET  /api/swap/jobs/{id}            μƒνƒœ 쑰회
  POST /api/swap/cancel               μž‘μ—… μ·¨μ†Œ (λŒ€κΈ°Β·μ§„ν–‰ 쀑인 μž‘μ—…μ„ Comfy Cloud μ—μ„œλ„ 멈좀)
  GET  /api/swap/jobs/{id}/thumb      결과 미리보기(JPEG)
  GET  /api/swap/jobs/{id}/view       κ²°κ³Ό 원본 크기(미리보기용, 파일λͺ…Β·ν™•μž₯자 μœ μ§€ ν˜•μ‹)
  GET  /api/swap/jobs/{id}/download   κ²°κ³Ό λ‹€μš΄λ‘œλ“œ β€” μ—…λ‘œλ“œν•œ 파일λͺ…Β·ν™•μž₯자 κ·ΈλŒ€λ‘œ
  POST /api/swap/zip                  μ„ νƒν•œ μž‘μ—… ZIP β€” 폴더 ꡬ쑰·파일λͺ… κ·ΈλŒ€λ‘œ
  POST /api/swap/regen                μ§€λ‚œ 결과의 μ›λ³ΈμœΌλ‘œ μƒˆ κ²°κ³Ό ν•˜λ‚˜ 더 (원본 μž¬μ—…λ‘œλ“œ 없이)
  GET  /api/whoami                    λ‘œκ·ΈμΈν•œ μ‚¬μš©μž (HF OAuth, 선택)
  GET  /api/dashboard                 생성 기둝 λŒ€μ‹œλ³΄λ“œ 집계 (κΈ°κ°„: ?from=YYYY-MM-DD&to=YYYY-MM-DD)
  GET  /api/swap/faces                μ‚½μž…ν•  μ–Όκ΅΄(λ‚¨μž/μ—¬μž) κΈ°λ³Έ 정보
  POST /api/swap/faces/{gender}       μ–Όκ΅΄ ꡐ체 μ—…λ‘œλ“œ
  GET  /api/swap/defaults             μ›Œν¬ν”Œλ‘œμš° κΈ°λ³Έκ°’(κ³ κΈ‰ μ„€μ •)
  GET  /api/history                   μ§€λ‚œ κ²°κ³Ό λͺ©λ‘ (영ꡬ μ €μž₯λΆ„ 포함)
  POST /api/history/hide              λͺ©λ‘μ—μ„œ μ§€μš°κΈ° (μ €μž₯μ†Œ νŒŒμΌμ€ 남김)
  GET  /api/history/status            영ꡬ μ €μž₯ μƒνƒœ
  GET  /api/health                    μ›Œν¬ν”Œλ‘œμš°Β·API ν‚€ μƒνƒœ
"""
from __future__ import annotations

import hashlib
import io
import json
import os
import re
import tempfile
import threading
import time
import traceback
import unicodedata
import uuid
import zipfile
from typing import Any, Dict, List, Optional
from urllib.parse import quote

from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
from PIL import Image

import comfy_client as cc
import history

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
FACE_DIR = os.path.join(BASE_DIR, "assets", "faces")

MAX_CONCURRENT = int(os.environ.get("COMFY_MAX_CONCURRENT", "4"))
JOB_TTL_SEC = int(os.environ.get("JOB_TTL_SEC", str(6 * 3600)))
GENDERS = ("male", "female")

_OPEN_API = ("/api/health", "/api/whoami")          # 둜그인 전에도 μ—΄λ € μžˆμ–΄μ•Ό ν•˜λŠ” 것(μƒνƒœ 확인, 둜그인 μ—¬λΆ€ 확인)


def _guard(request: Request) -> None:
    """둜그인이 ν•„μš”ν•œ ν™˜κ²½μ—μ„œλŠ” /api/* 전체λ₯Ό λ‘œκ·ΈμΈν•œ μ‚¬λžŒλ§Œ μ“°κ²Œ ν•œλ‹€(ν™”λ©΄ '/' 은 index_page κ°€ 둜그인 μ•ˆλ‚΄λ₯Ό 보여 μ€€λ‹€)."""
    if not _login_required():
        return
    path = request.url.path
    if not path.startswith("/api/") or path in _OPEN_API:
        return
    if not get_current_user(request)["is_logged_in"]:
        raise HTTPException(401, "둜그인이 ν•„μš”ν•΄μš”. 화면을 μƒˆλ‘œκ³ μΉ¨ν•œ λ’€ Hugging Face 둜 λ‘œκ·ΈμΈν•΄ μ£Όμ„Έμš”.")


app = FastAPI(title="Face swap batch", dependencies=[Depends(_guard)], docs_url=None, redoc_url=None, openapi_url=None)   # 둜그인 μ „μ—λŠ” 경둜 λͺ©λ‘(λ¬Έμ„œ)도 λ…ΈμΆœν•˜μ§€ μ•ŠλŠ”λ‹€

history.init()

# ── 둜그인(HF OAuth) β€” μ‚¬μš© 톡계에 'λˆ„κ°€ μΌλŠ”μ§€'λ₯Ό 남기기 μœ„ν•¨ ────────────────────
# Space 의 README.md 에 `hf_oauth: true` κ°€ 있으면 HF κ°€ OAUTH_CLIENT_ID 등을 λ„£μ–΄ μ€€λ‹€. κ·Έλ•Œλ§Œ 둜그인 κΈ°λŠ₯을 뢙인닀.
try:
    from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth
except Exception:  # noqa: BLE001
    attach_huggingface_oauth = parse_huggingface_oauth = None
_OAUTH_ATTACHED = False
if attach_huggingface_oauth is not None and os.environ.get("OAUTH_CLIENT_ID"):
    try:
        attach_huggingface_oauth(app)
        _OAUTH_ATTACHED = True
        print("[OAUTH] HF OAuth 연결됨")
    except Exception as _e:  # noqa: BLE001
        print(f"[OAUTH] μ—°κ²° μ‹€νŒ¨: {_e}")
# ν…ŒμŠ€νŠΈΒ·λ‘œμ»¬ 개발용(Space μ—μ„œλŠ” μ“°μ§€ μ•ŠλŠ”λ‹€): DEV_USER="아이디|이름" β†’ κ·Έ μ‚¬λžŒμœΌλ‘œ λ‘œκ·ΈμΈν•œ κ²ƒμ²˜λŸΌ λ™μž‘, DEV_OAUTH=1 β†’ 둜그인 ν•„μˆ˜ ν™”λ©΄ ν™•μΈμš©
_DEV_USER = os.environ.get("DEV_USER", "")
if os.environ.get("DEV_OAUTH") == "1":
    _OAUTH_ATTACHED = True
# README 에 hf_oauth: true κ°€ μžˆμ–΄ 둜그인이 λΆ™μœΌλ©΄, λ‘œκ·ΈμΈν•œ μ‚¬λžŒλ§Œ μ“Έ 수 μžˆλ‹€(SPAO V2 와 같은 방식). README 에 μ—†μœΌλ©΄ μ˜ˆμ „μ²˜λŸΌ 둜그인 없이 λ™μž‘ν•œλ‹€.
# 둜그인이 λΆ™μ–΄ μžˆμ–΄λ„ 둜그인 없이 μ“°κ²Œ ν•˜λ €λ©΄ REQUIRE_LOGIN=false.
REQUIRE_LOGIN = os.environ.get("REQUIRE_LOGIN", "true").lower() in ("1", "true", "yes", "on")


def get_current_user(request: Request) -> Dict[str, Any]:
    anon = {"username": "anonymous", "name": "", "email": "", "is_logged_in": False}
    if _DEV_USER:
        uid, _, nm = _DEV_USER.partition("|")
        return {"username": uid, "name": nm or uid, "email": "", "is_logged_in": True}
    if not _OAUTH_ATTACHED or parse_huggingface_oauth is None:
        return anon
    try:
        info = parse_huggingface_oauth(request)
        ui = getattr(info, "user_info", None) if info else None
        if not ui:
            return anon
        return {"username": getattr(ui, "preferred_username", "") or "anonymous", "name": getattr(ui, "name", "") or "",
                "email": getattr(ui, "email", "") or "", "is_logged_in": True}
    except Exception as e:  # noqa: BLE001
        print(f"[OAUTH] μ‚¬μš©μž 확인 μ‹€νŒ¨: {e}")
        return anon


def _login_required() -> bool:
    return REQUIRE_LOGIN and _OAUTH_ATTACHED


def _require_login(user: Dict[str, Any]) -> None:
    if _login_required() and not user["is_logged_in"]:
        raise HTTPException(401, "둜그인이 ν•„μš”ν•΄μš”. ν™”λ©΄ μœ„μͺ½μ˜ '둜그인'을 눌러 Hugging Face 둜 λ‘œκ·ΈμΈν•œ λ’€ λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.")


_LOGIN_GATE_HTML = """<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/><title>AI μ–Όκ΅΄ ꡐ체 Β· 둜그인</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>*{font-family:'Pretendard',-apple-system,sans-serif;box-sizing:border-box}body{margin:0;background:#1a1a1a;color:#f9fafb;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{width:min(420px,92vw);background:#1e1e1e;border:1px solid #2a2a2a;border-radius:20px;padding:36px 32px;text-align:center}
.logo{width:44px;height:44px;border-radius:12px;background:#3B82F6;display:inline-flex;align-items:center;justify-content:center;margin-bottom:18px}
h1{font-size:18px;margin:0 0 6px}p{font-size:13px;color:#9ca3af;line-height:1.6;margin:0 0 24px}
a.btn{display:block;background:#3B82F6;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:13px;border-radius:12px}a.btn:hover{background:#2563EB}
.note{font-size:11.5px;color:#6b7280;margin-top:16px}</style></head><body><div class="card">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg></div>
<h1>AI μ–Όκ΅΄ ꡐ체</h1><p>μ΄λžœλ“œμ΄λ…Έν”Œ 쑰직 λ©€λ²„λ§Œ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€.<br/>Hugging Face κ³„μ •μœΌλ‘œ λ‘œκ·ΈμΈν•΄ μ£Όμ„Έμš”.</p>
<a class="btn" id="login" href="/oauth/huggingface/login">πŸ€— Hugging Face둜 둜그인</a>
<div class="note" id="note">둜그인 ν›„ μžλ™μœΌλ‘œ λŒμ•„μ˜΅λ‹ˆλ‹€. 쑰직 멀버가 μ•„λ‹ˆλ©΄ 접근이 κ±°λΆ€λ©λ‹ˆλ‹€.</div></div>
<script>if(window.top!==window.self){var a=document.getElementById("login");a.target="_blank";a.rel="noopener";document.getElementById("note").textContent="μƒˆ νƒ­μ—μ„œ λ‘œκ·ΈμΈν•œ λ’€, 이 화면을 μƒˆλ‘œκ³ μΉ¨ν•΄ μ£Όμ„Έμš”. 쑰직 멀버가 μ•„λ‹ˆλ©΄ 접근이 κ±°λΆ€λ©λ‹ˆλ‹€.";}</script>
</body></html>"""

_SEM = threading.Semaphore(MAX_CONCURRENT)
_JOBS: Dict[str, Dict[str, Any]] = {}
_LOCK = threading.Lock()
_FACES: Dict[str, bytes] = {}     # faceId β†’ 원본 λ°”μ΄νŠΈ (κΈ°λ³Έ μ–Όκ΅΄ + μ‚¬μš©μžκ°€ κ΅μ²΄ν•œ μ–Όκ΅΄)

STAGE_LABEL = {
    "queued_local": "λŒ€κΈ° 쀑",
    "uploading": "이미지 μ—…λ‘œλ“œ 쀑",
    "submitting": "μ›Œν¬ν”Œλ‘œμš° 제좜 쀑",
    "queued_waiting": "Comfy λŒ€κΈ°μ—΄",
    "queued_limited": "Comfy λŒ€κΈ°μ—΄ (λ™μ‹œ μ‹€ν–‰ μ œν•œ)",
    "pending": "Comfy λŒ€κΈ°μ—΄",
    "preparing": "GPU μ€€λΉ„ 쀑",
    "executing": "생성 쀑",
    "running": "생성 쀑",
    "downloading": "κ²°κ³Ό λ°›λŠ” 쀑",
    "done": "μ™„λ£Œ",
    "error": "μ‹€νŒ¨",
    "cancelled": "μ·¨μ†Œλ¨",
}


# ── μ–Όκ΅΄ ──────────────────────────────────────────────────────────────────────
def _load_default_faces() -> None:
    for g in GENDERS:
        path = os.path.join(FACE_DIR, f"{g}.jpg")
        if os.path.exists(path):
            with open(path, "rb") as f:
                _FACES[f"default-{g}"] = f.read()
        else:
            print(f"[FACE] κΈ°λ³Έ 얼꡴이 μ—†μŠ΅λ‹ˆλ‹€: {path}")


_load_default_faces()


def _jpeg_thumb(data: bytes, max_side: int, quality: int = 85) -> bytes:
    img = Image.open(io.BytesIO(data))
    img.load()
    from PIL import ImageOps
    img = ImageOps.exif_transpose(img).convert("RGB")
    img.thumbnail((max_side, max_side), Image.LANCZOS)
    out = io.BytesIO()
    img.save(out, "JPEG", quality=quality)
    return out.getvalue()


@app.get("/api/swap/faces")
def faces_info():
    return {g: {"id": f"default-{g}", "available": f"default-{g}" in _FACES} for g in GENDERS}


FACE_MAX_BYTES = 25 * 1024 * 1024          # 직접 μ˜¬λ¦¬λŠ” μ–Όκ΅΄(페λ₯΄μ†Œλ‚˜ λͺ¨λΈ) ν•œ μž₯의 μ΅œλŒ€ 크기
FACE_MIN_SIDE = 200                        # 이보닀 μž‘μœΌλ©΄ 머리λ₯Ό 잘 μ°Ύμ§€ λͺ»ν•œλ‹€
FACE_MAX_CUSTOM = 40                       # μ„œλ²„ λ©”λͺ¨λ¦¬μ— λ‘˜ 직접 올린 μ–Όκ΅΄ 수(였래된 것뢀터 정리)


@app.post("/api/swap/faces/{gender}")
async def upload_face(gender: str, file: UploadFile = File(...)):
    """직접 올린 페λ₯΄μ†Œλ‚˜ λͺ¨λΈ(μ–Όκ΅΄). 같은 사진은 같은 id κ°€ λ‚˜μ˜¨λ‹€(λ‚΄μš© ν•΄μ‹œ) β†’ 화면을 μƒˆλ‘œ 열어도 λ‹€μ‹œ 올렀 같은 id 둜 이어 μ“΄λ‹€."""
    if gender not in GENDERS:
        raise HTTPException(400, "gender λŠ” male λ˜λŠ” female 이어야 ν•©λ‹ˆλ‹€.")
    data = await file.read()
    if not data:
        raise HTTPException(400, "빈 νŒŒμΌμž…λ‹ˆλ‹€.")
    if len(data) > FACE_MAX_BYTES:
        raise HTTPException(413, f"사진이 λ„ˆλ¬΄ μ»€μš”(μ΅œλŒ€ {FACE_MAX_BYTES // 1024 // 1024}MB).")
    try:
        im = Image.open(io.BytesIO(data))
        im.verify()
        w, h = Image.open(io.BytesIO(data)).size
    except Exception:  # noqa: BLE001
        raise HTTPException(400, "이미지 파일이 μ•„λ‹™λ‹ˆλ‹€. JPGΒ·PNGΒ·WEBP 사진을 올렀 μ£Όμ„Έμš”.")
    if min(w, h) < FACE_MIN_SIDE:
        raise HTTPException(400, f"사진이 λ„ˆλ¬΄ μž‘μ•„μš”({w}Γ—{h}). 짧은 변이 {FACE_MIN_SIDE}px 이상인 μ–Όκ΅΄ 사진을 올렀 μ£Όμ„Έμš”.")
    fid = f"up-{hashlib.sha256(data).hexdigest()[:16]}"
    with _LOCK:
        _FACES.pop(fid, None)                                          # λ‹€μ‹œ 올리면 'κ°€μž₯ 졜근'으둜
        _FACES[fid] = data
        custom = [k for k in _FACES if k.startswith("up-")]
        for old in custom[:-FACE_MAX_CUSTOM]:
            _FACES.pop(old, None)
    return {"id": fid, "width": w, "height": h}


def _prep_settings(opts: Dict[str, Any], gender: str, face_id: str) -> Dict[str, Any]:
    """ν™”λ©΄μ—μ„œ 온 섀정에 μ„œλ²„κ°€ μ•„λŠ” 값을 보탠닀: 성별(ν”„λ‘¬ν”„νŠΈ κ΅¬μ„±μš©), 머리 μŠ€νƒ€μΌ μ„€λͺ…(μ—†μœΌλ©΄ κΈ°λ³Έ 얼꡴일 λ•Œλ§Œ κΈ°λ³Έ μ„€λͺ…)."""
    opts = dict(opts)
    opts["gender"] = gender
    if "hair_desc" not in opts:
        opts["hair_desc"] = cc.DEFAULT_HAIR.get(gender, "") if (face_id or f"default-{gender}").startswith("default-") else ""
    elif not isinstance(opts["hair_desc"], str):
        opts["hair_desc"] = ""
    else:
        opts["hair_desc"] = " ".join(opts["hair_desc"].split())[:400]      # ν•œ 쀄·400μžκΉŒμ§€
    return opts


@app.get("/api/swap/face-img/{face_id}")
def face_img(face_id: str):
    data = _FACES.get(face_id)
    if not data:
        raise HTTPException(404, "μ–Όκ΅΄ 이미지λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
    return Response(_jpeg_thumb(data, 520), media_type="image/jpeg", headers={"Cache-Control": "no-cache"})


# ── μž‘μ—… ──────────────────────────────────────────────────────────────────────
def _update(job_id: str, **kw: Any) -> None:
    with _LOCK:
        if job_id in _JOBS:
            _JOBS[job_id].update(kw)


def _gc() -> None:
    """λλ‚œ μž‘μ—…μ˜ 'μ§„ν–‰ μƒνƒœ' ν•­λͺ©λ§Œ μ •λ¦¬ν•œλ‹€. κ²°κ³Ό νŒŒμΌμ€ history κ°€ 계속 λ³΄κ΄€ν•œλ‹€."""
    now = time.time()
    with _LOCK:
        for j in [j for j, v in _JOBS.items() if v.get("finished_at") and now - v["finished_at"] > JOB_TTL_SEC]:
            _JOBS.pop(j, None)


def _clean_rel(rel: str, fallback: str) -> str:
    """μ—…λ‘œλ“œλœ μƒλŒ€κ²½λ‘œ β†’ μ•ˆμ „ν•œ 'a/b/c.jpg' ν˜•νƒœ. 파일λͺ…은 κ·ΈλŒ€λ‘œ μœ μ§€."""
    rel = (rel or fallback or "image.png").replace("\\", "/")
    parts = [p for p in rel.split("/") if p not in ("", ".", "..")]
    return "/".join(parts) or "image.png"


_ORPHANS: set = set()      # κ²°κ³ΌλŠ” λ²„λ Έμ§€λ§Œ Cloud μ—μ„œ 아직 돌고 μžˆμ„ 수 μžˆλŠ” '우리' μž‘μ—… (μ‹€ν–‰ 쀑이라 μ€‘λ‹¨ν•˜μ§€ λͺ»ν•œ 것)


def _remote_cancel(prompt_ids: set) -> None:
    """Comfy Cloud μͺ½ μ·¨μ†Œ (λ„€νŠΈμ›Œν¬ 호좜이라 λ’€μ—μ„œ 처리).

    interrupt λŠ” κ³„μ •μ˜ μ‹€ν–‰ 쀑 μž‘μ—… μ „λΆ€λ₯Ό λ©ˆμΆ”λ―€λ‘œ, Cloud μ—μ„œ λ„λŠ” μž‘μ—…μ΄ μ „λΆ€ '우리 것'일 λ•Œλ§Œ μ“΄λ‹€.
    μ˜ˆμ „μ— μ·¨μ†Œν–ˆμ§€λ§Œ μ€‘λ‹¨ν•˜μ§€ λͺ»ν•΄ 계속 λ„λŠ” 우리 μž‘μ—…(_ORPHANS)도 우리 κ²ƒμœΌλ‘œ λ³Έλ‹€.
    """
    own = set(prompt_ids) | set(_ORPHANS)
    for pid in prompt_ids:
        try:
            res = cc.cancel_job(pid, allowed_running=own)
        except Exception as e:  # noqa: BLE001
            print(f"[CANCEL] {pid} μ·¨μ†Œ μš”μ²­ μ‹€νŒ¨: {e}")
            res = "soft"
        if res == "soft":
            _ORPHANS.add(pid)
        else:
            _ORPHANS.discard(pid)
            if res == "interrupted":
                _ORPHANS.clear()           # interrupt λŠ” μ‹€ν–‰ μ€‘μ΄λ˜ 우리 μž‘μ—…μ„ λͺ¨λ‘ λ©ˆμ·„λ‹€
        with _LOCK:
            for j in _JOBS.values():
                if j.get("promptId") == pid:
                    j["remoteCancel"] = res
        print(f"[CANCEL] prompt {pid} β†’ {res}")


def _input_gone(msg: str) -> bool:
    """Comfy Cloud 에 올렀 λ‘” μž…λ ₯ 파일이 μ—†μ–΄μ‘Œμ„ λ•Œμ˜ 였λ₯˜μΈμ§€ (μ‹€μ œ Cloud λŠ” μ œμΆœμ€ λ°›μ•„ μ£Όκ³  μ‹€ν–‰ λ‹¨κ³„μ—μ„œ ImageDownloadError 둜 μ‹€νŒ¨μ‹œν‚¨λ‹€)."""
    m = (msg or "").lower()
    return any(k in m for k in ("imagedownloaderror", "doesn't exist", "does not exist", "value_not_in_list", "not in list", "input file"))


def _run_job(job_id: str, image_bytes: Optional[bytes], face_bytes: bytes, settings: Dict[str, Any],
             main_name: Optional[str] = None, source_bytes: Optional[bytes] = None, remember_input: bool = True) -> None:
    """image_bytes: μƒˆλ‘œ 올린 원본. μž¬μƒμ„±(regen)일 λ•ŒλŠ” None 이고, λŒ€μ‹  이전에 Comfy Cloud 에 올렀 λ‘” μž…λ ₯ 파일 이름(main_name)을 μ“΄λ‹€."""
    started = time.time()
    reused = main_name is not None
    with _LOCK:
        ev: threading.Event = _JOBS[job_id]["cancel"]
    cancelled = ev.is_set
    with _SEM:
        try:
            if cancelled():
                raise cc.ComfyCancelled()
            _update(job_id, stage="uploading")
            if main_name is None:
                main_name = cc.ensure_uploaded(image_bytes, "main")
            face_name = cc.ensure_uploaded(face_bytes, "face")
            if cancelled():
                raise cc.ComfyCancelled()

            _update(job_id, stage="submitting")
            wf = cc.build_workflow(main_name, face_name, settings)
            prompt_id = cc.submit(wf)
            _update(job_id, promptId=prompt_id, stage="queued_waiting")
            print(f"[JOB] {job_id} β†’ prompt {prompt_id}")
            if cancelled():                                      # μ œμΆœν•˜λŠ” 사이에 μ·¨μ†Œκ°€ λ“€μ–΄μ˜¨ 경우
                _remote_cancel({prompt_id})
                raise cc.ComfyCancelled()

            STATUS_TO_STAGE = {"waiting_to_dispatch": "queued_waiting", "pending": "queued_waiting",
                               "in_progress": "executing", "running": "executing"}

            def on_status(st: str) -> None:
                _update(job_id, stage=STATUS_TO_STAGE.get(st) or (st if st in STAGE_LABEL else "executing"))

            result = cc.wait_for_result(prompt_id, on_status=on_status, should_cancel=cancelled)
            _update(job_id, stage="downloading")
            Image.open(io.BytesIO(result)).verify()          # 이미지인지 확인
            elapsed = round(time.time() - started, 1)
            with _LOCK:
                j = dict(_JOBS.get(job_id) or {})
            history.record(job_id, {
                "rel_path": j.get("rel_path", ""), "filename": j.get("filename", ""), "gender": j.get("gender", ""),
                "elapsedSec": elapsed, "comfy_image": main_name if remember_input else None,   # μž¬μƒμ„±μ— μ“°λ €κ³  μž…λ ₯ 파일 이름을 κΈ°μ–΅(μ €ν™”μ§ˆ 미리보기둜 λ§Œλ“  건 κΈ°μ–΅ν•˜μ§€ μ•ŠλŠ”λ‹€)
                "user_username": (j.get("user") or {}).get("username", ""), "user_name": (j.get("user") or {}).get("name", ""),   # λŒ€μ‹œλ³΄λ“œμš©
                "regen": bool(j.get("regen") or j.get("regenOf")),
                "settings": {k: settings.get(k) for k in ("face_strength", "hair_strength", "hair_desc", "denoise", "prompt", "mask_main", "mask_ref") if settings.get(k) is not None},
            }, result, image_bytes or source_bytes)
            _update(job_id, status="done", stage="done", elapsedSec=elapsed, finished_at=time.time())
            print(f"[JOB] {job_id} done in {elapsed}s")
        except cc.ComfyCancelled:
            if ev.is_set():                      # μ‚¬μš©μžκ°€ μ·¨μ†Œν•œ 경우: Comfy Cloud μͺ½ μ·¨μ†Œ κ²°κ³Όκ°€ λ‚˜μ˜¨ 뒀에 'μ·¨μ†Œλ¨'으둜 μ•Œλ¦°λ‹€
                for _ in range(60):
                    with _LOCK:
                        j = _JOBS.get(job_id) or {}
                        if j.get("remoteCancel") or not j.get("promptId"):
                            break
                    time.sleep(0.25)
            print(f"[JOB] {job_id} cancelled")
            _update(job_id, status="cancelled", stage="cancelled", finished_at=time.time())
        except cc.ComfyError as e:
            print(f"[JOB] {job_id} error: {e}")
            gone = reused and _input_gone(str(e))
            _update(job_id, status="error", stage="error", finished_at=time.time(), errorCode="input_gone" if gone else None,
                    error="이전에 올렀 λ‘” 원본을 Comfy Cloud μ—μ„œ μ°Ύμ§€ λͺ»ν–ˆμ–΄μš”(보관 기간이 지났을 수 μžˆμ–΄μš”)." if gone else str(e))
        except Exception as e:  # noqa: BLE001
            traceback.print_exc()
            _update(job_id, status="error", stage="error", error=f"예기치 λͺ»ν•œ 였λ₯˜: {str(e)[:200]}", finished_at=time.time())


@app.post("/api/swap/jobs")
async def create_job(
    request: Request,
    image: UploadFile = File(...),
    rel_path: str = Form(""),
    gender: str = Form(...),
    face_id: str = Form(""),
    settings: str = Form("{}"),
    preview: str = Form(""),                 # "1" 이면 원본이 μ•„λ‹ˆλΌ μ €μž₯된 미리보기둜 λ§Œλ“  μž‘μ—… β†’ 이 μž…λ ₯은 μž¬μƒμ„±μ— μ“°μ§€ μ•ŠλŠ”λ‹€
    regen: str = Form(""),                   # "1" 이면 λ§ˆμŒμ— μ•ˆ λ“€μ–΄ λ‹€μ‹œ λ§Œλ“  μž‘μ—…(톡계에 μž¬μƒμ„±μœΌλ‘œ 기둝)
):
    user = get_current_user(request)
    _require_login(user)
    if gender not in GENDERS:
        raise HTTPException(400, "gender λŠ” male λ˜λŠ” female 이어야 ν•©λ‹ˆλ‹€.")
    st = cc.status()
    if not st["ok"]:
        raise HTTPException(500, st["error"])
    data = await image.read()
    if not data:
        raise HTTPException(400, "빈 νŒŒμΌμž…λ‹ˆλ‹€.")
    face = _FACES.get(face_id or f"default-{gender}")
    if not face:
        raise HTTPException(400, f"{'λ‚¨μž' if gender == 'male' else 'μ—¬μž'} μ–Όκ΅΄ 이미지가 μ—†μŠ΅λ‹ˆλ‹€. 얼꡴을 λ¨Όμ € μ˜¬λ €μ£Όμ„Έμš”.")
    try:
        opts = json.loads(settings or "{}")
        if not isinstance(opts, dict):
            opts = {}
    except json.JSONDecodeError:
        opts = {}
    opts = _prep_settings(opts, gender, face_id)

    _gc()
    rel = _clean_rel(rel_path, image.filename or "")
    job_id = uuid.uuid4().hex[:12]
    with _LOCK:
        _JOBS[job_id] = {
            "status": "running", "stage": "queued_local", "created_at": time.time(),
            "rel_path": rel, "filename": rel.split("/")[-1], "gender": gender,
            "cancel": threading.Event(), "user": user, "regen": bool(regen),
        }
    threading.Thread(target=_run_job, args=(job_id, data, face, opts, None, None, not preview), daemon=True).start()
    return {"jobId": job_id}


@app.post("/api/swap/regen")
def regen_job(request: Request, from_id: str = Form(...), face_id: str = Form(""), settings: str = Form("{}")):
    """μ§€λ‚œ κ²°κ³Ό(from_id)와 같은 μ›λ³ΈμœΌλ‘œ μƒˆ κ²°κ³Όλ₯Ό ν•˜λ‚˜ 더 λ§Œλ“ λ‹€.
    원본을 λ‹€μ‹œ μ˜¬λ¦¬μ§€ μ•Šκ³ , κ·Έλ•Œ Comfy Cloud 에 올렀 λ‘” μž…λ ₯ νŒŒμΌμ„ κ·ΈλŒ€λ‘œ μ“΄λ‹€ β†’ 원본 ν™”μ§ˆ μœ μ§€, μƒˆλ‘œκ³ μΉ¨ 후에도 λ™μž‘."""
    user = get_current_user(request)
    _require_login(user)
    st = cc.status()
    if not st["ok"]:
        raise HTTPException(500, st["error"])
    e = history.entry(from_id)
    if not e:
        raise HTTPException(404, "μ§€λ‚œ κ²°κ³Όλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.")
    comfy_image = e.get("comfy_image")
    if not comfy_image:
        raise HTTPException(409, "이 κ²°κ³ΌλŠ” 원본 정보가 μ €μž₯λ˜μ§€ μ•Šμ•˜μ–΄μš”(이전 λ²„μ „μœΌλ‘œ λ§Œλ“  κ²°κ³Ό).")
    gender = e.get("gender")
    if gender not in GENDERS:
        raise HTTPException(400, "성별 정보가 μ—†λŠ” κ²°κ³Όμ˜ˆμš”.")
    face = _FACES.get(face_id or f"default-{gender}")
    if not face:
        raise HTTPException(400, f"{'λ‚¨μž' if gender == 'male' else 'μ—¬μž'} μ–Όκ΅΄ 이미지가 μ—†μŠ΅λ‹ˆλ‹€.")
    try:
        opts = json.loads(settings or "{}")
        if not isinstance(opts, dict):
            opts = {}
    except json.JSONDecodeError:
        opts = {}
    opts = _prep_settings(opts, gender, face_id)
    src_path = history.path(from_id, "source")                       # λͺ©λ‘ λΉ„κ΅μš© 미리보기만 μ΄μ–΄λ°›λŠ”λ‹€
    src = None
    if src_path:
        with open(src_path, "rb") as f:
            src = f.read()
    _gc()
    job_id = uuid.uuid4().hex[:12]
    rel = e.get("rel_path", "")
    with _LOCK:
        _JOBS[job_id] = {
            "status": "running", "stage": "queued_local", "created_at": time.time(),
            "rel_path": rel, "filename": e.get("filename") or rel.split("/")[-1], "gender": gender,
            "cancel": threading.Event(), "regenOf": from_id, "user": user, "regen": True,
        }
    threading.Thread(target=_run_job, args=(job_id, None, face, opts, comfy_image, src), daemon=True).start()
    return {"jobId": job_id}


@app.post("/api/swap/cancel")
def cancel_jobs(payload: Dict[str, Any] = Body(...)):
    """μ§„ν–‰Β·λŒ€κΈ° 쀑인 μž‘μ—…μ„ μ·¨μ†Œν•œλ‹€. Comfy Cloud μͺ½ μ·¨μ†ŒλŠ” λ’€μ—μ„œ μ²˜λ¦¬ν•œλ‹€."""
    ids = [str(i) for i in payload.get("ids", [])]
    prompt_ids = set()
    n = 0
    with _LOCK:
        for i in ids:
            j = _JOBS.get(i)
            if j and j.get("status") == "running":
                j["cancel"].set()
                n += 1
                if j.get("promptId"):
                    prompt_ids.add(j["promptId"])
    if prompt_ids:
        threading.Thread(target=_remote_cancel, args=(prompt_ids,), daemon=True).start()
    return {"cancelled": n}


@app.get("/api/swap/jobs/{job_id}")
def job_info(job_id: str):
    with _LOCK:
        job = _JOBS.get(job_id)
        snap = dict(job) if job else None
    if snap is None:
        e = history.entry(job_id)                  # μž¬μ‹œμž‘ 후에도 λλ‚œ μž‘μ—…μ€ 기둝에 남아 μžˆλ‹€
        if e:
            return {"status": "done", "stage": "done", "stageLabel": STAGE_LABEL["done"], "error": None, "elapsedSec": e.get("elapsedSec", 0)}
        raise HTTPException(404, "μž‘μ—…μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€ (μ„œλ²„κ°€ μž¬μ‹œμž‘λ˜μ—ˆμ„ 수 μžˆμŠ΅λ‹ˆλ‹€).")
    stage = snap.get("stage", "")
    return {
        "status": snap["status"],
        "stage": stage,
        "stageLabel": STAGE_LABEL.get(stage, stage),
        "error": snap.get("error"),
        "errorCode": snap.get("errorCode"),
        "cancelMode": snap.get("remoteCancel"),      # removed | interrupted | soft(Cloud μ—μ„œλŠ” 이미 μ‹€ν–‰ 쀑이라 λκΉŒμ§€ μ§„ν–‰)
        "elapsedSec": round((snap.get("finished_at") or time.time()) - snap["created_at"], 1),
    }


# ── κ²°κ³Ό 파일 (원본 파일λͺ…Β·ν™•μž₯자 μœ μ§€) ───────────────────────────────────────
def _done_job(job_id: str) -> Dict[str, Any]:
    e = history.entry(job_id)
    path = history.path(job_id, "result") if e else None
    if not e or not path:
        raise HTTPException(404, "μ €μž₯된 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€.")
    return {**e, "path": path}


def _encode_like_original(job: Dict[str, Any]) -> tuple[bytes, str, str]:
    """κ²°κ³Όλ₯Ό 원본과 같은 파일λͺ…Β·ν™•μž₯자둜 인코딩. (bytes, filename, media_type)

    jpg/jpeg β†’ JPEG(q95) Β· png β†’ PNG Β· webp β†’ WEBP.
    κ·Έ μ™Έ ν™•μž₯자(bmp λ“±)λŠ” 파일λͺ… 쀄기λ₯Ό μœ μ§€ν•˜κ³  .png 둜 μ €μž₯ν•œλ‹€.
    """
    name = job["filename"]
    stem, ext = os.path.splitext(name)
    low = ext.lower()
    img = Image.open(job["path"])
    img.load()
    img = img.convert("RGB")
    out = io.BytesIO()
    if low in (".jpg", ".jpeg"):
        img.save(out, "JPEG", quality=95, subsampling=0, optimize=True)
        return out.getvalue(), name, "image/jpeg"
    if low == ".webp":
        img.save(out, "WEBP", quality=95)
        return out.getvalue(), name, "image/webp"
    img.save(out, "PNG", compress_level=6)
    return out.getvalue(), (name if low == ".png" else f"{stem}.png"), "image/png"


def _content_disposition(kind: str, filename: str) -> str:
    ascii_fallback = re.sub(r"[^A-Za-z0-9._-]", "_", unicodedata.normalize("NFKD", filename).encode("ascii", "ignore").decode()) or "result"
    return f"{kind}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename)}"


@app.get("/api/swap/jobs/{job_id}/thumb")
def job_thumb(job_id: str):
    p = history.path(job_id, "thumb")
    if not p:
        raise HTTPException(404, "썸넀일이 μ—†μŠ΅λ‹ˆλ‹€.")
    return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "max-age=3600"})


@app.get("/api/swap/jobs/{job_id}/source")
def job_source(job_id: str):
    p = history.path(job_id, "source")
    if not p:
        raise HTTPException(404, "원본 미리보기가 μ—†μŠ΅λ‹ˆλ‹€.")
    return FileResponse(p, media_type="image/jpeg", headers={"Cache-Control": "max-age=3600"})


@app.get("/api/swap/jobs/{job_id}/view")
def job_view(job_id: str):
    job = _done_job(job_id)
    data, name, mt = _encode_like_original(job)
    return Response(data, media_type=mt, headers={"Content-Disposition": _content_disposition("inline", name)})


@app.get("/api/swap/jobs/{job_id}/download")
def job_download(job_id: str):
    job = _done_job(job_id)
    data, name, mt = _encode_like_original(job)
    return Response(data, media_type=mt, headers={"Content-Disposition": _content_disposition("attachment", name)})


@app.post("/api/swap/zip")
def zip_download(ids: str = Form(...), name: str = Form("faceswap_results")):
    """μ„ νƒν•œ μ™„λ£Œ μž‘μ—…μ„ ZIP 으둜. 폴더 ꡬ쑰와 파일λͺ…을 μ—…λ‘œλ“œν•œ κ·ΈλŒ€λ‘œ μœ μ§€ν•œλ‹€."""
    wanted = [i for i in re.split(r"[,\s]+", ids) if i]
    if not wanted:
        raise HTTPException(400, "λ‹€μš΄λ‘œλ“œν•  μž‘μ—…μ΄ μ—†μŠ΅λ‹ˆλ‹€.")
    tmp = tempfile.TemporaryFile()
    used: set[str] = set()
    count = 0
    with zipfile.ZipFile(tmp, "w", zipfile.ZIP_STORED) as zf:
        for jid in wanted:
            try:
                job = _done_job(jid)
            except HTTPException:
                continue
            data, fname, _ = _encode_like_original(job)
            folder = "/".join(job["rel_path"].split("/")[:-1])
            arc = f"{folder}/{fname}" if folder else fname
            base, ext = os.path.splitext(arc)
            n = 2
            while arc in used:
                arc = f"{base} ({n}){ext}"
                n += 1
            used.add(arc)
            zf.writestr(arc, data)
            count += 1
    if count == 0:
        tmp.close()
        raise HTTPException(404, "μ™„λ£Œλœ κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€.")
    tmp.seek(0)

    def _iter():
        try:
            while chunk := tmp.read(1024 * 1024):
                yield chunk
        finally:
            tmp.close()

    safe = re.sub(r'[\\/:*?"<>|]', "_", name).strip() or "faceswap_results"
    zip_name = f"{safe}.zip"
    return StreamingResponse(_iter(), media_type="application/zip",
                             headers={"Content-Disposition": _content_disposition("attachment", zip_name)})


# ── μ§€λ‚œ κ²°κ³Ό ─────────────────────────────────────────────────────────────────
@app.get("/api/history")
def history_list(q: str = "", limit: int = 1000):
    items = history.list_entries(q, min(max(limit, 1), 3000))
    return {"items": [{**{k: e.get(k) for k in ("id", "rel_path", "filename", "folder", "gender", "date", "time", "elapsedSec")}, "canRegen": bool(e.get("comfy_image"))} for e in items],
            "total": len(items)}


@app.post("/api/history/hide")
def history_hide(payload: Dict[str, Any] = Body(...)):
    ids = [str(i) for i in payload.get("ids", [])]
    return {"hidden": history.hide(ids)}


CREDITS_PER_IMAGE = float(os.environ.get("CREDITS_PER_IMAGE", "8.6"))     # λŒ€λž΅μΉ˜(μž₯λ‹Ή GPU μ•½ 22초 Γ— 0.39 ν¬λ ˆλ”§/초). λŒ€μ‹œλ³΄λ“œμ˜ 'λŒ€λž΅ ν¬λ ˆλ”§'μ—λ§Œ μ“΄λ‹€.


@app.get("/api/dashboard")
def dashboard(request: Request, date_from: str = Query("", alias="from"), date_to: str = Query("", alias="to"), recent: int = 60):
    """생성 기둝 λŒ€μ‹œλ³΄λ“œ: μ§€λ‚œ κ²°κ³Ό 기둝(μ™„λ£Œλœ μƒμ„±λ§Œ)을 κΈ°κ°„λ³„λ‘œ μ§‘κ³„ν•œλ‹€. λͺ©λ‘μ—μ„œ 숨긴 것도 ν¬ν•¨ν•΄μ„œ μ„Όλ‹€."""
    _require_login(get_current_user(request))
    df, dt = (date_from or "")[:10], (date_to or "")[:10]
    rows = [e for e in history.all_entries() if (not df or e.get("date", "") >= df) and (not dt or e.get("date", "") <= dt)]
    days: Dict[str, Dict[str, Any]] = {}
    users: Dict[str, Dict[str, Any]] = {}
    folders: Dict[str, Dict[str, Any]] = {}
    files: set = set()
    hours = [0] * 24
    gender = {"male": 0, "female": 0}
    elapsed_all: List[float] = []
    regen_total = 0
    for e in rows:
        day = e.get("date", "")
        is_regen = bool(e.get("regen"))
        rel = unicodedata.normalize("NFC", e.get("rel_path") or e.get("filename") or "")
        stamp = e.get("created_at", "")
        d = days.setdefault(day, {"day": day, "gen": 0, "regen": 0})
        d["gen"] += 1
        d["regen"] += 1 if is_regen else 0
        regen_total += 1 if is_regen else 0
        files.add(rel)
        if e.get("gender") in gender:
            gender[e["gender"]] += 1
        try:
            hours[int((e.get("time") or "00:00")[:2])] += 1
        except ValueError:
            pass
        es = e.get("elapsedSec")
        if isinstance(es, (int, float)):
            elapsed_all.append(float(es))
        uk = e.get("user_username", "")                           # "" = 이 κΈ°λŠ₯ 이전 기둝(미기둝), "anonymous" = 둜그인 없이 μ‚¬μš©
        u = users.setdefault(uk, {"username": uk, "name": "", "gen": 0, "regen": 0, "files": set(), "days": set(), "elapsed": [], "last": ""})
        u["name"] = e.get("user_name") or u["name"]
        u["gen"] += 1
        u["regen"] += 1 if is_regen else 0
        u["files"].add(rel)
        u["days"].add(day)
        if isinstance(es, (int, float)):
            u["elapsed"].append(float(es))
        u["last"] = max(u["last"], stamp)
        fk = rel.rsplit("/", 1)[0] if "/" in rel else history.NO_FOLDER
        f = folders.setdefault(fk, {"folder": fk, "gen": 0, "files": set(), "last": ""})
        f["gen"] += 1
        f["files"].add(rel)
        f["last"] = max(f["last"], stamp)

    def avg(xs: List[float]) -> Optional[float]:
        return round(sum(xs) / len(xs), 1) if xs else None

    user_list = sorted(({"username": u["username"], "name": u["name"], "gen": u["gen"], "regen": u["regen"], "files": len(u["files"]),
                         "active_days": len(u["days"]), "avg_elapsed": avg(u["elapsed"]), "last": u["last"].replace("T", " ")[:16]} for u in users.values()),
                       key=lambda x: (-x["gen"], x["username"]))
    folder_list = sorted(({"folder": f["folder"], "gen": f["gen"], "files": len(f["files"]), "last": f["last"].replace("T", " ")[:16]} for f in folders.values()),
                         key=lambda x: (-x["gen"], x["folder"]))
    rows.sort(key=lambda e: e.get("created_at", ""), reverse=True)
    recent_list = [{"id": e["id"], "when": f"{e.get('date', '')} {e.get('time', '')}", "rel_path": e.get("rel_path", ""), "filename": e.get("filename", ""),
                    "gender": e.get("gender", ""), "elapsed": e.get("elapsedSec"), "regen": bool(e.get("regen")),
                    "username": e.get("user_username", ""), "name": e.get("user_name", "")} for e in rows[:max(1, min(recent, 200))]]
    st = history.status()
    all_days = sorted(d for d in days if d)
    return {
        "summary": {"gen": len(rows), "files": len(files), "regen": regen_total, "users": len([u for u in users if u and u != "anonymous"]), "days": len(all_days),
                    "avg_elapsed": avg(elapsed_all), "credits_est": round(len(rows) * CREDITS_PER_IMAGE),
                    "first_day": all_days[0] if all_days else "", "last_day": all_days[-1] if all_days else "",
                    "legacy_rows": users.get("", {}).get("gen", 0)},
        "daily": [days[d] for d in sorted(days)], "gender": gender, "hours": hours,
        "users": user_list, "folders": folder_list, "recent": recent_list,
        "filter": {"from": df, "to": dt}, "durable": bool(st.get("durable")), "remote": st.get("remote", ""),
    }


@app.get("/api/history/status")
def history_status():
    return history.status()


@app.on_event("shutdown")
def _on_shutdown() -> None:
    history.flush(20)


# ── μ„€μ • / μƒνƒœ ───────────────────────────────────────────────────────────────
@app.get("/api/swap/defaults")
def defaults():
    try:
        return cc.workflow_defaults()
    except cc.ComfyError as e:
        raise HTTPException(500, str(e))


@app.get("/api/health")
def health():
    return {**cc.status(), "maxConcurrent": MAX_CONCURRENT, "oauth": _OAUTH_ATTACHED}


@app.get("/api/whoami")
def whoami(request: Request):
    """ν™”λ©΄ μœ„μͺ½μ˜ 둜그인 ν‘œμ‹œμš©."""
    u = get_current_user(request)
    return {**u, "oauth_enabled": _OAUTH_ATTACHED, "login_required": _login_required(),
            "login_url": "/oauth/huggingface/login" if _OAUTH_ATTACHED else None,
            "logout_url": "/oauth/huggingface/logout" if _OAUTH_ATTACHED else None}


def _find_index() -> Optional[str]:
    """ν™”λ©΄ 파일 μœ„μΉ˜: 루트의 index.html 을 μš°μ„ , μ—†μœΌλ©΄ static/index.html. (λ‘˜ 쀑 어디에 μ˜¬λ €λ„ λ™μž‘)"""
    for cand in (os.path.join(BASE_DIR, "index.html"), os.path.join(BASE_DIR, "static", "index.html")):
        if os.path.exists(cand):
            return cand
    return None


@app.get("/", include_in_schema=False)
@app.get("/index.html", include_in_schema=False)
def index_page(request: Request):
    if _login_required() and not get_current_user(request)["is_logged_in"]:
        return HTMLResponse(_LOGIN_GATE_HTML, status_code=401, headers={"Cache-Control": "no-cache"})
    path = _find_index()
    if not path:
        raise HTTPException(500, "index.html 파일이 μ—†μŠ΅λ‹ˆλ‹€. server.py 와 같은 폴더(λ˜λŠ” static/)에 μ˜¬λ €μ£Όμ„Έμš”.")
    return FileResponse(path, media_type="text/html", headers={"Cache-Control": "no-cache"})