Spaces:
Sleeping
Sleeping
Download server.py from sunyoung00/SPAO: direct link, hf CLI and curl.
- Browser
- Download file 35.2 kB
-
https://huggingface.co/spaces/sunyoung00/SPAO/resolve/main/server.py
- Command line
-
hf download hf://spaces/sunyoung00/SPAO/server.py
-
curl -L -o server.py https://huggingface.co/spaces/sunyoung00/SPAO/resolve/main/server.py
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() | |
| def faces_info(): | |
| return {g: {"id": f"default-{g}", "available": f"default-{g}" in _FACES} for g in GENDERS} | |
| 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} | |
| 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()) | |
| 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} | |
| 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} | |
| 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} | |
| 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)}" | |
| 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"}) | |
| 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"}) | |
| 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)}) | |
| 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)}) | |
| 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)}) | |
| # ββ μ§λ κ²°κ³Ό βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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)} | |
| 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 ν¬λ λ§/μ΄). λμ보λμ 'λλ΅ ν¬λ λ§'μλ§ μ΄λ€. | |
| 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", ""), | |
| } | |
| def history_status(): | |
| return history.status() | |
| def _on_shutdown() -> None: | |
| history.flush(20) | |
| # ββ μ€μ / μν βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def defaults(): | |
| try: | |
| return cc.workflow_defaults() | |
| except cc.ComfyError as e: | |
| raise HTTPException(500, str(e)) | |
| def health(): | |
| return {**cc.status(), "maxConcurrent": MAX_CONCURRENT, "oauth": _OAUTH_ATTACHED} | |
| 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 | |
| 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"}) | |