SPAO / server.py
sunyoung00's picture
Rename server (34).py to server.py
a5ba639 verified
Raw History Blame Contribute Delete
35.2 kB
"""
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}
@app.post("/api/swap/faces/{gender}")
async def upload_face(gender: str, file: UploadFile = File(...)):
if gender not in GENDERS:
raise HTTPException(400, "gender λŠ” male λ˜λŠ” female 이어야 ν•©λ‹ˆλ‹€.")
data = await file.read()
try:
Image.open(io.BytesIO(data)).verify()
except Exception: # noqa: BLE001
raise HTTPException(400, "이미지 파일이 μ•„λ‹™λ‹ˆλ‹€.")
fid = f"up-{hashlib.sha256(data).hexdigest()[:16]}"
_FACES[fid] = data
return {"id": fid}
@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 ("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 = {}
_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 = {}
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"})