import os
import json
import base64
import asyncio
import traceback
import datetime
import threading
import re
from typing import Dict, List, Any, Optional, Tuple
from io import BytesIO
from fastapi import FastAPI, Body, HTTPException, BackgroundTasks, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, Response
from urllib.parse import unquote, quote
import httpx
from openai import OpenAI
from PIL import Image
# HF OAuth (optional - graceful fallback if not available in env)
try:
from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth
_HF_OAUTH_AVAILABLE = True
except Exception as _hf_oauth_import_err:
print(f"[OAUTH] huggingface_hub OAuth helpers not available: {_hf_oauth_import_err}")
_HF_OAUTH_AVAILABLE = False
attach_huggingface_oauth = None
parse_huggingface_oauth = None
# ── Environment ──────────────────────────────────────────────
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
# gpt-image-2 (2026-04-21 release). Override via env if needed (e.g. fallback to gpt-image-1.5).
OPENAI_IMAGE_MODEL = os.environ.get("OPENAI_IMAGE_MODEL", "gpt-image-2")
# gpt-image-2 prompt cap is large but we keep a safety margin to avoid 400 errors.
OPENAI_PROMPT_MAX_CHARS = int(os.environ.get("OPENAI_PROMPT_MAX_CHARS", "32000"))
# Resize any input image down to this long-side before sending to the API.
# 2304 matches the largest portrait output (1536x2304 for 4K), so generated
# images sent back through /api/edit do NOT get downscaled — preventing the
# generational quality loss that accumulates when an image is repeatedly
# edited (resize → re-encode → API regenerate → resize again …).
# Quality 95 with 4:4:4 chroma (set in _shrink_image_bytes) keeps re-encode
# loss visually negligible (~45 dB PSNR) at ~3× the bytes of the previous Q90.
OPENAI_INPUT_MAX_SIDE = int(os.environ.get("OPENAI_INPUT_MAX_SIDE", "2304"))
OPENAI_INPUT_JPEG_QUALITY = int(os.environ.get("OPENAI_INPUT_JPEG_QUALITY", "95"))
# Tone-reference: a fixed photo whose color distribution should guide the
# generated model's skin tone. We do NOT pass the original photo (its face,
# outfit, and pose would leak into outputs); instead we mosaic it down to a
# tiny grid (TONE_REF_MOSAIC_GRID cells per axis) so all recognizable form
# is destroyed and only the color palette survives. Applied only on first
# generation (MODE B); skipped for transforms and edits where the existing
# image already dictates the tone.
TONE_REF_ENABLED = os.environ.get("TONE_REF_ENABLED", "1") == "1"
TONE_REF_FILE = os.environ.get("TONE_REF_FILE", "tone_reference.png")
TONE_REF_MOSAIC_W = int(os.environ.get("TONE_REF_MOSAIC_W", "6"))
TONE_REF_MOSAIC_H = int(os.environ.get("TONE_REF_MOSAIC_H", "9"))
HF_TOKEN = os.environ.get("HF_TOKEN", "")
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "")
# ── Shared OpenAI client (reuses httpx connection pool) ──────
# gpt-image-2 can take 30–120s for high quality. A read timeout of 180s is safe;
# connect is short because we fail fast on DNS/network issues.
_OPENAI_HTTP_TIMEOUT = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
_openai_client_instance: Optional[OpenAI] = None
def get_openai_client() -> OpenAI:
"""Module-level singleton. Reusing the client avoids re-creating the
underlying httpx pool on every request (~200–500ms/request savings).
"""
global _openai_client_instance
if _openai_client_instance is None:
if not OPENAI_API_KEY:
raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured")
_openai_client_instance = OpenAI(
api_key=OPENAI_API_KEY,
timeout=_OPENAI_HTTP_TIMEOUT,
# SDK-level retries for transient 5xx/429; our own retry handles size errors.
max_retries=2,
)
print(f"[OPENAI] Initialized shared client (read_timeout={_OPENAI_HTTP_TIMEOUT.read}s)")
return _openai_client_instance
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
# ── HF OAuth ─────────────────────────────────────────────────
# When deployed on a Space with `hf_oauth: true` in README.md, this attaches
# /oauth/huggingface/login and /oauth/huggingface/logout endpoints and enables
# session-based user identification via `parse_huggingface_oauth(request)`.
_OAUTH_ATTACHED = False
if _HF_OAUTH_AVAILABLE:
try:
attach_huggingface_oauth(app)
_OAUTH_ATTACHED = True
print("[OAUTH] HF OAuth endpoints attached successfully")
except Exception as _attach_err:
print(f"[OAUTH] attach_huggingface_oauth failed (likely running outside Space): {_attach_err}")
def get_current_user(request: Request) -> dict:
"""Extract the logged-in user from the HF OAuth session.
Returns a dict with keys: username, name, email, is_logged_in.
Falls back to anonymous if OAuth isn't set up or user isn't logged in.
"""
default = {"username": "anonymous", "name": "", "email": "", "is_logged_in": False}
if not _OAUTH_ATTACHED or parse_huggingface_oauth is None:
return default
try:
oauth_info = parse_huggingface_oauth(request)
if oauth_info is None or not getattr(oauth_info, "user_info", None):
return default
ui = oauth_info.user_info
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 parse_err:
print(f"[OAUTH] parse_huggingface_oauth error: {parse_err}")
return default
# REQUIRE_LOGIN=true (기본값) 환경에서 OAuth가 붙어 있으면 로그인된 사용자만 API 호출 가능.
# 로컬 개발(OAuth 미연결)에서는 자동으로 anonymous 허용.
REQUIRE_LOGIN = os.environ.get("REQUIRE_LOGIN", "true").lower() in ("1", "true", "yes", "on")
def require_login(current_user: dict) -> None:
"""Raise 401 if OAuth is on but the user isn't logged in."""
if REQUIRE_LOGIN and _OAUTH_ATTACHED and not current_user["is_logged_in"]:
raise HTTPException(
status_code=401,
detail="로그인이 필요합니다. 우측 상단의 'Hugging Face로 로그인' 버튼을 눌러 주세요.",
)
# ── Static files ─────────────────────────────────────────────
if os.path.exists("assets"):
app.mount("/assets", StaticFiles(directory="assets"), name="assets")
_LOGIN_GATE_HTML = """
CLOVIS AI 룩북 생성기 · 로그인
CLOVIS AI 룩북 생성기
이랜드이노플 조직 멤버만 사용할 수 있습니다. Hugging Face 계정으로 로그인해 주세요.
"""
# ══════════════════════════════════════════════════════════════
# 사용자별 상세 갤러리
# - 각 생성/편집의 입력·출력 이미지, 프롬프트, 소요시간을 보여줌
# - /logs/user/{username} : HTML 갤러리
# - /api/logs/user/{username} : 상세 JSON
# - /logs/asset/{path} : private dataset 이미지 프록시(토큰으로 받아 스트리밍)
# ══════════════════════════════════════════════════════════════
import re as _re
_DSINDEX_CACHE: Dict[str, Any] = {"index": None, "loaded_at": 0.0}
_TS_RE = _re.compile(r"(\d{8}_\d{6}_\d{3})")
_ASSET_PREFIXES = ("images/", "thumbs/", "inputs/product/", "inputs/persona/")
def _load_dataset_index(force: bool = False) -> Dict[str, dict]:
"""dataset의 전체 파일 목록을 한 번 받아 ts -> {output, thumb, persona, products} 로 매핑."""
now = _time.time()
cached = _DSINDEX_CACHE.get("index")
if (not force) and cached is not None and (now - _DSINDEX_CACHE["loaded_at"] < _STATS_TTL_SEC):
return cached
index: Dict[str, dict] = {}
if not HF_TOKEN or not HF_DATASET_REPO:
_DSINDEX_CACHE.update(index=index, loaded_at=now)
return index
try:
from huggingface_hub import HfApi
files = HfApi(token=HF_TOKEN).list_repo_files(repo_id=HF_DATASET_REPO, repo_type="dataset")
except Exception as e:
print(f"[STATS] list_repo_files 실패: {e}")
files = []
for fp in files:
m = _TS_RE.search(fp)
if not m:
continue
ts = m.group(1)
slot = index.setdefault(ts, {"output": None, "thumb": None, "persona": None, "products": []})
if fp.startswith("images/"):
slot["output"] = fp
elif fp.startswith("thumbs/"):
slot["thumb"] = fp
elif fp.startswith("inputs/persona/"):
slot["persona"] = fp
elif fp.startswith("inputs/product/"):
slot["products"].append(fp)
for slot in index.values():
slot["products"].sort()
_DSINDEX_CACHE.update(index=index, loaded_at=now)
return index
_SLOT_KO = {"outer": "아우터", "top": "상의", "bottom": "하의", "shoes": "신발", "inner": "이너웨어",
"belt": "벨트", "background": "배경", "pose": "포즈"}
_DETAIL_KIND_KO = {"texture": "조직감", "material": "소재", "logo": "로고", "neckline": "넥라인", "collar": "카라",
"sleeve": "소매", "hem": "밑단", "waist": "허리", "pocket": "포켓", "closure": "지퍼·버튼",
"trim": "부자재", "sole": "밑창", "lacing": "끈", "heel": "뒤축", "buckle": "버클", "edge": "스티치"}
def _comfy_input_labels(rec: dict, paths: list) -> tuple:
"""Comfy 기록의 입력 이미지 라벨/순서 복원.
저장 시 순서: product_image_filenames(메인 의상 + 디테일 참조) → sub/(배경·포즈).
파일명은 push 시 _unique_filenames 로 정리되므로 같은 함수를 적용해 basename 으로 매칭한다."""
sf = rec.get("slot_filenames") or {}
ref_keys = [k for k in sf if k in ("background", "pose")]
main_keys = [k for k in sf if k not in ("background", "pose")]
drefs = [d for d in (rec.get("detail_refs") or []) if isinstance(d, dict)]
pnames = [str(x) for x in (rec.get("product_image_filenames") or [])]
main_labels = [_SLOT_KO.get(k, k) for k in main_keys] + \
[f"{_SLOT_KO.get(d.get('garment'), d.get('garment'))}·{_DETAIL_KIND_KO.get(d.get('kind'), d.get('kind'))}" for d in drefs]
if len(pnames) != len(main_labels): # 형식이 안 맞으면 예전 방식으로
return [_SLOT_KO.get(k, k) for k in sf.keys()], paths
expected = [(False, n, lb) for n, lb in zip(_unique_filenames(pnames, "product"), main_labels)] + \
[(True, n, _SLOT_KO.get(k, k)) for n, k in zip(_unique_filenames([str(sf[k]) for k in ref_keys], "sub_product"), ref_keys)]
remaining = list(paths)
ordered, labels = [], []
for is_sub, name, label in expected:
hit = next((p for p in remaining if ("/sub/" in p) == is_sub and os.path.basename(p) == name), None)
if hit is None:
continue
remaining.remove(hit)
ordered.append(hit)
labels.append(label)
for p in remaining: # 매칭 안 된 파일은 뒤에 그대로
ordered.append(p)
labels.append("제품")
return labels, ordered
def _asset_url(path: Optional[str]) -> Optional[str]:
return ("/logs/asset/" + path) if path else None
@app.get("/logs/asset/{path:path}")
def logs_asset(path: str):
"""private dataset 이미지를 토큰으로 받아 그대로 반환(브라우저 가 직접 못 받으니 프록시)."""
decoded = unquote(path)
if not decoded.startswith(_ASSET_PREFIXES) or ".." in decoded:
raise HTTPException(status_code=404, detail="not allowed")
if not HF_TOKEN or not HF_DATASET_REPO:
raise HTTPException(status_code=404, detail="dataset not configured")
url = f"https://huggingface.co/datasets/{HF_DATASET_REPO}/resolve/main/{quote(decoded, safe='/')}"
try:
r = httpx.get(url, headers={"Authorization": f"Bearer {HF_TOKEN}"},
timeout=20.0, follow_redirects=True)
except Exception as e:
raise HTTPException(status_code=502, detail=f"fetch failed: {e}")
if r.status_code != 200:
raise HTTPException(status_code=404, detail="asset not found")
media = r.headers.get("content-type", "application/octet-stream")
return Response(content=r.content, media_type=media,
headers={"Cache-Control": "public, max-age=3600"})
def _build_user_detail(username: str, date_from: str = "", date_to: str = "", limit: int = 200) -> dict:
records = _load_log_records()
index = _load_dataset_index()
df = (date_from or "").replace("-", "")
dt = (date_to or "").replace("-", "")
items = []
name = email = ""
logged_in = False
gen = edit = 0
elapsed_all = []
for rec in records:
if _user_key(rec) != username:
continue
day = _rec_day(rec)
if df and day < df:
continue
if dt and day > dt:
continue
ts = str(rec.get("ts") or "")
assets = index.get(ts, {})
is_edit = (rec.get("type") == "edited")
if rec.get("user_name"):
name = rec["user_name"]
if rec.get("user_email"):
email = rec["user_email"]
if rec.get("user_logged_in"):
logged_in = True
es = rec.get("elapsed_sec")
products = []
for p in (rec.get("product_infos") or []):
if isinstance(p, dict):
products.append(p.get("category") or p.get("name") or "제품")
elif p:
products.append(str(p))
product_paths = list(assets.get("products") or [])
if not products and isinstance(rec.get("slot_filenames"), dict):
# Comfy 브리지 기록: 저장 순서(메인 의상 → 디테일 참조 → sub/배경·포즈)대로 파일을 재정렬하고 라벨을 붙인다.
# 폴더 목록은 알파벳순이라 디테일 참조(원본 파일명)가 앞으로 끼어들 수 있음.
products, product_paths = _comfy_input_labels(rec, product_paths)
items.append({
"ts": ts, "time": _fmt_ts(ts), "day": day,
"kind": "편집" if is_edit else "생성", "is_edit": is_edit,
"shot": rec.get("shot_label") or "",
"mode": rec.get("mode") or "",
"angle": rec.get("camera_angle") or (rec.get("options") or {}).get("angle") or "",
"gaze": rec.get("gaze") or (rec.get("options") or {}).get("gaze") or "",
"model_face": rec.get("model_face") or "",
"elapsed": es if isinstance(es, (int, float)) else None,
"has_reference": bool(rec.get("has_reference")),
"has_background": bool(rec.get("has_background")),
"is_background_change": bool(rec.get("is_background_change")),
"custom_prompt": ((rec.get("edit_instruction") if is_edit else rec.get("custom_prompt")) or ""),
"full_prompt": rec.get("prompt") or "",
"product_cats": products,
"output_url": _asset_url(assets.get("output") or assets.get("thumb")),
"thumb_url": _asset_url(assets.get("thumb") or assets.get("output")),
"persona_url": _asset_url(assets.get("persona")),
"product_urls": [_asset_url(p) for p in product_paths],
})
if is_edit:
edit += 1
else:
gen += 1
if isinstance(es, (int, float)):
elapsed_all.append(float(es))
items.sort(key=lambda x: x["ts"], reverse=True)
truncated = len(items) > limit
items = items[:limit]
avg = round(sum(elapsed_all) / len(elapsed_all), 1) if elapsed_all else None
return {
"user": {"username": username, "name": name, "email": email, "logged_in": logged_in},
"summary": {
"gen": gen, "edit": edit, "total": gen + edit, "avg_elapsed": avg,
"shown": len(items), "truncated": truncated, "limit": limit,
"configured": bool(HF_TOKEN and HF_DATASET_REPO),
},
"items": items,
"filter": {"from": date_from, "to": date_to},
}
def _render_user_html(payload: dict) -> str:
data_json = json.dumps(payload, ensure_ascii=False).replace("", "<\\/")
return _USER_HTML_TEMPLATE.replace("__DATA__", data_json)
@app.get("/api/my/history")
def api_my_history(request: Request):
"""로그인한 본인의 생성 기록 (HF Dataset 영구 저장분). 앱의 '지난 기록' 탭에서 사용."""
user = get_current_user(request)
if _OAUTH_ATTACHED and not user["is_logged_in"]:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if request.query_params.get("refresh"):
_load_log_records(force=True)
_load_dataset_index(force=True)
payload = _build_user_detail(user["username"], limit=60)
items = [{
"ts": it["ts"], "time": it["time"],
"shot": it["shot"], "angle": it["angle"], "gaze": it["gaze"],
"face": it["model_face"], "elapsed": it["elapsed"],
"prompt": it["custom_prompt"], "fullPrompt": it["full_prompt"],
"imageUrl": it["output_url"], "thumbUrl": it["thumb_url"],
} for it in payload["items"] if it.get("output_url")]
return JSONResponse({"items": items, "truncated": payload["summary"]["truncated"],
"configured": payload["summary"]["configured"]})
@app.get("/api/logs/user/{username}")
def api_logs_user(request: Request, username: str):
qp = request.query_params
if qp.get("refresh"):
_load_log_records(force=True)
_load_dataset_index(force=True)
return JSONResponse(_build_user_detail(unquote(username), qp.get("from", ""), qp.get("to", "")))
@app.get("/logs/user/{username}", response_class=HTMLResponse)
def logs_user_page(request: Request, username: str):
qp = request.query_params
if qp.get("refresh"):
_load_log_records(force=True)
_load_dataset_index(force=True)
payload = _build_user_detail(unquote(username), qp.get("from", ""), qp.get("to", ""))
return HTMLResponse(_render_user_html(payload))
_USER_HTML_TEMPLATE = r"""
사용 상세 · AI 룩북
"""
@app.get("/index.css")
def index_css():
if os.path.exists("index.css"):
return FileResponse("index.css", media_type="text/css")
return JSONResponse(content={}, status_code=204)
@app.get("/{filename}")
def serve_root_files(request: Request, filename: str):
decoded = unquote(filename)
if decoded.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp')):
if os.path.exists(decoded) and not decoded.startswith("."):
return FileResponse(decoded)
raise HTTPException(status_code=404, detail=f"Image not found: {decoded}")
if decoded.startswith(".") or decoded.endswith((".py", ".json", ".txt", ".md", ".env")):
# 스캐너 봇의 /.env, /server.py 류 요청은 index.html 로 응답하지 않는다
raise HTTPException(status_code=404, detail="Not found")
if os.path.exists("index.html"):
return _serve_index(request)
raise HTTPException(status_code=404, detail="Not found")
# ── Image preprocessing ──────────────────────────────────────
def _shrink_image_bytes(
raw: bytes,
max_side: int = OPENAI_INPUT_MAX_SIDE,
jpeg_quality: int = OPENAI_INPUT_JPEG_QUALITY,
preserve_format_if_possible: bool = False,
) -> Tuple[bytes, str]:
"""Resize an image to `max_side` on its longest edge and re-encode as JPEG.
Rationale: face presets (~2–3 MB PNG) and user uploads blow up both upload
time and the model's input token count on `images.edit`. A 2304-long-side
JPEG at Q95 with 4:4:4 chroma typically lands at ~0.6–1.2 MB with no
visible quality loss, even after repeated edit cycles.
Why these specific knobs (verified by simulation, see comments at the top
of this file):
• max_side=2304 matches the largest portrait OUTPUT size (1536x2304 for
4K), so a generated image fed back through /api/edit is NOT downscaled.
Without this, every edit pass shrinks the image by ~25–33 % only for
the API to regenerate it back up — accumulating real detail loss.
• quality=95 lifts JPEG re-encode artifacts from ~32 dB PSNR (Q90, very
visible after 3+ edits) to ~45 dB (essentially indistinguishable).
• subsampling=0 (4:4:4) keeps full-resolution chroma. Pillow's default
below Q95 is 4:2:0, which halves color channels and accumulates
color-bleed/mosquito-noise on every edit cycle.
Idempotent in practice: feeding the encoder its own output again converges
on a fixed point — measured drift across 5 generations is ~0.06 dB PSNR.
Returns: (bytes, mime_type). Falls back to the original bytes on failure.
"""
try:
img = Image.open(BytesIO(raw))
original_format = (img.format or "").upper()
w, h = img.size
long_side = max(w, h)
if preserve_format_if_possible and long_side <= max_side:
if original_format == "PNG" and img.mode in ("RGB", "RGBA"):
return raw, "image/png"
if original_format in ("JPEG", "JPG") and img.mode == "RGB":
return raw, "image/jpeg"
# gpt-image-2 accepts RGB. Flatten alpha onto white (safer for garments
# on white backgrounds; we don't use transparency anywhere in outputs).
if img.mode in ("RGBA", "LA"):
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[-1])
img = bg
elif img.mode == "P":
img = img.convert("RGBA")
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[-1])
img = bg
elif img.mode != "RGB":
img = img.convert("RGB")
if long_side > max_side:
scale = max_side / long_side
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
img = img.resize(new_size, Image.LANCZOS)
buf = BytesIO()
img.save(
buf,
format="JPEG",
quality=jpeg_quality,
optimize=True,
progressive=False,
subsampling=0, # 4:4:4 — preserves full chroma across edit cycles
)
out = buf.getvalue()
return out, "image/jpeg"
except Exception as e:
print(f"[RESIZE] Failed ({type(e).__name__}: {e}); returning original bytes")
return raw, "image/png"
def _shrink_base64(b64_data: str) -> Tuple[str, str]:
"""Convenience: take a base64-encoded image, shrink it, return (b64, mime)."""
try:
raw = base64.b64decode(b64_data)
except Exception:
return b64_data, "image/png"
out_bytes, mime = _shrink_image_bytes(raw)
return base64.b64encode(out_bytes).decode("utf-8"), mime
# ── Server-side face presets ──────────────────────────────────
_FACE_PRESET_CACHE: Dict[str, dict] = {}
def _load_face_presets():
preset_files = [
("preset_17", "face_preset_17.png"),
("preset_18", "face_preset_18.png"),
("preset_19", "face_preset_19.png"),
]
total_before = 0
total_after = 0
for pid, filename in preset_files:
path = os.path.join(os.path.dirname(__file__) or ".", filename)
if os.path.exists(path):
with open(path, "rb") as f:
raw = f.read()
# Pre-resize once at startup; preserve PNG when already within API max side.
shrunk, mime = _shrink_image_bytes(raw, preserve_format_if_possible=True)
b64 = base64.b64encode(shrunk).decode("utf-8")
_FACE_PRESET_CACHE[pid] = {"data": b64, "mime_type": mime}
total_before += len(raw)
total_after += len(shrunk)
print(f"[FACE] Loaded preset '{pid}' from {filename} ({len(raw):,} → {len(shrunk):,} bytes, {mime})")
else:
print(f"[FACE] Preset file not found: {path}")
if total_before > 0:
saved_pct = 100 * (1 - total_after / total_before)
print(f"[FACE] Total preset memory: {total_before:,} → {total_after:,} bytes (-{saved_pct:.0f}%)")
_load_face_presets()
# ── Server-side tone reference (color palette) ────────────────
# A single static photo whose color distribution defines the target skin
# tone / color grading for generated models. Stored as a heavily mosaiced
# image so all recognizable structure (face, outfit, pose, background) is
# destroyed — only the color palette remains. The model receives this as
# the SECOND input image in MODE B and is instructed via the prompt to
# treat it strictly as a color reference.
_TONE_REF_CACHE: Dict[str, Any] = {}
def _load_tone_ref():
if not TONE_REF_ENABLED:
print("[TONE] Disabled via TONE_REF_ENABLED=0")
return
path = os.path.join(os.path.dirname(__file__) or ".", TONE_REF_FILE)
if not os.path.exists(path):
print(f"[TONE] Reference file not found: {path} — feature inactive")
return
try:
with open(path, "rb") as f:
raw = f.read()
palette = _build_tone_palette_image(raw)
if not palette:
raise ValueError("palette build failed")
b64 = palette["data"]
mime = palette["mime_type"]
_TONE_REF_CACHE["data"] = b64
_TONE_REF_CACHE["mime_type"] = mime
print(
f"[TONE] Loaded reference from {TONE_REF_FILE} "
f"(palette strip {TONE_REF_MOSAIC_W} swatches, "
f"{len(base64.b64decode(b64)):,} bytes, {mime})"
)
except Exception as e:
print(f"[TONE] Failed to load reference ({type(e).__name__}: {e}) — feature inactive")
def _get_tone_ref_part() -> Optional[dict]:
"""Return the tone-reference inline_data part, or None if unavailable/disabled."""
if not TONE_REF_ENABLED or "data" not in _TONE_REF_CACHE:
return None
return {
"inline_data": {
"data": _TONE_REF_CACHE["data"],
"mime_type": _TONE_REF_CACHE["mime_type"],
}
}
def _build_tone_palette_image(image_bytes: bytes) -> Optional[Dict[str, str]]:
"""Build a tiny horizontal palette strip — color info only, never a scene background."""
try:
src = Image.open(BytesIO(image_bytes)).convert("RGB")
grid_w, grid_h = TONE_REF_MOSAIC_W, TONE_REF_MOSAIC_H
tiny = src.resize((grid_w, grid_h), Image.LANCZOS)
swatch_w = 14
strip_h = 16
strip = Image.new("RGB", (grid_w * swatch_w, strip_h))
for x in range(grid_w):
colors = [tiny.getpixel((x, y)) for y in range(grid_h)]
r = sum(c[0] for c in colors) // grid_h
g = sum(c[1] for c in colors) // grid_h
b = sum(c[2] for c in colors) // grid_h
color = (r, g, b)
for dx in range(swatch_w):
for y in range(strip_h):
strip.putpixel((x * swatch_w + dx, y), color)
buf = BytesIO()
strip.save(buf, format="PNG")
shrunk, mime = _shrink_image_bytes(buf.getvalue())
return {
"data": base64.b64encode(shrunk).decode("utf-8"),
"mime_type": mime,
}
except Exception as e:
print(f"[TONE] Failed to build tone palette strip ({type(e).__name__}: {e})")
return None
def _extract_tone_palette_text(image_bytes: bytes) -> str:
"""Summarize dominant colors as text so the model can grade without copying a grid."""
try:
src = Image.open(BytesIO(image_bytes)).convert("RGB")
tiny = src.resize((TONE_REF_MOSAIC_W, TONE_REF_MOSAIC_H), Image.LANCZOS)
hex_colors = []
for x in range(TONE_REF_MOSAIC_W):
colors = [tiny.getpixel((x, y)) for y in range(TONE_REF_MOSAIC_H)]
r = sum(c[0] for c in colors) // len(colors)
g = sum(c[1] for c in colors) // len(colors)
b = sum(c[2] for c in colors) // len(colors)
hex_colors.append(f"#{r:02x}{g:02x}{b:02x}")
return "Target palette (grade only): " + ", ".join(hex_colors)
except Exception:
return ""
def _mosaic_image_for_tone_ref(image_bytes: bytes) -> Optional[Dict[str, str]]:
"""Backward-compatible alias — palette strip only."""
return _build_tone_palette_image(image_bytes)
def _get_user_tone_ref_part(tone_image_b64: str) -> Optional[dict]:
if not tone_image_b64 or not isinstance(tone_image_b64, str) or not tone_image_b64.startswith("data:"):
return None
parsed = _parse_base64(tone_image_b64)
palette = _build_tone_palette_image(base64.b64decode(parsed["data"]))
if not palette:
return None
return {
"inline_data": {
"data": palette["data"],
"mime_type": palette["mime_type"],
}
}
def _parse_user_tone_reference(tone_image_b64: str) -> Tuple[Optional[dict], str]:
"""Return palette strip (ORIGINAL only) and palette text for prompt grading."""
if not tone_image_b64 or not isinstance(tone_image_b64, str) or not tone_image_b64.startswith("data:"):
return None, ""
parsed = _parse_base64(tone_image_b64)
raw = base64.b64decode(parsed["data"])
palette_text = _extract_tone_palette_text(raw)
tone_part = _get_user_tone_ref_part(tone_image_b64)
return tone_part, palette_text
def _build_tone_background_lock() -> str:
return (
"TONE BACKGROUND LOCK (CRITICAL): The visible scene background MUST remain a continuous soft light-gray photography studio backdrop. "
"Do NOT render any mosaic grid, checkerboard, color tiles, palette swatches, or pattern from the tone reference as the background. "
"The tone reference influences color grade and mood only — never the background texture or layout."
)
def _build_tone_reference_instruction(
mode: str = "original",
has_face: bool = True,
palette_text: str = "",
) -> str:
palette_line = f"{palette_text}. " if palette_text else ""
background_lock = _build_tone_background_lock()
if mode == "transform":
return (
f"TONE / MOOD REFERENCE (CRITICAL): {palette_line}"
"Use this palette guidance ONLY for overall color temperature, contrast, saturation, white balance, and photographic mood. "
"Apply a similar tone and mood while preserving the source image's model identity, face, outfit, pose, framing, composition, and studio background exactly. "
"DO NOT copy garment colors, background layout, mosaic patterns, or facial identity from the tone reference. "
f"{background_lock}"
)
position = "The small palette strip immediately after the face reference" if has_face else "The small palette strip"
return (
f"TONE / MOOD REFERENCE (CRITICAL — READ CAREFULLY): {position} is NOT a person, outfit, pose, scene, or background plate. "
f"It is a tiny horizontal color palette extracted from a user reference photograph. {palette_line}"
"USE IT FOR: matching overall color temperature, white balance, contrast, saturation, and photographic mood. "
"DO NOT: copy any face, hair, outfit, garment color, pose, framing, background layout, grid, or tile pattern from this image. "
"The model's face identity must still come from the face reference — apply the tone reference to global color grade and atmosphere only. "
f"{background_lock}"
)
_load_tone_ref()
@app.get("/api/face-presets")
def get_face_presets():
preset_labels = {
"preset_17": "여성 1", "preset_18": "여성 2", "preset_19": "여성 3",
}
presets = []
for pid in _FACE_PRESET_CACHE:
presets.append({
"id": pid,
"label": preset_labels.get(pid, pid),
"url": f"/{pid.replace('preset_', 'face_preset_')}.png",
})
return {"presets": presets}
# ── Helpers ──────────────────────────────────────────────────
def _parse_base64(data_url: str) -> dict:
if ";base64," in data_url:
header, data = data_url.split(";base64,", 1)
mime = header.split(":", 1)[1] if ":" in header else "image/png"
return {"mime_type": mime, "data": data}
return {"mime_type": "image/png", "data": data_url}
def _hair_identity_lock(gender: str) -> str:
"""Front-view hair: reduce left/right asymmetry and random restyles vs face reference."""
if gender == "male":
return (
"HAIR IDENTITY (CRITICAL): Match the face reference hair precisely — same cut, length, and side volume; "
"do NOT randomize thickness, wave, or introduce asymmetric styling not visible in the reference."
)
return (
"HAIR IDENTITY LOCK (CRITICAL — match face reference): Replicate hair EXACTLY: same visible length on LEFT and RIGHT "
"(no one-side-only short cuts or 'half different haircut'), same part line (center vs side), same bangs/forehead exposure, "
"same straight/wave texture and overall volume. Do NOT randomize hair texture or layering. "
"Long hair must fall naturally past both shoulders with balanced symmetry in front view. "
"Do NOT tuck behind one ear only unless clearly shown in the reference."
)
def _resolve_image_size(payload_size: Any) -> str:
"""Default to 2K; allow 1K / 2K / 4K per request."""
if not payload_size:
return "2K"
try:
v = str(payload_size).strip().upper()
except Exception:
return "2K"
return v if v in {"1K", "2K", "4K"} else "2K"
def _openai_image_size(image_size_key: Any) -> str:
"""Map UI 1K/2K/4K to gpt-image-2 portrait ``size``.
gpt-image-2 accepts flexible resolutions; however, to guarantee
compatibility we use the officially documented portrait sizes and
fall back to `auto` for higher tiers that may fail at account level.
Officially supported portrait sizes for GPT Image family: 1024x1536.
"""
tier = _resolve_image_size(image_size_key)
if tier == "1K":
return "1024x1536"
if tier == "4K":
# gpt-image-2 accepts flexible resolutions but very large sizes may
# exceed the per-request token/pixel budget on some accounts.
# Use a safe high-res portrait; let the model handle fidelity.
return "1536x2304"
# 2K default
return "1536x2048"
def _openai_quality(image_size_key: Any) -> str:
"""Pick quality based on requested tier."""
tier = _resolve_image_size(image_size_key)
if tier == "4K":
return "high"
if tier == "2K":
return "high"
return "medium"
def _openai_prompt_and_image_files(parts: List[dict]) -> tuple:
"""Split Gemini-style content parts into a single prompt and binary image inputs.
Every inline image is passed through `_shrink_image_bytes` here. Face presets
use `preserve_format=True` so PNG bytes are not re-encoded to JPEG before API upload.
"""
text_chunks: List[str] = []
image_files: List[BytesIO] = []
total_before = 0
total_after = 0
for p in parts:
if "text" in p:
text_chunks.append(p["text"])
elif "inline_data" in p:
idata = p["inline_data"]
raw_b64 = idata["data"]
raw = base64.b64decode(raw_b64) if isinstance(raw_b64, str) else raw_b64
total_before += len(raw)
# Edit source images can preserve PNG/JPEG bytes when they already
# fit the input size; this avoids adding compression texture that
# the image model may amplify on skin.
shrunk, shrunk_mime = _shrink_image_bytes(
raw,
preserve_format_if_possible=bool(p.get("preserve_format")),
)
total_after += len(shrunk)
buf = BytesIO(shrunk)
buf.name = "image.jpg" if "jpeg" in shrunk_mime else "image.png"
image_files.append(buf)
if image_files and total_before > 0:
saved_pct = 100 * (1 - total_after / total_before)
print(f"[RESIZE] {len(image_files)} input image(s): {total_before:,} → {total_after:,} bytes (-{saved_pct:.0f}%)")
prompt = "\n\n".join(text_chunks)
# Guard against rare "prompt too long" errors from the API.
if len(prompt) > OPENAI_PROMPT_MAX_CHARS:
print(f"[OPENAI] Prompt length {len(prompt)} > {OPENAI_PROMPT_MAX_CHARS}; truncating.")
prompt = prompt[:OPENAI_PROMPT_MAX_CHARS]
return prompt, image_files
def _translate_openai_error(err: Exception) -> HTTPException:
"""OpenAI SDK / API 에러를 적절한 HTTP 상태코드 + 한글 메시지로 변환.
OpenAI 에러는 `getattr(err, "body", dict)["error"]["code"]`에 유형이 담겨 있고,
SDK가 못 받은 경우를 대비해 메시지 문자열로도 한 번 더 매칭.
"""
code = ""
msg_raw = ""
try:
body = getattr(err, "body", None) or {}
if isinstance(body, dict):
err_obj = body.get("error") or {}
code = (err_obj.get("code") or "").lower()
msg_raw = err_obj.get("message") or ""
except Exception:
pass
s = (str(err) + " " + code + " " + msg_raw).lower()
# 결제 한도 (가장 먼저 체크 — 400으로 위장되어 오는 경우가 있음)
if "billing_hard_limit_reached" in s or "billing hard limit" in s:
return HTTPException(
status_code=402,
detail="OpenAI API 결제 한도에 도달했습니다. 관리자에게 사용량 한도 상향을 요청해 주세요.",
)
if "insufficient_quota" in s:
return HTTPException(
status_code=402,
detail="OpenAI API 크레딧이 부족합니다. 결제 정보를 확인해 주세요.",
)
# Rate limit
if "rate_limit" in s or "rate limit" in s or "too many requests" in s or " 429" in s:
return HTTPException(
status_code=429,
detail="요청이 몰려 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.",
)
# 콘텐츠 정책
if "content_policy" in s or "safety_violation" in s or "safety system" in s or "moderation_blocked" in s:
return HTTPException(
status_code=400,
detail="이미지 생성이 OpenAI 콘텐츠 정책에 의해 거부되었습니다. 프롬프트나 입력 이미지를 확인해 주세요.",
)
# 인증
if "invalid_api_key" in s or "incorrect api key" in s or "authentication" in s:
return HTTPException(
status_code=401,
detail="OpenAI API 키가 유효하지 않거나 설정되지 않았습니다. 환경변수를 확인해 주세요.",
)
# 모델 접근 권한
if "model_not_found" in s or "does not have access to model" in s or "not_found" in s:
return HTTPException(
status_code=403,
detail=f"모델 '{OPENAI_IMAGE_MODEL}'에 접근할 수 없습니다. 계정 등급/모델 액세스 권한을 확인해 주세요.",
)
# 타임아웃 / 네트워크
if "timeout" in s or "timed out" in s:
return HTTPException(
status_code=504,
detail="OpenAI API 응답 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.",
)
if "connection" in s and ("error" in s or "refused" in s or "reset" in s):
return HTTPException(
status_code=502,
detail="OpenAI API 서버에 연결할 수 없습니다. 네트워크 상태를 확인해 주세요.",
)
# 잘못된 요청 (사이즈 외 기타)
if "invalid_request" in s or "bad request" in s:
short = (msg_raw or str(err))[:300]
return HTTPException(
status_code=400,
detail=f"요청이 OpenAI API에 의해 거부되었습니다: {short}",
)
# 그 외 (원문 메시지 일부 첨부)
short = (msg_raw or str(err))[:300]
return HTTPException(
status_code=502,
detail=f"이미지 생성 중 오류가 발생했습니다: {short}",
)
def _openai_image_generate(client: OpenAI, parts: List[dict], image_size_key: Any) -> str:
"""gpt-image-2: images.edit with reference images, else images.generate.
Important notes for gpt-image-2 (2026-04-21):
- `response_format` is NOT supported (the API always returns b64_json).
- `input_fidelity` is NOT supported — gpt-image-2 auto-processes input
images at high fidelity internally.
- `background="transparent"` is NOT supported.
- Size parameter is flexible but we use documented portrait sizes.
"""
prompt, image_files = _openai_prompt_and_image_files(parts)
size_value = _openai_image_size(image_size_key)
quality_value = _openai_quality(image_size_key)
common = {
"model": OPENAI_IMAGE_MODEL,
"prompt": prompt,
"size": size_value,
"quality": quality_value,
"n": 1,
}
try:
if image_files:
for f in image_files:
f.seek(0)
print(f"[OPENAI] images.edit | model={OPENAI_IMAGE_MODEL} | size={size_value} | quality={quality_value} | refs={len(image_files)}")
result = client.images.edit(image=image_files, **common)
else:
print(f"[OPENAI] images.generate | model={OPENAI_IMAGE_MODEL} | size={size_value} | quality={quality_value}")
result = client.images.generate(**common)
except Exception as api_err:
# Retry with `size="auto"` if the requested size is rejected.
err_str = str(api_err).lower()
is_size_err = any(k in err_str for k in ("size", "dimension", "resolution", "pixel"))
if is_size_err and size_value != "auto":
print(f"[OPENAI] Size '{size_value}' rejected: {api_err}. Retrying with size='auto'.")
common["size"] = "auto"
try:
if image_files:
for f in image_files:
f.seek(0)
result = client.images.edit(image=image_files, **common)
else:
result = client.images.generate(**common)
except Exception as retry_err:
print(f"[OPENAI] Retry failed: {retry_err}")
raise _translate_openai_error(retry_err)
else:
print(f"[OPENAI] API error: {api_err}")
raise _translate_openai_error(api_err)
if not getattr(result, "data", None):
raise HTTPException(status_code=500, detail="No image was returned by OpenAI.")
b64 = result.data[0].b64_json
if not b64:
# Some SDK versions expose URL instead; attempt to read `url` as fallback.
url = getattr(result.data[0], "url", None)
if url:
raise HTTPException(status_code=500, detail=f"OpenAI returned URL instead of b64 data: {url}")
raise HTTPException(status_code=500, detail="No image data in OpenAI response.")
return f"data:image/png;base64,{b64}"
def _get_face_parts(model_face) -> list:
parts = []
face_data = None
face_mime = None
if isinstance(model_face, str):
if model_face.startswith("preset_") and model_face in _FACE_PRESET_CACHE:
cached = _FACE_PRESET_CACHE[model_face]
face_data = cached["data"]
face_mime = cached["mime_type"]
print(f"[FACE] Using preset '{model_face}' ({len(face_data)} base64 chars)")
elif model_face.startswith("data:"):
parsed = _parse_base64(model_face)
face_data = parsed["data"]
face_mime = parsed["mime_type"]
print(f"[FACE] Using user upload ({len(face_data)} base64 chars)")
if face_data:
parts.append({
"inline_data": {"data": face_data, "mime_type": face_mime},
"preserve_format": True,
})
parts.append({"text": (
"[FACE REFERENCE IMAGE] This is the EXACT face/person to use as the model. "
"You MUST preserve this person's facial identity with extreme precision: "
"same face shape, eye shape, eyebrow shape, nose, lips, and hairstyle. "
"FACIAL SKIN (HIGHEST PRIORITY): Copy the reference face's skin EXACTLY — same even tone, same smoothness, same lighting. "
"Render face skin as one clean continuous catalog tone matching the reference. "
"Do NOT add under-eye darkness, cheek stains, nose shadows, forehead patches, pores-as-noise, mottling, speckles, or asymmetric skin discoloration. "
"Do NOT add new freckles, moles, or random dots not visible in this reference. "
"HAIRSTYLE: Match hair length, part, left–right balance, and texture to this reference — "
"do NOT invent asymmetric cuts, uneven lengths, or a different wave pattern. "
"The generated model must look like the SAME PERSON in every shot."
)})
return parts
# ── Garment length → body position mapping ──────────────────
_MODEL_HEIGHTS = {
"preset_9": 175, "preset_11": 175, "preset_12": 190, "preset_13": 185,
"preset_kids_1": 118, "preset_kids_2": 120, "preset_kids_3": 138,
"preset_kids_7": 140,
"preset_kids_4": 142, "preset_kids_5": 158, "preset_kids_6": 162,
}
_MODEL_GENDERS = {
"preset_9": "female", "preset_11": "female", "preset_12": "male", "preset_13": "male",
"preset_kids_1": "female", "preset_kids_2": "male", "preset_kids_3": "female",
"preset_kids_7": "female",
"preset_kids_4": "male", "preset_kids_5": "female", "preset_kids_6": "male",
}
_MODEL_AGE_GROUPS = {
"preset_9": "adult", "preset_11": "adult", "preset_12": "adult", "preset_13": "adult",
"preset_kids_1": "kids", "preset_kids_2": "kids", "preset_kids_3": "kids",
"preset_kids_7": "kids",
"preset_kids_4": "kids", "preset_kids_5": "kids", "preset_kids_6": "kids",
}
# elem_low=초등 저학년, elem_high=초등 고학년, middle=중학교
_MODEL_KIDS_GRADES = {
"preset_kids_1": "elem_low", "preset_kids_2": "elem_low",
"preset_kids_3": "elem_high", "preset_kids_7": "elem_high", "preset_kids_4": "elem_high",
"preset_kids_5": "middle", "preset_kids_6": "middle",
}
_KIDS_GRADE_BODY_HINTS = {
"elem_low": (
"The model is a lower-elementary child (Korean grades 1–3, approximately 7–9 years old, ~118–122cm tall). "
"Use the smallest child body scale — noticeably shorter than upper-elementary or middle-school models."
),
"elem_high": (
"The model is an upper-elementary child (Korean grades 4–6, approximately 10–12 years old, ~138–142cm tall). "
"Use a mid-height child body scale — taller than lower-elementary but clearly shorter than a middle-school teen."
),
"middle": (
"The model is a middle-school teenager (Korean grades 7–9, approximately 13–15 years old, ~158–162cm tall). "
"Use a taller youth body scale — clearly taller than elementary children but still not an adult fashion model."
),
}
_KIDS_GRADE_HEIGHT_RANGES = {
"elem_low": (118, 122),
"elem_high": (138, 142),
"middle": (158, 162),
}
def _build_kids_height_lock_prompt(kids_grade: Optional[str], model_height_cm: float) -> str:
if not kids_grade or model_height_cm <= 0:
return ""
wrong_grade_hints = {
"elem_low": "Do NOT render as upper-elementary (~138cm) or middle-school (~158cm) height.",
"elem_high": (
"Do NOT render as lower-elementary (~118cm) height — the body must look clearly taller than a 7–9 year old. "
"If the model looks like grades 1–3 stature, the output is WRONG."
),
"middle": "Do NOT render as elementary-child height (~118–142cm) — the body must look like a middle-school teen.",
}
lo, hi = _KIDS_GRADE_HEIGHT_RANGES.get(kids_grade, (model_height_cm - 2, model_height_cm + 2))
grade_label = {
"elem_low": "lower-elementary (grades 1–3)",
"elem_high": "upper-elementary (grades 4–6)",
"middle": "middle-school (grades 7–9)",
}.get(kids_grade, kids_grade)
return (
f"KIDS HEIGHT LOCK (CRITICAL): This model is {grade_label}. "
f"Total standing height MUST be approximately {model_height_cm:.0f}cm (acceptable range {lo:.0f}–{hi:.0f}cm). "
f"{wrong_grade_hints.get(kids_grade, '')} "
"In the full-body frame, taller grades must occupy more vertical body height than younger grades."
)
def _resolve_model_kids_grade(model_face, payload_kids_grade=None) -> Optional[str]:
if payload_kids_grade in _KIDS_GRADE_BODY_HINTS:
return payload_kids_grade
if isinstance(model_face, str) and model_face.startswith("preset_"):
return _MODEL_KIDS_GRADES.get(model_face)
return None
def _resolve_model_age_group(model_face, payload_age_group=None) -> str:
if payload_age_group in ("adult", "kids"):
return payload_age_group
if isinstance(model_face, str) and model_face.startswith("preset_"):
return _MODEL_AGE_GROUPS.get(model_face, "adult")
return "adult"
def _resolve_model_gender(model_face, payload_gender=None) -> str:
"""Resolve model gender: payload override > preset mapping > default."""
if payload_gender and payload_gender in ("male", "female"):
return payload_gender
if isinstance(model_face, str) and model_face.startswith("preset_"):
return _MODEL_GENDERS.get(model_face, "female")
return "female"
def _resolve_model_height(model_face, payload_height) -> float:
try:
h = float(payload_height) if payload_height else 0
if h > 0: return h
except (ValueError, TypeError): pass
if isinstance(model_face, str) and model_face.startswith("preset_"):
return _MODEL_HEIGHTS.get(model_face, 170)
return 170
def _calc_garment_endpoint(model_height_cm: float, garment_length_cm: float, category: str) -> str:
if model_height_cm <= 0 or garment_length_cm <= 0: return ""
start_map = {"상의":0.82, "아우터":0.82, "원피스":0.82, "하의":0.57, "스커트":0.57}
start_ratio = start_map.get(category, 0.57)
hem = model_height_cm * start_ratio - garment_length_cm
r = hem / model_height_cm
if r <= 0.03: pos = "reaching the floor"
elif r <= 0.07: pos = "at the ankle bone"
elif r <= 0.12: pos = "between ankle and mid-calf"
elif r <= 0.18: pos = "at mid-calf"
elif r <= 0.23: pos = "just below the knee"
elif r <= 0.28: pos = "at the knee"
elif r <= 0.33: pos = "just above the knee"
elif r <= 0.38: pos = "at mid-thigh"
elif r <= 0.43: pos = "at upper thigh (short)"
elif r <= 0.50: pos = "at hip level (very short)"
elif r <= 0.60: pos = "at the waist"
elif r <= 0.68: pos = "at the midriff (crop)"
else: pos = "above the chest (very cropped)"
return f"Model {model_height_cm:.0f}cm, garment {garment_length_cm:.0f}cm → hem MUST end {pos} (~{hem:.0f}cm from ground)."
def _build_garment_length_prompts(product_infos: list, model_height_cm: float) -> str:
if not model_height_cm or model_height_cm <= 0: return ""
guides = []
for i, info in enumerate(product_infos):
try: tl = float(info.get("totalLength", 0))
except: tl = 0
if tl > 0:
g = _calc_garment_endpoint(model_height_cm, tl, info.get("category", ""))
if g: guides.append(f"[Item {i+1} - {info.get('category','')}] {g}")
return ("GARMENT LENGTH (CRITICAL):\n" + "\n".join(guides)) if guides else ""
def _build_camera_prompt(
shot_label: str,
gender: str = "female",
age_group: str = "adult",
model_height_cm: float = 0,
kids_grade: Optional[str] = None,
) -> str:
rear_hair_rule = (
"HAIR (CRITICAL): Keep a masculine back silhouette. Hair should remain short/neutral and must NOT be styled forward over both shoulders."
if gender == "male"
else (
"HAIR (CRITICAL): This is a FEMALE model. The source image shows long hair — you MUST preserve it. "
"Keep the feminine long-hair silhouette visible from the back. Hair must stay OUTSIDE the hood and fall naturally behind/around shoulders. "
"Do NOT replace with short or male-style hair. Do NOT tie into a bun/ponytail unless explicitly requested. "
"The back-of-head hair length and style must match the long hair visible in the source."
)
)
hood_hair_rule = (
"HAIR (CRITICAL): Keep a masculine short/neutral hairstyle. Hair should stay above or around neck length and must NOT fall long over the shoulders. Keep hair OUTSIDE the hood and do NOT tuck it into the hood."
if gender == "male"
else "HAIR (CRITICAL): Keep hair OUTSIDE the hood. Do NOT tuck hair into the hood; let hair fall naturally around/over the shoulders."
)
male_full_body_silhouette_rule = (
"MALE SILHOUETTE CUE (IMPORTANT): Keep the upper torso visually broad and structured. "
"Hold the chest open with shoulders squared and slightly expanded, and keep the arms a little away from the torso so the shoulder span reads clearly. "
"Avoid a soft rounded silhouette that makes the shoulders look narrow. "
"The shoulder line should read almost horizontal and boxy, with a clean right-angled shoulder impression rather than a hanger-like downward slope. "
if gender == "male"
else ""
)
pocket_hand_rule = (
"Hand detail (CRITICAL): keep a feminine hand appearance — smooth skin, slender fingers, and soft natural contours. Avoid visible veins, pronounced tendons, or muscular hand definition."
if gender == "female"
else "Hand detail (CRITICAL): keep a natural masculine hand appearance with realistic structure and subtle veins/tendons only if naturally visible."
)
female_hand_rule = (
"FEMALE HAND AESTHETIC (CRITICAL): Hands should look refined and feminine — slim fingers, soft knuckles, smooth skin, minimal visible veins/tendons, and no bulky/masculine hand structure."
if gender == "female"
else ""
)
front_head_scale_rule = (
"Keep head size natural for a child (head proportionally larger than adult models — roughly 1/6 to 1/7 of total body height). "
"Do NOT shrink the head to adult 9-head fashion proportions."
if age_group == "kids"
else "Keep head size natural for a commercial lookbook (roughly 1/7.8 to 1/8.3 of total visible body height). "
"Avoid tiny-head fashion-illustration exaggeration and avoid ultra-long-leg distortion. "
"Keep a balanced torso-to-leg ratio so the natural waist sits near mid-frame."
)
kids_height_rule = (
f" BODY HEIGHT LOCK: {_build_kids_height_lock_prompt(kids_grade, model_height_cm)}"
if age_group == "kids" and model_height_cm > 0
else ""
)
mapping = {
"전신(정면)": f"Full body length shot, directly front view, standing straight and looking at the camera. {male_full_body_silhouette_rule}{hood_hair_rule} {_hair_identity_lock(gender)} {female_hand_rule} CANDIDATE CONSISTENCY LOCK (CRITICAL): All independently generated front candidates must use the same catalog camera template and body scale. Use a fixed lookbook camera/framing template — camera height around upper-hip/waist level, neutral perspective, and stable subject distance. {front_head_scale_rule} Keep margins consistent: about 5-6% space above the head and 6-8% below the feet. Maintain the same apparent model height, shoulder width, torso-to-leg ratio, foot scale, and body width in every candidate. Variation is allowed only in tiny hand relaxation or micro posture, never in camera zoom, crop, or body proportions.{kids_height_rule} The entire model from head to toe must be visible.",
"전신(자유포즈)": f"Full body length shot with a natural walking-inspired lookbook pose. Keep the body mostly front-facing with a subtle 8-12 degree angle and smooth weight transfer. One leg steps forward naturally with a softly bent front knee (never locked straight), while the rear leg stays slightly bent with a gentle heel lift for realistic gait flow. One hand rests naturally in a pocket and the other arm hangs loose with a small natural swing. Shoulders stay relaxed, torso upright with a subtle lean, and expression calm/confident (no smile). The pose should feel candid and effortless, not stiff or mannequin-like. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
"전신(자유포즈2)": f"Full body length shot, front-facing with a lively editorial pose. The model shifts weight clearly to one leg, creating a natural asymmetric S-curve. One arm is raised with the hand lightly touching the side/back of the head, while the other arm rests naturally near the hip or inside a pocket. The shoulders stay relaxed, torso has a slight lean, and one knee is softly bent for movement. Expression is calm and confident, looking toward the camera with a natural non-smiling look. The pose should feel like a real Korean fashion campaign cut — effortless but dynamic, not stiff. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
"전신(측면)": f"Full body length shot, 45-degree profile view, highlighting the fit and silhouette from a natural side-front perspective. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
"전신(측면포즈1)": f"Full body side-inspired lookbook pose with a soft three-quarter angle (about 30-40 degrees). Keep one leg as the weight-bearing leg and let the other leg rest slightly forward with a relaxed bend at the knee. Keep both hands naturally inside the outerwear pockets, shoulders relaxed, and chin slightly lowered for a calm editorial mood. Preserve clean side silhouette readability while keeping the entire body from head to toe visible. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule}",
"전신(측면포즈2)": f"Full body winter lookbook pose with a soft side-front angle (about 20-35 degrees). Keep both hands naturally inside the jacket or hoodie pockets, shoulders relaxed, and the head slightly turned toward camera with a calm neutral expression. Use a grounded stance: one leg placed slightly forward and the other supporting weight, with a subtle bend for a natural casual silhouette. Keep the overall mood cozy and effortless, and ensure the entire model from head to toe remains visible. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule}",
"전신(후면)": f"Full body back view shot, 180-degree rotation showing the back design, rear fit, and overall silhouette of the outfit. {rear_hair_rule} Keep hair naturally arranged so key garment back details remain visible as much as possible without forcing all hair to the front. The entire model from head to toe must be visible.",
"상반신": (
"Upper body medium-wide shot with generous studio margins. Pull the camera back slightly — NOT a tight bust crop. "
"Frame from the head down to slightly below the waist, with visible background above the head and on both sides of the shoulders. "
"DO NOT show thighs, knees, or feet."
),
"상반신(자유포즈)": (
"Upper body medium-wide shot with a slight three-quarter angle and generous studio margins. "
"The model's body is turned about 20-30 degrees from the camera with one hand placed naturally behind the hip or lower back. "
"The head turns toward the camera with a calm, neutral expression — NO smiling. "
"Pull the camera back slightly — NOT a tight bust crop. Frame from the top of the head to slightly below the waist with visible background on all sides. "
"Focus on the garment's neckline, texture, buttons, and fit. DO NOT show thighs, knees, or feet."
),
"상반신(후면)": (
f"Upper body back view medium-wide shot with generous studio margins. The model is turned 180 degrees showing the back. "
f"Pull the camera back slightly — NOT a tight crop. Frame from the top of the head to slightly below the waist with visible background above and beside the body. "
f"The full head, both shoulders, and upper hip area must be visible. {rear_hair_rule} "
f"Keep hair naturally arranged so garment back details remain visible as much as possible without forcing all hair to the front. "
f"Focus on the back design, yoke seam, neckline from behind, shoulder construction, and fabric texture. DO NOT show thighs, knees, or feet."
),
"하반신": "Lower body focused shot. The frame starts just below the chin/neck, cutting off the top of the head. Show the model's body from neck down to the feet including the full outfit, legs, and shoes. The focus should be on the lower garment fit and leg silhouette, but the top garment should also be partially visible for context.",
"디테일(상의)": (
"Close-up detail shot of the UPPER GARMENT focused on the chest and neckline area.\n"
"COMPOSITION (CRITICAL): The garment must be perfectly HORIZONTALLY CENTERED in the frame. "
"Both shoulder seams must be fully visible inside the frame with approximately EQUAL margin on LEFT and RIGHT — "
"the crop must NOT be asymmetric, and no shoulder or sleeve edge may be clipped. "
"The torso is perfectly frontal (zero rotation), with both shoulders at equal height.\n"
"CROP BOUNDARIES (STRICT): "
"TOP EDGE must sit at or just below the lower lip — do NOT show mouth, nose, or eyes; only the underside of the chin/jawline may touch the top edge. "
"BOTTOM EDGE around mid-chest to upper abdomen (roughly between the pectoral line and the bottom of the ribcage). "
"LEFT/RIGHT EDGE roughly 8–12% padding outside each shoulder seam, symmetric on both sides.\n"
"FOCUS (CRITICAL): Tack-sharp, even focus across the ENTIRE visible garment surface — fabric weave, knit stitch texture, neckline ribbing, and shoulder seam must all be crisp. "
"Think f/5.6–f/8 studio lookbook detail cut: no shallow depth-of-field blur, no soft focus, no background bokeh, no motion blur.\n"
"STYLING LOCK: Do NOT roll sleeves, fold cuffs, or alter sleeve styling; preserve original sleeve length and garment styling exactly. "
"Do NOT show full body, hands, or full arms/forearms."
),
"디테일(포켓)": f"Close-up detail shot focused on the side pocket area, similar to a lookbook pocket detail cut. Frame around the lower jacket/top hem and upper hip region so the pocket opening and surrounding seam structure are dominant. Pose detail: the hand is naturally inserted into the pocket with only part of the hand visible. {pocket_hand_rule} IMPORTANT: Keep the top untucked exactly as in the source look (NO tucking into bottoms). Preserve the original hem overlap and natural drape above the pocket area. Emphasize pocket opening shape, topstitch lines, hem line, and nearby sleeve cuff detail. Keep the face and full body out of frame.",
"디테일(신발)": "Close-up detail shot of the SHOES and lower hem area. Focus on the footwear design, shoe material, sole details, and how the bottom garment hem falls around the ankle and shoe. Frame from mid-calf down to the floor.",
}
return mapping.get(shot_label, "Full body front facing fashion photograph.")
def _build_default_shoes_prompt(age_group: str = "adult") -> str:
if age_group == "kids":
return (
"SHOES (DEFAULT): Unless the user specifies different footwear, the model MUST be wearing "
"plain white canvas sneakers — low-top, lace-up, solid all-white (white upper, white laces, white rubber sole). "
"Clean, simple kids' sneaker style similar to classic white Vans or Keds."
)
return (
"SHOES (DEFAULT): Unless the user specifies different footwear, the model MUST be wearing classic black leather loafers."
)
def _build_body_proportion_prompt(
shot_label: str,
gender: str = "female",
age_group: str = "adult",
model_face=None,
model_height_cm: float = 0,
kids_grade: Optional[str] = None,
) -> str:
"""Build body proportion guidance based on shot type, gender, and age group."""
if age_group == "kids":
grade_hint = _KIDS_GRADE_BODY_HINTS.get(kids_grade or "", (
"The model is a child. Use natural child body proportions — the head is proportionally larger "
"relative to the body compared to adult fashion models."
))
height_hint = f" Total body height MUST be approximately {model_height_cm:.0f}cm." if model_height_cm > 0 else ""
return (
f"CHILD MODEL BODY (CRITICAL): {grade_hint}{height_hint} "
"Do NOT apply adult 9-head fashion canon proportions. "
"Maintain age-appropriate limb length, torso size, and overall stature consistent with the specified school grade."
)
ratio_lock = _build_model_ratio_lock(shot_label, gender)
male_build = (
"MALE MODEL BUILD (CRITICAL): The model must have broad, well-defined shoulders — "
"shoulder width should be approximately 2.5 to 3 times the head width, creating a strong V-shaped upper body silhouette. "
"The chest should be wide and the waist relatively narrow. "
"Do NOT generate narrow or sloping shoulders. The broad-shouldered frame is essential for a masculine fashion-model look. "
"MALE TORSO/LEG BALANCE (CRITICAL): Keep a natural commercial male torso length — NOT a fashion-illustration short torso. "
"Do NOT exaggerate leg length so that the upper body looks stubby. "
"Waist-to-floor and head-to-waist should feel balanced for a real catalog model, not an elongated runway sketch."
) if gender == "male" else ""
if _is_full_body_shot(shot_label):
return (
"BODY PROPORTIONS (CRITICAL): Use a commercial Korean lookbook body — about 7.8–8.2 head heights. "
"Do NOT use extreme 9-head fashion-illustration proportions with a short torso and ultra-long legs. "
"NATURAL WAIST LOCK: The anatomical waist / natural waistband line must sit near the vertical middle of the frame "
"(about 48–52% from the image top). Do NOT push the waist high in the frame. "
"TORSO LENGTH: Keep a full, natural torso from shoulders to waist — never compress the upper body. "
"LEG LENGTH: Legs may be slightly elegant, but crotch-to-floor should be about 47–50% of total body height — "
"NOT clearly dominating the silhouette. Avoid short-torso / long-leg distortion. "
"Think SPAO / commercial catalog photography of a real model — NOT fashion-sketch anatomy. "
f"{male_build}"
"FRAMING GUIDE: The model should fill approximately 86–89% of the frame height, "
"with a stable margin of ~5–6% above the head and ~6–8% below the feet (avoid large variation between outputs). "
"CONSISTENCY LOCK: Keep camera distance and perspective stable across generations; "
"do NOT alternate between short-torso elongated legs and natural mid-waist framing. "
"Do NOT distort or compress body proportions. "
f"{ratio_lock}"
)
elif _is_upper_body_shot(shot_label):
return (
"BODY PROPORTIONS (IMPORTANT): The model must have natural, well-proportioned upper body anatomy "
"consistent with a commercial lookbook model. Shoulders, neck, and torso should appear elegant but not compressed. "
f"{male_build}"
"FRAMING GUIDE: Leave ~5% margin above the head. The frame should end slightly below the waist. "
"Do NOT distort shoulder width, neck length, or torso proportions. "
f"{ratio_lock}"
)
elif _is_lower_body_shot(shot_label):
return (
"BODY PROPORTIONS (IMPORTANT): The legs and lower body must look natural and commercial, "
"consistent with a balanced 8-head lookbook silhouette — not ultra-elongated runway legs. "
"Do NOT over-lengthen the legs relative to the implied torso. "
f"{ratio_lock}"
)
return ""
def _is_full_body_shot(shot_label: str) -> bool:
s = str(shot_label or "")
return s.startswith("전신") or s.startswith("?꾩떊") or s.startswith("?袁⑸뻿") or "Full body" in s
def _is_upper_body_shot(shot_label: str) -> bool:
s = str(shot_label or "")
return s.startswith("상반신") or s.startswith("?곷컲") or s.startswith("?怨룹뺘") or "Upper body" in s
def _build_upper_body_framing_guide() -> str:
"""Loose waist-up framing with visible studio background on all sides."""
return (
"UPPER-BODY FRAMING (LOOSE MEDIUM SHOT — NOT TIGHT): Pull the camera back for a relaxed waist-up composition. "
"Do NOT use a tight bust/portrait crop that fills the frame edge-to-edge. "
"The model should occupy about 68-75% of frame height with visible soft light-gray studio background above the head, beside the shoulders, and below the waist crop. "
"Layout guide: top of hair at 10-14% from image top, chin around 32-36%, shoulder line around 42-48%, "
"waistline around 72-78%, bottom edge at upper hips/waistband around 78-84% — NEVER show mid-thigh or lower. "
"Horizontal: leave about 15-20% background margin on each side of the shoulders. "
"Keep this same loose framing scale across all upper-body variants (front, free pose, back)."
)
def _is_lower_body_shot(shot_label: str) -> bool:
s = str(shot_label or "")
return s.startswith("하반신") or s.startswith("?섎컲") or s.startswith("??롮뺘") or "Lower body" in s
def _build_even_studio_lighting_prompt(scope: str = "") -> str:
label = f" — {scope}" if scope else ""
return (
f"LIGHTING (CRITICAL{label}): Use soft, even, low-contrast studio lighting across the entire frame. "
"Facial lighting must be uniformly filled with minimal left/right falloff. "
"Do NOT use strong directional key light from one side. Do NOT create cheek shadows, nose shadows, under-eye patches, or asymmetric facial tone. "
"Keep skin tone smooth and continuous from forehead to jaw on both sides. "
"Garment lighting may have gentle depth, but the face must read as evenly lit catalog skin — not remapped or relit."
)
def _build_even_face_lighting_override(scope: str = "") -> str:
label = f" — {scope}" if scope else ""
return (
f"FACE LIGHTING OVERRIDE (CRITICAL{label}): "
"Render facial skin as a clean, continuous, evenly lit tone with NO mottling, blotches, dappled patches, speckles, stains, or asymmetric shadow. "
"Match the reference face identity exactly; do NOT re-paint skin texture or add new marks. "
"A uniform lookbook face tone is more important than visible pores or directional shadow on the face."
)
def _build_face_skin_lock_prompt(has_face: bool = False) -> str:
if not has_face:
return ""
return (
"FACE SKIN LOCK (HIGHEST PRIORITY): The face reference image defines the exact skin appearance. "
"Copy the reference face skin tone and smoothness exactly — one even catalog complexion with no patchy relighting. "
"Do NOT invent under-eye circles, cheek stains, nose-bridge smudges, forehead blotches, pore noise, or asymmetric shadow. "
"Do NOT apply fabric-style texture, grain, or wrinkle detail to facial skin. "
"If the output face skin looks uneven, blotchy, or dirty compared to the reference, the result is WRONG."
)
def _build_wall_shadow_block() -> str:
return """WALL SHADOW (MANDATORY):
Add an almost non-existent wall shadow behind the model.
- Wall shadow must stay at near-zero opacity and very broad diffusion.
- Do NOT show a readable human silhouette on the wall (no head/shoulder outline).
- Blend the shadow into the wall tone until it is barely detectable even on close look.
- Keep shadow offset minimal and very close to the body; avoid lateral spread.
- Keep only a near-invisible micro contact shadow under the shoes.
- This is a minimal depth cue only, effectively close to no shadow.
- These wall-shadow rules apply to the BACKGROUND ONLY — do NOT cast readable shadows onto the face."""
_FABRIC_REALISM_SUFFIX = (
"These fabric texture rules apply to GARMENTS ONLY. "
"Do NOT add weave, grain, wrinkles, or micro-texture to facial skin."
)
def _build_transform_lighting_prompt(shot_label: str) -> str:
if _is_upper_body_shot(shot_label):
return _build_even_studio_lighting_prompt("UPPER-BODY")
return (
"LIGHTING: Maintain directional studio lighting from the LEFT side — the left side of the model should be brighter, "
"with soft natural shadow falloff on the right side. Do NOT flatten the lighting."
)
def _build_transform_face_lighting_override(shot_label: str) -> str:
if _is_upper_body_shot(shot_label):
return _build_even_face_lighting_override("UPPER-BODY")
return (
"FACE LIGHTING OVERRIDE: Keep the face clean and evenly filled even under directional lighting. "
"Do NOT create mottled, blotchy, dappled, patchy, speckled, stained, or uneven shadows on the face. "
"Facial skin tone must remain continuous and natural, with gentle fill on the shadow side. "
"For full-body lookbook shots, prioritize a clean even face tone over visible micro skin texture."
)
def _build_model_ratio_lock(shot_label: str, gender: str = "female") -> str:
"""Common proportion/camera lock to reduce body-ratio drift between cuts."""
shoulder_rule = (
"Male shoulder span should remain about 2.5-3.0 head widths with a stable V-shaped torso. "
if gender == "male"
else "Female shoulder span should remain about 2.0-2.25 head widths with a slim editorial torso. "
)
return (
"MODEL RATIO LOCK (CRITICAL): Use one fixed commercial lookbook body template across all outputs for this session. "
"Keep the same apparent model height, head size, shoulder width, neck length, torso length, waist height, hip height, knee height, ankle position, and foot scale from cut to cut. "
"The head must stay about 1/7.8 to 1/8.3 of total visible body height; never switch to a tiny 9-head fashion-sketch head. "
"WAIST HEIGHT LOCK: The natural waist / belt line must stay near mid-frame (about 48-52% from image top) across candidates — "
"do NOT raise the waist and elongate the legs. "
"Leg length from crotch to floor should remain about 47-50% of total body height, with the knee at a consistent vertical position. "
f"{shoulder_rule}"
"Use the same lookbook camera template every time: neutral lens, no wide-angle stretch, camera around waist/upper-hip height, straight verticals, and stable subject distance. "
"Changing pose, angle, crop, or background may move limbs or change visible area, but must NOT change the model's body scale, head-to-body ratio, leg-to-torso ratio, or overall stature."
)
def _build_crop_template_lock(shot_label: str, age_group: str = "adult") -> str:
"""Common framing lock so shot variants keep the same visual scale."""
if _is_full_body_shot(shot_label):
if age_group == "kids":
return (
"FULL-BODY CROP TEMPLATE (CRITICAL): Use a fixed child lookbook framing box. "
"Keep natural child head-to-body ratio (head proportionally larger than adult fashion models). "
"Keep 5-6% headroom and 6-8% footroom. The full figure from head to toe must be visible. "
"Do NOT shrink the head to adult 9-head fashion proportions."
)
return (
"FULL-BODY CROP TEMPLATE (CRITICAL): Use a fixed catalog mannequin box for every independently generated full-body image. "
"Keep the head top, shoulders, waistline, knees, ankles, and feet at the same approximate vertical scale in every candidate. "
"Use this fixed vertical layout: top of hair at 5-6% from the image top, shoulder line around 18-22%, "
"natural waist / waistband around 48-52% (near mid-frame — NEVER raise waist to ~40%), "
"crotch/shorts rise around 52-56%, knees around 72-76%, soles/feet around 93-94%. "
"Use this fixed horizontal layout: body centerline at 50% image width, shoulder span about 17-21% of image width for female models and 21-25% for male models, hip span stable, feet centered and not cropped. "
"The full figure must occupy about 86-89% of image height with 5-6% headroom and 6-8% footroom. "
"Do NOT zoom in or zoom out between full-body candidates. Do NOT make the head larger, legs longer, feet smaller, or body narrower/wider to create variation. "
"Do NOT shorten the torso or raise the waistline to create a long-leg look. "
"For dynamic poses, fit the pose inside the same body-height box instead of changing the camera distance. "
"A walking/free pose may change limb angles, but the top of hair and soles must stay on the same vertical crop marks."
)
if _is_upper_body_shot(shot_label):
return _build_upper_body_framing_guide()
if _is_lower_body_shot(shot_label):
return (
"LOWER-BODY CROP TEMPLATE (CRITICAL): Keep lower-body magnification consistent with the source model. "
"Do NOT zoom the legs or feet larger/smaller than the source scale."
)
return ""
def _build_transform_crop_box_lock(shot_label: str) -> str:
"""Extra crop lock for transform shots derived from a selected reference cut."""
s = str(shot_label or "")
applies_full_body = (
s.startswith("전신(자유포즈")
or s.startswith("전신(측면")
or s.startswith("전신(후면")
or s.startswith("?꾩떊(?먯쑀")
or s.startswith("?꾩떊(痢")
or s.startswith("?꾩떊(?꾨")
)
applies_upper_body = (
s == "상반신"
or s.startswith("상반신(자유포즈")
or s == "?곷컲"
or s.startswith("?곷컲")
or s == "?怨룹뺘"
or s.startswith("?怨룹뺘")
)
if applies_full_body:
extra = (
"For the full-body free-pose-2 variant specifically, do not enlarge the upper body or face to emphasize the raised-arm pose; keep it the same size as the selected front image. "
if s.startswith("전신(자유포즈2") or "자유포즈2" in s
else ""
)
return (
"SELECTED FRONT CROP BOX LOCK (HIGHEST PRIORITY FOR THIS TRANSFORM): "
"Use the first image's full-body crop box as the fixed camera frame. "
"Do not recompose, zoom in, zoom out, change camera distance, or change lens perspective. "
"Keep the top of hair and soles/feet at the same approximate pixel positions as the first image. "
"Keep shoulder line, waistline, hip line, knee height, ankle height, body centerline, and foot scale aligned to the first image as much as the new pose/angle allows. "
"Only pose/angle may change. The model's apparent height, head size, leg-to-torso ratio, body width, and overall scale must remain the same. "
"Fit the new pose inside the existing crop box instead of changing the crop box. "
f"{extra}"
)
if applies_upper_body:
return (
"SELECTED UPPER-BODY CROP BOX LOCK (HIGHEST PRIORITY FOR THIS TRANSFORM): "
"Use one fixed loose upper-body crop box for upper-body front, free-pose, and back shots. "
"Do not recompose to a tighter face crop, zoom in, or fill the frame edge-to-edge with the body. "
"Keep top-of-hair, face size, chin height, shoulder line, torso width, waistline, and bottom crop at the same approximate pixel positions across upper-body shots. "
+ _build_upper_body_framing_guide()
+ " The free pose may rotate shoulders/torso or move one arm, but must stay inside the same loose crop box."
)
return ""
def _build_transform_instruction(shot_label: str, gender: str = "female") -> str:
"""PICasso-style: transform the front shot into a different angle/crop."""
rear_hair_rule = (
"HAIR (CRITICAL): Keep a masculine back silhouette. Hair should remain short/neutral and must NOT be styled forward over both shoulders. "
if gender == "male"
else (
"HAIR (CRITICAL): This is a FEMALE model. The source image shows long hair — you MUST preserve it in the back view. "
"Keep the feminine long-hair silhouette visible from behind. Hair must stay OUTSIDE the hood and fall naturally behind/around shoulders. "
"Do NOT replace with short or male-style hair. Do NOT tie into a bun/ponytail unless explicitly requested. "
"The back-of-head hair length and style must match the long hair visible in the source."
)
)
hood_hair_rule = (
"HAIR (CRITICAL): Keep a masculine short/neutral hairstyle. Hair should stay above or around neck length and must NOT fall long over the shoulders. Keep hair OUTSIDE the hood and do NOT tuck it into the hood. "
if gender == "male"
else "HAIR (CRITICAL): Keep hair OUTSIDE the hood. Do NOT tuck hair into the hood; let hair fall naturally around/over the shoulders. "
)
male_full_body_silhouette_rule = (
"MALE SILHOUETTE CUE (IMPORTANT): Keep the upper torso visually broad and structured. "
"Hold the chest open with shoulders squared and slightly expanded, and keep the arms a little away from the torso so the shoulder span reads clearly. "
"Avoid a soft rounded silhouette that makes the shoulders look narrow. "
"The shoulder line should read almost horizontal and boxy, with a clean right-angled shoulder impression rather than a hanger-like downward slope. "
if gender == "male"
else ""
)
pocket_hand_rule = (
"Hand detail (CRITICAL): keep a feminine hand appearance — smooth skin, slender fingers, and soft natural contours. Avoid visible veins, pronounced tendons, or muscular hand definition. "
if gender == "female"
else "Hand detail (CRITICAL): keep a natural masculine hand appearance with realistic structure and subtle veins/tendons only if naturally visible. "
)
female_hand_rule = (
"FEMALE HAND AESTHETIC (CRITICAL): Hands should look refined and feminine — slim fingers, soft knuckles, smooth skin, minimal visible veins/tendons, and no bulky/masculine hand structure. "
if gender == "female"
else ""
)
hair_front_lock = (
"HAIR LOCK (ABSOLUTE): Match reference hair exactly — same cut length and side volume; "
"do NOT randomize thickness or asymmetric styling not shown in the reference."
if gender == "male"
else (
"HAIR LOCK (ABSOLUTE — non-negotiable): Copy hair EXACTLY from the reference — same visible length on LEFT and RIGHT, "
"same part line, same texture (straight/wavy) and volume. Do NOT asymmetrically shorten one side, do NOT change wave pattern, "
"do NOT restyle or thin one side only. Long hair must fall symmetrically past the shoulders as in the reference."
)
)
mapping = {
"전신(정면)": (
"Re-render this full-body FRONT studio fashion photo using the reference image as the ground truth. "
"BODY / HEIGHT LOCK (NON-NEGOTIABLE): Keep the EXACT same body height, child age scale, head-to-body ratio, "
"crop magnification, and vertical footprint as the reference. Do NOT shrink the model to a younger/shorter grade. "
"Permitted micro-variations ONLY: subtle lighting balance on face and clothing, tiny shoulder relaxation, neutral hand placement, "
"or a sub-2° stance shift — remain a straight-on full-body lookbook framing. "
f"{hair_front_lock} "
"Keep the EXACT same model identity, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(자유포즈)": (
"Transform this front-facing full body shot into a NATURAL WALKING-INSPIRED LOOKBOOK POSE. "
"Keep the body mostly front-facing with a subtle 8-12 degree angle and smooth dynamic weight transfer. "
"Place one leg stepping forward with a softly bent front knee (never straight-locked), and keep the rear leg slightly bent with a light heel lift. "
"Set one hand naturally in a pocket and keep the other arm relaxed with a small natural swing. "
"Keep shoulders soft, torso mostly upright with a subtle natural lean, and expression calm/confident (NO smiling). "
"The pose should feel candid, effortless, and alive — NOT rigid, NOT mannequin-like, and NOT frozen. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(자유포즈2)": (
"Transform this front-facing full body shot into a DYNAMIC NATURAL FASHION POSE. "
"CAMERA / SCALE LOCK (NON-NEGOTIABLE): Keep the exact same camera distance, crop box, body height in frame, head size, shoulder width, waist height, leg length, and foot scale as the source front shot. Do NOT enlarge the model to make the pose feel more dynamic. "
"Shift body weight clearly onto one leg to create a natural asymmetric S-curve and subtle hip tilt. "
"Raise one arm gently near the side of the head (near temple/ear) while keeping the elbow compact and inside the original body-width frame; do not lift the arm so high that the crop needs to zoom out or the upper body becomes larger. Keep the other arm relaxed near the hip or inside a pocket. "
"Keep the shoulders soft, add a slight torso lean, and let one knee bend naturally for movement. "
"The expression is calm, cool, and neutral — looking toward the camera with understated confidence. NO smiling. "
"The pose should feel like a Korean lookbook campaign cut — effortless but alive, never mannequin-like. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(측면)": (
"Transform this front-facing full body shot into a SLIGHT ANGLED VIEW. "
"Rotate the model's BODY approximately 15–25 degrees to create a subtle side-front silhouette while keeping the FACE looking directly at the camera. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(측면포즈1)": (
"Transform this front-facing full body shot into a SIDE-INSPIRED THREE-QUARTER LOOKBOOK POSE. "
"Rotate the body to about 30-40 degrees. Keep one leg as the weight-bearing leg and place the other leg slightly forward with a soft natural bend. "
"Place both hands naturally inside outerwear pockets, keep shoulders relaxed, and keep the mood calm/editorial. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(측면포즈2)": (
"Transform this front-facing full body shot into a WINTER LOOKBOOK SIDE-FRONT POSE inspired by a casual cozy campaign cut. "
"Rotate the body to about 20-35 degrees side-front angle (NOT full side profile). "
"Place both hands naturally into jacket/hoodie pockets, keep shoulders relaxed, and keep the head slightly turned toward camera with a calm neutral expression. "
"Set a grounded stance with one leg slightly forward and the other weight-bearing, with a subtle natural knee bend. "
f"{male_full_body_silhouette_rule}"
f"{female_hand_rule}"
f"{hood_hair_rule}"
"Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
"The entire model from head to toe must remain visible."
),
"전신(후면)": (
"Transform this front-facing full body shot into a BACK VIEW. "
"Rotate the model 180 degrees to show the back of the exact same outfit. "
f"{rear_hair_rule}"
"Keep hair naturally arranged so key garment back details remain visible as much as possible, without forcing all hair forward. "
"Keep the same body type, hair style, shoes, and soft light-gray studio background. "
"Show the back details of the garment clearly. Head to toe must be visible."
),
"상반신": (
"CROP AND REFRAME this image into an UPPER BODY ONLY shot with a LOOSE medium-wide composition. "
"ZOOM OUT slightly — do NOT create a tight bust/portrait crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
"The output image MUST show ONLY the upper body — from the full head and hair to slightly below the waistline, revealing the upper hips and waistband. "
"ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
"Pose direction (IMPORTANT): keep the model mostly front-facing with a subtle natural asymmetry — one shoulder slightly lowered, torso turned about 5-10 degrees, and both arms relaxed naturally at the sides. "
"Keep the expression calm and effortless, avoiding stiff mannequin posture. "
"Focus on the fit, texture, and silhouette of the top garment while keeping natural facial detail. "
"Keep the same body type, hair style, and soft light-gray studio background."
),
"상반신(자유포즈)": (
"CROP AND REFRAME this full body image into an UPPER BODY ONLY shot with a LOOSE medium-wide composition. "
"ZOOM OUT slightly — do NOT create a tight bust/portrait crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
"The output image MUST show ONLY the upper body — from the top of the head to slightly below the waist. "
"ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
"The model's body is turned about 20-30 degrees from the camera, with one hand placed naturally behind the hip or lower back. "
"The head turns toward the camera with a calm, cool, neutral expression — NO smiling. "
"Focus on the garment's neckline, fabric texture, buttons, and overall fit. "
"Keep the EXACT same model, face, hair, outfit, and soft light-gray studio background."
),
"상반신(후면)": (
"CROP AND REFRAME this image into an UPPER BODY BACK VIEW ONLY with a LOOSE medium-wide composition. "
"ZOOM OUT slightly — do NOT create a tight back crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
"The output image MUST show ONLY the upper body from behind — from the top of the head to slightly below the waist. "
"ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
"Rotate the model 180 degrees to show the back of the upper garment. "
f"{rear_hair_rule}"
"Keep hair naturally arranged so key upper-garment back details remain visible as much as possible, without forcing all hair forward. "
"Focus on the back design, yoke seam, shoulder construction, and fabric texture. "
"Keep the EXACT same model, hair, outfit, and soft light-gray studio background."
),
"하반신": (
"Transform this image into a LOWER BODY CLOSE-UP. "
"Frame the camera to focus on the model from the waistline or belt line down to the feet. "
"Compose the shot so the lower garment and leg silhouette dominate the frame. "
"Keep the same body type, hair style, shoes, and soft light-gray studio background. "
"Do not show the chest, shoulders, or face. "
),
"디테일(상의)": (
"Transform this image into a CLOSE-UP DETAIL SHOT of the UPPER GARMENT focused on the chest and neckline area.\n"
"COMPOSITION (CRITICAL): The garment must be perfectly HORIZONTALLY CENTERED in the new crop. "
"Both shoulder seams must be fully visible inside the frame with approximately EQUAL margin on LEFT and RIGHT — "
"no asymmetric crop, and no shoulder or sleeve edge may be clipped off either side. "
"Keep torso perfectly frontal (zero rotation), both shoulders at equal height.\n"
"CROP BOUNDARIES (STRICT): "
"TOP EDGE at or just below the lower lip — do NOT show mouth, nose, or eyes; only the underside of the chin/jawline may touch the top edge. "
"BOTTOM EDGE around mid-chest to upper abdomen (between the pectoral line and the bottom of the ribcage). "
"LEFT/RIGHT EDGE roughly 8–12% padding outside each shoulder seam, symmetric on both sides.\n"
"FOCUS (CRITICAL): Tack-sharp, even focus across the ENTIRE visible garment surface — fabric weave, knit stitch, neckline ribbing, and shoulder seam must all be crisp. "
"f/5.6–f/8 studio lookbook detail cut: no shallow DOF blur, no soft focus, no bokeh, no motion blur.\n"
"STYLING LOCK (CRITICAL): Do NOT roll sleeves, fold cuffs, or alter sleeve styling; keep original sleeve length and garment styling exactly. "
"Do NOT show hands, full arms/forearms, or the full body. "
"Keep the same soft light-gray studio background."
),
"디테일(포켓)": (
"Transform this image into a CLOSE-UP DETAIL SHOT centered on the side POCKET area, matching a lookbook pocket composition. "
"Crop and reframe around lower top/jacket hem to upper-hip region so the pocket opening is the visual center. "
"Pose detail (IMPORTANT): place the hand naturally inside the pocket, showing only a partial hand emerging from the opening. "
f"{pocket_hand_rule}"
"Styling lock (CRITICAL): keep the top untucked exactly as-is; DO NOT tuck the top into the bottoms. Preserve natural hem overlap and drape above the pocket. "
"Highlight pocket opening curve, seam lines, edge stitching, hem line, and adjacent sleeve cuff/fabric texture. "
"Do NOT show face or full body; keep the crop tight and product-detail focused. "
"Keep the same soft light-gray studio background. "
),
"디테일(신발)": (
"Transform this image into a CLOSE-UP DETAIL SHOT of the SHOES and LOWER HEM. "
"Crop and reframe to focus on the footwear, shoe material, sole, and how the garment hem falls around the ankle. "
"Frame from mid-calf down to the floor. "
"Keep the same soft light-gray studio background. "
),
}
return mapping.get(shot_label, "")
def _build_sub_accessory_prompt(
sub_product_slots: list,
sub_product_images: list,
accessory_image_start: int = 1,
) -> tuple[str, bool]:
"""Build accessory instructions for sub-product reference images (제품 1·2 악세사리)."""
if not sub_product_images or not sub_product_slots:
return "", False
lines = []
image_idx = accessory_image_start
for slot in sub_product_slots:
if not isinstance(slot, dict):
continue
product_index = int(slot.get("productIndex", 0)) + 1
image_count = int(slot.get("imageCount", 1))
if image_count > 1:
image_ref = f"Images {image_idx}–{image_idx + image_count - 1}"
else:
image_ref = f"Image {image_idx}"
view_desc = "front view" + (" and back view" if image_count > 1 else "")
lines.append(
f"- {image_ref}: Accessory for Clothing Item {product_index} (main garment #{product_index}). "
f"Copy this accessory from the reference ({view_desc}) and wear/style it naturally with Clothing Item {product_index}. "
f"The accessory MUST be visible in the final photo — do NOT omit it."
)
image_idx += image_count
if not lines:
return "", False
accessory_block = (
"ACCESSORIES (CRITICAL — MUST APPEAR IN OUTPUT):\n"
+ "\n".join(lines)
+ "\n- The model wears the main garments PLUS these accessory items. Accessories are add-ons to Clothing Items 1–2, not replacements.\n"
+ "- Reproduce each accessory's shape, color, material, and placement as shown in its reference image(s).\n"
+ "- Do NOT add any other accessories beyond those reference images."
)
return accessory_block, True
def _build_input_image_order_guide(
has_face: bool,
has_tone_ref: bool,
product_image_count: int,
sub_product_slots: list,
sub_product_images: list,
has_background: bool,
) -> str:
idx = 1
lines = ["INPUT IMAGE ORDER (CRITICAL — use the correct reference for each task):"]
if has_face:
lines.append(f"- Image {idx}: FACE reference (model identity only)")
idx += 1
if has_tone_ref:
lines.append(f"- Image {idx}: TONE palette strip (color grade only — NOT clothing or background)")
idx += 1
if product_image_count:
end = idx + product_image_count - 1
range_label = f"Images {idx}–{end}" if product_image_count > 1 else f"Image {idx}"
lines.append(f"- {range_label}: MAIN GARMENT product photos (wear these exact garments)")
idx += product_image_count
if sub_product_images and sub_product_slots:
for slot in sub_product_slots:
if not isinstance(slot, dict):
continue
product_index = int(slot.get("productIndex", 0)) + 1
image_count = int(slot.get("imageCount", 1))
end = idx + image_count - 1
range_label = f"Images {idx}–{end}" if image_count > 1 else f"Image {idx}"
lines.append(f"- {range_label}: ACCESSORY for Clothing Item {product_index}")
idx += image_count
if has_background:
lines.append(f"- Image {idx}: BACKGROUND plate")
return "\n".join(lines)
def _build_single_image_composition_lock() -> str:
"""Prevent collage/grid/contact-sheet outputs when users ask for variety."""
return (
"SINGLE-IMAGE COMPOSITION (CRITICAL — HIGHEST PRIORITY):\n"
"- Output exactly ONE full-frame photograph.\n"
"- Show exactly ONE person, ONE pose, ONE expression, ONE camera angle.\n"
"- Do NOT create a grid, collage, contact sheet, split-screen, triptych, panel layout, "
"storyboard, or multiple thumbnail crops of the same model in one image.\n"
"- Do NOT duplicate the model or show before/after / variation / expression panels.\n"
"- The entire canvas must read as a single commercial fashion photo, not a compilation."
)
def _sanitize_custom_prompt_for_single_image(raw: str) -> str:
"""Reframe prompts that often make the model output multi-panel layouts."""
if not raw or not str(raw).strip():
return ""
text = str(raw).strip()
single_expression_note = (
"Use ONE subtle natural expression in this single photograph "
"(do NOT show multiple expressions or faces in one image)"
)
single_shot_note = (
"Generate one single shot only "
"(do NOT put multiple cuts/poses in one frame)"
)
replacements = [
(r"표정\s*(이\s*)?(다양|여러|다르)[^\./,]*", single_expression_note),
(r"다양한\s*표정[^\./,]*", single_expression_note),
(r"여러\s*(가지\s*)?표정[^\./,]*", single_expression_note),
(r"(diverse|various|multiple|different)\s+expressions?[^\./,]*", single_expression_note),
(r"expression\s+(variety|variations?|options?)[^\./,]*", single_expression_note),
(r"여러\s*컷[^\./,]*", single_shot_note),
(r"여러\s*장[^\./,]*", single_shot_note),
(r"한\s*장\s*안\s*에\s*여러[^\./,]*", single_shot_note),
(r"(multiple|several|various)\s+(shots?|cuts?|poses?|looks?|frames?)[^\./,]*", single_shot_note),
(r"contact\s*sheet[^\./,]*", single_shot_note),
(r"grid\s*layout[^\./,]*", single_shot_note),
(r"collage[^\./,]*", single_shot_note),
]
sanitized = text
for pattern, repl in replacements:
sanitized = re.sub(pattern, repl, sanitized, flags=re.IGNORECASE)
sanitized = re.sub(r"\s*/\s*/+", " / ", sanitized)
sanitized = re.sub(r"\s{2,}", " ", sanitized).strip(" /")
return sanitized.strip()
# ── HF Dataset Push (background) ─────────────────────────────
def _make_jpg_thumb(img_bytes: bytes, max_side: int = 512) -> BytesIO:
try:
from PIL import Image
im = Image.open(BytesIO(img_bytes)).convert("RGB")
im.thumbnail((max_side, max_side), Image.LANCZOS)
bio = BytesIO()
im.save(bio, "JPEG", quality=85)
bio.seek(0)
return bio
except Exception:
return BytesIO(img_bytes)
def _sanitize_upload_filename(name: Optional[str], fallback: str) -> str:
"""Keep a readable original basename while making it safe for HF paths."""
raw = (name or "").strip()
base = os.path.basename(raw.replace("\\", "/")) if raw else ""
if not base or base in (".", ".."):
base = fallback
# Keep letters/digits/._- and common unicode letters; collapse everything else.
cleaned = re.sub(r"[^\w.\-가-힣]+", "_", base, flags=re.UNICODE)
cleaned = cleaned.strip("._") or fallback
# Avoid path traversal leftovers and overly long names.
cleaned = cleaned.replace("..", "_")
if len(cleaned) > 120:
stem, ext = os.path.splitext(cleaned)
cleaned = stem[:100] + ext[:20]
return cleaned
def _unique_filenames(names: list, fallback_prefix: str) -> list:
used = set()
out = []
for i, name in enumerate(names):
safe = _sanitize_upload_filename(name, f"{fallback_prefix}_{i}")
stem, ext = os.path.splitext(safe)
candidate = safe
n = 2
while candidate.lower() in used:
candidate = f"{stem}_{n}{ext}"
n += 1
used.add(candidate.lower())
out.append(candidate)
return out
def _push_to_hub_sync(record: dict, image_bytes: bytes, ts: str,
product_images_b64: list = None, face_b64: str = None,
product_filenames: list = None, face_filename: str = None,
sub_product_images_b64: list = None, sub_product_filenames: list = None):
"""Run in background thread — never blocks the API response."""
if not HF_TOKEN or not HF_DATASET_REPO:
return
try:
from huggingface_hub import HfApi, CommitOperationAdd
except ImportError:
return
api = HfApi(token=HF_TOKEN)
try:
api.create_repo(HF_DATASET_REPO, repo_type="dataset", exist_ok=True)
except Exception:
pass
day = ts[:8]
ops = []
filename = f"fashion_{ts}_0.jpg"
ops.append(CommitOperationAdd(path_in_repo=f"images/{day}/{filename}", path_or_fileobj=BytesIO(image_bytes)))
ops.append(CommitOperationAdd(path_in_repo=f"thumbs/{day}/{filename}", path_or_fileobj=_make_jpg_thumb(image_bytes)))
saved_product_paths = []
if product_images_b64:
names = list(product_filenames or [])
while len(names) < len(product_images_b64):
names.append(f"product_{len(names)}.png")
names = _unique_filenames(names[:len(product_images_b64)], "product")
print(f"[HUB] Product filenames: {names}")
for i, img_b64 in enumerate(product_images_b64):
try:
if isinstance(img_b64, str) and ";base64," in img_b64:
raw = base64.b64decode(img_b64.split(";base64,", 1)[1])
# Keep original filename; nest under ts folder so gallery index can match.
path = f"inputs/product/{day}/{ts}/{names[i]}"
ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw)))
saved_product_paths.append(path)
except Exception:
pass
saved_sub_paths = []
if sub_product_images_b64:
names = list(sub_product_filenames or [])
while len(names) < len(sub_product_images_b64):
names.append(f"sub_product_{len(names)}.png")
names = _unique_filenames(names[:len(sub_product_images_b64)], "sub_product")
for i, img_b64 in enumerate(sub_product_images_b64):
try:
if isinstance(img_b64, str) and ";base64," in img_b64:
raw = base64.b64decode(img_b64.split(";base64,", 1)[1])
path = f"inputs/product/{day}/{ts}/sub/{names[i]}"
ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw)))
saved_sub_paths.append(path)
except Exception:
pass
saved_persona_path = None
if face_b64:
try:
raw_face = base64.b64decode(face_b64)
persona_name = _sanitize_upload_filename(face_filename, f"persona_{ts}.jpg")
# Ensure persona filename still contains ts for gallery matching when user name lacks it.
if ts not in persona_name:
stem, ext = os.path.splitext(persona_name)
persona_name = f"{stem}_{ts}{ext or '.jpg'}"
path = f"inputs/persona/{day}/{persona_name}"
ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw_face)))
saved_persona_path = path
except Exception:
pass
record = dict(record or {})
if saved_product_paths:
record["product_input_paths"] = saved_product_paths
record["product_input_filenames"] = [os.path.basename(p) for p in saved_product_paths]
if saved_sub_paths:
record["sub_product_input_paths"] = saved_sub_paths
record["sub_product_input_filenames"] = [os.path.basename(p) for p in saved_sub_paths]
if saved_persona_path:
record["persona_input_path"] = saved_persona_path
record["persona_input_filename"] = os.path.basename(saved_persona_path)
ops.append(CommitOperationAdd(
path_in_repo=f"logs/{day}/fashion_{ts}.json",
path_or_fileobj=BytesIO(json.dumps(record, ensure_ascii=False, indent=2).encode("utf-8"))
))
try:
api.create_commit(repo_id=HF_DATASET_REPO, repo_type="dataset", operations=ops,
commit_message=f"Add lookbook generation: {ts}")
print(f"[HUB] ✅ Pushed to {HF_DATASET_REPO} ({len(ops)} files)")
except Exception as e:
print(f"[HUB] Upload error: {e}")
def _push_to_hub_background(
record, image_bytes, ts,
product_images_b64=None, face_b64=None,
product_filenames=None, face_filename=None,
sub_product_images_b64=None, sub_product_filenames=None,
):
"""Fire-and-forget: push to hub in a background thread."""
t = threading.Thread(
target=_push_to_hub_sync,
args=(record, image_bytes, ts),
kwargs={
"product_images_b64": product_images_b64,
"face_b64": face_b64,
"product_filenames": product_filenames,
"face_filename": face_filename,
"sub_product_images_b64": sub_product_images_b64,
"sub_product_filenames": sub_product_filenames,
},
daemon=True,
)
t.start()
print(f"[HUB] Background upload started for {ts}")
# ══════════════════════════════════════════════════════════════
# API: Generate fitting image
# ══════════════════════════════════════════════════════════════
@app.post("/api/generate")
async def api_generate(request: Request, payload: Dict = Body(...)):
client = get_openai_client()
current_user = get_current_user(request)
require_login(current_user)
product_images = payload.get("productImages", [])
product_image_filenames = payload.get("productImageFilenames", [])
sub_product_images = payload.get("subProductImages", [])
sub_product_image_filenames = payload.get("subProductImageFilenames", [])
sub_product_slots = payload.get("subProductSlots", [])
model_face = payload.get("modelFace")
model_face_filename = payload.get("modelFaceFilename")
product_infos = payload.get("productInfos", [])
shot_label = payload.get("shotLabel", "전신(정면)")
custom_prompt = payload.get("customPrompt", "")
sanitized_custom_prompt = _sanitize_custom_prompt_for_single_image(custom_prompt)
single_image_lock = _build_single_image_composition_lock()
if sanitized_custom_prompt != (custom_prompt or "").strip():
print(f"[GENERATE] Custom prompt sanitized for single-image output")
reference_image = payload.get("referenceImage")
background_image = payload.get("backgroundImage")
tone_reference_image = payload.get("toneReferenceImage")
image_size = _resolve_image_size(payload.get("imageSize"))
model_height_cm = _resolve_model_height(model_face, payload.get("modelHeight", 0))
model_gender = _resolve_model_gender(model_face, payload.get("modelGender"))
model_age_group = _resolve_model_age_group(model_face, payload.get("modelAgeGroup"))
model_kids_grade = _resolve_model_kids_grade(model_face, payload.get("modelKidsGrade"))
garment_length_guide = _build_garment_length_prompts(product_infos, model_height_cm)
has_reference = bool(
reference_image
and isinstance(reference_image, str)
and reference_image.startswith("data:")
)
has_background = bool(
background_image
and isinstance(background_image, str)
and background_image.startswith("data:")
)
request_started_at = datetime.datetime.now()
request_ts = request_started_at.strftime("%Y-%m-%d %H:%M:%S")
print(f"[GENERATE] [{request_ts}] User: {current_user['username']}{' (logged in)' if current_user['is_logged_in'] else ' (anon)'} | Shot: {shot_label} | Reference: {'YES' if has_reference else 'NO'} | Background: {'YES' if has_background else 'NO'} | ToneRef: {'YES' if tone_reference_image else 'NO'}")
wall_shadow_block = _build_wall_shadow_block()
# ──────────────────────────────────────────────────────────
# MODE A: TRANSFORM (reference exists → PICasso pattern)
# ──────────────────────────────────────────────────────────
if has_reference:
transform_instruction = _build_transform_instruction(shot_label, model_gender)
if not transform_instruction:
camera_angle = _build_camera_prompt(shot_label, model_gender, model_age_group, model_height_cm, model_kids_grade)
transform_instruction = (
f"Transform this full body fashion photo. Change the angle/framing to: {camera_angle}. "
f"Keep the EXACT same model, face, outfit, shoes, and soft light-gray studio background"
)
parts = []
parsed_ref = _parse_base64(reference_image)
ref_size_kb = len(parsed_ref["data"]) * 3 / 4 / 1024
print(f"[GENERATE] → TRANSFORM MODE | ref ~{ref_size_kb:.0f} KB")
parts.append({"inline_data": {"data": parsed_ref["data"], "mime_type": parsed_ref["mime_type"]}})
if has_background:
parsed_bg = _parse_base64(background_image)
bg_size_kb = len(parsed_bg["data"]) * 3 / 4 / 1024
print(f"[GENERATE] → Background ref ~{bg_size_kb:.0f} KB")
parts.append({"inline_data": {"data": parsed_bg["data"], "mime_type": parsed_bg["mime_type"]}})
user_tone_part, user_tone_palette_text = _parse_user_tone_reference(tone_reference_image) if tone_reference_image else (None, "")
has_user_tone_ref = bool(user_tone_palette_text)
if has_user_tone_ref:
print("[GENERATE] → User tone palette text attached (TRANSFORM, image omitted)")
transform_crop_box_lock = _build_transform_crop_box_lock(shot_label)
body_proportion_guide = _build_body_proportion_prompt(
shot_label, model_gender, model_age_group, model_face, model_height_cm, model_kids_grade
)
crop_template_guide = _build_crop_template_lock(shot_label, model_age_group)
background_constraint = (
"Use the LAST provided image as the target background plate. Keep that background's tone, floor line, perspective depth, and scene structure, and place the SAME model naturally into it. Change background only."
if has_background
else "Keep a soft light-gray studio background (subtle warm gray, NOT pure white). No heavy gradients, no props. A gentle tonal shift between wall and floor is acceptable for natural depth."
)
background_reference_note = (
"BACKGROUND REFERENCE (CRITICAL): The LAST image is the background plate. Use it as the target environment and preserve its visual layout."
if has_background else ""
)
tone_transform_note = _build_tone_reference_instruction(
mode="transform", palette_text=user_tone_palette_text
) if has_user_tone_ref else ""
accessory_transform_note = ""
_, has_sub_accessories_transform = _build_sub_accessory_prompt(sub_product_slots, sub_product_images, 0)
if has_sub_accessories_transform:
accessory_start = 2 + (1 if has_background else 0)
for img_b64 in sub_product_images:
if isinstance(img_b64, str) and img_b64.startswith("data:"):
parsed = _parse_base64(img_b64)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
accessory_transform_note = (
_build_sub_accessory_prompt(sub_product_slots, sub_product_images, accessory_start)[0]
+ "\n- While transforming angle/crop only, keep every listed accessory visible on the model — do NOT remove them."
)
print(f"[GENERATE] → Sub accessories attached (TRANSFORM): {len(sub_product_images)} image(s)")
prompt_text = f"""**IMAGE TRANSFORMATION TASK**:
{transform_instruction}
{transform_crop_box_lock}
{garment_length_guide}
{body_proportion_guide}
{crop_template_guide}
{background_reference_note}
{tone_transform_note}
{accessory_transform_note}
{_build_transform_face_lighting_override(shot_label)}
{_build_face_skin_lock_prompt(True)}
{wall_shadow_block}
{single_image_lock}
**STRICT CONSTRAINTS**:
1. **SOURCE TRUTH**: Use the provided Full Body image as the ONLY reference. Do NOT change the model identity, face, hair, or the garment design/color/fit. Hair identity must remain the same (length, part direction, volume, texture, and overall silhouette). Do NOT add new facial marks, under-eye blotches, cheek spots, freckles, stains, or asymmetric skin patches that are not clearly visible in the reference.
2. **BACKGROUND**: {background_constraint}
3. **{_build_transform_lighting_prompt(shot_label)}**
{_build_transform_face_lighting_override(shot_label)}
4. **QUALITY**: Maintain 8k resolution and photorealistic commercial fashion photography style.
5. **SHOES**: Keep the exact same shoes from the reference image.
6. **FABRIC REALISM**: The clothing must look like real worn fabric — show natural wrinkles at joints, gravity-driven drape, material-specific texture (knit stitch, denim whiskering, cotton creases), and subtle compression folds where the garment is pressed or pulled. Do NOT render fabric as flat or CG-smooth. {_FABRIC_REALISM_SUFFIX}
7. **BODY PROPORTIONS**: {"Maintain natural child body proportions." if model_age_group == "kids" else "Maintain commercial lookbook proportions (~8-head). Keep the natural waist near mid-frame. Do NOT shorten the torso or exaggerate leg length."}
{"8. **ADDITIONAL**: " + sanitized_custom_prompt if sanitized_custom_prompt else ""}"""
parts.append({"text": prompt_text})
# ──────────────────────────────────────────────────────────
# MODE B: ORIGINAL (no reference → full generation)
# ──────────────────────────────────────────────────────────
else:
parts = []
face_parts = _get_face_parts(model_face)
parts.extend(face_parts)
has_face = len(face_parts) > 0
user_tone_part, user_tone_palette_text = _parse_user_tone_reference(tone_reference_image) if tone_reference_image else (None, "")
has_tone_ref = user_tone_part is not None
if user_tone_part:
parts.append(user_tone_part)
print("[GENERATE] → User tone palette strip attached (ORIGINAL)")
for img_b64 in product_images:
if isinstance(img_b64, str) and img_b64.startswith("data:"):
parsed = _parse_base64(img_b64)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
product_image_count = sum(
1 for img_b64 in product_images
if isinstance(img_b64, str) and img_b64.startswith("data:")
)
accessory_image_start = 1 + (1 if has_face else 0) + (1 if has_tone_ref else 0) + product_image_count
sub_accessory_prompt, has_sub_accessories = _build_sub_accessory_prompt(
sub_product_slots, sub_product_images, accessory_image_start
)
if has_sub_accessories:
print(f"[GENERATE] → Sub accessories: {len(sub_product_images)} image(s), slots={sub_product_slots}")
for img_b64 in sub_product_images:
if isinstance(img_b64, str) and img_b64.startswith("data:"):
parsed = _parse_base64(img_b64)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
if has_background:
parsed_bg = _parse_base64(background_image)
bg_size_kb = len(parsed_bg["data"]) * 3 / 4 / 1024
print(f"[GENERATE] → Background ref ~{bg_size_kb:.0f} KB")
parts.append({"inline_data": {"data": parsed_bg["data"], "mime_type": parsed_bg["mime_type"]}})
outfit_details = "\n".join([
f"Clothing Item {i+1}: Category={info.get('category','')}, Fit={info.get('fit','')}, Length={info.get('length','')}"
+ (f", Total Length={info.get('totalLength','')}cm" if info.get('totalLength') else "")
for i, info in enumerate(product_infos)
])
# Dynamic outfit exclusion: prevent the model from hallucinating garments
# (most commonly a jacket/outer) that weren't actually provided. The words
# "hood" and "pocket" appear in our pose/hair rules and prime the model to
# imagine outerwear, so we explicitly negate that.
_CATEGORY_EN = {
"상의": "top (shirt / t-shirt / knit / sweater / blouse)",
"하의": "bottom (pants / trousers / jeans / shorts)",
"원피스": "dress (one-piece)",
"스커트": "skirt",
"아우터": "outer layer (jacket / coat / cardigan / hoodie / vest / blazer / anorak)",
}
provided_cats = {(info.get("category") or "").strip() for info in product_infos}
missing_cats = [_CATEGORY_EN[k] for k in _CATEGORY_EN if k not in provided_cats and k in _CATEGORY_EN]
if has_sub_accessories:
outfit_exclusion_rule = (
f"OUTFIT STRICTNESS (CRITICAL): The model MUST wear the {len(product_infos)} main garment(s) listed in OUTFIT → Details "
f"AND the accessories specified in ACCESSORIES below. "
f"Accessories are add-on items for Clothing Items 1–2 — they do NOT replace main garments and do NOT count as extra layers."
)
if missing_cats:
forbidden_list = "; ".join(missing_cats)
outfit_exclusion_rule += (
f"\n- DO NOT add forbidden main garments: {forbidden_list}."
f"\n- Specifically, if '아우터 (outer layer)' is forbidden, do not add a jacket/coat/cardigan/hoodie over the top."
)
outfit_exclusion_rule += (
"\n- DO NOT add any accessories beyond those shown in the accessory reference images."
"\n- The words 'hood' and 'pocket' in pose/hair rules describe hair or hand placement — NOT clothing."
)
elif missing_cats:
forbidden_list = "; ".join(missing_cats)
outfit_exclusion_rule = (
f"OUTFIT STRICTNESS (CRITICAL — READ CAREFULLY): The model MUST wear EXACTLY the {len(product_infos)} "
f"garment(s) listed above — no more, no less, nothing added on top.\n"
f"- DO NOT add any of the following to the outfit, under any circumstance: {forbidden_list}.\n"
f"- Specifically, if '아우터 (outer layer)' is in the forbidden list above, the topmost visible garment "
f"must be the top/knit/shirt itself. NEVER add a jacket, coat, cardigan, hoodie, vest, or blazer over it.\n"
f"- The words 'hood' and 'pocket' may appear in the pose or hair instructions below. Those words describe "
f"HAIR placement or HAND position ONLY — they are NOT a cue to add a hooded jacket or a garment with pockets. "
f"If the provided garments have no hood/pocket, the generated image must also have no hood/pocket.\n"
f"- Do NOT invent additional layers, accessories (scarf, belt, tie), or undergarments visible beyond what is listed."
)
else:
outfit_exclusion_rule = (
"OUTFIT STRICTNESS: The model wears EXACTLY the garments listed above, with no extra layers or accessories."
)
input_image_order_guide = _build_input_image_order_guide(
has_face, has_tone_ref, product_image_count,
sub_product_slots, sub_product_images, has_background,
)
camera_angle = _build_camera_prompt(shot_label, model_gender, model_age_group, model_height_cm, model_kids_grade)
body_proportion_guide = _build_body_proportion_prompt(
shot_label, model_gender, model_age_group, model_face, model_height_cm, model_kids_grade
)
crop_template_guide = _build_crop_template_lock(shot_label, model_age_group)
kids_height_lock = _build_kids_height_lock_prompt(model_kids_grade, model_height_cm) if model_age_group == "kids" else ""
face_instruction = (
"MODEL IDENTITY (CRITICAL): The model in this photo MUST be the EXACT same person "
"as shown in the face reference image. Copy the facial features precisely — "
"same face shape, eyes, nose, lips, eyebrows, skin tone, and hair.\n"
+ _hair_identity_lock(model_gender) + "\n"
+ _build_face_skin_lock_prompt(True)
) if has_face else "MODEL: The model should have an attractive, professional look."
# Tone reference guard — only emitted when the mosaic image is actually
# in the input list. Wording is deliberately blunt because the SDK
# otherwise tends to read additional images as faces or backgrounds.
tone_instruction = _build_tone_reference_instruction(
mode="original", has_face=has_face, palette_text=user_tone_palette_text
) if has_tone_ref else ""
background_setting = (
"SETTING (CRITICAL): Use the LAST provided image as the target background plate. Match that background's perspective, floor contact, depth, and global tone while preserving the model and outfit exactly. Background replacement must look naturally photographed, not composited."
if has_background
else "SETTING (CRITICAL): Soft light-gray studio background with a subtle warm gray tone — NOT pure white. A gentle, natural tonal transition between the wall and floor is acceptable to create depth and realism, similar to a real photography studio. No heavy gradients, no textures, no props."
)
background_reminder = (
"- The LAST provided image is the target background; replace environment only and keep subject identity/garment geometry unchanged."
if has_background else
"- The background must be a soft light-gray studio tone, NOT pure white."
)
prompt_text = f"""Act as a professional fashion photographer.
Task: Create a high-quality lifestyle/studio fashion photography image.
{face_instruction}
{tone_instruction}
{input_image_order_guide}
FRAMING AND COMPOSITION (MANDATORY): {camera_angle}
OUTFIT: The model is wearing a coordinated outfit consisting of the provided product images.
Details: {outfit_details}
{outfit_exclusion_rule}
{sub_accessory_prompt}
{garment_length_guide}
{body_proportion_guide}
{kids_height_lock}
{crop_template_guide}
FABRIC REALISM (CRITICAL): The clothing must look like REAL worn fabric, NOT flat CG or ironed-smooth textures.
- Add natural wrinkles and creases at joints (elbows, knees, waist, crotch) from body movement.
- Show gravity-driven drape and fabric weight — heavier fabrics hang with deeper folds, lighter fabrics flow softly.
- Render material-specific texture: knit should show visible yarn/stitch texture, denim should show subtle whiskering/fading at stress points, cotton should have soft organic creases, wool should have a fuzzy nap, silk/satin should show light-catching sheen with flowing drape.
- Include subtle compression wrinkles where the garment is pulled or pressed (e.g. waistband, pocket areas, where hands rest).
- The fabric should interact naturally with the body — not floating or stiff like a 3D render.
- {_FABRIC_REALISM_SUFFIX}
{_build_default_shoes_prompt(model_age_group)}
{background_setting}
{_build_even_studio_lighting_prompt("FULL-BODY")}
{_build_even_face_lighting_override("FULL-BODY")}
{_build_face_skin_lock_prompt(has_face)}
{wall_shadow_block}
{single_image_lock}
{"ADDITIONAL INSTRUCTIONS: " + sanitized_custom_prompt if sanitized_custom_prompt else ""}
IMPORTANT REMINDERS:
- Output ONE single photograph only. Never a grid, collage, or multi-panel layout.
- Strictly adhere to the FRAMING AND COMPOSITION instruction.
- OUTFIT: wear the main garments listed in OUTFIT → Details{" AND the accessories in ACCESSORIES — accessories MUST be visible" if has_sub_accessories else ""}. Do NOT add extra main garments (jacket/coat/cardigan/hood) unless listed in OUTFIT → Details.{" Do NOT add unlisted accessories." if not has_sub_accessories else " Do NOT add accessories beyond the reference images."} The words "hood" or "pocket" in pose/hair rules refer to hair or hand placement — NOT to clothing.
{background_reminder}
{"- The tone reference is for overall color grade and mood only — never copy its grid/pattern as the background." if has_tone_ref else ""}
{"- The model's face MUST match the reference face image exactly, with the same clean even skin tone." if has_face else ""}
{"- Follow the GARMENT LENGTH guide precisely." if garment_length_guide else ""}
{"- Maintain natural child body proportions." if model_age_group == "kids" and body_proportion_guide else ("- Maintain commercial lookbook body proportions with natural waist near mid-frame; do NOT use short-torso / ultra-long-leg distortion." if body_proportion_guide else "")}"""
parts.append({"text": prompt_text})
print(f"[GENERATE] → ORIGINAL MODE | products: {len(product_images)}, sub_accessories: {len(sub_product_images)}, face: {has_face}, tone_ref: {has_tone_ref}")
# ──────────────────────────────────────────────────────────
# Call OpenAI (gpt-image-2)
# ──────────────────────────────────────────────────────────
try:
print(f"[GENERATE] Calling OpenAI Images API ({OPENAI_IMAGE_MODEL})...")
# `_openai_image_generate` is sync (OpenAI SDK calls are blocking).
# Inside an async endpoint, calling it directly freezes the event loop
# and serializes all concurrent requests. Offloading to the default
# threadpool lets FastAPI actually serve requests in parallel.
image_b64 = await asyncio.to_thread(_openai_image_generate, client, parts, image_size)
print(f"[GENERATE] OpenAI API responded | image payload ~{len(image_b64)} chars")
# ── Push to HF Dataset IN BACKGROUND (non-blocking) ──
try:
now = datetime.datetime.now()
ts = now.strftime("%Y%m%d_%H%M%S_%f")[:-3]
elapsed_ms = int((now - request_started_at).total_seconds() * 1000)
raw_bytes = base64.b64decode(image_b64.split(";base64,", 1)[1]) if ";base64," in image_b64 else b""
face_b64_hub = None
face_filename_hub = None
if isinstance(model_face, str):
if model_face.startswith("preset_") and model_face in _FACE_PRESET_CACHE:
face_b64_hub = _FACE_PRESET_CACHE[model_face]["data"]
face_filename_hub = f"{model_face}.png"
elif model_face.startswith("data:") and ";base64," in model_face:
face_b64_hub = model_face.split(";base64,", 1)[1]
face_filename_hub = model_face_filename
# ⚡ Background push — response returns immediately
_push_to_hub_background(
record={
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), "ts": ts,
"app": "ai-model-lookbook", "shot_label": shot_label,
"user_username": current_user["username"],
"user_name": current_user["name"],
"user_email": current_user["email"],
"user_logged_in": current_user["is_logged_in"],
"product_infos": product_infos, "custom_prompt": custom_prompt or "(none)",
# 전체 엔지니어링 프롬프트도 저장(상세 통계에서 표시용). 길이 방어를 위해 8000자 컷.
"prompt": (prompt_text[:8000] if isinstance(prompt_text, str) else ""),
"model_face": model_face if isinstance(model_face, str) and model_face.startswith("preset_") else "user_upload" if model_face else "none",
"has_reference": has_reference,
"has_background": has_background,
"has_tone_reference": bool(tone_reference_image),
"mode": "TRANSFORM" if has_reference else "ORIGINAL",
"elapsed_ms": elapsed_ms,
"elapsed_sec": round(elapsed_ms / 1000, 3),
"product_image_filenames": product_image_filenames or [],
"sub_product_image_filenames": sub_product_image_filenames or [],
"model_face_filename": model_face_filename or face_filename_hub,
},
image_bytes=raw_bytes, ts=ts,
product_images_b64=product_images, face_b64=face_b64_hub,
product_filenames=product_image_filenames,
face_filename=face_filename_hub,
sub_product_images_b64=sub_product_images,
sub_product_filenames=sub_product_image_filenames,
)
except Exception as hub_err:
print(f"[HUB] Failed to start background push: {hub_err}")
elapsed_ms = int((datetime.datetime.now() - request_started_at).total_seconds() * 1000)
print(f"[GENERATE] ✅ Returning response for '{shot_label}' | request_ts={request_ts} | elapsed_ms={elapsed_ms}")
return {"imageUrl": image_b64}
except HTTPException:
raise
except Exception as e:
elapsed_ms = int((datetime.datetime.now() - request_started_at).total_seconds() * 1000)
print(f"[GENERATE] ❌ Failed after {elapsed_ms} ms")
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"이미지 생성 중 예기치 못한 오류가 발생했습니다: {str(e)[:200]}")
# ── API: Edit existing image ─────────────────────────────────
@app.post("/api/edit")
async def api_edit(request: Request, payload: Dict = Body(...)):
client = get_openai_client()
current_user = get_current_user(request)
require_login(current_user)
current_image = payload.get("currentImage", "")
edit_instruction = payload.get("editInstruction", "")
sanitized_edit_instruction = _sanitize_custom_prompt_for_single_image(edit_instruction)
single_image_lock = _build_single_image_composition_lock()
image_size = _resolve_image_size(payload.get("imageSize"))
reference_images = payload.get("referenceImages", [])
if not reference_images and payload.get("referenceImage"):
reference_images = [payload["referenceImage"]]
# Detect if the edit instruction is a background/scene change. When it is,
# the hardcoded "preserve background" clauses below would conflict with
# (and override) the user's intent — so we strip them for these requests.
# Signatures come from BG_INTEGRATION_BASE in the frontend.
_BG_CHANGE_SIGNATURES = (
"Re-shoot this EXACT model",
"MUST REBUILD",
"you MUST replace the background",
"SCENE MOOD HINT",
"SCENE SELECTION",
"CREATIVE DIRECTION",
)
is_background_change = any(sig in edit_instruction for sig in _BG_CHANGE_SIGNATURES)
if is_background_change:
print(f"[EDIT] Detected background-change intent — skipping 'preserve background' clauses")
parts = []
if current_image and current_image.startswith("data:"):
parsed = _parse_base64(current_image)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}, "preserve_format": True})
for ref in reference_images:
if ref and isinstance(ref, str) and ref.startswith("data:"):
parsed = _parse_base64(ref)
parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
if reference_images:
ref_count = sum(1 for r in reference_images if r and isinstance(r, str) and r.startswith("data:"))
if is_background_change:
# During background changes, references are for style/mood hints, not for locking bg.
ref_text = (
f"Use the following {ref_count} image(s) as visual reference(s) for style or mood hints only."
if ref_count > 1
else "Use the second image as a visual reference for style or mood hints only."
)
else:
ref_text = (
f"Use the following {ref_count} image(s) as visual reference(s) for the requested changes only. "
"Do NOT copy background, environment, or tone from the reference images — preserve the background from the first image."
) if ref_count > 1 else (
"Use the second image as a visual reference for the requested changes only. "
"Do NOT copy background or environment from the reference — preserve the background from the first image."
)
parts.append({"text": ref_text})
if is_background_change:
# Background changes: user's instruction IS the scene directive. Do NOT
# add any "preserve background" counter-instruction here.
parts.append({
"text": f"{sanitized_edit_instruction}\n\n"
f"{single_image_lock}\n\n"
f"LOCAL EDIT ONLY (ABSOLUTE): Apply only the requested change. Do NOT perform a full-image retouch, beauty retouch, relighting pass, color grading pass, tone matching pass, or global contrast/saturation/white-balance change.\n\n"
f"SUBJECT LIGHTING LOCK (CRITICAL): Preserve the first image's lighting on the person exactly. Face tone, skin brightness, shadow direction, highlight intensity, color temperature, and garment lighting must remain unchanged. The new background may be integrated with shadows/contact only, but must NOT relight or recolor the person.\n\n"
f"EDIT LOCALITY LOCK (CRITICAL): All unrequested regions from the first image must remain visually unchanged. Unless the instruction explicitly asks to edit face or skin, the face, neck, hands, legs, and all exposed skin are non-editable reference areas, not areas to regenerate.\n\n"
f"FACE / SKIN LOCK (CRITICAL): Preserve the first image's face exactly. Skin texture, facial lighting, natural highlights, pores, and skin tone gradients must remain unchanged. Do NOT smooth, repaint, mottle, blur, recolor, brighten, darken, or patchily relight the person's skin.\n\n"
f"MODEL RATIO LOCK (CRITICAL): Preserve the first image's body proportions exactly. Do NOT change apparent height, head size, shoulder width, torso length, waist/hip/knee position, leg length, ankle position, or foot scale. Background or pose changes must not alter the model's body scale.\n\n"
f"The output must be a high-quality fashion photograph. Keep the original subject pixels visually stable while applying the requested change."
})
else:
parts.append({
"text": f"Modify this fashion photo based on the following instruction: {sanitized_edit_instruction}. "
f"{single_image_lock} "
f"LOCAL EDIT ONLY (ABSOLUTE): Apply only the requested change. Do NOT perform a full-image retouch, beauty retouch, relighting pass, color grading pass, tone matching pass, or global contrast/saturation/white-balance change. "
f"SUBJECT LIGHTING LOCK (CRITICAL): Preserve the first image's lighting on the person exactly. Face tone, skin brightness, shadow direction, highlight intensity, color temperature, and garment lighting must remain unchanged. "
f"EDIT LOCALITY LOCK (CRITICAL): All unrequested regions from the first image must remain visually unchanged. Unless the instruction explicitly asks to edit face or skin, the face, neck, hands, legs, and all exposed skin are non-editable reference areas, not areas to regenerate. "
f"Maintain the same model and outfit with high fidelity, and preserve composition unless the instruction explicitly requests a framing change. "
f"MODEL RATIO LOCK (CRITICAL): Preserve the first image's body proportions exactly. Do NOT change apparent height, head size, shoulder width, torso length, waist/hip/knee position, leg length, ankle position, or foot scale. "
f"FACE / SKIN LOCK (CRITICAL): Preserve the first image's face exactly. Skin texture, facial lighting, natural highlights, pores, and skin tone gradients must remain unchanged. Do NOT smooth, repaint, mottle, blur, recolor, brighten, darken, or patchily relight the person's skin. "
f"BACKGROUND (CRITICAL): Keep the exact same background — tone, color, and environment — as in the first image. "
f"Do NOT change the background unless the instruction explicitly requests a background change. "
f"The output must be a high-quality fashion photograph. Keep all non-requested pixels visually stable."
})
try:
image_b64 = await asyncio.to_thread(_openai_image_generate, client, parts, image_size)
# Background push for edit too
try:
now = datetime.datetime.now()
ts = now.strftime("%Y%m%d_%H%M%S_%f")[:-3]
raw_bytes = base64.b64decode(image_b64.split(";base64,", 1)[1]) if ";base64," in image_b64 else b""
_push_to_hub_background(
record={
"timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), "ts": ts,
"app": "ai-model-lookbook", "type": "edited",
"user_username": current_user["username"],
"user_name": current_user["name"],
"user_email": current_user["email"],
"user_logged_in": current_user["is_logged_in"],
"edit_instruction": edit_instruction,
"is_background_change": is_background_change,
},
image_bytes=raw_bytes, ts=ts,
)
except Exception:
pass
return {"imageUrl": image_b64}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"이미지 편집 중 예기치 못한 오류가 발생했습니다: {str(e)[:200]}")
# ══════════════════════════════════════════════════════════════
# Comfy Cloud 브리지 (SPAO 워크플로우) — 제출→폴링 구조, comfy_routes.py 참조
# ══════════════════════════════════════════════════════════════
try:
from comfy_routes import build_comfy_router
app.include_router(build_comfy_router(get_current_user, require_login, _FACE_PRESET_CACHE, _push_to_hub_background))
print("[COMFY] routes mounted: /api/comfy/generate, /api/comfy/jobs/{id}, /api/comfy/health")
except Exception as _comfy_err:
traceback.print_exc()
print(f"[COMFY] failed to mount routes: {_comfy_err}")