sunyoung00 commited on
Commit
bb5ff16
·
verified ·
1 Parent(s): ec4323d

Upload 5 files

Browse files
Files changed (5) hide show
  1. comfy_client (27).py +431 -0
  2. comfy_routes (29).py +400 -0
  3. index (67).html +1059 -0
  4. server (48).py +0 -0
  5. workflow_api (28).json +2042 -0
comfy_client (27).py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ comfy_client.py — Comfy Cloud REST 클라이언트 (SPAO 룩북 워크플로우 전용)
3
+
4
+ 흐름: 이미지 업로드(해시 캐시) → workflow_api.json 패치 → /api/prompt 제출
5
+ → /api/job/{id}/status 폴링 → 출력 파일 다운로드(/api/view 302 → signed URL)
6
+
7
+ 필요 환경변수:
8
+ COMFY_CLOUD_API_KEY platform.comfy.org 에서 발급한 키 (Space secret)
9
+ COMFY_BASE_URL 기본 https://cloud.comfy.org
10
+ COMFY_WORKFLOW_PATH 기본 workflow_api.json (Comfy Cloud 에디터에서 Export (API) 로 저장한 파일)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import hashlib
16
+ import io
17
+ import json
18
+ import os
19
+ import random
20
+ import threading
21
+ import time
22
+ from typing import Any, Dict, List, Optional, Tuple
23
+
24
+ import httpx
25
+ from PIL import Image
26
+
27
+ BASE_URL = os.environ.get("COMFY_BASE_URL", "https://cloud.comfy.org").rstrip("/")
28
+ API_KEY = os.environ.get("COMFY_CLOUD_API_KEY", "")
29
+ WORKFLOW_PATH = os.environ.get("COMFY_WORKFLOW_PATH", "workflow_api.json")
30
+
31
+ # ── 워크플로우 노드 ID (SPAO.json 기준, API export 시 그대로 유지됨) ──────────
32
+ IMAGE_NODES = {
33
+ "face": "646", # 얼굴
34
+ "outer": "901", # 아우터
35
+ "top": "902", # 상의
36
+ "bottom": "908", # 하의
37
+ "shoes": "909", # 신발
38
+ "inner": "957", # 이너웨어
39
+ "belt": "958", # 벨트
40
+ "background": "907", # 배경
41
+ "pose": "916", # 레퍼런스 포즈
42
+ }
43
+
44
+ # CustomCombo 노드: (node_id, 옵션 목록) — 옵션 순서 = INDEX 출력값
45
+ COMBO_NODES = {
46
+ "shot": ("917", ["전신컷", "상반신컷", "하반신컷", "레퍼런스 구도 그대로"]),
47
+ "angle": ("962", ["정면", "반측면", "측면", "로우앵글", "하이앵글", "후면"]),
48
+ "gaze": ("992", ["카메라 응시", "자연스러운 시선"]),
49
+ "age": ("918", ["성인", "키즈"]),
50
+ "outer_length": ("930", ["허리", "골반", "허벅지"]),
51
+ "outer_fit": ("931", ["슬림", "표준", "오버"]),
52
+ "top_length": ("932", ["허리", "골반", "허벅지"]),
53
+ "top_fit": ("933", ["슬림", "표준", "오버"]),
54
+ "bottom_length": ("934", ["허벅지", "무릎", "종아리", "발목", "신발"]),
55
+ "bottom_fit": ("935", ["슬림", "표준", "오버"]),
56
+ }
57
+
58
+ # 디테일 참조: 클로즈업을 GPT-image의 image_9~12 슬롯으로 직접 전달. 안 쓰는 노드는 제출 전에 삭제
59
+ # (Comfy Cloud는 연결된 LoadImage의 파일이 없으면 검증 실패하므로 — v8 교훈).
60
+ DETAIL_NODES = ["1060", "1061", "1062", "1063"] # image_9, 10, 11, 12 순
61
+ DETAIL_GARMENT_EN = {
62
+ "outer": "OUTERWEAR (image 1)", "top": "TOP (image 2)", "bottom": "BOTTOMS (image 5)",
63
+ "shoes": "SHOES (image 6)", "inner": "INNERWEAR (image 8 LEFT)", "belt": "BELT (image 8 RIGHT)",
64
+ }
65
+ # 키는 index.html DETAIL_KINDS_BY_GARMENT 항목과 1:1. texture/material은 전체 적용, 나머지는 국소 적용.
66
+ _DETAIL_LOCAL_RULE = ("apply ONLY to that region; keep the item's silhouette, length, fit, color and every other part "
67
+ "exactly as in its main photo — this is a localized construction reference, not a new item")
68
+ DETAIL_KIND_EN = {
69
+ "texture": "FABRIC TEXTURE close-up — reproduce this exact weave/knit/pile and surface finish across the whole garment at realistic scale",
70
+ "material": "MATERIAL close-up (leather, suede, mesh, canvas…) — reproduce this exact material grain, sheen and surface finish across the whole item at realistic scale",
71
+ "logo": "LOGO/PRINT close-up — reproduce this exact graphic with the same shape, colors and proportions, at the same position on the item as in its main photo",
72
+ "neckline": f"NECKLINE close-up — reproduce this exact neckline shape, ribbing, binding and stitching; {_DETAIL_LOCAL_RULE}",
73
+ "collar": f"COLLAR/HOOD/LAPEL close-up — reproduce this exact collar or hood construction, shape, lining and stitching; {_DETAIL_LOCAL_RULE}",
74
+ "sleeve": f"SLEEVE/CUFF close-up — reproduce this exact cuff construction, ribbing, hem finish and stitching at the sleeve ends; {_DETAIL_LOCAL_RULE}",
75
+ "hem": f"HEM close-up — reproduce this exact bottom-edge finish (ribbing, banding, raw/rolled edge, stitching); {_DETAIL_LOCAL_RULE}",
76
+ "pocket": f"POCKET close-up — reproduce this exact pocket type, shape, placement and stitching; {_DETAIL_LOCAL_RULE}",
77
+ "closure": f"CLOSURE close-up (zipper, buttons, snaps) — reproduce this exact closure hardware and placement; {_DETAIL_LOCAL_RULE}",
78
+ "trim": f"TRIM/HARDWARE close-up (label, drawstring, tab…) — reproduce this exact component with the same shape, material and placement; {_DETAIL_LOCAL_RULE}",
79
+ "waist": f"WAISTBAND close-up — reproduce this exact waistband construction (band, belt loops, drawstring, fly); {_DETAIL_LOCAL_RULE}",
80
+ "sole": f"SOLE close-up — reproduce this exact sole shape, tread, height and color; {_DETAIL_LOCAL_RULE}",
81
+ "lace": f"LACES/EYELETS/TONGUE close-up — reproduce this exact lacing hardware and tongue construction; {_DETAIL_LOCAL_RULE}",
82
+ "heel": f"HEEL/HEEL-TAB close-up — reproduce this exact heel counter and tab construction; {_DETAIL_LOCAL_RULE}",
83
+ "buckle": f"BUCKLE close-up — reproduce this exact buckle shape, metal finish and prong; {_DETAIL_LOCAL_RULE}",
84
+ "stitch": f"STITCH/EDGE/HOLES close-up — reproduce this exact stitching, edge finish and hole pattern; {_DETAIL_LOCAL_RULE}",
85
+ }
86
+
87
+ PROMPT_NODE = "914" # StringConcatenate "프롬프트 작성" → string_b
88
+ SEED_NODES = ["781", "989"] # KSampler 시드 / 손 보정 샘플러
89
+ OUTPUT_NODE = "760" # SaveImage "Image Save(Output)"
90
+
91
+ TERMINAL_OK = {"success"}
92
+ TERMINAL_FAIL = {"error", "non_retryable_error", "lost", "cancelled"}
93
+
94
+ _HTTP_TIMEOUT = httpx.Timeout(connect=10.0, read=120.0, write=120.0, pool=10.0)
95
+
96
+
97
+ class ComfyError(RuntimeError):
98
+ pass
99
+
100
+
101
+ def _headers(json_ct: bool = True) -> Dict[str, str]:
102
+ if not API_KEY:
103
+ raise ComfyError("COMFY_CLOUD_API_KEY 환경변수가 설정되지 않았습니다 (Space Settings → Secrets).")
104
+ h = {"X-API-Key": API_KEY}
105
+ if json_ct:
106
+ h["Content-Type"] = "application/json"
107
+ return h
108
+
109
+
110
+ # ── 워크플로우 템플릿 ──────────────────────────────────────────────────────────
111
+ _WORKFLOW_TEMPLATE: Optional[Dict[str, Any]] = None
112
+ _WORKFLOW_ERROR: Optional[str] = None
113
+
114
+
115
+ def load_workflow_template() -> Dict[str, Any]:
116
+ global _WORKFLOW_TEMPLATE, _WORKFLOW_ERROR
117
+ if _WORKFLOW_TEMPLATE is not None:
118
+ return _WORKFLOW_TEMPLATE
119
+ if not os.path.exists(WORKFLOW_PATH):
120
+ _WORKFLOW_ERROR = f"{WORKFLOW_PATH} 파일이 없습니다. Comfy Cloud 에디터에서 워크플로우를 열고 Export (API)로 저장해 Space 루트에 넣어주세요."
121
+ raise ComfyError(_WORKFLOW_ERROR)
122
+ with open(WORKFLOW_PATH, "r", encoding="utf-8") as f:
123
+ wf = json.load(f)
124
+ if not isinstance(wf, dict) or "nodes" in wf:
125
+ _WORKFLOW_ERROR = f"{WORKFLOW_PATH} 는 API 포맷이 아닙니다 (nodes/links 캔버스 저장 형식). Export (API) 로 다시 저장해주세요."
126
+ raise ComfyError(_WORKFLOW_ERROR)
127
+ missing = [nid for nid in list(IMAGE_NODES.values()) + [PROMPT_NODE, OUTPUT_NODE] + SEED_NODES if nid not in wf]
128
+ if missing:
129
+ _WORKFLOW_ERROR = f"workflow_api.json 에 기대한 노드 ID가 없습니다: {missing}. SPAO.json 과 다른 워크플로우를 export 했는지 확인해주세요."
130
+ raise ComfyError(_WORKFLOW_ERROR)
131
+ _WORKFLOW_TEMPLATE = wf
132
+ _WORKFLOW_ERROR = None
133
+ print(f"[COMFY] Loaded workflow template {WORKFLOW_PATH} ({len(wf)} nodes)")
134
+ return wf
135
+
136
+
137
+ def workflow_status() -> Dict[str, Any]:
138
+ try:
139
+ wf = load_workflow_template()
140
+ return {"ok": True, "nodes": len(wf), "path": WORKFLOW_PATH, "api_key": bool(API_KEY)}
141
+ except ComfyError as e:
142
+ return {"ok": False, "error": str(e), "path": WORKFLOW_PATH, "api_key": bool(API_KEY)}
143
+
144
+
145
+ # ── 이미지 업로드 (내용 해시 캐시) ─────────────────────────────────────────────
146
+ _UPLOAD_CACHE: Dict[str, str] = {} # sha256 → Comfy 저장소 파일명
147
+ _UPLOAD_LOCK = threading.Lock()
148
+
149
+
150
+ def normalize_image_bytes(data: bytes, max_side: int = 2048) -> Tuple[bytes, str]:
151
+ """알파 제거·과대 이미지 축소 후 (bytes, ext) 반환. 동일 입력 → 동일 바이트 (캐시 안정성)."""
152
+ img = Image.open(io.BytesIO(data))
153
+ img.load()
154
+ if img.mode not in ("RGB", "L"):
155
+ bg = Image.new("RGB", img.size, (255, 255, 255))
156
+ if "A" in img.getbands():
157
+ bg.paste(img.convert("RGBA"), mask=img.convert("RGBA").split()[-1])
158
+ else:
159
+ bg.paste(img.convert("RGB"))
160
+ img = bg
161
+ elif img.mode == "L":
162
+ img = img.convert("RGB")
163
+ w, h = img.size
164
+ if max(w, h) > max_side:
165
+ s = max_side / max(w, h)
166
+ img = img.resize((max(1, int(w * s)), max(1, int(h * s))), Image.LANCZOS)
167
+ out = io.BytesIO()
168
+ img.save(out, format="PNG", compress_level=6)
169
+ return out.getvalue(), "png"
170
+
171
+
172
+ def ensure_uploaded(data: bytes, label: str = "") -> str:
173
+ """Comfy 저장소에 이미지가 있도록 보장하고 파일명을 돌려준다. 같은 바이트는 한 번만 업로드."""
174
+ norm, ext = normalize_image_bytes(data)
175
+ digest = hashlib.sha256(norm).hexdigest()
176
+ with _UPLOAD_LOCK:
177
+ cached = _UPLOAD_CACHE.get(digest)
178
+ if cached:
179
+ return cached
180
+ filename = f"{digest}.{ext}"
181
+ with httpx.Client(timeout=_HTTP_TIMEOUT) as client:
182
+ r = client.post(
183
+ f"{BASE_URL}/api/upload/image",
184
+ headers=_headers(json_ct=False),
185
+ files={"image": (filename, norm, "image/png")},
186
+ data={"type": "input", "overwrite": "true"},
187
+ )
188
+ if r.status_code >= 400:
189
+ raise ComfyError(f"이미지 업로드 실패({label or filename}): HTTP {r.status_code} {r.text[:200]}")
190
+ name = r.json().get("name") or filename
191
+ with _UPLOAD_LOCK:
192
+ _UPLOAD_CACHE[digest] = name
193
+ print(f"[COMFY] uploaded {label or ''} → {name} ({len(norm):,} bytes)")
194
+ return name
195
+
196
+
197
+ _PLACEHOLDER_CACHE: Dict[str, bytes] = {}
198
+
199
+
200
+ def placeholder_image_bytes(kind: str = "blank") -> bytes:
201
+ """비어 있는 슬롯(아우터/이너/벨트/포즈 미업로드)용 흰색 이미지."""
202
+ if kind in _PLACEHOLDER_CACHE:
203
+ return _PLACEHOLDER_CACHE[kind]
204
+ size = (1024, 1024) if kind == "blank" else (1024, 1536)
205
+ img = Image.new("RGB", size, (255, 255, 255))
206
+ out = io.BytesIO()
207
+ img.save(out, format="PNG")
208
+ _PLACEHOLDER_CACHE[kind] = out.getvalue()
209
+ return _PLACEHOLDER_CACHE[kind]
210
+
211
+
212
+ # ── 워크플로우 패치 ────────────────────────────────────────────────────────────
213
+ def _set_combo(wf: Dict[str, Any], node_id: str, options: List[str], label: str) -> None:
214
+ """CustomCombo 노드의 choice를 바꾸고, 그 노드의 STRING/INDEX 출력을 쓰는
215
+ 모든 소비자 입력을 상수로 치환한다 (백엔드 CustomCombo 동작에 의존하지 않도록)."""
216
+ if label not in options:
217
+ raise ComfyError(f"'{label}' 은(는) 허용된 옵션이 아닙니다: {options}")
218
+ idx = options.index(label)
219
+ node = wf.get(node_id)
220
+ if node is not None:
221
+ node.setdefault("inputs", {})["choice"] = label
222
+ for other in wf.values():
223
+ for k, v in list(other.get("inputs", {}).items()):
224
+ if isinstance(v, list) and len(v) == 2 and str(v[0]) == node_id:
225
+ other["inputs"][k] = label if v[1] == 0 else idx
226
+
227
+
228
+ def build_workflow(image_names: Dict[str, str], combos: Dict[str, str], prompt: str,
229
+ details: Optional[List[Dict[str, str]]] = None) -> Dict[str, Any]:
230
+ """details: [{"name": Comfy 업로드 파일명, "garment": IMAGE_NODES 의상 키, "kind": DETAIL_KIND_EN 키}] 최대 4개."""
231
+ wf = copy.deepcopy(load_workflow_template())
232
+ for key, node_id in IMAGE_NODES.items():
233
+ name = image_names.get(key)
234
+ if name:
235
+ wf[node_id]["inputs"]["image"] = name
236
+ for key, (node_id, options) in COMBO_NODES.items():
237
+ label = combos.get(key)
238
+ if label:
239
+ _set_combo(wf, node_id, options, label)
240
+
241
+ # ── 디테일 참조 (image_9~12) ──
242
+ details = [d for d in (details or []) if d.get("name") and d.get("garment") in DETAIL_GARMENT_EN
243
+ and d.get("kind") in DETAIL_KIND_EN][:len(DETAIL_NODES)]
244
+ if details and DETAIL_NODES[0] not in wf:
245
+ raise ComfyError("워크플로우에 디테일 참조 노드(1060~1063)가 없습니다. 최신 workflow_api.json 으로 교체해주세요.")
246
+ lines = []
247
+ for i, nid in enumerate(DETAIL_NODES):
248
+ if i < len(details):
249
+ d = details[i]
250
+ wf[nid]["inputs"]["image"] = d["name"]
251
+ lines.append(f"image {9 + i} = {DETAIL_GARMENT_EN[d['garment']]}: {DETAIL_KIND_EN[d['kind']]}")
252
+ else: # 안 쓰는 디테일 노드는 노드와 GPT-image 입력을 함께 삭제
253
+ wf.pop(nid, None)
254
+ wf["915"]["inputs"].pop(f"model.images.image_{9 + i}", None)
255
+ if lines:
256
+ # 디테일 지시는 프롬프트 '맨 끝'(종단 오버라이드 뒤)에 서버가 동적 concat 노드로 주입한다.
257
+ # 중간(사용자 프롬프트 자리)에 넣으면 뒤따르는 의상 스펙·오버라이드에 묻혀 반영이 약해짐.
258
+ detail_text = ("FINAL DETAIL OVERRIDE — the close-up reference images below are the MOST AUTHORITATIVE "
259
+ "sources for material, texture and construction. Where a close-up covers a garment or a "
260
+ "region, it OVERRIDES the written garment spec and even the apparent surface of that "
261
+ "garment's main photo: " + " | ".join(lines) + ". Render these textures and constructions "
262
+ "at realistic scale with clearly visible fidelity — do not smooth them away.")
263
+ prev = wf["915"]["inputs"]["prompt"]
264
+ wf["1900"] = {"inputs": {"string_a": prev, "string_b": detail_text, "delimiter": "\n\n"},
265
+ "class_type": "StringConcatenate",
266
+ "_meta": {"title": "디테일 오버라이드 (종단, 서버 주입)"}}
267
+ wf["915"]["inputs"]["prompt"] = ["1900", 0]
268
+
269
+ wf[PROMPT_NODE]["inputs"]["string_b"] = prompt or ""
270
+ for nid in SEED_NODES:
271
+ if nid in wf and "seed" in wf[nid]["inputs"]:
272
+ wf[nid]["inputs"]["seed"] = random.randint(0, 2**53)
273
+ return wf
274
+
275
+
276
+ # ── 제출 / 상태 / 다운로드 ─────────────────────────────────────────────────────
277
+ def submit(wf: Dict[str, Any]) -> str:
278
+ payload = {"prompt": wf, "extra_data": {"api_key_comfy_org": API_KEY}}
279
+ with httpx.Client(timeout=_HTTP_TIMEOUT) as client:
280
+ r = client.post(f"{BASE_URL}/api/prompt", headers=_headers(), json=payload)
281
+ if r.status_code >= 400:
282
+ detail = r.text[:400]
283
+ try:
284
+ j = r.json()
285
+ errs = j.get("node_errors") or j.get("error")
286
+ if errs:
287
+ detail = json.dumps(errs, ensure_ascii=False)[:600]
288
+ except Exception:
289
+ pass
290
+ hint = {401: "API 키가 잘못되었습니다.", 402: "Comfy 크레딧이 부족합니다.", 429: "Comfy Cloud 구독이 비활성입니다."}.get(r.status_code, "")
291
+ raise ComfyError(f"워크플로우 제출 실패 (HTTP {r.status_code}) {hint} {detail}")
292
+ j = r.json()
293
+ if j.get("error"):
294
+ raise ComfyError(f"워크플로우 검증 오류: {json.dumps(j.get('node_errors') or j['error'], ensure_ascii=False)[:600]}")
295
+ return j["prompt_id"]
296
+
297
+
298
+ def job_status(prompt_id: str) -> str:
299
+ with httpx.Client(timeout=_HTTP_TIMEOUT) as client:
300
+ r = client.get(f"{BASE_URL}/api/job/{prompt_id}/status", headers=_headers())
301
+ if r.status_code >= 400:
302
+ raise ComfyError(f"상태 조회 실패: HTTP {r.status_code} {r.text[:200]}")
303
+ return str(r.json().get("status", ""))
304
+
305
+
306
+ def _find_image_refs(obj: Any, acc: List[Dict[str, Any]]) -> None:
307
+ """응답 JSON 어디에 있든 {filename, subfolder, type} 형태의 이미지 참조를 수집."""
308
+ if isinstance(obj, dict):
309
+ if "filename" in obj and isinstance(obj["filename"], str):
310
+ acc.append(obj)
311
+ for v in obj.values():
312
+ _find_image_refs(v, acc)
313
+ elif isinstance(obj, list):
314
+ for v in obj:
315
+ _find_image_refs(v, acc)
316
+
317
+
318
+ def _refs_from_outputs(outputs: Any) -> List[Dict[str, Any]]:
319
+ """ComfyUI outputs 객체({node_id: {images: [...]}})에서 SaveImage(760) 이미지 우선 추출."""
320
+ if not isinstance(outputs, dict):
321
+ return []
322
+ refs: List[Dict[str, Any]] = []
323
+ if isinstance(outputs.get(OUTPUT_NODE), dict):
324
+ _find_image_refs(outputs[OUTPUT_NODE], refs)
325
+ if not refs:
326
+ _find_image_refs(outputs, refs)
327
+ return [x for x in refs if x.get("type", "output") == "output"]
328
+
329
+
330
+ def job_outputs(prompt_id: str, attempts: int = 5, delay: float = 2.0) -> List[Dict[str, Any]]:
331
+ """완료된 작업의 출력 파일 목록.
332
+ /api/jobs/{id} 의 outputs → legacy history 순으로 확인. 'workflow'/'prompt' 필드 안의
333
+ 노드 정의(같은 노드 ID를 가짐)는 절대 탐색하지 않는다. 상태가 success 로 바뀐 직후
334
+ outputs 가 아직 비어 있을 수 있어 몇 번 재시도한다."""
335
+ last_debug = ""
336
+ with httpx.Client(timeout=_HTTP_TIMEOUT) as client:
337
+ for attempt in range(attempts):
338
+ for url in (f"{BASE_URL}/api/jobs/{prompt_id}",
339
+ f"{BASE_URL}/api/history_v2/{prompt_id}",
340
+ f"{BASE_URL}/api/history/{prompt_id}"):
341
+ try:
342
+ r = client.get(url, headers=_headers())
343
+ except Exception as e: # noqa: BLE001
344
+ last_debug = f"{url}: {e}"
345
+ continue
346
+ if r.status_code >= 400:
347
+ last_debug = f"{url}: HTTP {r.status_code} {r.text[:120]}"
348
+ continue
349
+ try:
350
+ data = r.json()
351
+ except Exception:
352
+ continue
353
+ candidates = []
354
+ if isinstance(data, dict):
355
+ candidates.append(data.get("outputs")) # /api/jobs
356
+ if isinstance(data.get(prompt_id), dict): # /api/history
357
+ candidates.append(data[prompt_id].get("outputs"))
358
+ for v in data.values(): # 기타 래핑
359
+ if isinstance(v, dict) and isinstance(v.get("outputs"), dict):
360
+ candidates.append(v["outputs"])
361
+ for c in candidates:
362
+ refs = _refs_from_outputs(c)
363
+ if refs:
364
+ return refs
365
+ if isinstance(data, dict):
366
+ last_debug = f"{url}: keys={list(data.keys())[:12]} outputs_count={data.get('outputs_count')}"
367
+ time.sleep(delay)
368
+ print(f"[COMFY] no outputs for {prompt_id}: {last_debug}")
369
+ return []
370
+
371
+
372
+ def download_output(ref: Dict[str, Any]) -> bytes:
373
+ params = {"filename": ref["filename"], "subfolder": ref.get("subfolder", ""), "type": ref.get("type", "output")}
374
+ with httpx.Client(timeout=_HTTP_TIMEOUT, follow_redirects=False) as client:
375
+ r = client.get(f"{BASE_URL}/api/view", headers=_headers(json_ct=False), params=params)
376
+ if r.status_code in (301, 302, 303, 307, 308):
377
+ signed = r.headers.get("location")
378
+ r2 = client.get(signed, follow_redirects=True) # signed URL: 헤더 없이
379
+ r2.raise_for_status()
380
+ return r2.content
381
+ if r.status_code == 200:
382
+ return r.content
383
+ raise ComfyError(f"출력 다운로드 실패: HTTP {r.status_code}")
384
+
385
+
386
+ def cancel(prompt_id: str) -> str:
387
+ """실행 중/대기 중 작업 취소 요청. 배포별로 엔드포인트가 달라 순서대로 시도하고, 수락된 URL을 반환."""
388
+ attempts = [
389
+ ("POST", f"{BASE_URL}/api/job/{prompt_id}/cancel", None),
390
+ ("POST", f"{BASE_URL}/api/jobs/{prompt_id}/cancel", None),
391
+ ("POST", f"{BASE_URL}/api/interrupt", {"prompt_id": prompt_id}),
392
+ ("POST", f"{BASE_URL}/api/queue", {"delete": [prompt_id]}),
393
+ ]
394
+ last_err = ""
395
+ with httpx.Client(timeout=_HTTP_TIMEOUT) as client:
396
+ for method, url, body in attempts:
397
+ try:
398
+ r = client.request(method, url, headers=_headers(), json=body)
399
+ if r.status_code < 300:
400
+ return url
401
+ last_err = f"{url} → HTTP {r.status_code}"
402
+ except Exception as e: # noqa: BLE001
403
+ last_err = f"{url} → {e}"
404
+ raise ComfyError(f"취소 요청이 수락되지 않았습니다 ({last_err})")
405
+
406
+
407
+ class CancelledByUser(ComfyError):
408
+ pass
409
+
410
+
411
+ def wait_for_result(prompt_id: str, timeout_sec: int = 1200, interval: float = 3.0,
412
+ on_status=None, should_stop=None) -> bytes:
413
+ """터미널 상태까지 폴링 후 첫 출력 이미지 bytes 반환. should_stop() 이 True 면 즉시 중단."""
414
+ start = time.time()
415
+ last = ""
416
+ while time.time() - start < timeout_sec:
417
+ if should_stop and should_stop():
418
+ raise CancelledByUser("사용자가 작업을 취소했습니다.")
419
+ st = job_status(prompt_id)
420
+ if st != last and on_status:
421
+ on_status(st)
422
+ last = st
423
+ if st in TERMINAL_OK:
424
+ refs = job_outputs(prompt_id)
425
+ if not refs:
426
+ raise ComfyError("작업은 성공했지만 출력 이미지를 찾지 못했습니다 (SaveImage 노드 760 확인).")
427
+ return download_output(refs[0])
428
+ if st in TERMINAL_FAIL:
429
+ raise ComfyError(f"Comfy Cloud 작업 실패: {st}")
430
+ time.sleep(interval)
431
+ raise ComfyError(f"{timeout_sec}초 내에 작업이 끝나지 않았습니다.")
comfy_routes (29).py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ comfy_routes.py — HF Space(FastAPI) ↔ Comfy Cloud 브리지 라우터
3
+
4
+ POST /api/comfy/generate → 즉시 {jobId} 반환, 백그라운드에서 업로드→제출→폴링→다운로드
5
+ GET /api/comfy/jobs/{jobId} → {status, stage, imageUrl?, error?, elapsedSec}
6
+ GET /api/comfy/jobs → 현재 사용자(로그인 계정 또는 X-Client-Id)의 작업 목록 (새로고침 복구용)
7
+ GET /api/comfy/health → 워크플로우 파일/API 키 상태
8
+
9
+ server.py 에서:
10
+ from comfy_routes import build_comfy_router
11
+ app.include_router(build_comfy_router(get_current_user, require_login, _FACE_PRESET_CACHE, _push_to_hub_background))
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import datetime
17
+ import io
18
+ import os
19
+ import re
20
+ import threading
21
+ import time
22
+ import traceback
23
+ import uuid
24
+ from typing import Any, Callable, Dict, Optional
25
+
26
+ from fastapi import APIRouter, Body, HTTPException, Request
27
+
28
+ import comfy_client as cc
29
+
30
+ _JOBS: Dict[str, Dict[str, Any]] = {}
31
+ _JOBS_LOCK = threading.Lock()
32
+ _JOB_TTL_SEC = int(os.environ.get("COMFY_JOB_TTL_SEC", str(6 * 60 * 60))) # 완료/실패 작업 보관 시간 (기본 6시간)
33
+ _MAX_CONCURRENT = int(os.environ.get("COMFY_MAX_CONCURRENT", "2"))
34
+ _SEM = threading.Semaphore(_MAX_CONCURRENT)
35
+
36
+ STAGE_LABEL = {
37
+ "queued_local": "대기 중",
38
+ "uploading": "이미지 업로드 중",
39
+ "submitting": "워크플로우 제출 중",
40
+ "submitted": "제출됨",
41
+ "queued_waiting": "Comfy 대기열",
42
+ "queued_limited": "Comfy 대기열 (동시 실행 제한)",
43
+ "preparing": "GPU 준비 중",
44
+ "executing": "생성 중",
45
+ "downloading": "결과 다운로드 중",
46
+ "done": "완료",
47
+ "error": "오류",
48
+ "cancelling": "취소 중",
49
+ "cancelled": "취소됨",
50
+ }
51
+
52
+
53
+ def _parse_data_url(data_url: Optional[str]) -> Optional[bytes]:
54
+ if not data_url or not isinstance(data_url, str):
55
+ return None
56
+ if data_url.startswith("data:") and ";base64," in data_url:
57
+ return base64.b64decode(data_url.split(";base64,", 1)[1])
58
+ return None
59
+
60
+
61
+ _REF_PAD_RATIO = float(os.environ.get("COMFY_REF_PAD_RATIO", "0.08")) # 레퍼런스 여백 비율 (각 변, 0이면 비활성)
62
+ _REF_EDGE_TOUCH = float(os.environ.get("COMFY_REF_EDGE_TOUCH", "0.03")) # 피사체가 이 비율 안으로 가장자리에 닿으면 여백 추가
63
+
64
+
65
+ def _pad_reference_pose(data: bytes) -> bytes:
66
+ """'레퍼런스 구도 그대로'용: 피사체가 프레임 가장자리에 닿아 있는 레퍼런스에 배경색 여백을 둘러
67
+ GPT가 '여백 0'을 재현하려다 머리·발을 잘라내는 것을 막는다. 이미 여백이 충분하면 원본 그대로."""
68
+ if _REF_PAD_RATIO <= 0:
69
+ return data
70
+ try:
71
+ from PIL import Image, ImageChops
72
+ img = Image.open(io.BytesIO(data)).convert("RGB")
73
+ w, h = img.size
74
+ # 배경색: 네 모서리 픽셀의 중앙값
75
+ corners = [img.getpixel(p) for p in ((0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1))]
76
+ bg = tuple(sorted(c[i] for c in corners)[2] for i in range(3))
77
+ diff = ImageChops.difference(img, Image.new("RGB", (w, h), bg)).convert("L").point(lambda v: 255 if v > 40 else 0)
78
+ bbox = diff.getbbox()
79
+ if not bbox:
80
+ return data
81
+ l, t, r, b = bbox
82
+ touches = (t <= h * _REF_EDGE_TOUCH or (h - b) <= h * _REF_EDGE_TOUCH
83
+ or l <= w * _REF_EDGE_TOUCH or (w - r) <= w * _REF_EDGE_TOUCH)
84
+ if not touches:
85
+ return data
86
+ px, py = int(w * _REF_PAD_RATIO), int(h * _REF_PAD_RATIO)
87
+ out = Image.new("RGB", (w + 2 * px, h + 2 * py), bg)
88
+ out.paste(img, (px, py))
89
+ buf = io.BytesIO()
90
+ out.save(buf, format="PNG")
91
+ print(f"[COMFY] reference pose padded {w}x{h} → {out.size[0]}x{out.size[1]} (bg={bg})")
92
+ return buf.getvalue()
93
+ except Exception as e: # noqa: BLE001
94
+ print(f"[COMFY] reference pad skipped: {e}")
95
+ return data
96
+
97
+
98
+ def _safe_name(name: Optional[str], fallback: str) -> str:
99
+ """원본 파일명은 최대한 유지하되 HF 경로에 안전하게 (server._sanitize_upload_filename 과 동일 규칙)."""
100
+ raw = (name or "").strip() if isinstance(name, str) else ""
101
+ base = os.path.basename(raw.replace("\\", "/")) if raw else ""
102
+ if not base or base in (".", ".."):
103
+ return fallback
104
+ cleaned = re.sub(r"[^\w.\-가-힣]+", "_", base, flags=re.UNICODE).strip("._") or fallback
105
+ cleaned = cleaned.replace("..", "_")
106
+ if len(cleaned) > 120:
107
+ stem, ext = os.path.splitext(cleaned)
108
+ cleaned = stem[:100] + ext[:20]
109
+ return cleaned
110
+
111
+
112
+ def _update(job_id: str, **kw) -> None:
113
+ with _JOBS_LOCK:
114
+ if job_id in _JOBS:
115
+ _JOBS[job_id].update(kw)
116
+
117
+
118
+ def _gc_jobs() -> None:
119
+ now = time.time()
120
+ with _JOBS_LOCK:
121
+ for jid in [j for j, v in _JOBS.items() if v.get("finished_at") and now - v["finished_at"] > _JOB_TTL_SEC]:
122
+ _JOBS.pop(jid, None)
123
+
124
+
125
+ def build_comfy_router(
126
+ get_current_user: Callable[[Request], dict],
127
+ require_login: Callable[[dict], None],
128
+ face_preset_cache: Dict[str, dict],
129
+ push_to_hub_background: Optional[Callable] = None,
130
+ ) -> APIRouter:
131
+ router = APIRouter(prefix="/api/comfy")
132
+
133
+ def _run_pipeline(job_id: str, payload: dict, user: dict) -> None:
134
+ started = time.time()
135
+ with _SEM:
136
+ try:
137
+ uploads = payload.get("images") or {}
138
+ combos = {k: v for k, v in (payload.get("options") or {}).items() if v}
139
+ if not uploads.get("pose") and combos.get("shot") == "레퍼런스 구도 그대로":
140
+ raise cc.ComfyError("'레퍼런스 구도 그대로'를 선택하면 레퍼런스 포즈 이미지가 필요합니다.")
141
+ if not any(_parse_data_url(uploads.get(k)) for k in ("outer", "top", "bottom")):
142
+ raise cc.ComfyError("아우터·상의·하의 중 최소 한 개의 의상 이미지가 필요합니다.")
143
+
144
+ _update(job_id, stage="uploading")
145
+ images: Dict[str, str] = {}
146
+
147
+ # ── 얼굴: 프리셋 id 또는 업로드 data URL ──
148
+ face = payload.get("face")
149
+ face_bytes: Optional[bytes] = None
150
+ if isinstance(face, str) and face.startswith("preset_") and face in face_preset_cache:
151
+ face_bytes = base64.b64decode(face_preset_cache[face]["data"])
152
+ else:
153
+ face_bytes = _parse_data_url(face)
154
+ if not face_bytes:
155
+ raise cc.ComfyError("얼굴 프리셋을 선택하거나 얼굴 이미지를 업로드해주세요.")
156
+ images["face"] = cc.ensure_uploaded(face_bytes, "face")
157
+
158
+ # ── 의상/배경/포즈 슬롯 ──
159
+ # 상반신컷: 신발 이미지가 들어가면 "제공된 옷은 반드시 보여라" 규칙 때문에 전신으로 빠지므로
160
+ # Comfy(GPT)에 보내는 신발 슬롯만 빈 플레이스홀더로 치환한다 (Dataset 기록의 uploads 는 원본 유지).
161
+ skip_slots = {"shoes"} if combos.get("shot") == "상반신컷" else set()
162
+ for key in ["outer", "top", "bottom", "shoes", "inner", "belt", "pose"]:
163
+ b = None if key in skip_slots else _parse_data_url(uploads.get(key))
164
+ if b and key == "pose" and combos.get("shot") == "레퍼런스 구도 그대로":
165
+ b = _pad_reference_pose(b) # 가장자리에 닿은 레퍼런스에 배경색 여백 추가
166
+ images[key] = cc.ensure_uploaded(b, key) if b else cc.ensure_uploaded(cc.placeholder_image_bytes("blank"), f"{key}-empty")
167
+ bg = _parse_data_url(uploads.get("background"))
168
+ if not bg and os.path.exists("background.png"):
169
+ with open("background.png", "rb") as f:
170
+ bg = f.read()
171
+ images["background"] = cc.ensure_uploaded(bg, "background") if bg else cc.ensure_uploaded(cc.placeholder_image_bytes("studio"), "background-empty")
172
+
173
+ prompt = (payload.get("prompt") or "").strip()
174
+
175
+ _update(job_id, stage="submitting")
176
+ # ── 디테일 참조 (최대 4장): 해당 옷의 메인 이미지가 있을 때만 전달 ──
177
+ detail_specs = []
178
+ for d in (payload.get("details") or [])[: len(cc.DETAIL_NODES)]:
179
+ if not isinstance(d, dict):
180
+ continue
181
+ g, kind = d.get("garment"), d.get("kind")
182
+ db = _parse_data_url(d.get("image"))
183
+ if not db or g not in cc.DETAIL_GARMENT_EN or kind not in cc.DETAIL_KIND_EN:
184
+ continue
185
+ if not _parse_data_url(uploads.get(g)):
186
+ continue
187
+ detail_specs.append({"name": cc.ensure_uploaded(db, f"detail:{g}:{kind}"),
188
+ "garment": g, "kind": kind})
189
+
190
+ wf = cc.build_workflow(images, combos, prompt, details=detail_specs)
191
+ prompt_id = cc.submit(wf)
192
+ _update(job_id, stage="submitted", promptId=prompt_id)
193
+ print(f"[COMFY] job {job_id} → prompt_id {prompt_id} (user={user.get('username')})")
194
+
195
+ def on_status(st: str) -> None:
196
+ patch = {"stage": st if st in STAGE_LABEL else "executing", "comfyStatus": st}
197
+ with _JOBS_LOCK:
198
+ started = _JOBS.get(job_id, {}).get("exec_started_at")
199
+ # 실제 워크플로우 실행이 시작된 시점(대기열·GPU 준비 제외)부터 소요시간을 잰다
200
+ if st not in ("submitted", "queued_waiting", "queued_limited", "preparing") and not started:
201
+ patch["exec_started_at"] = time.time()
202
+ _update(job_id, **patch)
203
+
204
+ def cancelled() -> bool:
205
+ with _JOBS_LOCK:
206
+ return bool(_JOBS.get(job_id, {}).get("cancelRequested"))
207
+
208
+ img_bytes = cc.wait_for_result(prompt_id, on_status=on_status, should_stop=cancelled)
209
+ _update(job_id, stage="downloading")
210
+ b64 = base64.b64encode(img_bytes).decode("utf-8")
211
+ data_url = f"data:image/png;base64,{b64}"
212
+ with _JOBS_LOCK:
213
+ exec_started = _JOBS.get(job_id, {}).get("exec_started_at") or started
214
+ elapsed = round(time.time() - exec_started, 1) # 순수 생성 시간
215
+ waited = round(exec_started - started, 1) # 업로드+대기열 시간
216
+ _update(job_id, waitedSec=waited)
217
+ _update(job_id, status="done", stage="done", imageUrl=data_url, elapsedSec=elapsed, finished_at=time.time())
218
+ print(f"[COMFY] job {job_id} done in {elapsed}s ({len(img_bytes):,} bytes)")
219
+
220
+ # ── HF Dataset 기록 (기존 로직 재사용, 실패해도 무시) ──
221
+ if push_to_hub_background:
222
+ try:
223
+ # HF Space 컨테이너는 UTC — 대시보드 기록은 한국 시간(KST) 기준으로 남긴다
224
+ now = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=9)))
225
+ ts = now.strftime("%Y%m%d_%H%M%S_%f")[:-3]
226
+ names = payload.get("imageFilenames") if isinstance(payload.get("imageFilenames"), dict) else {}
227
+ garment_keys = ["outer", "top", "bottom", "shoes", "inner", "belt"]
228
+ present = [k for k in garment_keys if uploads.get(k)]
229
+ product_b64 = [uploads[k] for k in present]
230
+ product_names = [_safe_name(names.get(k), f"{k}.png") for k in present]
231
+ ref_keys = [k for k in ("background", "pose") if uploads.get(k)]
232
+ ref_b64 = [uploads[k] for k in ref_keys]
233
+ ref_names = [_safe_name(names.get(k), f"{k}.png") for k in ref_keys]
234
+ is_preset = isinstance(face, str) and face.startswith("preset_")
235
+ face_filename = f"{face}.png" if is_preset else _safe_name(payload.get("faceFilename"), "face_upload.png")
236
+ push_to_hub_background(
237
+ record={
238
+ "timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), "ts": ts,
239
+ "app": "spao-comfy-lookbook", "engine": "comfy-cloud",
240
+ "shot_label": combos.get("shot", ""), "camera_angle": combos.get("angle", ""),
241
+ "gaze": combos.get("gaze", ""), "age_group": combos.get("age", ""),
242
+ "user_username": user.get("username"), "user_name": user.get("name"),
243
+ "user_email": user.get("email"), "user_logged_in": user.get("is_logged_in"),
244
+ "custom_prompt": prompt or "(none)", "comfy_prompt_id": prompt_id, "mode": "COMFY",
245
+ "model_face": face if is_preset else "user_upload", "model_face_filename": face_filename,
246
+ "has_reference": bool(uploads.get("pose")), "has_background": bool(uploads.get("background")),
247
+ "elapsed_sec": elapsed, "elapsed_ms": int(elapsed * 1000), "waited_sec": waited,
248
+ "product_image_filenames": product_names,
249
+ "slot_filenames": {k: _safe_name(names.get(k), f"{k}.png") for k in present + ref_keys},
250
+ "options": combos,
251
+ },
252
+ image_bytes=img_bytes, ts=ts,
253
+ product_images_b64=product_b64, product_filenames=product_names,
254
+ face_b64=base64.b64encode(face_bytes).decode("utf-8"), face_filename=face_filename,
255
+ sub_product_images_b64=ref_b64, sub_product_filenames=ref_names,
256
+ )
257
+ except Exception as hub_err:
258
+ print(f"[HUB] comfy push failed: {hub_err}")
259
+ except cc.CancelledByUser:
260
+ print(f"[COMFY] job {job_id} cancelled by user")
261
+ _update(job_id, status="cancelled", stage="cancelled", finished_at=time.time())
262
+ except cc.ComfyError as e:
263
+ with _JOBS_LOCK:
264
+ was_cancel = bool(_JOBS.get(job_id, {}).get("cancelRequested"))
265
+ if was_cancel: # 취소 요청 후 Comfy 쪽에서 cancelled 로 끝난 경우도 취소로 처리
266
+ print(f"[COMFY] job {job_id} cancelled (comfy: {e})")
267
+ _update(job_id, status="cancelled", stage="cancelled", finished_at=time.time())
268
+ else:
269
+ print(f"[COMFY] job {job_id} error: {e}")
270
+ _update(job_id, status="error", stage="error", error=str(e), finished_at=time.time())
271
+ except Exception as e: # noqa: BLE001
272
+ traceback.print_exc()
273
+ _update(job_id, status="error", stage="error", error=f"예기치 못한 오류: {str(e)[:200]}", finished_at=time.time())
274
+
275
+ def _prewarm() -> None:
276
+ """서버 시작 시 얼굴 프리셋·기본 배경·빈 슬롯 이미지를 미리 올려 첫 요청을 빠르게."""
277
+ try:
278
+ for pid, v in list(face_preset_cache.items()):
279
+ cc.ensure_uploaded(base64.b64decode(v["data"]), f"preset:{pid}")
280
+ if os.path.exists("background.png"):
281
+ with open("background.png", "rb") as f:
282
+ cc.ensure_uploaded(f.read(), "background")
283
+ cc.ensure_uploaded(cc.placeholder_image_bytes("blank"), "empty-slot")
284
+ print("[COMFY] prewarm done")
285
+ except Exception as e: # noqa: BLE001
286
+ print(f"[COMFY] prewarm skipped: {e}")
287
+
288
+ if cc.API_KEY and os.environ.get("COMFY_PREWARM", "true").lower() in ("1", "true", "yes", "on"):
289
+ threading.Thread(target=_prewarm, daemon=True).start()
290
+
291
+ @router.get("/health")
292
+ def comfy_health():
293
+ return cc.workflow_status()
294
+
295
+ def _owner_of(request: Request, user: dict) -> str:
296
+ """로그인 사용자는 계정명, 비로그인은 브라우저가 보내는 X-Client-Id 로 작업 소유자를 식별."""
297
+ if user.get("is_logged_in") and user.get("username"):
298
+ return f"user:{user['username']}"
299
+ cid = (request.headers.get("x-client-id") or "").strip()[:64]
300
+ return f"client:{cid}" if cid else "anonymous"
301
+
302
+ def _job_view(job_id: str, snap: Dict[str, Any], with_image: bool = True) -> Dict[str, Any]:
303
+ stage = snap.get("stage", "")
304
+ return {
305
+ "id": job_id,
306
+ "status": snap.get("status"),
307
+ "stage": stage,
308
+ "stageLabel": STAGE_LABEL.get(stage, stage),
309
+ "createdAt": int(snap["created_at"] * 1000),
310
+ # elapsedSec: 워크플로우 실행 시작 이후 경과(대기 중이면 null). 완료 시엔 순수 생성 시간.
311
+ "elapsedSec": snap.get("elapsedSec") if snap.get("status") == "done"
312
+ else (round(time.time() - snap["exec_started_at"], 1) if snap.get("exec_started_at") else None),
313
+ "execStartedAt": int(snap["exec_started_at"] * 1000) if snap.get("exec_started_at") else None,
314
+ "waitedSec": snap.get("waitedSec"),
315
+ "meta": snap.get("meta") or {},
316
+ "imageUrl": snap.get("imageUrl") if with_image else None,
317
+ "error": snap.get("error"),
318
+ "promptId": snap.get("promptId"),
319
+ }
320
+
321
+ @router.get("/jobs")
322
+ def comfy_list_jobs(request: Request):
323
+ """이 사용자의 작업(진행 중 + 보관 기간 내 완료/실패)을 오래된 순으로 반환."""
324
+ owner = _owner_of(request, get_current_user(request))
325
+ _gc_jobs()
326
+ with _JOBS_LOCK:
327
+ items = [(jid, dict(v)) for jid, v in _JOBS.items() if v.get("owner") == owner]
328
+ items.sort(key=lambda kv: kv[1]["created_at"])
329
+ return {"jobs": [_job_view(jid, v) for jid, v in items]}
330
+
331
+ @router.post("/generate")
332
+ async def comfy_generate(request: Request, payload: Dict = Body(...)):
333
+ user = get_current_user(request)
334
+ require_login(user)
335
+ st = cc.workflow_status()
336
+ if not st["ok"]:
337
+ raise HTTPException(status_code=500, detail=st["error"])
338
+ if not st["api_key"]:
339
+ raise HTTPException(status_code=500, detail="COMFY_CLOUD_API_KEY 가 설정되지 않았습니다.")
340
+ _gc_jobs()
341
+ job_id = uuid.uuid4().hex[:12]
342
+ meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {}
343
+ with _JOBS_LOCK:
344
+ _JOBS[job_id] = {"status": "running", "stage": "queued_local", "created_at": time.time(),
345
+ "user": user.get("username"), "owner": _owner_of(request, user), "meta": meta}
346
+ threading.Thread(target=_run_pipeline, args=(job_id, payload, user), daemon=True).start()
347
+ return {"jobId": job_id}
348
+
349
+ @router.post("/jobs/{job_id}/cancel")
350
+ def comfy_cancel(request: Request, job_id: str):
351
+ """진행 중 작업 취소: 폴링 중단 플래그를 세우고 Comfy Cloud 실행도 중단 요청 (크레딧 절약)."""
352
+ user = get_current_user(request)
353
+ require_login(user)
354
+ with _JOBS_LOCK:
355
+ job = _JOBS.get(job_id)
356
+ if job is None:
357
+ raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다.")
358
+ if job.get("owner") != _owner_of(request, user):
359
+ raise HTTPException(status_code=403, detail="본인 작업만 취소할 수 있습니다.")
360
+ if job.get("status") != "running":
361
+ return {"ok": True, "status": job.get("status")}
362
+ job["cancelRequested"] = True
363
+ pid = job.get("promptId")
364
+ _update(job_id, stage="cancelling")
365
+ if pid: # Comfy Cloud 쪽 실행도 실제로 중단. 실패해도 폴링 루프는 플래그를 보고 곧 멈춤.
366
+ def _comfy_cancel() -> None:
367
+ try:
368
+ url = cc.cancel(pid)
369
+ print(f"[COMFY] job {job_id} cancel accepted via {url}")
370
+ except Exception as e: # noqa: BLE001
371
+ print(f"[COMFY] job {job_id} comfy-side cancel failed: {e}")
372
+ threading.Thread(target=_comfy_cancel, daemon=True).start()
373
+ return {"ok": True}
374
+
375
+ @router.delete("/jobs/{job_id}")
376
+ def comfy_delete(request: Request, job_id: str):
377
+ """완료/실패/취소된 작업을 서버 보관 목록에서 제거 (새로고침 복구 대상에서 빠짐). HF Dataset 기록은 유지."""
378
+ user = get_current_user(request)
379
+ require_login(user)
380
+ with _JOBS_LOCK:
381
+ job = _JOBS.get(job_id)
382
+ if job is None:
383
+ return {"ok": True} # 이미 없음 — 멱등 처리
384
+ if job.get("owner") != _owner_of(request, user):
385
+ raise HTTPException(status_code=403, detail="본인 작업만 지울 수 있습니다.")
386
+ if job.get("status") == "running":
387
+ raise HTTPException(status_code=409, detail="진행 중인 작업은 먼저 취소해주세요.")
388
+ _JOBS.pop(job_id, None)
389
+ return {"ok": True}
390
+
391
+ @router.get("/jobs/{job_id}")
392
+ def comfy_job(job_id: str):
393
+ with _JOBS_LOCK:
394
+ job = _JOBS.get(job_id)
395
+ if job is None:
396
+ raise HTTPException(status_code=404, detail="작업을 찾을 수 없습니다 (서버가 재시작되었을 수 있습니다).")
397
+ snap = dict(job)
398
+ return _job_view(job_id, snap)
399
+
400
+ return router
index (67).html ADDED
@@ -0,0 +1,1059 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>SPAO AI 룩북 생성기</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
9
+ <script>
10
+ tailwind.config = { darkMode: 'class', theme: { extend: { colors: { brand: { blue: '#3B82F6', hover: '#2563EB' } } } } }
11
+ </script>
12
+ <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
13
+ <link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
14
+ <style>
15
+ * { font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
16
+ body { -webkit-font-smoothing: antialiased; background: #1a1a1a; color: #f9fafb; margin: 0; overflow: hidden; }
17
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
18
+ ::-webkit-scrollbar-track { background: #1a1a1a; }
19
+ ::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
20
+ ::-webkit-scrollbar-thumb:hover { background: #4b5563; }
21
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
22
+ @keyframes shimmer { 0% { background-position: -600px 0; } 100% { background-position: 600px 0; } }
23
+ @keyframes pulseRing { 0% { transform: scale(0.85); opacity: .9; } 100% { transform: scale(1.6); opacity: 0; } }
24
+ .animate-fadeIn { animation: fadeIn .25s ease-out; }
25
+ .shimmer { background: linear-gradient(90deg, #232323 0%, #2c2c2c 40%, #232323 80%); background-size: 600px 100%; animation: shimmer 1.6s linear infinite; }
26
+ .pulse-ring::before { content: ''; position: absolute; inset: -6px; border-radius: 9999px; border: 2px solid #3B82F6; animation: pulseRing 1.4s ease-out infinite; }
27
+ .ai-label-overlay { position: absolute; top: 12pt; right: 12pt; font-weight: 300; font-size: 10pt; color: #3c3c3c; pointer-events: none; z-index: 2; letter-spacing: -0.02em; }
28
+ .drop-active { border-color: #3B82F6 !important; background: rgba(59,130,246,.08) !important; }
29
+ textarea { resize: vertical; }
30
+ </style>
31
+ <script type="importmap">
32
+ { "imports": {
33
+ "react": "https://esm.sh/react@19.1.0",
34
+ "react/": "https://esm.sh/react@19.1.0/",
35
+ "react-dom/": "https://esm.sh/react-dom@19.1.0/",
36
+ "lucide-react": "https://esm.sh/lucide-react@0.462.0?external=react"
37
+ } }
38
+ </script>
39
+ </head>
40
+ <body>
41
+ <div id="root"></div>
42
+ <script type="module">
43
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
44
+ import ReactDOM from 'react-dom/client';
45
+ import { Download, X, Upload, Sparkles, RefreshCw, AlertTriangle, Clock, Check, ImagePlus, User, Maximize2, Star, Trash2 } from 'lucide-react';
46
+
47
+ const h = React.createElement;
48
+
49
+ // ═══════════════════════════════════════════════════════════
50
+ // Constants — 워크플로우(SPAO.json) CustomCombo 옵션과 1:1
51
+ // ═══════════════════════════════════════════════════════════
52
+ const SHOT_OPTIONS = ['전신컷', '상반신컷', '하반신컷', '레퍼런스 구도 그대로'];
53
+ const ANGLE_OPTIONS = ['정면', '반측면', '측면', '로우앵글', '하이앵글', '후면'];
54
+ const GAZE_OPTIONS = ['카메라 응시', '자연스러운 시선'];
55
+ const AGE_OPTIONS = ['성인', '키즈'];
56
+ const DETAIL_GROUPS = [
57
+ { key: 'outer', label: '아우터', length: 'outer_length', fit: 'outer_fit' },
58
+ { key: 'top', label: '상의', length: 'top_length', fit: 'top_fit' },
59
+ { key: 'bottom', label: '하의', length: 'bottom_length', fit: 'bottom_fit' },
60
+ ];
61
+ const DETAIL_OPTIONS = {
62
+ outer_length: { label: '아우터 길이', options: ['허리', '골반', '허벅지'], def: '골반' },
63
+ outer_fit: { label: '아우터 핏', options: ['슬림', '표준', '오버'], def: '표준' },
64
+ top_length: { label: '상의 길이', options: ['허리', '골반', '허벅지'], def: '골반' },
65
+ top_fit: { label: '상의 핏', options: ['슬림', '표준', '오버'], def: '오버' },
66
+ bottom_length: { label: '하의 길이', options: ['허벅지', '무릎', '종아리', '발목', '신발'], def: '신발' },
67
+ bottom_fit: { label: '하의 핏', options: ['슬림', '표준', '오버'], def: '오버' },
68
+ };
69
+
70
+ const ADULT_FACE_PRESETS = [
71
+ { id: 'preset_9', label: '여성 1', url: '/face_preset_9.png' },
72
+ // 여성 2(preset_16)는 화면에서 숨김 — 다시 노출하려면 아래 줄 주석 해제
73
+ // { id: 'preset_16', label: '여성 2', url: '/face_preset_16.png' },
74
+ { id: 'preset_15', label: '남성', url: '/face_preset_15.png' },
75
+ ];
76
+ const KIDS_FACE_PRESETS = [
77
+ { id: 'preset_kids_1', label: '키즈 A', url: '/face_preset_kids_1.png' },
78
+ { id: 'preset_kids_2', label: '키즈 B', url: '/face_preset_kids_2.png' },
79
+ { id: 'preset_kids_3', label: '키즈 C', url: '/face_preset_kids_3.png' },
80
+ { id: 'preset_kids_7', label: '키즈 G', url: '/face_preset_kids_7.png' },
81
+ { id: 'preset_kids_4', label: '키즈 D', url: '/face_preset_kids_4.png' },
82
+ { id: 'preset_kids_5', label: '키즈 E', url: '/face_preset_kids_5.png' },
83
+ { id: 'preset_kids_6', label: '키즈 F', url: '/face_preset_kids_6.png' },
84
+ ];
85
+
86
+ const IMAGE_SLOTS = [
87
+ { key: 'outer', label: '아우터', required: false },
88
+ { key: 'top', label: '상의', required: false },
89
+ { key: 'bottom', label: '하의', required: false, hint: '원피스도 여기' },
90
+ { key: 'shoes', label: '신발', required: false },
91
+ { key: 'inner', label: '이너웨어', required: false },
92
+ { key: 'belt', label: '벨트', required: false },
93
+ { key: 'background', label: '배경', required: false, hint: '비우면 기본 스튜디오' },
94
+ { key: 'pose', label: '레퍼런스 포즈', required: false, hint: "'레퍼런스 구도 그대로' 선택 시 필수" },
95
+ ];
96
+
97
+ // AI 표기 — 저장 시 캔버스에 삽입 (기존 앱과 동일 규격)
98
+ const AI_LABEL_TEXT = 'AI 생성 이미지';
99
+ const AI_LABEL_FONT_PT = 28;
100
+ const AI_LABEL_MARGIN_PT = 12 * (28 / 10);
101
+
102
+ // ═══════════════════════════════════════════════════════════
103
+ // Helpers
104
+ // ═══════════════════════════════════════════════════════════
105
+ const fileToDataUrl = (file, maxSide = 1536) => new Promise((resolve, reject) => {
106
+ const reader = new FileReader();
107
+ reader.onerror = reject;
108
+ reader.onload = () => {
109
+ const img = new Image();
110
+ img.onerror = reject;
111
+ img.onload = () => {
112
+ const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
113
+ const w = Math.round(img.width * scale), hgt = Math.round(img.height * scale);
114
+ const canvas = document.createElement('canvas');
115
+ canvas.width = w; canvas.height = hgt;
116
+ const ctx = canvas.getContext('2d');
117
+ const keepPng = /png|webp/i.test(file.type);
118
+ if (!keepPng) { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, hgt); }
119
+ ctx.drawImage(img, 0, 0, w, hgt);
120
+ resolve(keepPng ? canvas.toDataURL('image/png') : canvas.toDataURL('image/jpeg', 0.92));
121
+ };
122
+ img.src = reader.result;
123
+ };
124
+ reader.readAsDataURL(file);
125
+ });
126
+
127
+ const drawAiDisclosureOnCanvas = (ctx, width, height) => {
128
+ ctx.save();
129
+ ctx.textAlign = 'right'; ctx.textBaseline = 'top'; ctx.fillStyle = '#3c3c3c';
130
+ ctx.font = `300 ${AI_LABEL_FONT_PT}pt 'Pretendard', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif`;
131
+ const ptToPx = 96 / 72;
132
+ const m = Math.max(AI_LABEL_MARGIN_PT * ptToPx, Math.min(width, height) * 0.028, 18);
133
+ ctx.fillText(AI_LABEL_TEXT, width - m, m);
134
+ ctx.restore();
135
+ };
136
+
137
+ async function downloadWithLabel(imageUrl, filename, withLabel = true) {
138
+ const img = new Image();
139
+ await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = imageUrl; });
140
+ const canvas = document.createElement('canvas');
141
+ canvas.width = img.naturalWidth; canvas.height = img.naturalHeight;
142
+ const ctx = canvas.getContext('2d');
143
+ ctx.drawImage(img, 0, 0);
144
+ if (withLabel) drawAiDisclosureOnCanvas(ctx, canvas.width, canvas.height);
145
+ const a = document.createElement('a');
146
+ a.href = canvas.toDataURL('image/png'); a.download = filename; a.click();
147
+ }
148
+
149
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
150
+
151
+ // 비로그인 사용자도 작업을 다시 불러올 수 있도록 브라우저별 ID를 보관 (로그인 시엔 서버가 계정명으로 묶음)
152
+ const CLIENT_ID = (() => {
153
+ try {
154
+ let id = localStorage.getItem('spao_client_id');
155
+ if (!id) { id = crypto.randomUUID(); localStorage.setItem('spao_client_id', id); }
156
+ return id;
157
+ } catch { return 'c-' + Math.random().toString(36).slice(2); }
158
+ })();
159
+ const apiHeaders = (json = false) => ({ 'X-Client-Id': CLIENT_ID, ...(json ? { 'Content-Type': 'application/json' } : {}) });
160
+
161
+ // 제출 → jobId → 폴링. 긴 HTTP 연결을 잡고 있지 않으므로 Space 프록시 타임아웃과 무관.
162
+ async function submitToComfy(payload) {
163
+ const res = await fetch('/api/comfy/generate', { method: 'POST', headers: apiHeaders(true), body: JSON.stringify(payload) });
164
+ if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.detail || `HTTP ${res.status}`); }
165
+ return (await res.json()).jobId;
166
+ }
167
+ async function pollComfyJob(jobId, onProgress) {
168
+ let misses = 0;
169
+ while (true) {
170
+ await sleep(3000);
171
+ let r;
172
+ try { r = await fetch(`/api/comfy/jobs/${jobId}`, { headers: apiHeaders() }); } catch { if (++misses > 10) throw new Error('서버 응답이 없습니다.'); continue; }
173
+ if (r.status === 404) throw new Error('작업 정보를 잃어버렸습니다 (서버 ��시작). 다시 시도해주세요.');
174
+ if (!r.ok) { if (++misses > 10) throw new Error(`HTTP ${r.status}`); continue; }
175
+ misses = 0;
176
+ const j = await r.json();
177
+ onProgress?.(j);
178
+ if (j.status === 'done') return j;
179
+ if (j.status === 'cancelled') { const e = new Error('취소됨'); e.cancelled = true; throw e; }
180
+ if (j.status === 'error') throw new Error(j.error || '생성 실패');
181
+ }
182
+ }
183
+
184
+ const fmtSec = (s) => { s = Math.round(s || 0); return s >= 60 ? `${Math.floor(s / 60)}분 ${s % 60}초` : `${s}초`; };
185
+
186
+ // ═══ 채널 사이즈 다운로드 ═══════════════════════════════════
187
+ const EXPORT_PRESETS = [
188
+ { id: 'orig', label: '원본', w: 1536, h: 2048 },
189
+ { id: 'eland', label: '이랜드몰·네이버·사방넷', w: 1400, h: 1400 },
190
+ { id: 'musinsa', label: '무신사', w: 1000, h: 1200 },
191
+ { id: 'zigzag', label: '지그재그', w: 1080, h: 1296 },
192
+ { id: 'cafe24', label: '카페24·공홈', w: 710, h: 1050 },
193
+ ];
194
+
195
+ // 100% = 세로를 꽉 채움. 가로가 남으면 가장자리 '열' 연장(균일한 배경 기둥이라 안전),
196
+ // 가로가 넘치면 좌우 배경을 잘라냄(카페24처럼 원본보다 세로가 긴 채널 — 세로 연장은 머리카락·얼굴이
197
+ // 걸린 윗줄을 늘려 줄무늬가 생기므로 절대 하지 않음). 드래그는 크롭이 있는 축만, 빈틈 없는 범위로 제한.
198
+ function exportLayout(sw, sh, W, H, zoom, offX, offY) {
199
+ const s = (H / sh) * zoom;
200
+ const dw = sw * s, dh = sh * s;
201
+ const maxOffX = Math.max(0, (dw - W) / 2); // 가로 이동 허용 폭 (좌우 크롭 시)
202
+ const maxOffY = Math.max(0, (dh - H) / 2); // 세로 이동 허용 폭 (확대 시)
203
+ const ox = Math.max(-maxOffX, Math.min(maxOffX, offX || 0));
204
+ const oy = Math.max(-maxOffY, Math.min(maxOffY, offY || 0));
205
+ const dx = (W - dw) / 2 + ox;
206
+ const dy = (H - dh) / 2 + oy;
207
+ return { dw, dh, dx, dy, maxOffX, maxOffY };
208
+ }
209
+
210
+ // AI 재생성이 아닌 무손실 합성: 원본 가장자리 1px 열/행을 늘려 붙여 여백을 채움 (그라데이션 줄 단위 연속)
211
+ function renderExportCanvas(ctx, img, W, H, zoom, offX, offY, withLabel) {
212
+ const sw = img.naturalWidth, sh = img.naturalHeight;
213
+ const { dw, dh, dx, dy } = exportLayout(sw, sh, W, H, zoom, offX, offY);
214
+ ctx.clearRect(0, 0, W, H);
215
+ ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
216
+ ctx.drawImage(img, dx, dy, dw, dh);
217
+ if (dx > 0.5) { // 좌우 여백 → 가장자리 1px '열' 연장
218
+ const padL = Math.ceil(dx), padR = Math.ceil(W - (dx + dw));
219
+ ctx.drawImage(img, 0, 0, 1, sh, 0, dy, padL, dh);
220
+ if (padR > 0) ctx.drawImage(img, sw - 1, 0, 1, sh, W - padR, dy, padR, dh);
221
+ }
222
+ if (withLabel) drawAiDisclosureOnCanvas(ctx, W, H);
223
+ }
224
+
225
+ function ChannelExportModal({ result, aiLabel, onClose }) {
226
+ const [preset, setPreset] = useState(EXPORT_PRESETS[0]);
227
+ const [zoom, setZoom] = useState(100);
228
+ const [offX, setOffX] = useState(0);
229
+ const [offY, setOffY] = useState(0);
230
+ const [img, setImg] = useState(null);
231
+ const canvasRef = useRef(null);
232
+ const dragRef = useRef(null);
233
+
234
+ useEffect(() => {
235
+ const im = new Image();
236
+ im.onload = () => setImg(im);
237
+ im.src = result.imageUrl;
238
+ }, [result]);
239
+
240
+ useEffect(() => {
241
+ const cv = canvasRef.current;
242
+ if (!cv || !img) return;
243
+ cv.width = preset.w; cv.height = preset.h;
244
+ renderExportCanvas(cv.getContext('2d'), img, preset.w, preset.h, zoom / 100, offX, offY, aiLabel);
245
+ }, [img, preset, zoom, offX, offY, aiLabel]);
246
+
247
+ const layout = img ? exportLayout(img.naturalWidth, img.naturalHeight, preset.w, preset.h, zoom / 100, offX, offY) : null;
248
+ const draggable = !!(layout && (layout.maxOffX > 0.5 || layout.maxOffY > 0.5));
249
+
250
+ const onPointerDown = (e) => {
251
+ if (!draggable) return;
252
+ e.currentTarget.setPointerCapture(e.pointerId);
253
+ dragRef.current = { x: e.clientX, y: e.clientY, ox: offX, oy: offY,
254
+ scale: preset.h / e.currentTarget.getBoundingClientRect().height };
255
+ };
256
+ const onPointerMove = (e) => {
257
+ const d = dragRef.current;
258
+ if (!d || !layout) return;
259
+ const nx = d.ox + (e.clientX - d.x) * d.scale;
260
+ const ny = d.oy + (e.clientY - d.y) * d.scale;
261
+ setOffX(Math.max(-layout.maxOffX, Math.min(layout.maxOffX, nx)));
262
+ setOffY(Math.max(-layout.maxOffY, Math.min(layout.maxOffY, ny)));
263
+ };
264
+ const onPointerUp = () => { dragRef.current = null; };
265
+ const resetView = () => { setZoom(100); setOffX(0); setOffY(0); };
266
+ const selectPreset = (p) => { setPreset(p); setOffX(0); setOffY(0); };
267
+
268
+ const doDownload = () => {
269
+ if (!img) return;
270
+ const cv = document.createElement('canvas');
271
+ cv.width = preset.w; cv.height = preset.h;
272
+ renderExportCanvas(cv.getContext('2d'), img, preset.w, preset.h, zoom / 100, offX, offY, aiLabel);
273
+ const a = document.createElement('a');
274
+ a.href = cv.toDataURL('image/png');
275
+ a.download = `spao_${result.meta.shot || '컷'}_${result.meta.angle || ''}_${result.id}_${preset.w}x${preset.h}.png`;
276
+ a.click();
277
+ };
278
+
279
+ return h('div', { className: 'fixed inset-0 z-[60] bg-black/85 flex items-center justify-center p-4 animate-fadeIn', onClick: onClose },
280
+ h('div', { className: 'bg-[#202020] border border-[#333] rounded-2xl w-full max-w-4xl max-h-[94vh] overflow-y-auto p-5', onClick: e => e.stopPropagation() },
281
+ h('div', { className: 'flex items-center justify-between mb-4' },
282
+ h('h3', { className: 'text-[14.5px] font-semibold text-gray-100' }, '채널 사이즈 다운로드'),
283
+ h('button', { type: 'button', onClick: onClose, className: 'w-8 h-8 rounded-lg hover:bg-[#2a2a2a] text-gray-400 hover:text-white flex items-center justify-center' }, h(X, { size: 16 }))),
284
+ h('div', { className: 'flex flex-nowrap gap-1.5 mb-4 overflow-x-auto pb-1' },
285
+ EXPORT_PRESETS.map(p => h('button', { key: p.id, type: 'button', onClick: () => selectPreset(p), title: `${p.w}×${p.h}`,
286
+ className: `shrink-0 whitespace-nowrap px-2.5 py-1.5 rounded-lg text-[11.5px] font-medium transition-colors ${preset.id === p.id ? 'bg-brand-blue text-white' : 'bg-[#2a2a2a] text-gray-300 hover:text-white'}` },
287
+ `${p.label} `, h('span', { className: `text-[10.5px] ${preset.id === p.id ? 'text-blue-200' : 'text-gray-500'}` }, `${p.w}×${p.h}`)))),
288
+ h('div', { className: 'flex justify-center bg-[#181818] rounded-xl p-3 mb-4' },
289
+ h('canvas', { ref: canvasRef, onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp,
290
+ className: 'max-h-[52vh] max-w-full h-auto rounded-md bg-white select-none',
291
+ style: { cursor: draggable ? 'grab' : 'default', touchAction: 'none', aspectRatio: `${preset.w} / ${preset.h}` } })),
292
+ h('div', { className: 'flex items-center gap-3 mb-1.5' },
293
+ h('span', { className: 'text-[12px] text-gray-400 shrink-0' }, '모델 크기'),
294
+ h('input', { type: 'range', min: 100, max: 180, step: 1, value: zoom,
295
+ onChange: e => setZoom(Number(e.target.value)), className: 'flex-1 accent-[#3b82f6]' }),
296
+ h('span', { className: 'text-[12px] text-gray-300 w-11 text-right' }, `${zoom}%`),
297
+ h('button', { type: 'button', onClick: resetView, className: 'px-2.5 py-1 rounded-lg text-[11.5px] bg-[#2a2a2a] hover:bg-[#333] text-gray-300' }, '초기화')),
298
+ h('p', { className: 'text-[11px] text-gray-500 mb-4' },
299
+ draggable ? '미리보기를 끌어서 위치를 맞출 수 있어요. ' : '',
300
+ '가로 여백은 AI 재생성 없이 배경 가장자리를 연장해 채우고, 원본보다 세로로 긴 채널은 좌우 배경을 살짝 잘라 맞춥니다.',
301
+ aiLabel ? " 'AI 생성 이미지' 표기 포함." : ''),
302
+ h('div', { className: 'flex justify-end gap-2' },
303
+ h('button', { type: 'button', onClick: doDownload, disabled: !img,
304
+ className: 'h-10 px-5 rounded-xl bg-brand-blue hover:bg-brand-hover disabled:opacity-50 text-white text-[13px] font-semibold flex items-center gap-1.5' },
305
+ h(Download, { size: 15 }), `${preset.w}×${preset.h} 저장`))));
306
+ }
307
+
308
+ // ═══════════════════════════════════════════════════════════
309
+ // UI atoms
310
+ // ═══════════════════════════════════════════════════════════
311
+ const SectionTitle = ({ children, right }) => h('div', { className: 'flex items-center justify-between mb-2.5' },
312
+ h('h3', { className: 'text-[13px] font-semibold text-gray-200 tracking-tight' }, children), right || null);
313
+
314
+ const Segmented = ({ options, value, onChange, cols }) => h('div', {
315
+ className: `grid gap-1.5`, style: { gridTemplateColumns: `repeat(${cols || options.length}, minmax(0, 1fr))` } },
316
+ options.map(o => h('button', {
317
+ key: o, type: 'button', onClick: () => onChange(o),
318
+ className: `px-2 py-2 rounded-lg text-[12.5px] font-medium border transition-colors ${value === o
319
+ ? 'bg-brand-blue border-brand-blue text-white'
320
+ : 'bg-[#242424] border-[#333] text-gray-300 hover:border-[#4b5563] hover:text-white'}`
321
+ }, o)));
322
+
323
+ // ── 디테일 참조: 클로즈업을 생성 모델에 직접 전달 (전체 최대 4장). 키는 서버 DETAIL_KIND_EN과 1:1 ──
324
+ const DETAIL_MAX = 4;
325
+ const DETAIL_SLOTS = ['a', 'b'];
326
+ const K = {
327
+ texture: { key: 'texture', short: '조직감', label: '조직감 (원단 전체 적용)' },
328
+ material: { key: 'material', short: '소재', label: '소재 (가죽·스웨이드·메시 등, 전체 적용)' },
329
+ logo: { key: 'logo', short: '로고', label: '로고·프린트 (같은 위치 재현)' },
330
+ neckline: { key: 'neckline', short: '넥라인', label: '넥라인' },
331
+ collar: { key: 'collar', short: '카라·후드', label: '카라·후드·라펠' },
332
+ sleeve: { key: 'sleeve', short: '소매', label: '소매·커프스' },
333
+ hem: { key: 'hem', short: '밑단', label: '밑단·헴' },
334
+ pocket: { key: 'pocket', short: '포켓', label: '포켓' },
335
+ closure: { key: 'closure', short: '지퍼·버튼', label: '지퍼·버튼·스냅' },
336
+ trim: { key: 'trim', short: '부자재', label: '부자재 (라벨·드로스트링 등)' },
337
+ waist: { key: 'waist', short: '허리', label: '허리 (웨이스트밴드·벨트루프)' },
338
+ sole: { key: 'sole', short: '밑창', label: '밑창' },
339
+ lace: { key: 'lace', short: '끈', label: '끈·아일렛·설포' },
340
+ heel: { key: 'heel', short: '뒤축', label: '뒤축·힐탭' },
341
+ buckle: { key: 'buckle', short: '버클', label: '버클' },
342
+ stitch: { key: 'stitch', short: '스티치', label: '스티치·엣지·벨트홀' },
343
+ };
344
+ const DETAIL_KINDS_BY_GARMENT = {
345
+ outer: [K.texture, K.logo, K.collar, K.sleeve, K.hem, K.pocket, K.closure, K.trim],
346
+ top: [K.texture, K.logo, K.neckline, K.sleeve, K.hem, K.pocket, K.trim],
347
+ bottom: [K.texture, K.logo, K.waist, K.hem, K.pocket, K.closure, K.trim],
348
+ shoes: [K.material, K.logo, K.sole, K.lace, K.heel, K.trim],
349
+ inner: [K.texture, K.logo, K.neckline, K.sleeve, K.hem],
350
+ belt: [K.material, K.buckle, K.logo, K.stitch],
351
+ };
352
+ const DETAIL_GARMENTS = [
353
+ { key: 'outer', label: '아우터' }, { key: 'top', label: '상의' }, { key: 'bottom', label: '하의' },
354
+ { key: 'shoes', label: '신발' }, { key: 'inner', label: '이너웨어' }, { key: 'belt', label: '벨트' },
355
+ ];
356
+ const defaultKind = (g, slot) => {
357
+ const list = DETAIL_KINDS_BY_GARMENT[g] || [];
358
+ if (slot === 'a') return list[0]?.key;
359
+ return g === 'belt' ? 'buckle' : (list.find(k => k.key === 'logo') || list[1] || list[0])?.key;
360
+ };
361
+
362
+ const UploadTile = ({ slot, value, onChange, disabled }) => {
363
+ const inputRef = useRef(null);
364
+ const [drag, setDrag] = useState(false);
365
+ const handleFiles = async (files) => {
366
+ const f = files?.[0]; if (!f || !f.type.startsWith('image/')) return;
367
+ onChange(await fileToDataUrl(f), f.name);
368
+ };
369
+ return h('div', {
370
+ className: `relative rounded-xl border overflow-hidden group ${drag ? 'drop-active' : ''} ${value ? 'border-[#3a3a3a]' : 'border-dashed border-[#3a3a3a] bg-[#212121] hover:border-[#4b5563]'}`,
371
+ style: { aspectRatio: '3 / 4' },
372
+ onDragOver: e => { e.preventDefault(); setDrag(true); }, onDragLeave: () => setDrag(false),
373
+ onDrop: e => { e.preventDefault(); setDrag(false); if (!disabled) handleFiles(e.dataTransfer.files); },
374
+ onClick: () => !disabled && !value && inputRef.current?.click(),
375
+ },
376
+ h('input', { ref: inputRef, type: 'file', accept: 'image/*', className: 'hidden', onChange: e => { handleFiles(e.target.files); e.target.value = ''; } }),
377
+ value
378
+ ? h(React.Fragment, null,
379
+ h('img', { src: value, className: 'w-full h-full object-cover bg-white', alt: slot.label }),
380
+ !disabled && h('button', { type: 'button', onClick: e => { e.stopPropagation(); onChange(null, null); },
381
+ className: 'absolute top-1.5 right-1.5 w-6 h-6 rounded-full bg-black/70 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity' }, h(X, { size: 13 })),
382
+ h('div', { className: 'absolute bottom-0 inset-x-0 px-2 py-1 bg-gradient-to-t from-black/70 to-transparent text-[11px] text-white' }, slot.label))
383
+ : h('div', { className: 'absolute inset-0 flex flex-col items-center justify-center gap-1 text-gray-500 cursor-pointer' },
384
+ h(ImagePlus, { size: 18 }),
385
+ h('span', { className: 'text-[11.5px] text-gray-300' }, slot.label, slot.required && h('span', { className: 'text-brand-blue ml-0.5' }, '*')),
386
+ slot.hint && h('span', { className: 'text-[10px] text-gray-500 px-2 text-center leading-tight' }, slot.hint))
387
+ );
388
+ };
389
+
390
+ const FaceTile = ({ preset, selected, onClick }) => h('button', {
391
+ type: 'button', onClick, title: preset.label,
392
+ className: `relative rounded-xl overflow-hidden border-2 transition-colors ${selected ? 'border-brand-blue' : 'border-transparent hover:border-[#4b5563]'}`,
393
+ style: { aspectRatio: '1 / 1' } },
394
+ h('img', { src: preset.url, className: 'w-full h-full object-cover', alt: preset.label }),
395
+ selected && h('div', { className: 'absolute top-1 right-1 w-5 h-5 rounded-full bg-brand-blue flex items-center justify-center' }, h(Check, { size: 12, className: 'text-white' })),
396
+ h('div', { className: 'absolute bottom-0 inset-x-0 px-1.5 py-0.5 bg-black/60 text-[10.5px] text-white text-center' }, preset.label));
397
+
398
+ // ═══════════════════════════════════════════════════════════
399
+ // App
400
+ // ═══════════════════════════════════════════════════════════
401
+ function App() {
402
+ const [user, setUser] = useState({ username: 'anonymous', is_logged_in: false, oauth_enabled: false });
403
+ const [health, setHealth] = useState(null);
404
+
405
+ const [age, setAge] = useState('성인');
406
+ const [facePreset, setFacePreset] = useState('preset_9');
407
+ const [faceUpload, setFaceUpload] = useState(null);
408
+ const [shot, setShot] = useState('전신컷');
409
+ const [angle, setAngle] = useState('반측면');
410
+ const [gaze, setGaze] = useState('자연스러운 시선');
411
+ const [prompt, setPrompt] = useState('');
412
+ const [details, setDetails] = useState(Object.fromEntries(Object.entries(DETAIL_OPTIONS).map(([k, v]) => [k, v.def])));
413
+ const [detailTab, setDetailTab] = useState('top');
414
+ const [detailKinds, setDetailKinds] = useState({}); // 'top:a' → 부위 키
415
+ const [images, setImages] = useState({});
416
+ const [imageNames, setImageNames] = useState({}); // 슬롯별 원본 파일명 (Dataset 로그·스타일코드 집계용)
417
+ const [faceUploadName, setFaceUploadName] = useState(null);
418
+
419
+ const [jobs, setJobs] = useState([]); // 진행 중/실패 작업 [{id, jobId, meta, stageLabel, startedAt, error}]
420
+ const [tick, setTick] = useState(0); // 경과시간 표시용 1초 타이머
421
+ const [results, setResults] = useState([]); // [{id, imageUrl, meta, elapsedSec}]
422
+ const [current, setCurrent] = useState(null);
423
+ const [error, setError] = useState(null);
424
+ const [aiLabel, setAiLabel] = useState(true);
425
+ const [lightbox, setLightbox] = useState(false); // 결과 이미지 확대 보기
426
+ const [exportFor, setExportFor] = useState(null); // 채널 사이즈 다운로드 모달 대상
427
+ const [galleryOpen, setGalleryOpen] = useState(false); // 지난 기록 전체 화면 갤러리
428
+ const [gFilter, setGFilter] = useState({ shot: '', angle: '', date: '', favOnly: false, q: '' });
429
+ const [gGroup, setGGroup] = useState('date'); // 갤러리 그룹 기준: date | style
430
+ const [gSelected, setGSelected] = useState({}); // 갤러리 선택 (호버 체크로 바로 선택)
431
+ const [zipBusy, setZipBusy] = useState(false);
432
+ const [zipModal, setZipModal] = useState(false); // ZIP 채널 사이즈 선택 모달
433
+ const [zipSizes, setZipSizes] = useState({ orig: true }); // ZIP에 담을 채널 (복수 선택)
434
+ const [favs, setFavs] = useState(() => { try { return JSON.parse(localStorage.getItem('spao_favs') || '{}'); } catch { return {}; } });
435
+ const [history, setHistory] = useState(null); // 지난 기록 (null = 미로드)
436
+ const [historyLoading, setHistoryLoading] = useState(false);
437
+ const [historyError, setHistoryError] = useState(null);
438
+ const [selectMode, setSelectMode] = useState(false); // 세션 결과 다중 선택 모드
439
+ const [selected, setSelected] = useState({}); // 선택된 결과 id 집합
440
+ useEffect(() => {
441
+ if (!lightbox) return;
442
+ const onKey = (e) => { if (e.key === 'Escape') setLightbox(false); };
443
+ window.addEventListener('keydown', onKey);
444
+ return () => window.removeEventListener('keydown', onKey);
445
+ }, [lightbox]);
446
+ const faceFileRef = useRef(null);
447
+
448
+ useEffect(() => {
449
+ fetch('/api/whoami').then(r => r.ok ? r.json() : null).then(d => d && setUser(d)).catch(() => {});
450
+ fetch('/api/comfy/health').then(r => r.ok ? r.json() : { ok: false, error: '브리지 라우트가 없습니다' }).then(setHealth).catch(() => setHealth({ ok: false, error: '상태 확인 실패' }));
451
+ }, []);
452
+
453
+ const activeJobs = jobs.filter(j => !j.error);
454
+ useEffect(() => {
455
+ if (activeJobs.length === 0) return;
456
+ const id = setInterval(() => setTick(t => t + 1), 1000);
457
+ return () => clearInterval(id);
458
+ }, [activeJobs.length]);
459
+
460
+ useEffect(() => { // 연령 바꾸면 해당 그룹 첫 프리셋으로
461
+ const list = age === '키즈' ? KIDS_FACE_PRESETS : ADULT_FACE_PRESETS;
462
+ if (!faceUpload && !list.some(p => p.id === facePreset)) setFacePreset(list[0].id);
463
+ }, [age]);
464
+
465
+ const presets = age === '키즈' ? KIDS_FACE_PRESETS : ADULT_FACE_PRESETS;
466
+ const kindOf = (g, slot) => { // 저장된 값이 그 옷 종류에 없으면 기본값으로
467
+ const list = DETAIL_KINDS_BY_GARMENT[g] || [];
468
+ const v = detailKinds[`${g}:${slot}`];
469
+ return list.some(k => k.key === v) ? v : defaultKind(g, slot);
470
+ };
471
+ const detailList = DETAIL_GARMENTS.flatMap(g => DETAIL_SLOTS.map(slot => ({
472
+ garment: g.key, slot, kind: kindOf(g.key, slot),
473
+ image: images[`detail:${g.key}:${slot}`], filename: imageNames[`detail:${g.key}:${slot}`],
474
+ }))).filter(x => x.image && images[x.garment]).slice(0, DETAIL_MAX);
475
+
476
+ const setImg = (key, v, name) => { setImages(prev => ({ ...prev, [key]: v })); setImageNames(prev => ({ ...prev, [key]: v ? (name || null) : null })); };
477
+
478
+ const missing = [];
479
+ if (!images.outer && !images.top && !images.bottom) missing.push('의상 1개 이상 (아우터·상의·하의)');
480
+ if (shot === '레퍼런스 구도 그대로' && !images.pose) missing.push('레퍼런스 포즈');
481
+ const canGenerate = missing.length === 0 && (faceUpload || facePreset) && health?.ok;
482
+
483
+ const startJob = async (payload, meta) => {
484
+ const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
485
+ setJobs(prev => [...prev, { id, meta, stageLabel: '요청 전송 중', startedAt: Date.now() }]);
486
+ const patch = (p) => setJobs(prev => prev.map(j => j.id === id ? { ...j, ...p } : j));
487
+ let jobId;
488
+ try {
489
+ jobId = await submitToComfy(payload);
490
+ } catch (e) {
491
+ patch({ error: e.message }); return;
492
+ }
493
+ patch({ jobId, stageLabel: '대기 중' });
494
+ await trackJob(id, jobId, meta, patch, payload);
495
+ };
496
+
497
+ const handleGenerate = () => {
498
+ if (user.oauth_enabled && !user.is_logged_in) { alert("로그인이 필요합니다. 우측 상단의 'Hugging Face로 로그인' 버튼을 눌러주세요."); return; }
499
+ if (!canGenerate) return;
500
+ setError(null);
501
+ const meta = { shot, angle, gaze, age, prompt, face: faceUpload ? '업로드' : (presets.find(p => p.id === facePreset)?.label || facePreset) };
502
+ const payload = { face: faceUpload || facePreset, faceFilename: faceUpload ? faceUploadName : null, images, imageFilenames: imageNames,
503
+ options: { shot, angle, gaze, age, ...details }, prompt, meta,
504
+ details: detailList.map(x => ({ garment: x.garment, kind: x.kind, image: x.image, filename: x.filename })) };
505
+ startJob(payload, meta);
506
+ };
507
+
508
+ // 세션 결과에 보관된 payload 로 동일 조건 재생성 (현재 폼 상태는 건드리지 않음)
509
+ const regenFrom = (r) => { if (r.payload) { setError(null); startJob(r.payload, r.meta); } };
510
+
511
+ // 진행 중 작업 취소: 서버가 폴링을 멈추고 Comfy Cloud 실행도 중단 요청
512
+ const cancelJob = async (j) => {
513
+ if (!j.jobId || j.cancelling) return;
514
+ setJobs(prev => prev.map(x => x.id === j.id ? { ...x, cancelling: true, stageLabel: '취소 중…' } : x));
515
+ try { await fetch(`/api/comfy/jobs/${j.jobId}/cancel`, { method: 'POST', headers: apiHeaders() }); } catch {}
516
+ };
517
+
518
+ // 세션 결과 삭제: 화면 + 서버 보관분(새로고침 복구 대상) 모두 제거
519
+ const deleteResult = async (r) => {
520
+ if (!confirm('이 결과를 지울까요? 되돌릴 수 없어요.')) return;
521
+ setResults(prev => {
522
+ const next = prev.filter(x => x.id !== r.id);
523
+ setCurrent(cur => (cur?.id === r.id ? (next[0] || null) : cur));
524
+ return next;
525
+ });
526
+ try { if (r.jobId) await fetch(`/api/comfy/jobs/${r.jobId}`, { method: 'DELETE', headers: apiHeaders() }); } catch {}
527
+ };
528
+
529
+ // 지난 기록: HF Dataset 영구 저장분에서 본인 기록만 (최근 60건)
530
+ const loadHistory = async (refresh = false) => {
531
+ if (historyLoading) return;
532
+ setHistoryLoading(true); setHistoryError(null);
533
+ try {
534
+ const r = await fetch(`/api/my/history${refresh ? '?refresh=1' : ''}`, { headers: apiHeaders() });
535
+ if (r.status === 401) throw new Error('로그인하면 지난 기록을 볼 수 있어요.');
536
+ if (!r.ok) throw new Error(`기록을 불러오지 못했습니다 (HTTP ${r.status})`);
537
+ const d = await r.json();
538
+ setHistory((d.items || []).filter(it => it.output_url || it.thumb_url).map(it => ({
539
+ id: `h_${it.ts}`, ts: String(it.ts || ''), day: String(it.ts || '').slice(0, 8),
540
+ codes: it.style_codes || [], files: it.filenames || [], colors: it.style_colors || [],
541
+ imageUrl: it.output_url || it.thumb_url,
542
+ thumbUrl: it.thumb_url || it.output_url,
543
+ meta: { shot: it.shot || '', angle: it.angle || '', gaze: it.gaze || '', age: '',
544
+ prompt: (it.custom_prompt && it.custom_prompt !== '(none)') ? it.custom_prompt : '' },
545
+ elapsedSec: it.elapsed, history: true, timeLabel: it.time || '',
546
+ })));
547
+ } catch (e) { setHistoryError(e.message); }
548
+ finally { setHistoryLoading(false); }
549
+ };
550
+ const openGallery = () => { setGalleryOpen(true); setGSelected({}); if (history === null) loadHistory(); };
551
+
552
+ const toggleFav = (ts) => setFavs(prev => {
553
+ const next = { ...prev, [ts]: !prev[ts] };
554
+ if (!next[ts]) delete next[ts];
555
+ try { localStorage.setItem('spao_favs', JSON.stringify(next)); } catch {}
556
+ return next;
557
+ });
558
+
559
+ // 기록에서 보기: 뷰어로 가져오고 갤러리 닫기
560
+ const viewHistoryItem = (r) => { setCurrent(r); setGalleryOpen(false); };
561
+
562
+ // 기록 삭제: HF Dataset에서 실제 삭제 (통계 집계에서도 빠짐)
563
+ const deleteHistoryItems = async (items) => {
564
+ if (!items.length) return;
565
+ if (!confirm(`선택한 ${items.length}개 기록을 지난 기록에서 지울까요? 통계 대시보드 집계에는 그대로 남아요.`)) return;
566
+ const ids = items.map(r => r.id);
567
+ setHistory(prev => prev.filter(r => !ids.includes(r.id)));
568
+ setCurrent(cur => (cur && ids.includes(cur.id)) ? null : cur);
569
+ setGSelected({});
570
+ for (const r of items) {
571
+ try { await fetch(`/api/my/history/${r.ts}`, { method: 'DELETE', headers: apiHeaders() }); } catch {}
572
+ }
573
+ };
574
+
575
+ // 기록 재생성: 서버가 Dataset에 저장된 입력 이미지·옵션으로 payload를 복원
576
+ const regenHistory = async (r) => {
577
+ try {
578
+ const res = await fetch(`/api/my/history/${r.ts}/payload`, { headers: apiHeaders() });
579
+ if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.detail || `재생성 정보를 불러오지 못했습니다 (HTTP ${res.status})`); }
580
+ const pl = await res.json();
581
+ const meta = { shot: pl.options?.shot || '', angle: pl.options?.angle || '', gaze: pl.options?.gaze || '',
582
+ age: pl.options?.age || '', prompt: pl.prompt || '', face: pl.faceLabel || '기록' };
583
+ setGalleryOpen(false); setError(null);
584
+ startJob({ face: pl.face, faceFilename: pl.faceFilename || null, images: pl.images || {},
585
+ imageFilenames: pl.imageFilenames || {}, options: pl.options || {}, prompt: pl.prompt || '', meta }, meta);
586
+ } catch (e) { alert(e.message); }
587
+ };
588
+
589
+ // 선택 항목 ZIP 다운로드 — 선택한 채널 사이즈별로 변환해 한 번에 담음 (AI 표기 토글 반영)
590
+ const loadImg = (url) => new Promise((resolve, reject) => {
591
+ const im = new Image();
592
+ im.onload = () => resolve(im);
593
+ im.onerror = () => reject(new Error('이미지 로드 실패'));
594
+ im.src = url;
595
+ });
596
+
597
+ const canvasToBlob = (cv) => new Promise((resolve, reject) =>
598
+ cv.toBlob(b => b ? resolve(b) : reject(new Error('변환 실패')), 'image/png'));
599
+
600
+ const downloadZip = async (items) => {
601
+ const sizes = EXPORT_PRESETS.filter(p => zipSizes[p.id]);
602
+ if (!items.length || !sizes.length || zipBusy) return;
603
+ setZipBusy(true);
604
+ try {
605
+ if (!window.JSZip) throw new Error('ZIP 모듈을 불러오지 못했습니다. 새로고침 후 다시 시도해주세요.');
606
+ const zip = new window.JSZip();
607
+ const cv = document.createElement('canvas');
608
+ const ctx = cv.getContext('2d');
609
+ for (const r of items) {
610
+ const im = await loadImg(r.imageUrl);
611
+ const base = `spao_${r.ts}_${(r.meta.shot || '컷').replace(/[\/:*?"<>|]/g, '')}`;
612
+ for (const p of sizes) {
613
+ cv.width = p.w; cv.height = p.h;
614
+ renderExportCanvas(ctx, im, p.w, p.h, 1, 0, 0, aiLabel); // 100% 기준(전체 포함) 변환
615
+ const blob = await canvasToBlob(cv);
616
+ const name = `${base}_${p.w}x${p.h}.png`;
617
+ zip.file(sizes.length > 1 ? `${p.w}x${p.h}/${name}` : name, blob);
618
+ }
619
+ }
620
+ const out = await zip.generateAsync({ type: 'blob' });
621
+ const a = document.createElement('a');
622
+ a.href = URL.createObjectURL(out);
623
+ a.download = sizes.length > 1
624
+ ? `spao_기록_${items.length}장_${sizes.length}개사이즈.zip`
625
+ : `spao_기록_${items.length}장_${sizes[0].w}x${sizes[0].h}.zip`;
626
+ a.click();
627
+ setTimeout(() => URL.revokeObjectURL(a.href), 8000);
628
+ setGSelected({}); setZipModal(false);
629
+ } catch (e) { alert(e.message); }
630
+ setZipBusy(false);
631
+ };
632
+
633
+ const toggleSelectMode = () => { setSelectMode(v => !v); setSelected({}); };
634
+ const toggleSelected = (id) => setSelected(prev => ({ ...prev, [id]: !prev[id] }));
635
+ const selectedIds = Object.keys(selected).filter(k => selected[k]);
636
+
637
+ const deleteSelected = async () => {
638
+ if (!selectedIds.length) return;
639
+ if (!confirm(`선택한 ${selectedIds.length}개 결과를 지울까요? 되돌릴 수 없어요.`)) return;
640
+ const items = results.filter(r => selectedIds.includes(r.id));
641
+ setResults(prev => {
642
+ const next = prev.filter(r => !selectedIds.includes(r.id));
643
+ setCurrent(cur => (cur && selectedIds.includes(cur.id)) ? (next[0] || null) : cur);
644
+ return next;
645
+ });
646
+ setSelected({}); setSelectMode(false);
647
+ for (const r of items) {
648
+ try { if (r.jobId) await fetch(`/api/comfy/jobs/${r.jobId}`, { method: 'DELETE', headers: apiHeaders() }); } catch {}
649
+ }
650
+ };
651
+
652
+ // 서버 작업을 완료까지 따라가며 결과/에러를 반영 (새 요청과 새로고침 복구 둘 다 사용)
653
+ const trackJob = async (id, jobId, meta, patch, payload) => {
654
+ try {
655
+ const j = await pollComfyJob(jobId, (st) => patch({ stageLabel: st.stageLabel, execStartedAt: st.execStartedAt || null }));
656
+ const item = { id, jobId, imageUrl: j.imageUrl, meta, elapsedSec: j.elapsedSec, createdAt: j.createdAt, payload };
657
+ setResults(prev => prev.some(r => r.id === id) ? prev : [item, ...prev]);
658
+ setCurrent(prev => prev || item);
659
+ setJobs(prev => prev.filter(x => x.id !== id));
660
+ } catch (e) {
661
+ if (e.cancelled) { setJobs(prev => prev.filter(x => x.id !== id)); return; }
662
+ patch({ error: e.message });
663
+ }
664
+ };
665
+ const dismissJob = (id) => setJobs(prev => prev.filter(j => j.id !== id));
666
+
667
+ // 새로고침/재접속 시 서버에 남아 있는 내 작업 복구
668
+ useEffect(() => {
669
+ (async () => {
670
+ try {
671
+ const r = await fetch('/api/comfy/jobs', { headers: apiHeaders() });
672
+ if (!r.ok) return;
673
+ const { jobs: saved } = await r.json();
674
+ const done = saved.filter(j => j.status === 'done' && j.imageUrl)
675
+ .map(j => ({ id: j.id, jobId: j.id, imageUrl: j.imageUrl, meta: j.meta || {}, elapsedSec: j.elapsedSec, createdAt: j.createdAt }))
676
+ .sort((a, b) => b.createdAt - a.createdAt);
677
+ if (done.length) { setResults(done); setCurrent(prev => prev || done[0]); }
678
+ const pending = saved.filter(j => j.status !== 'done' && j.status !== 'cancelled');
679
+ if (pending.length) {
680
+ setJobs(pending.map(j => ({ id: j.id, jobId: j.id, meta: j.meta || {}, stageLabel: j.stageLabel, startedAt: j.createdAt, execStartedAt: j.execStartedAt || null, error: j.error || null })));
681
+ for (const j of pending.filter(x => x.status === 'running')) {
682
+ const patch = (p) => setJobs(prev => prev.map(x => x.id === j.id ? { ...x, ...p } : x));
683
+ trackJob(j.id, j.id, j.meta || {}, patch);
684
+ }
685
+ }
686
+ } catch {}
687
+ })();
688
+ }, []);
689
+
690
+ // ── Left: settings ────────────────────────────────────────
691
+ const left = h('aside', { className: 'w-full lg:w-[420px] xl:w-[460px] shrink-0 h-full overflow-y-auto border-r border-[#2a2a2a] bg-[#1e1e1e]' },
692
+ h('div', { className: 'p-5 space-y-6' },
693
+
694
+ h('section', null,
695
+ h(SectionTitle, { right: h(Segmented, { options: AGE_OPTIONS, value: age, onChange: setAge }) }, '얼굴'),
696
+ h('div', { className: 'grid grid-cols-4 gap-2' },
697
+ presets.map(p => h(FaceTile, { key: p.id, preset: p, selected: !faceUpload && facePreset === p.id, onClick: () => { setFaceUpload(null); setFacePreset(p.id); } })),
698
+ h('button', { type: 'button', onClick: () => faceFileRef.current?.click(),
699
+ className: `relative rounded-xl overflow-hidden border-2 ${faceUpload ? 'border-brand-blue' : 'border-dashed border-[#3a3a3a] hover:border-[#4b5563] bg-[#242424]'}`, style: { aspectRatio: '1 / 1' } },
700
+ faceUpload
701
+ ? h(React.Fragment, null, h('img', { src: faceUpload, className: 'w-full h-full object-cover' }),
702
+ h('span', { onClick: e => { e.stopPropagation(); setFaceUpload(null); }, className: 'absolute top-1 right-1 w-5 h-5 rounded-full bg-black/70 flex items-center justify-center' }, h(X, { size: 11 })),
703
+ h('div', { className: 'absolute bottom-0 inset-x-0 py-0.5 bg-black/60 text-[10.5px] text-center' }, '업로드'))
704
+ : h('div', { className: 'absolute inset-0 flex flex-col items-center justify-center gap-1 text-gray-400' }, h(User, { size: 16 }), h('span', { className: 'text-[10.5px]' }, '직접 업로드'))),
705
+ h('input', { ref: faceFileRef, type: 'file', accept: 'image/*', className: 'hidden', onChange: async e => { const f = e.target.files?.[0]; if (f) { setFaceUpload(await fileToDataUrl(f, 1024)); setFaceUploadName(f.name); } e.target.value = ''; } }))),
706
+
707
+ h('section', null, h(SectionTitle, null, '샷 선택'), h(Segmented, { options: SHOT_OPTIONS, value: shot, onChange: setShot, cols: 2 })),
708
+ h('section', null, h(SectionTitle, null, '카메라 앵글'), h(Segmented, { options: ANGLE_OPTIONS, value: angle, onChange: setAngle, cols: 3 })),
709
+ h('section', null, h(SectionTitle, null, '시선'), h(Segmented, { options: GAZE_OPTIONS, value: gaze, onChange: setGaze })),
710
+
711
+ h('section', null,
712
+ h(SectionTitle, null, '프롬프트 작성'),
713
+ h('textarea', { value: prompt, onChange: e => setPrompt(e.target.value), rows: 3,
714
+ placeholder: '추가 지시사항 (선택). 예: 손을 주머니에 넣고 살짝 미소, 소매를 한 번 접어 올린 스타일링',
715
+ className: 'w-full rounded-xl bg-[#242424] border border-[#333] px-3 py-2.5 text-[13px] text-gray-100 placeholder-gray-500 focus:outline-none focus:border-brand-blue' })),
716
+
717
+ h('section', null,
718
+ h(SectionTitle, { right: h('span', { className: 'text-[11px] text-gray-500' }, '아우터·상의·하의 중 1개 이상') }, '이미지 업로드'),
719
+ h('div', { className: 'grid grid-cols-4 gap-2' },
720
+ IMAGE_SLOTS.map(s => h(UploadTile, { key: s.key, slot: s, value: images[s.key], onChange: (v, name) => setImg(s.key, v, name) })))),
721
+
722
+ h('section', null,
723
+ h(SectionTitle, null, '길이 · 핏'),
724
+ h('div', { className: 'flex gap-1.5 mb-3' },
725
+ DETAIL_GROUPS.map(g => h('button', {
726
+ key: g.key, type: 'button', onClick: () => setDetailTab(g.key),
727
+ className: `px-3 py-1.5 rounded-lg text-[12.5px] font-semibold transition-colors ${detailTab === g.key
728
+ ? 'bg-brand-blue text-white'
729
+ : (images[g.key] ? 'bg-[#2a2a2a] text-gray-200 hover:bg-[#333]' : 'bg-[#242424] text-gray-500 hover:text-gray-300')}`
730
+ }, g.label, images[g.key] ? h('span', { className: `ml-1.5 inline-block w-1.5 h-1.5 rounded-full align-middle ${detailTab === g.key ? 'bg-white' : 'bg-brand-blue'}` }) : null))),
731
+ (() => {
732
+ const g = DETAIL_GROUPS.find(x => x.key === detailTab);
733
+ const rows = [['길이', g.length], ['핏', g.fit]];
734
+ return h('div', { key: g.key, className: 'space-y-2 animate-fadeIn' },
735
+ rows.map(([label, k]) => h('div', { key: k, className: 'grid grid-cols-[52px_1fr] items-center gap-2' },
736
+ h('span', { className: 'text-[12px] text-gray-400' }, label),
737
+ h('div', { className: 'flex flex-wrap gap-1.5' },
738
+ DETAIL_OPTIONS[k].options.map(o => h('button', {
739
+ key: o, type: 'button', onClick: () => setDetails(d => ({ ...d, [k]: o })),
740
+ className: `px-3 py-1.5 rounded-lg text-[12.5px] font-medium border transition-colors ${details[k] === o
741
+ ? 'bg-brand-blue border-brand-blue text-white'
742
+ : 'bg-[#242424] border-[#333] text-gray-300 hover:border-[#4b5563] hover:text-white'}`
743
+ }, o))))),
744
+ !images[g.key] && h('p', { className: 'text-[11px] text-gray-500 pt-1' }, `${g.label} 이미지를 올리지 않으면 이 설정은 사용되지 않아요.`));
745
+ })()),
746
+
747
+ h('section', null,
748
+ h(SectionTitle, { right: h('span', { className: `text-[11px] ${detailList.length >= DETAIL_MAX ? 'text-amber-400' : 'text-gray-500'}` }, `선택 · ${detailList.length}/${DETAIL_MAX}장`) }, '디테일 참조'),
749
+ h('p', { className: 'text-[11px] text-gray-500 mb-2.5 leading-relaxed' },
750
+ '클로즈업을 올리고 어떤 부위인지 골라주세요 (옷 종류별로 항목이 달라요). 조직감·소재는 옷 전체에, 나머지는 그 부위에만 반영하고 실루엣은 메인 사진 그대로 유지하도록 전달돼요. 참조가 많으면 정확도가 떨어져서 전체 4장까지만.'),
751
+ h('div', { className: 'grid grid-cols-2 gap-x-4 gap-y-3' },
752
+ DETAIL_GARMENTS.map(g => {
753
+ const hasMain = !!images[g.key];
754
+ return h('div', { key: g.key, className: hasMain ? '' : 'opacity-40' },
755
+ h('div', { className: 'text-[11.5px] text-gray-400 mb-1' }, g.label,
756
+ !hasMain && h('span', { className: 'text-gray-600' }, ' · 메인 이미지 필요')),
757
+ h('div', { className: 'grid grid-cols-2 gap-2' },
758
+ DETAIL_SLOTS.map(slot => {
759
+ const dkey = `detail:${g.key}:${slot}`;
760
+ const kind = kindOf(g.key, slot);
761
+ const kindList = DETAIL_KINDS_BY_GARMENT[g.key] || [];
762
+ const kindObj = kindList.find(k => k.key === kind) || kindList[0];
763
+ const locked = !hasMain || (!images[dkey] && detailList.length >= DETAIL_MAX);
764
+ return h('div', { key: slot },
765
+ h(UploadTile, { slot: { key: dkey, label: kindObj?.short || '디테일' }, value: images[dkey],
766
+ disabled: locked, onChange: (v, name) => { if (!locked || !v) setImg(dkey, v, name); } }),
767
+ h('select', { value: kind, disabled: !hasMain, title: kindObj?.label,
768
+ onChange: e => setDetailKinds(prev => ({ ...prev, [`${g.key}:${slot}`]: e.target.value })),
769
+ className: `mt-1 w-full h-7 rounded-lg bg-[#242424] border text-[11px] px-1.5 ${images[dkey] ? 'border-brand-blue text-gray-100' : 'border-[#333] text-gray-400'} disabled:cursor-default` },
770
+ kindList.map(k => h('option', { key: k.key, value: k.key, className: 'bg-[#242424] text-gray-200' }, k.short))));
771
+ })));
772
+ }))),
773
+
774
+ h('div', { className: 'pt-3 pb-3 sticky bottom-0 bg-[#1e1e1e] border-t border-[#2a2a2a] -mx-5 px-5' },
775
+ missing.length > 0 && h('p', { className: 'text-[11.5px] text-gray-500 mb-2' }, `필요: ${missing.join(', ')}`),
776
+ activeJobs.length > 0 && missing.length === 0 && h('p', { className: 'text-[11.5px] text-gray-500 mb-2' }, `${activeJobs.length}건 진행 중 — 설정을 바꿔 바로 다음 컷을 걸어둘 수 있어요`),
777
+ h('button', { type: 'button', onClick: handleGenerate, disabled: !canGenerate,
778
+ className: `w-full h-12 rounded-xl font-semibold text-[14px] flex items-center justify-center gap-2 transition-colors ${canGenerate ? 'bg-brand-blue hover:bg-brand-hover text-white' : 'bg-[#2a2a2a] text-gray-500 cursor-not-allowed'}` },
779
+ h(Sparkles, { size: 16 }),
780
+ activeJobs.length > 0 ? `룩북 생성 (${activeJobs.length}건 진행 중)` : '룩북 생성'))));
781
+
782
+ // ── Right: result ─────────────────────────────────────────
783
+ const right = h('main', { className: 'flex-1 h-full overflow-y-auto' },
784
+ h('div', { className: 'max-w-5xl mx-auto p-6 lg:p-8' },
785
+ !health?.ok && health && h('div', { className: 'mb-5 rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-[13px] text-amber-200 flex gap-2' },
786
+ h(AlertTriangle, { size: 16, className: 'shrink-0 mt-0.5' }), h('span', null, health.error || 'Comfy 브리지 설정을 확인해주세요.')),
787
+ health?.ok && health.loginRequired === false && h('div', { className: 'mb-5 rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-[13px] text-amber-200 flex gap-2' },
788
+ h(AlertTriangle, { size: 16, className: 'shrink-0 mt-0.5' }), h('span', null, 'HF OAuth가 연결되지 않아 로그인 없이 누구나 생성할 수 있는 상태입니다. README의 hf_oauth 설정과 Space 재시작을 확인해주세요.')),
789
+ error && h('div', { className: 'mb-5 rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-[13px] text-red-200 flex gap-2 animate-fadeIn' },
790
+ h(AlertTriangle, { size: 16, className: 'shrink-0 mt-0.5' }), h('span', { className: 'break-all' }, error)),
791
+
792
+ jobs.length > 0 && h('div', { className: 'mb-5 rounded-xl border border-[#2a2a2a] bg-[#202020] overflow-hidden animate-fadeIn' },
793
+ h('div', { className: 'px-4 py-2.5 border-b border-[#2a2a2a] text-[12.5px] font-semibold text-gray-300 flex items-center gap-2' },
794
+ h(RefreshCw, { size: 13, className: activeJobs.length ? 'animate-spin text-brand-blue' : 'text-gray-500' }),
795
+ `진행 중 ${activeJobs.length}건`, jobs.length - activeJobs.length > 0 ? h('span', { className: 'text-red-300 font-normal' }, ` · 실패 ${jobs.length - activeJobs.length}건`) : null),
796
+ jobs.map((j, i) => h('div', { key: j.id, className: `px-4 py-2.5 flex items-center gap-3 text-[12.5px] ${i > 0 ? 'border-t border-[#262626]' : ''}` },
797
+ h('div', { className: `w-1.5 h-1.5 rounded-full shrink-0 ${j.error ? 'bg-red-400' : 'bg-brand-blue pulse-ring relative'}` }),
798
+ h('div', { className: 'flex-1 min-w-0' },
799
+ h('div', { className: 'text-gray-100 truncate' }, `${j.meta.face} · ${j.meta.shot} · ${j.meta.angle} · ${j.meta.gaze}`, j.meta.prompt ? h('span', { className: 'text-gray-500' }, ` · "${j.meta.prompt}"`) : null),
800
+ h('div', { className: j.error ? 'text-red-300 break-all' : 'text-gray-400' },
801
+ j.error ? j.error : (j.execStartedAt ? `${j.stageLabel} · ${fmtSec((Date.now() - j.execStartedAt) / 1000)}` : `${j.stageLabel} · 대기 ${fmtSec((Date.now() - j.startedAt) / 1000)}`))),
802
+ j.error
803
+ ? h('button', { type: 'button', onClick: () => dismissJob(j.id), className: 'text-gray-500 hover:text-white' }, h(X, { size: 14 }))
804
+ : (j.jobId ? h('button', { type: 'button', onClick: () => cancelJob(j), disabled: !!j.cancelling,
805
+ className: `px-2.5 py-1 rounded-lg text-[11.5px] shrink-0 transition-colors ${j.cancelling ? 'bg-[#2a2a2a] text-gray-500 cursor-default' : 'bg-[#2a2a2a] hover:bg-red-500/80 text-gray-300 hover:text-white'}` },
806
+ j.cancelling ? '취소 중…' : '취소') : null)))),
807
+
808
+ h('div', { className: 'grid grid-cols-1 md:grid-cols-[minmax(0,1fr)_200px] gap-6' },
809
+ // 메인 뷰어
810
+ h('div', { className: 'rounded-2xl bg-[#202020] border border-[#2a2a2a] overflow-hidden relative', style: { minHeight: '60vh' } },
811
+ !current && activeJobs.length > 0
812
+ ? h('div', { className: 'absolute inset-0 flex flex-col items-center justify-center gap-5' },
813
+ h('div', { className: 'shimmer absolute inset-0 opacity-60' }),
814
+ h('div', { className: 'relative pulse-ring w-14 h-14 rounded-full bg-brand-blue/20 flex items-center justify-center' }, h(Sparkles, { size: 22, className: 'text-brand-blue' })),
815
+ h('div', { className: 'relative text-center' },
816
+ h('div', { className: 'text-[15px] font-semibold text-gray-100' }, activeJobs[0].stageLabel),
817
+ h('div', { className: 'mt-1 text-[12.5px] text-gray-400 flex items-center justify-center gap-1' }, h(Clock, { size: 12 }),
818
+ activeJobs[0].execStartedAt ? `${fmtSec((Date.now() - activeJobs[0].execStartedAt) / 1000)} 경과 · 보통 1~2분 소요` : '실행 순서를 기다리는 중')),
819
+ h('p', { className: 'relative text-[11.5px] text-gray-500 max-w-xs text-center' }, '이 탭을 닫지 마세요. 생성이 끝나면 여기에 이미지가 표시됩니다.'))
820
+ : current
821
+ ? h('div', { className: 'animate-fadeIn flex flex-col' },
822
+ h('div', { className: 'flex justify-center bg-[#1c1c1c]' },
823
+ h('div', { className: 'relative inline-block' },
824
+ h('img', { src: current.imageUrl, onClick: () => setLightbox(true), title: '클릭하면 크게 보기',
825
+ className: 'max-h-[78vh] w-auto max-w-full block bg-white cursor-zoom-in', alt: '생성 결과' }),
826
+ aiLabel && h('div', { className: 'ai-label-overlay' }, AI_LABEL_TEXT),
827
+ h('button', { type: 'button', onClick: () => setLightbox(true), title: '크게 보기',
828
+ className: 'absolute bottom-3 right-3 w-9 h-9 rounded-lg bg-black/55 hover:bg-black/75 text-white flex items-center justify-center' }, h(Maximize2, { size: 16 })))),
829
+ h('div', { className: 'flex items-center justify-between gap-3 px-4 py-3 border-t border-[#2a2a2a]' },
830
+ h('label', { className: 'flex items-center gap-2 cursor-pointer select-none text-[12.5px] text-gray-300' },
831
+ h('button', { type: 'button', role: 'switch', 'aria-checked': aiLabel, onClick: () => setAiLabel(v => !v),
832
+ className: `relative w-9 h-5 rounded-full transition-colors ${aiLabel ? 'bg-brand-blue' : 'bg-[#3a3a3a]'}` },
833
+ h('span', { className: `absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${aiLabel ? 'translate-x-4' : ''}` })),
834
+ h('span', null, "'AI 생성 이미지' 표기"),
835
+ h('span', { className: 'text-gray-500' }, aiLabel ? '화면·다운로드에 포함' : '표기 없음')),
836
+ h('button', { type: 'button', onClick: () => setExportFor(current),
837
+ className: 'h-10 px-4 rounded-xl bg-brand-blue hover:bg-brand-hover text-white text-[13px] font-semibold flex items-center gap-1.5' }, h(Download, { size: 15 }), '다운로드')))
838
+ : h('div', { className: 'absolute inset-0 flex flex-col items-center justify-center text-gray-500 gap-2' },
839
+ h(Upload, { size: 28, className: 'opacity-50' }),
840
+ h('p', { className: 'text-[13.5px]' }, '왼쪽에서 얼굴·의상을 고르고 룩북 생성을 누르면'),
841
+ h('p', { className: 'text-[13.5px]' }, '한 번에 모델컷이 생성됩니다.'))),
842
+ // 히스토리
843
+ h('div', null,
844
+ h('div', { className: 'flex items-center gap-1 mb-2.5' },
845
+ h('span', { className: 'px-2.5 py-1 text-[12.5px] font-semibold text-gray-300' },
846
+ `이번 세션 ${results.length ? `(${results.length})` : ''}`),
847
+ h('button', { type: 'button', onClick: openGallery,
848
+ className: 'px-2.5 py-1 rounded-lg text-[12.5px] font-semibold text-gray-500 hover:text-gray-200 hover:bg-[#2a2a2a] transition-colors' },
849
+ '지난 기록'),
850
+ results.length > 0 && h('button', { type: 'button', onClick: toggleSelectMode,
851
+ className: `ml-auto px-2 py-1 rounded-lg text-[11.5px] transition-colors ${selectMode ? 'bg-[#2a2a2a] text-gray-100' : 'text-gray-500 hover:text-gray-300'}` },
852
+ selectMode ? '취소' : '선택')),
853
+ results.length === 0
854
+ ? h('p', { className: 'text-[12px] text-gray-500' }, '아직 없음')
855
+ : h('div', null,
856
+ h('div', { className: 'grid grid-cols-3 md:grid-cols-1 gap-2' },
857
+ results.map(r => h('div', { key: r.id, className: 'relative group' },
858
+ h('button', { type: 'button', onClick: () => selectMode ? toggleSelected(r.id) : setCurrent(r),
859
+ className: `w-full relative rounded-xl overflow-hidden border-2 ${selectMode
860
+ ? (selected[r.id] ? 'border-red-400' : 'border-[#3a3a3a] hover:border-[#4b5563]')
861
+ : (current?.id === r.id ? 'border-brand-blue' : 'border-transparent hover:border-[#4b5563]')}` },
862
+ h('img', { src: r.imageUrl, className: `w-full h-auto block bg-white ${selectMode && selected[r.id] ? 'opacity-60' : ''}` }),
863
+ selectMode && h('div', { className: `absolute top-1.5 left-1.5 w-5 h-5 rounded-full flex items-center justify-center border ${selected[r.id] ? 'bg-red-500 border-red-500 text-white' : 'bg-black/50 border-gray-400 text-transparent'}` },
864
+ h(Check, { size: 12 })),
865
+ h('div', { className: 'absolute bottom-0 inset-x-0 px-2 py-1 bg-black/65 text-[10.5px] text-left leading-tight' },
866
+ h('div', null, `${r.meta.face ? r.meta.face + ' · ' : ''}${r.meta.shot} · ${r.meta.angle}`),
867
+ h('div', { className: 'text-gray-300' }, `${r.meta.gaze} · ${fmtSec(r.elapsedSec)}`))),
868
+ !selectMode && h('div', { className: 'absolute top-1.5 right-1.5 hidden group-hover:flex gap-1' },
869
+ r.payload && h('button', { type: 'button', onClick: (e) => { e.stopPropagation(); regenFrom(r); }, title: '이 설정으로 다시 생성',
870
+ className: 'w-7 h-7 rounded-lg bg-black/60 hover:bg-brand-blue text-white flex items-center justify-center transition-colors' },
871
+ h(RefreshCw, { size: 13 })),
872
+ h('button', { type: 'button', onClick: (e) => { e.stopPropagation(); deleteResult(r); }, title: '이 결과 지우기',
873
+ className: 'w-7 h-7 rounded-lg bg-black/60 hover:bg-red-500 text-white flex items-center justify-center transition-colors' },
874
+ h(X, { size: 13 }))))),
875
+ ),
876
+ selectMode && h('div', { className: 'sticky bottom-0 mt-2 flex items-center gap-2 bg-[#202020]/95 backdrop-blur rounded-xl border border-[#2a2a2a] px-3 py-2' },
877
+ h('span', { className: 'text-[12px] text-gray-300' }, selectedIds.length ? `${selectedIds.length}개 선택` : '삭제할 결과를 선택하세요'),
878
+ h('button', { type: 'button', onClick: deleteSelected, disabled: !selectedIds.length,
879
+ className: `ml-auto h-8 px-3 rounded-lg text-[12px] font-semibold transition-colors ${selectedIds.length ? 'bg-red-500 hover:bg-red-600 text-white' : 'bg-[#2a2a2a] text-gray-500 cursor-not-allowed'}` },
880
+ `삭제${selectedIds.length ? ` (${selectedIds.length})` : ''}`))))),
881
+
882
+ current && h('div', { className: 'mt-5 text-[12px] text-gray-500' },
883
+ [current.history ? `지난 기록 (${current.timeLabel})` : null, current.meta.age, current.meta.shot, current.meta.angle, current.meta.gaze]
884
+ .filter(Boolean).join(' · '), current.meta.prompt ? ` · "${current.meta.prompt}"` : '')));
885
+
886
+ // ── Header ────────────────────────────────────────────────
887
+ const header = h('header', { className: 'h-14 shrink-0 border-b border-[#2a2a2a] bg-[#1a1a1a] flex items-center justify-between px-5' },
888
+ h('div', { className: 'flex items-center gap-5' },
889
+ h('div', { className: 'flex items-center gap-3' },
890
+ h('div', { className: 'w-8 h-8 rounded-lg bg-brand-blue flex items-center justify-center' }, h(Sparkles, { size: 16, className: 'text-white' })),
891
+ h('div', { className: 'text-[15px] font-bold tracking-tight' }, 'SPAO AI 룩북 생성기')),
892
+ user.oauth_enabled && h('div', { className: 'flex items-center gap-2 text-[12.5px] pl-5 border-l border-[#2a2a2a]' },
893
+ user.is_logged_in
894
+ ? h(React.Fragment, null,
895
+ h('span', { className: 'text-gray-300' }, user.name || user.username),
896
+ h('a', { href: user.logout_url, className: 'text-gray-500 hover:text-white' }, '로그아웃'))
897
+ : h('a', { href: user.login_url, className: 'px-3 py-1.5 rounded-lg bg-[#2a2a2a] hover:bg-[#333] text-gray-100' }, '🤗 Hugging Face로 로그인'))),
898
+ h('div', null,
899
+ health && !health.ok && h('span', { className: 'inline-flex items-center gap-1.5 text-[12.5px] text-amber-400' },
900
+ h('span', { className: 'w-1.5 h-1.5 rounded-full bg-amber-400' }), 'Comfy 설정 필요')));
901
+
902
+ const lightboxEl = lightbox && current && h('div', {
903
+ className: 'fixed inset-0 z-50 bg-black/90 flex items-center justify-center animate-fadeIn cursor-zoom-out',
904
+ onClick: () => setLightbox(false) },
905
+ h('img', { src: current.imageUrl, alt: '생성 결과 확대', onClick: e => e.stopPropagation(),
906
+ className: 'max-w-[96vw] max-h-[96vh] object-contain bg-white cursor-default shadow-2xl' }),
907
+ aiLabel && h('div', { className: 'absolute top-4 right-16 text-[12px] text-gray-300 font-light pointer-events-none' }, AI_LABEL_TEXT),
908
+ h('button', { type: 'button', onClick: () => setLightbox(false), title: '닫기 (Esc)',
909
+ className: 'absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center' }, h(X, { size: 18 })),
910
+ h('div', { className: 'absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2', onClick: e => e.stopPropagation() },
911
+ h('button', { type: 'button', onClick: () => setExportFor(current),
912
+ className: 'h-10 px-4 rounded-xl bg-brand-blue hover:bg-brand-hover text-white text-[13px] font-semibold flex items-center gap-1.5' }, h(Download, { size: 15 }), '다운로드')));
913
+
914
+ const exportEl = exportFor && h(ChannelExportModal, { result: exportFor, aiLabel, onClose: () => setExportFor(null) });
915
+
916
+ // ── 지난 기록 갤러리 (전체 화면, 날짜별 그룹 + 필터) ──
917
+ const gQuery = gFilter.q.trim().toLowerCase();
918
+ const gItems = (history || []).filter(r =>
919
+ (!gFilter.shot || r.meta.shot === gFilter.shot) &&
920
+ (!gFilter.angle || r.meta.angle === gFilter.angle) &&
921
+ (!gFilter.date || r.day === gFilter.date.replace(/-/g, '')) &&
922
+ (!gFilter.favOnly || favs[r.ts]) &&
923
+ (!gQuery || (r.codes || []).some(c => c.toLowerCase().includes(gQuery))
924
+ || (r.files || []).some(f => f.toLowerCase().includes(gQuery))));
925
+ const dayLabel = (d) => d.length === 8 ? `${d.slice(0, 4)}년 ${+d.slice(4, 6)}월 ${+d.slice(6, 8)}일` : d;
926
+ let gGroups = [];
927
+ if (gGroup === 'date') {
928
+ for (const r of gItems) {
929
+ const last = gGroups[gGroups.length - 1];
930
+ if (last && last.key === r.day) last.items.push(r);
931
+ else gGroups.push({ key: r.day, label: dayLabel(r.day), items: [r] });
932
+ }
933
+ } else {
934
+ // 스타일코드_컬러 기준 — 한 컷이 여러 코드를 쓰면 각 그룹에 모두 표시
935
+ const byColor = new Map();
936
+ for (const r of gItems) {
937
+ const keys = (r.colors && r.colors.length) ? r.colors : ['(스타일코드 없음)'];
938
+ for (const k of keys) {
939
+ if (!byColor.has(k)) byColor.set(k, []);
940
+ byColor.get(k).push(r);
941
+ }
942
+ }
943
+ gGroups = [...byColor.entries()].map(([k, items]) => ({ key: k, label: k, items }));
944
+ gGroups.sort((a, b) => a.key === '(스타일코드 없음)' ? 1 : b.key === '(스타일코드 없음)' ? -1 : a.key.localeCompare(b.key));
945
+ }
946
+ const gSelectedItems = gItems.filter(r => gSelected[r.id]);
947
+ const gOpt = (list) => [...new Set((history || []).map(r => list(r)).filter(Boolean))];
948
+
949
+ const galleryEl = galleryOpen && h('div', { className: 'fixed inset-0 z-[55] bg-[#161616] flex flex-col animate-fadeIn' },
950
+ h('div', { className: 'shrink-0 border-b border-[#2a2a2a] px-5 py-3 flex flex-wrap items-center gap-2' },
951
+ h('h3', { className: 'text-[14.5px] font-semibold text-gray-100 mr-2' }, '지난 기록'),
952
+ h('div', { className: 'flex rounded-lg overflow-hidden border border-[#333]' },
953
+ [['date', '날짜'], ['style', '스타일코드']].map(([v, lb]) =>
954
+ h('button', { key: v, type: 'button', onClick: () => setGGroup(v),
955
+ className: `h-8 px-3 text-[12px] transition-colors ${gGroup === v ? 'bg-brand-blue text-white' : 'bg-[#242424] text-gray-400 hover:text-gray-200'}` }, lb))),
956
+ h('select', { value: gFilter.shot, onChange: e => setGFilter(f => ({ ...f, shot: e.target.value })),
957
+ className: 'h-8 rounded-lg bg-[#242424] border border-[#333] text-[12px] text-gray-200 px-2' },
958
+ h('option', { value: '' }, '샷 전체'),
959
+ gOpt(r => r.meta.shot).map(v => h('option', { key: v, value: v }, v))),
960
+ h('select', { value: gFilter.angle, onChange: e => setGFilter(f => ({ ...f, angle: e.target.value })),
961
+ className: 'h-8 rounded-lg bg-[#242424] border border-[#333] text-[12px] text-gray-200 px-2' },
962
+ h('option', { value: '' }, '앵글 전체'),
963
+ gOpt(r => r.meta.angle).map(v => h('option', { key: v, value: v }, v))),
964
+ h('input', { type: 'date', value: gFilter.date, onChange: e => setGFilter(f => ({ ...f, date: e.target.value })),
965
+ className: 'h-8 rounded-lg bg-[#242424] border border-[#333] text-[12px] text-gray-200 px-2' }),
966
+ h('input', { type: 'search', value: gFilter.q, placeholder: '스타일코드·파일명 검색',
967
+ onChange: e => setGFilter(f => ({ ...f, q: e.target.value })),
968
+ className: 'h-8 w-44 rounded-lg bg-[#242424] border border-[#333] text-[12px] text-gray-200 px-2.5 placeholder:text-gray-600' }),
969
+ h('button', { type: 'button', onClick: () => setGFilter(f => ({ ...f, favOnly: !f.favOnly })),
970
+ className: `h-8 px-2.5 rounded-lg text-[12px] flex items-center gap-1 transition-colors ${gFilter.favOnly ? 'bg-amber-500/20 text-amber-300' : 'bg-[#242424] text-gray-400 hover:text-gray-200'}` },
971
+ h(Star, { size: 12, fill: gFilter.favOnly ? 'currentColor' : 'none' }), '즐겨찾기'),
972
+ (gFilter.shot || gFilter.angle || gFilter.date || gFilter.favOnly || gFilter.q) && h('button', { type: 'button',
973
+ onClick: () => setGFilter({ shot: '', angle: '', date: '', favOnly: false, q: '' }),
974
+ className: 'h-8 px-2.5 rounded-lg text-[12px] text-gray-500 hover:text-gray-200' }, '필터 해제'),
975
+ h('div', { className: 'ml-auto flex items-center gap-1.5' },
976
+ h('span', { className: 'text-[11.5px] text-gray-500 mr-1' }, historyLoading ? '불러오는 중…' : `${gItems.length}건`),
977
+ h('button', { type: 'button', onClick: () => loadHistory(true), title: '새로고침', disabled: historyLoading,
978
+ className: 'w-8 h-8 rounded-lg hover:bg-[#2a2a2a] text-gray-400 hover:text-gray-100 flex items-center justify-center' },
979
+ h(RefreshCw, { size: 14, className: historyLoading ? 'animate-spin' : '' })),
980
+ h('button', { type: 'button', onClick: () => setGalleryOpen(false), title: '닫기',
981
+ className: 'w-8 h-8 rounded-lg hover:bg-[#2a2a2a] text-gray-400 hover:text-white flex items-center justify-center' }, h(X, { size: 16 })))),
982
+ h('div', { className: 'flex-1 overflow-y-auto px-5 py-4' },
983
+ historyError ? h('p', { className: 'text-[13px] text-gray-500' }, historyError)
984
+ : history === null ? h('p', { className: 'text-[13px] text-gray-500' }, '불러오는 중…')
985
+ : gItems.length === 0 ? h('p', { className: 'text-[13px] text-gray-500' }, history.length ? '조건에 맞는 기록이 없어요.' : '저장된 기록이 없어요.')
986
+ : gGroups.map(g => h('div', { key: g.key, className: 'mb-6' },
987
+ h('div', { className: 'text-[13px] font-semibold text-gray-300 mb-2.5' }, `${g.label} `,
988
+ h('span', { className: 'text-gray-500 font-normal' }, `(${g.items.length})`)),
989
+ h('div', { className: 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-2.5' },
990
+ g.items.map(r => h('div', { key: `${g.key}_${r.id}`, className: 'relative group' },
991
+ h('button', { type: 'button', onClick: () => gSelectedItems.length ? setGSelected(prev => ({ ...prev, [r.id]: !prev[r.id] })) : viewHistoryItem(r),
992
+ className: `w-full relative rounded-xl overflow-hidden border-2 ${gSelected[r.id] ? 'border-brand-blue' : 'border-transparent hover:border-[#4b5563]'}` },
993
+ h('img', { src: r.thumbUrl, loading: 'lazy', className: `w-full aspect-[3/4] object-cover block bg-white ${gSelected[r.id] ? 'opacity-60' : ''}` }),
994
+ favs[r.ts] && h('div', { className: 'absolute top-1.5 left-8 text-amber-300 drop-shadow' }, h(Star, { size: 14, fill: 'currentColor' })),
995
+ h('div', { className: 'absolute bottom-0 inset-x-0 px-2 py-1 bg-black/60 text-[10px] text-left leading-tight opacity-0 group-hover:opacity-100 transition-opacity' },
996
+ h('div', null, [r.meta.shot, r.meta.angle].filter(Boolean).join(' · ') || '생성 기록'),
997
+ h('div', { className: 'text-gray-300' }, [(r.codes || []).slice(0, 2).join(' '), r.timeLabel].filter(Boolean).join(' · ')))),
998
+ h('span', { role: 'checkbox', 'aria-checked': !!gSelected[r.id], title: gSelected[r.id] ? '선택 해제' : '선택',
999
+ onClick: (e) => { e.stopPropagation(); setGSelected(prev => ({ ...prev, [r.id]: !prev[r.id] })); },
1000
+ className: `absolute top-1.5 left-1.5 w-5 h-5 rounded-full items-center justify-center border cursor-pointer ${gSelected[r.id]
1001
+ ? 'flex bg-brand-blue border-brand-blue text-white'
1002
+ : `hidden group-hover:flex bg-black/50 border-gray-300 text-transparent hover:text-gray-300 ${gSelectedItems.length ? '!flex' : ''}`}` },
1003
+ h(Check, { size: 12 })),
1004
+ !gSelectedItems.length && h('div', { className: 'absolute top-1.5 right-1.5 hidden group-hover:flex gap-1' },
1005
+ h('button', { type: 'button', title: favs[r.ts] ? '즐겨찾기 해제' : '즐겨찾기',
1006
+ onClick: (e) => { e.stopPropagation(); toggleFav(r.ts); },
1007
+ className: `w-7 h-7 rounded-lg flex items-center justify-center transition-colors ${favs[r.ts] ? 'bg-amber-500 text-white' : 'bg-black/60 hover:bg-amber-500 text-white'}` },
1008
+ h(Star, { size: 13, fill: favs[r.ts] ? 'currentColor' : 'none' })),
1009
+ h('button', { type: 'button', title: '같은 설정으로 재생성', onClick: (e) => { e.stopPropagation(); regenHistory(r); },
1010
+ className: 'w-7 h-7 rounded-lg bg-black/60 hover:bg-brand-blue text-white flex items-center justify-center transition-colors' },
1011
+ h(RefreshCw, { size: 13 })),
1012
+ h('button', { type: 'button', title: '채널 사이즈 다운로드', onClick: (e) => { e.stopPropagation(); setExportFor(r); },
1013
+ className: 'w-7 h-7 rounded-lg bg-black/60 hover:bg-brand-blue text-white flex items-center justify-center transition-colors' },
1014
+ h(Download, { size: 13 })),
1015
+ h('button', { type: 'button', title: '기록에서 삭제 (통계에는 유지)', onClick: (e) => { e.stopPropagation(); deleteHistoryItems([r]); },
1016
+ className: 'w-7 h-7 rounded-lg bg-black/60 hover:bg-red-500 text-white flex items-center justify-center transition-colors' },
1017
+ h(Trash2, { size: 13 }))))))))),
1018
+ gSelectedItems.length > 0 && h('div', { className: 'shrink-0 border-t border-[#2a2a2a] bg-[#1c1c1c] px-5 py-3 flex items-center gap-2' },
1019
+ h('span', { className: 'text-[12.5px] text-gray-300' }, `${gSelectedItems.length}개 선택`),
1020
+ h('button', { type: 'button', onClick: () => setGSelected({}),
1021
+ className: 'px-2.5 py-1 rounded-lg text-[12px] text-gray-500 hover:text-gray-200' }, '선택 해제'),
1022
+ h('div', { className: 'ml-auto flex gap-2' },
1023
+ h('button', { type: 'button', onClick: () => setZipModal(true), disabled: !gSelectedItems.length || zipBusy,
1024
+ className: `h-9 px-4 rounded-xl text-[12.5px] font-semibold flex items-center gap-1.5 transition-colors ${gSelectedItems.length && !zipBusy ? 'bg-brand-blue hover:bg-brand-hover text-white' : 'bg-[#2a2a2a] text-gray-500 cursor-not-allowed'}` },
1025
+ h(Download, { size: 14 }), `ZIP 다운로드${gSelectedItems.length ? ` (${gSelectedItems.length})` : ''}`),
1026
+ h('button', { type: 'button', onClick: () => deleteHistoryItems(gSelectedItems), disabled: !gSelectedItems.length,
1027
+ className: `h-9 px-4 rounded-xl text-[12.5px] font-semibold flex items-center gap-1.5 transition-colors ${gSelectedItems.length ? 'bg-red-500 hover:bg-red-600 text-white' : 'bg-[#2a2a2a] text-gray-500 cursor-not-allowed'}` },
1028
+ h(Trash2, { size: 14 }), `삭제${gSelectedItems.length ? ` (${gSelectedItems.length})` : ''}`))));
1029
+
1030
+ const zipSizeCount = EXPORT_PRESETS.filter(p => zipSizes[p.id]).length;
1031
+ const zipModalEl = zipModal && h('div', { className: 'fixed inset-0 z-[65] bg-black/85 flex items-center justify-center p-4 animate-fadeIn', onClick: () => !zipBusy && setZipModal(false) },
1032
+ h('div', { className: 'bg-[#202020] border border-[#333] rounded-2xl w-full max-w-md p-5', onClick: e => e.stopPropagation() },
1033
+ h('div', { className: 'flex items-center justify-between mb-1' },
1034
+ h('h3', { className: 'text-[14.5px] font-semibold text-gray-100' }, 'ZIP 다운로드 — 채널 사이즈'),
1035
+ h('button', { type: 'button', onClick: () => !zipBusy && setZipModal(false),
1036
+ className: 'w-8 h-8 rounded-lg hover:bg-[#2a2a2a] text-gray-400 hover:text-white flex items-center justify-center' }, h(X, { size: 16 }))),
1037
+ h('p', { className: 'text-[11.5px] text-gray-500 mb-3' },
1038
+ `선택한 ${gSelectedItems.length}장을 체크한 사이즈로 변환해 한 번에 담습니다. 2개 이상 고르면 사이즈별 폴더로 정리돼요.`,
1039
+ aiLabel ? " 'AI 생성 이미지' 표기 포함." : ''),
1040
+ h('div', { className: 'flex flex-col gap-1.5 mb-4' },
1041
+ EXPORT_PRESETS.map(p => h('label', { key: p.id,
1042
+ className: `flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer border transition-colors ${zipSizes[p.id] ? 'border-brand-blue bg-brand-blue/10' : 'border-[#333] hover:border-[#4b5563]'}` },
1043
+ h('input', { type: 'checkbox', checked: !!zipSizes[p.id],
1044
+ onChange: () => setZipSizes(prev => ({ ...prev, [p.id]: !prev[p.id] })), className: 'accent-[#3b82f6]' }),
1045
+ h('span', { className: 'text-[12.5px] text-gray-200' }, p.label),
1046
+ h('span', { className: 'ml-auto text-[11.5px] text-gray-500' }, `${p.w}×${p.h}`)))),
1047
+ h('button', { type: 'button', onClick: () => downloadZip(gSelectedItems), disabled: !zipSizeCount || zipBusy,
1048
+ className: `w-full h-10 rounded-xl text-[13px] font-semibold flex items-center justify-center gap-1.5 transition-colors ${zipSizeCount && !zipBusy ? 'bg-brand-blue hover:bg-brand-hover text-white' : 'bg-[#2a2a2a] text-gray-500 cursor-not-allowed'}` },
1049
+ h(Download, { size: 15 }),
1050
+ zipBusy ? 'ZIP 생성 중…' : `다운로드 (${gSelectedItems.length}장${zipSizeCount > 1 ? ` × ${zipSizeCount}사이즈` : ''})`)));
1051
+
1052
+ return h('div', { className: 'h-screen flex flex-col bg-[#1a1a1a]' }, header,
1053
+ h('div', { className: 'flex-1 flex flex-col lg:flex-row min-h-0' }, left, right), galleryEl, lightboxEl, exportEl, zipModalEl);
1054
+ }
1055
+
1056
+ ReactDOM.createRoot(document.getElementById('root')).render(h(App));
1057
+ </script>
1058
+ </body>
1059
+ </html>
server (48).py ADDED
The diff for this file is too large to render. See raw diff
 
workflow_api (28).json ADDED
@@ -0,0 +1,2042 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "38": {
3
+ "inputs": {
4
+ "clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
5
+ "type": "qwen_image",
6
+ "device": "default"
7
+ },
8
+ "class_type": "CLIPLoader",
9
+ "_meta": {
10
+ "title": "CLIP 로드"
11
+ }
12
+ },
13
+ "39": {
14
+ "inputs": {
15
+ "vae_name": "qwen_image_vae.safetensors"
16
+ },
17
+ "class_type": "VAELoader",
18
+ "_meta": {
19
+ "title": "VAE 로드"
20
+ }
21
+ },
22
+ "66": {
23
+ "inputs": {
24
+ "shift": 3,
25
+ "model": [
26
+ "218",
27
+ 0
28
+ ]
29
+ },
30
+ "class_type": "ModelSamplingAuraFlow",
31
+ "_meta": {
32
+ "title": "모델 샘플링 (AuraFlow)"
33
+ }
34
+ },
35
+ "75": {
36
+ "inputs": {
37
+ "strength": 1,
38
+ "pre_cfg": false,
39
+ "model": [
40
+ "66",
41
+ 0
42
+ ]
43
+ },
44
+ "class_type": "CFGNorm",
45
+ "_meta": {
46
+ "title": "CFGNorm"
47
+ }
48
+ },
49
+ "88": {
50
+ "inputs": {
51
+ "pixels": [
52
+ "340",
53
+ 1
54
+ ],
55
+ "vae": [
56
+ "39",
57
+ 0
58
+ ]
59
+ },
60
+ "class_type": "VAEEncode",
61
+ "_meta": {
62
+ "title": "VAE 인코드"
63
+ }
64
+ },
65
+ "110": {
66
+ "inputs": {
67
+ "prompt": [
68
+ "966",
69
+ 0
70
+ ],
71
+ "clip": [
72
+ "38",
73
+ 0
74
+ ],
75
+ "vae": [
76
+ "39",
77
+ 0
78
+ ],
79
+ "image1": [
80
+ "277",
81
+ 0
82
+ ],
83
+ "image2": [
84
+ "340",
85
+ 1
86
+ ],
87
+ "image3": [
88
+ "129",
89
+ 0
90
+ ]
91
+ },
92
+ "class_type": "TextEncodeQwenImageEditPlus",
93
+ "_meta": {
94
+ "title": "TextEncodeQwenImageEditPlus(negative)"
95
+ }
96
+ },
97
+ "111": {
98
+ "inputs": {
99
+ "prompt": [
100
+ "968",
101
+ 0
102
+ ],
103
+ "clip": [
104
+ "38",
105
+ 0
106
+ ],
107
+ "vae": [
108
+ "39",
109
+ 0
110
+ ],
111
+ "image1": [
112
+ "277",
113
+ 0
114
+ ],
115
+ "image2": [
116
+ "340",
117
+ 1
118
+ ],
119
+ "image3": [
120
+ "129",
121
+ 0
122
+ ]
123
+ },
124
+ "class_type": "TextEncodeQwenImageEditPlus",
125
+ "_meta": {
126
+ "title": "TextEncodeQwenImageEditPlus"
127
+ }
128
+ },
129
+ "129": {
130
+ "inputs": {
131
+ "detect_hand": "enable",
132
+ "detect_body": "enable",
133
+ "detect_face": "enable",
134
+ "resolution": 1024,
135
+ "bbox_detector": "yolox_l.onnx",
136
+ "pose_estimator": "dw-ll_ucoco_384_bs5.torchscript.pt",
137
+ "scale_stick_for_xinsr_cn": "disable",
138
+ "image": [
139
+ "183",
140
+ 0
141
+ ]
142
+ },
143
+ "class_type": "DWPreprocessor",
144
+ "_meta": {
145
+ "title": "DWPose Estimator"
146
+ }
147
+ },
148
+ "172": {
149
+ "inputs": {
150
+ "width": [
151
+ "183",
152
+ 1
153
+ ],
154
+ "height": [
155
+ "183",
156
+ 2
157
+ ],
158
+ "keep_proportions": true,
159
+ "upscale_method": "nearest-exact",
160
+ "crop": "disabled",
161
+ "mask": [
162
+ "177",
163
+ 2
164
+ ]
165
+ },
166
+ "class_type": "ResizeMask",
167
+ "_meta": {
168
+ "title": "Resize Mask"
169
+ }
170
+ },
171
+ "177": {
172
+ "inputs": {
173
+ "downscale_algorithm": "lanczos",
174
+ "upscale_algorithm": "lanczos",
175
+ "preresize": false,
176
+ "preresize_mode": "ensure minimum resolution",
177
+ "preresize_min_width": 1024,
178
+ "preresize_min_height": 1024,
179
+ "preresize_max_width": 16384,
180
+ "preresize_max_height": 16384,
181
+ "mask_fill_holes": true,
182
+ "mask_expand_pixels": 20,
183
+ "mask_invert": false,
184
+ "mask_blend_pixels": 5,
185
+ "mask_hipass_filter": 0.1,
186
+ "extend_for_outpainting": true,
187
+ "extend_up_factor": 1,
188
+ "extend_down_factor": 1,
189
+ "extend_left_factor": 1,
190
+ "extend_right_factor": 1,
191
+ "context_from_mask_extend_factor": 2.5,
192
+ "output_resize_to_target_size": true,
193
+ "output_target_width": 1024,
194
+ "output_target_height": 1024,
195
+ "output_padding": "128",
196
+ "image": [
197
+ "786",
198
+ 0
199
+ ],
200
+ "mask": [
201
+ "803",
202
+ 0
203
+ ]
204
+ },
205
+ "class_type": "InpaintCropImproved",
206
+ "_meta": {
207
+ "title": "✂️ Inpaint Crop (Improved)"
208
+ }
209
+ },
210
+ "183": {
211
+ "inputs": {
212
+ "width": 1024,
213
+ "height": 1024,
214
+ "interpolation": "lanczos",
215
+ "method": "keep proportion",
216
+ "condition": "downscale if bigger",
217
+ "multiple_of": 0,
218
+ "image": [
219
+ "177",
220
+ 1
221
+ ]
222
+ },
223
+ "class_type": "ImageResize+",
224
+ "_meta": {
225
+ "title": "🔧 Image Resize"
226
+ }
227
+ },
228
+ "218": {
229
+ "inputs": {
230
+ "lora_name": "Qwen-Image-Lightning-8steps-V1.0.safetensors",
231
+ "strength_model": 1,
232
+ "model": [
233
+ "652",
234
+ 0
235
+ ]
236
+ },
237
+ "class_type": "LoraLoaderModelOnly",
238
+ "_meta": {
239
+ "title": "LoRA 로드 (모델 전용)"
240
+ }
241
+ },
242
+ "247": {
243
+ "inputs": {
244
+ "fill": "telea",
245
+ "falloff": 0,
246
+ "image": [
247
+ "183",
248
+ 0
249
+ ],
250
+ "mask": [
251
+ "172",
252
+ 0
253
+ ]
254
+ },
255
+ "class_type": "INPAINT_MaskedFill",
256
+ "_meta": {
257
+ "title": "Fill Masked Area"
258
+ }
259
+ },
260
+ "277": {
261
+ "inputs": {
262
+ "blur": 200,
263
+ "falloff": 0,
264
+ "image": [
265
+ "247",
266
+ 0
267
+ ],
268
+ "mask": [
269
+ "172",
270
+ 0
271
+ ]
272
+ },
273
+ "class_type": "INPAINT_MaskedBlur",
274
+ "_meta": {
275
+ "title": "Blur Masked Area"
276
+ }
277
+ },
278
+ "316": {
279
+ "inputs": {
280
+ "amount": 10,
281
+ "device": "auto",
282
+ "mask": [
283
+ "172",
284
+ 0
285
+ ]
286
+ },
287
+ "class_type": "MaskBlur+",
288
+ "_meta": {
289
+ "title": "🔧 Mask Blur"
290
+ }
291
+ },
292
+ "329": {
293
+ "inputs": {
294
+ "prompt": "place this character into the image",
295
+ "clip": [
296
+ "38",
297
+ 0
298
+ ],
299
+ "vae": [
300
+ "39",
301
+ 0
302
+ ],
303
+ "image1": [
304
+ "183",
305
+ 0
306
+ ],
307
+ "image2": [
308
+ "340",
309
+ 1
310
+ ],
311
+ "image3": [
312
+ "129",
313
+ 0
314
+ ]
315
+ },
316
+ "class_type": "TextEncodeQwenImageEditPlus",
317
+ "_meta": {
318
+ "title": "TextEncodeQwenImageEditPlus"
319
+ }
320
+ },
321
+ "340": {
322
+ "inputs": {
323
+ "downscale_algorithm": "lanczos",
324
+ "upscale_algorithm": "lanczos",
325
+ "preresize": false,
326
+ "preresize_mode": "ensure minimum resolution",
327
+ "preresize_min_width": 1024,
328
+ "preresize_min_height": 1024,
329
+ "preresize_max_width": 16384,
330
+ "preresize_max_height": 16384,
331
+ "mask_fill_holes": false,
332
+ "mask_expand_pixels": 0,
333
+ "mask_invert": false,
334
+ "mask_blend_pixels": 0,
335
+ "mask_hipass_filter": 0.1,
336
+ "extend_for_outpainting": false,
337
+ "extend_up_factor": 1,
338
+ "extend_down_factor": 1,
339
+ "extend_left_factor": 1,
340
+ "extend_right_factor": 1,
341
+ "context_from_mask_extend_factor": 1,
342
+ "output_resize_to_target_size": true,
343
+ "output_target_width": 1024,
344
+ "output_target_height": 1024,
345
+ "output_padding": "32",
346
+ "image": [
347
+ "787",
348
+ 0
349
+ ],
350
+ "mask": [
351
+ "806",
352
+ 0
353
+ ]
354
+ },
355
+ "class_type": "InpaintCropImproved",
356
+ "_meta": {
357
+ "title": "✂️ Inpaint Crop (Improved)"
358
+ }
359
+ },
360
+ "385": {
361
+ "inputs": {
362
+ "noise_mask": true,
363
+ "positive": [
364
+ "111",
365
+ 0
366
+ ],
367
+ "negative": [
368
+ "110",
369
+ 0
370
+ ],
371
+ "vae": [
372
+ "39",
373
+ 0
374
+ ],
375
+ "pixels": [
376
+ "183",
377
+ 0
378
+ ],
379
+ "mask": [
380
+ "316",
381
+ 0
382
+ ]
383
+ },
384
+ "class_type": "InpaintModelConditioning",
385
+ "_meta": {
386
+ "title": "인페인팅 모델 조건 설정"
387
+ }
388
+ },
389
+ "387": {
390
+ "inputs": {
391
+ "strength": 1,
392
+ "model": [
393
+ "75",
394
+ 0
395
+ ]
396
+ },
397
+ "class_type": "DifferentialDiffusion",
398
+ "_meta": {
399
+ "title": "차등 확산"
400
+ }
401
+ },
402
+ "396": {
403
+ "inputs": {
404
+ "conditioning": [
405
+ "329",
406
+ 0
407
+ ],
408
+ "latent": [
409
+ "88",
410
+ 0
411
+ ]
412
+ },
413
+ "class_type": "ReferenceLatent",
414
+ "_meta": {
415
+ "title": "참조 잠재"
416
+ }
417
+ },
418
+ "494": {
419
+ "inputs": {
420
+ "samples": [
421
+ "781",
422
+ 0
423
+ ],
424
+ "vae": [
425
+ "39",
426
+ 0
427
+ ]
428
+ },
429
+ "class_type": "VAEDecode",
430
+ "_meta": {
431
+ "title": "VAE 디코드"
432
+ }
433
+ },
434
+ "518": {
435
+ "inputs": {
436
+ "stitcher": [
437
+ "177",
438
+ 0
439
+ ],
440
+ "inpainted_image": [
441
+ "494",
442
+ 0
443
+ ]
444
+ },
445
+ "class_type": "InpaintStitchImproved",
446
+ "_meta": {
447
+ "title": "✂️ Inpaint Stitch (Improved)"
448
+ }
449
+ },
450
+ "646": {
451
+ "inputs": {
452
+ "image": "3379b9a0bf1e3d4df8661327b49e994480546187d2339d631739c293e4e07b66.png"
453
+ },
454
+ "class_type": "LoadImage",
455
+ "_meta": {
456
+ "title": "얼굴"
457
+ }
458
+ },
459
+ "652": {
460
+ "inputs": {
461
+ "unet_name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
462
+ "weight_dtype": "fp8_e4m3fn"
463
+ },
464
+ "class_type": "UNETLoader",
465
+ "_meta": {
466
+ "title": "확산 모델 로드"
467
+ }
468
+ },
469
+ "716": {
470
+ "inputs": {
471
+ "width": [
472
+ "772",
473
+ 0
474
+ ],
475
+ "height": [
476
+ "772",
477
+ 1
478
+ ],
479
+ "interpolation": "lanczos",
480
+ "method": "keep proportion",
481
+ "condition": "always",
482
+ "multiple_of": 0,
483
+ "image": [
484
+ "518",
485
+ 0
486
+ ]
487
+ },
488
+ "class_type": "ImageResize+",
489
+ "_meta": {
490
+ "title": "🔧 Image Resize"
491
+ }
492
+ },
493
+ "760": {
494
+ "inputs": {
495
+ "filename_prefix": "faceswap",
496
+ "images": [
497
+ "991",
498
+ 0
499
+ ]
500
+ },
501
+ "class_type": "SaveImage",
502
+ "_meta": {
503
+ "title": "Image Save(Output)"
504
+ }
505
+ },
506
+ "772": {
507
+ "inputs": {
508
+ "image": [
509
+ "906",
510
+ 0
511
+ ]
512
+ },
513
+ "class_type": "Get Image Size",
514
+ "_meta": {
515
+ "title": "Get Image Size"
516
+ }
517
+ },
518
+ "781": {
519
+ "inputs": {
520
+ "seed": 165629919204817,
521
+ "steps": 8,
522
+ "cfg": 1,
523
+ "sampler_name": "euler",
524
+ "scheduler": "simple",
525
+ "denoise": 0.4,
526
+ "model": [
527
+ "387",
528
+ 0
529
+ ],
530
+ "positive": [
531
+ "385",
532
+ 0
533
+ ],
534
+ "negative": [
535
+ "385",
536
+ 1
537
+ ],
538
+ "latent_image": [
539
+ "385",
540
+ 2
541
+ ]
542
+ },
543
+ "class_type": "KSampler",
544
+ "_meta": {
545
+ "title": "시드"
546
+ }
547
+ },
548
+ "786": {
549
+ "inputs": {
550
+ "upscale_method": "lanczos",
551
+ "scale_by": 1,
552
+ "image": [
553
+ "906",
554
+ 0
555
+ ]
556
+ },
557
+ "class_type": "ImageScaleBy",
558
+ "_meta": {
559
+ "title": "이미지 확대 비율"
560
+ }
561
+ },
562
+ "787": {
563
+ "inputs": {
564
+ "upscale_method": "lanczos",
565
+ "scale_by": 1,
566
+ "image": [
567
+ "646",
568
+ 0
569
+ ]
570
+ },
571
+ "class_type": "ImageScaleBy",
572
+ "_meta": {
573
+ "title": "이미지 확대 비율"
574
+ }
575
+ },
576
+ "800": {
577
+ "inputs": {
578
+ "model_name": "mediapipe_face_fp32.safetensors"
579
+ },
580
+ "class_type": "LoadMediaPipeFaceLandmarker",
581
+ "_meta": {
582
+ "title": "MediaPipe 얼굴 랜드마커 불러오기"
583
+ }
584
+ },
585
+ "801": {
586
+ "inputs": {
587
+ "detector_variant": "full",
588
+ "num_faces": 1,
589
+ "min_confidence": 0.5,
590
+ "missing_frame_fallback": "empty",
591
+ "face_detection_model": [
592
+ "800",
593
+ 0
594
+ ],
595
+ "image": [
596
+ "786",
597
+ 0
598
+ ]
599
+ },
600
+ "class_type": "MediaPipeFaceLandmarker",
601
+ "_meta": {
602
+ "title": "MediaPipe 얼굴 랜드마커"
603
+ }
604
+ },
605
+ "802": {
606
+ "inputs": {
607
+ "regions": "all",
608
+ "face_landmarks": [
609
+ "801",
610
+ 0
611
+ ]
612
+ },
613
+ "class_type": "MediaPipeFaceMask",
614
+ "_meta": {
615
+ "title": "MediaPipe 얼굴 마스크"
616
+ }
617
+ },
618
+ "803": {
619
+ "inputs": {
620
+ "expand": 28,
621
+ "incremental_expandrate": 0,
622
+ "tapered_corners": true,
623
+ "flip_input": false,
624
+ "blur_radius": 18,
625
+ "lerp_alpha": 1,
626
+ "decay_factor": 1,
627
+ "fill_holes": false,
628
+ "mask": [
629
+ "978",
630
+ 0
631
+ ]
632
+ },
633
+ "class_type": "GrowMaskWithBlur",
634
+ "_meta": {
635
+ "title": "Grow Mask With Blur"
636
+ }
637
+ },
638
+ "804": {
639
+ "inputs": {
640
+ "detector_variant": "short",
641
+ "num_faces": 1,
642
+ "min_confidence": 0.5,
643
+ "missing_frame_fallback": "empty",
644
+ "face_detection_model": [
645
+ "800",
646
+ 0
647
+ ],
648
+ "image": [
649
+ "787",
650
+ 0
651
+ ]
652
+ },
653
+ "class_type": "MediaPipeFaceLandmarker",
654
+ "_meta": {
655
+ "title": "MediaPipe 얼굴 랜드마커"
656
+ }
657
+ },
658
+ "805": {
659
+ "inputs": {
660
+ "regions": "all",
661
+ "face_landmarks": [
662
+ "804",
663
+ 0
664
+ ]
665
+ },
666
+ "class_type": "MediaPipeFaceMask",
667
+ "_meta": {
668
+ "title": "MediaPipe 얼굴 마스크"
669
+ }
670
+ },
671
+ "806": {
672
+ "inputs": {
673
+ "expand": 30,
674
+ "incremental_expandrate": 0,
675
+ "tapered_corners": true,
676
+ "flip_input": false,
677
+ "blur_radius": 16,
678
+ "lerp_alpha": 1,
679
+ "decay_factor": 1,
680
+ "fill_holes": false,
681
+ "mask": [
682
+ "980",
683
+ 0
684
+ ]
685
+ },
686
+ "class_type": "GrowMaskWithBlur",
687
+ "_meta": {
688
+ "title": "Grow Mask With Blur"
689
+ }
690
+ },
691
+ "901": {
692
+ "inputs": {
693
+ "image": "20eb87113f03487d598cd47c9c0b08eebd988bcc02f5b9c8e41d7b40c10fea89.png"
694
+ },
695
+ "class_type": "LoadImage",
696
+ "_meta": {
697
+ "title": "아우터"
698
+ }
699
+ },
700
+ "902": {
701
+ "inputs": {
702
+ "image": "9098d7938ebb1a176aa321213c14e2a37b247377191b9bebff3a072759151428.jpg"
703
+ },
704
+ "class_type": "LoadImage",
705
+ "_meta": {
706
+ "title": "상의"
707
+ }
708
+ },
709
+ "903": {
710
+ "inputs": {
711
+ "images.image0": [
712
+ "901",
713
+ 0
714
+ ],
715
+ "images.image1": [
716
+ "902",
717
+ 0
718
+ ],
719
+ "images.image2": [
720
+ "646",
721
+ 0
722
+ ],
723
+ "images.image3": [
724
+ "907",
725
+ 0
726
+ ],
727
+ "images.image4": [
728
+ "908",
729
+ 0
730
+ ],
731
+ "images.image5": [
732
+ "909",
733
+ 0
734
+ ]
735
+ },
736
+ "class_type": "BatchImagesNode",
737
+ "_meta": {
738
+ "title": "이미지 배치"
739
+ }
740
+ },
741
+ "906": {
742
+ "inputs": {
743
+ "image": [
744
+ "915",
745
+ 0
746
+ ]
747
+ },
748
+ "class_type": "ImageRemoveAlpha+",
749
+ "_meta": {
750
+ "title": "🔧 Image Remove Alpha"
751
+ }
752
+ },
753
+ "907": {
754
+ "inputs": {
755
+ "image": "20eb87113f03487d598cd47c9c0b08eebd988bcc02f5b9c8e41d7b40c10fea89.png"
756
+ },
757
+ "class_type": "LoadImage",
758
+ "_meta": {
759
+ "title": "배경"
760
+ }
761
+ },
762
+ "908": {
763
+ "inputs": {
764
+ "image": "8555df2b7b4ddec1d899d7669dd8b9d8f916b2700cc5431d69d33ab1c1a8c782.jpg"
765
+ },
766
+ "class_type": "LoadImage",
767
+ "_meta": {
768
+ "title": "하의"
769
+ }
770
+ },
771
+ "909": {
772
+ "inputs": {
773
+ "image": "6828e4994ab12feaaa0dc17d8c46085626a00226145abf0f90eebde101098a04.png"
774
+ },
775
+ "class_type": "LoadImage",
776
+ "_meta": {
777
+ "title": "신발"
778
+ }
779
+ },
780
+ "910": {
781
+ "inputs": {
782
+ "string_a": "Reference images: 1 = outerwear, 2 = top, 3 = the face identity to use, 4 = the studio background, 5 = bottoms, 6 = shoes, 7 = pose reference (LEFT half: photo; RIGHT half: its exact OpenPose skeleton), 8 = two items side by side (LEFT half: innerwear; RIGHT half: a belt). Generate a photorealistic studio fashion e-commerce photograph of a person matching the apparent gender, age and identity of the face in image 3, wearing ONLY the garments actually provided. GARMENT RULES: a slot is empty ONLY if it is a blank placeholder containing no garment; a garment photographed on a plain white background IS a real garment. NEVER invent an item for an empty slot, and NEVER omit a provided one — with ONE exception: if the chosen framing/crop places a garment (or part of it) outside the frame, that garment is worn but simply not visible; NEVER widen or extend the framing just to reveal a provided garment or shoes. Outerwear (image 1), when provided, is always worn as the outermost layer over the top, clearly visible. If image 5 (bottoms) is a dress/one-piece, it is worn as a single garment covering the torso and legs; do NOT add a separate top or skirt unless image 2 actually provides one. Innerwear (image 8 LEFT) is worn under the top and shows only where the styling naturally allows (neckline, hem, cuffs); the belt (image 8 RIGHT) is worn at the waist and shows only when the top's length or silhouette naturally reveals it — never force a tuck or styling change to expose either. LOGO RULE: render ONLY the logos, prints, text and graphics that are actually visible in the provided product photos, at the same position and size. If a garment photo shows no logo or print, the generated garment carries no logo or print either — NEVER invent a brand mark, monogram, lettering or graphic of any kind. This rule removes NOTHING except invented graphics: the fabric's real texture, weave, knit, pile and every surface detail from the photo must still be reproduced fully and visibly. Reproduce every provided garment's exact color, pattern, fabric, silhouette and construction details wherever visible. POSE: copy from the person in image 7 the body pose, stance, limb and hand positions, body orientation, facial expression, gaze direction and head angle — and nothing else. Never copy that person's hairstyle, clothing, facial identity, or any object they hold, carry or wear (bag, phone, cup, hat, sunglasses, jewelry, headphones, etc.); if they hold something, render the same arm gesture EMPTY-HANDED with relaxed fingers. The generated body must align joint by joint with the skeleton (image 7 RIGHT), but the skeleton defines the POSE ONLY — shoulder width, height, limb length and build follow the Model body proportions instruction in this prompt, never the reference person's physique. If a Camera angle instruction elsewhere in this prompt specifies a head direction or gaze, it governs the HEAD; the skeleton governs the BODY. Never render the skeleton lines or any side-by-side layout — output a single normal photograph. If image 7 is a blank placeholder, use a natural relaxed lookbook pose. BACKGROUND & FINISH: use image 4 as the actual studio backdrop, faithfully reproducing its color, tone, texture and lighting; add nothing that is not in it. Realistic skin, soft even lighting, clean premium lookbook style. (Floor and ground handling depends on the shot instruction that follows.) Composition and pose:",
783
+ "string_b": [
784
+ "996",
785
+ 0
786
+ ],
787
+ "delimiter": " "
788
+ },
789
+ "class_type": "StringConcatenate",
790
+ "_meta": {
791
+ "title": "연결"
792
+ }
793
+ },
794
+ "912": {
795
+ "inputs": {
796
+ "prompts": "Full-body shot from head to toe, model standing straight, with REALISTIC natural human proportions (roughly 7 to 7.5 head-lengths tall) — NOT exaggerated 8-9 head fashion-illustration proportions; do NOT elongate the legs or shrink the head to look more 'model-like'. If the pose reference (image 7, LEFT half) contains a person, copy THAT person's body proportions instead: the same head-to-height ratio, the same leg-to-torso ratio, the same shoulder width and overall build. Keep about 5-6% headroom above the hair and 6-8% space below the feet; the ENTIRE model from head to toe must be fully visible and the feet must NOT be cropped. BACKGROUND & FLOOR: reproduce the given studio backdrop faithfully INCLUDING its floor. If the background reference shows a wall-to-floor line or a lighter floor area near the bottom, KEEP that floor line at the SAME height and reproduce its exact tone and soft shadow — do NOT erase, smooth away, or flatten it. The model stands on that floor. Only avoid inventing a floor line that is not present in the reference. GRID ANCHORS (identical in every generation so cuts align side by side): the top of the head starts at 4-6% of the frame height from the top edge, the eye line sits at 10-13%, the natural waistline / belt line at 42-46%, and the soles of the shoes at 95-97% of the frame height. Anchors are targets, not absolutes: never distort natural body proportions to hit them; if the pose (seated, crouching, leaning) makes one impossible, ignore it.\nUpper-body medium-wide shot with generous studio margins. Pull the camera back slightly, NOT a tight bust crop. Frame from the top of the head down to slightly below the waist, with visible background above the head and on both sides of the shoulders. ABSOLUTELY DO NOT show thighs, knees, shins, or feet; if legs are visible the crop is wrong. BACKGROUND & FLOOR: this is an UPPER-BODY crop — show ONLY the seamless studio backdrop behind the head and shoulders. There must be NO floor, no ground plane, no wall-to-floor line, no horizon line and no horizontal boundary or shadow line anywhere in the frame. GRID ANCHORS (identical in every generation so cuts align side by side): the top of the head starts at 5-7% of the frame height from the top edge, the eye line sits at 12-15%, the natural waistline / belt line at 58-63%, and the bottom frame edge crops at mid-thigh. Anchors are targets, not absolutes: never distort natural body proportions to hit them; if the pose (seated, crouching, leaning) makes one impossible, ignore it.\nLower-body shot: frame from the waist/hip down to just below the shoes, emphasizing the bottoms (image 5) and the shoes (image 6) with clear fabric texture, drape and fit. The head and upper torso are cropped out of frame — this is intentional; crop cleanly around the mid-torso. Keep a small margin of studio floor visible below the shoes, and keep the standard studio background. GRID ANCHOR: the waistline / belt line sits at 8-12% of the frame height from the top edge, and the soles of the shoes at 92-95% — keep these positions IDENTICAL in every generation. Anchors are targets, not absolutes: never distort natural body proportions to hit them; if the pose (seated, crouching, leaning) makes one impossible, ignore it.\nREFERENCE FRAMING (this may be a full-body shot, a partial crop, or anything in between — whatever the reference is): reproduce the framing and composition of the pose reference (image 7, LEFT half) as faithfully as possible — the SAME crop (including partial-body crops such as thigh-up or waist-up), the SAME subject size within the frame (the body occupies the same fraction of the frame height as in the reference — if the reference shows the whole body with visible space above the head and below the feet, reproduce that same space; do NOT enlarge the subject), the SAME camera distance, height and body orientation as the reference photo. This instruction OVERRIDES the standard full-body framing, headroom and margin rules elsewhere in this prompt. Everything else still applies as usual: the model wears the provided garments in front of the provided studio background, and the clothing, lighting, props, BACKGROUND, environment and identity of the reference person are NEVER copied — only pose, expression, gaze, framing and BODY PROPORTIONS. BACKGROUND SOURCE (STRICT): the background always comes ONLY from the provided studio background reference (image 4). Completely ignore the pose reference's background — its wall color, floor, furniture, outdoor scenery, location and ambient lighting must NOT appear in the output in any form. Keep the whole head, hands and feet inside the frame unless the reference itself clearly cuts them off; never introduce a crop that the reference does not have. BODY PROPORTIONS (copy from the reference): match the reference model's natural proportions exactly — the same head-to-height ratio (count how many head-lengths tall the reference person is and reproduce it), the same leg-to-torso ratio, the same shoulder width and overall build. Whatever the reference person's proportions are — longer or shorter legs, larger or smaller head, slimmer or broader build — reproduce those exact proportions in the generated model, as-is, without adjusting them toward any beauty standard in either direction. CROP PRIORITY: the reference crop decides what is shown. If a provided garment or any styling instruction refers to a body area outside the reference crop (e.g., shoes, pant hems, how trousers stack on the feet), do NOT extend the frame to show it — apply such instructions only to what is visible inside the crop and ignore the rest.",
797
+ "index": [
798
+ "917",
799
+ 1
800
+ ]
801
+ },
802
+ "class_type": "FL_PromptSelectorBasic",
803
+ "_meta": {
804
+ "title": "FL Prompt Selector Basic"
805
+ }
806
+ },
807
+ "914": {
808
+ "inputs": {
809
+ "string_a": [
810
+ "910",
811
+ 0
812
+ ],
813
+ "string_b": "",
814
+ "delimiter": " "
815
+ },
816
+ "class_type": "StringConcatenate",
817
+ "_meta": {
818
+ "title": "프롬프트 작성"
819
+ }
820
+ },
821
+ "915": {
822
+ "inputs": {
823
+ "prompt": [
824
+ "1045",
825
+ 0
826
+ ],
827
+ "model": "gpt-image-2",
828
+ "model.size": "Custom",
829
+ "model.custom_width": 1536,
830
+ "model.custom_height": 2048,
831
+ "model.background": "auto",
832
+ "model.quality": "medium",
833
+ "n": 1,
834
+ "seed": 1919368201,
835
+ "model.images.image_1": [
836
+ "901",
837
+ 0
838
+ ],
839
+ "model.images.image_2": [
840
+ "902",
841
+ 0
842
+ ],
843
+ "model.images.image_3": [
844
+ "646",
845
+ 0
846
+ ],
847
+ "model.images.image_4": [
848
+ "907",
849
+ 0
850
+ ],
851
+ "model.images.image_5": [
852
+ "908",
853
+ 0
854
+ ],
855
+ "model.images.image_6": [
856
+ "909",
857
+ 0
858
+ ],
859
+ "model.images.image_7": [
860
+ "961",
861
+ 0
862
+ ],
863
+ "model.images.image_8": [
864
+ "1043",
865
+ 0
866
+ ],
867
+ "model.images.image_9": [
868
+ "1060",
869
+ 0
870
+ ],
871
+ "model.images.image_10": [
872
+ "1061",
873
+ 0
874
+ ],
875
+ "model.images.image_11": [
876
+ "1062",
877
+ 0
878
+ ],
879
+ "model.images.image_12": [
880
+ "1063",
881
+ 0
882
+ ]
883
+ },
884
+ "class_type": "OpenAIGPTImageNodeV2",
885
+ "_meta": {
886
+ "title": "품질"
887
+ }
888
+ },
889
+ "916": {
890
+ "inputs": {
891
+ "image": "20eb87113f03487d598cd47c9c0b08eebd988bcc02f5b9c8e41d7b40c10fea89.png"
892
+ },
893
+ "class_type": "LoadImage",
894
+ "_meta": {
895
+ "title": "레퍼런스 포즈"
896
+ }
897
+ },
898
+ "917": {
899
+ "inputs": {
900
+ "choice": "레퍼런스 구도 그대로",
901
+ "index": 3,
902
+ "option1": "전신컷",
903
+ "option2": "상반신컷",
904
+ "option3": "하반신컷",
905
+ "option4": "레퍼런스 구도 그대로",
906
+ "option5": ""
907
+ },
908
+ "class_type": "CustomCombo",
909
+ "_meta": {
910
+ "title": "샷 선택"
911
+ }
912
+ },
913
+ "918": {
914
+ "inputs": {
915
+ "choice": "성인",
916
+ "index": 0,
917
+ "option1": "성인",
918
+ "option2": "키즈",
919
+ "option3": ""
920
+ },
921
+ "class_type": "CustomCombo",
922
+ "_meta": {
923
+ "title": "연령"
924
+ }
925
+ },
926
+ "930": {
927
+ "inputs": {
928
+ "choice": "골반",
929
+ "index": 1,
930
+ "option1": "허리",
931
+ "option2": "골반",
932
+ "option3": "허벅지",
933
+ "option4": ""
934
+ },
935
+ "class_type": "CustomCombo",
936
+ "_meta": {
937
+ "title": "아우터 길이"
938
+ }
939
+ },
940
+ "931": {
941
+ "inputs": {
942
+ "choice": "표준",
943
+ "index": 1,
944
+ "option1": "슬림",
945
+ "option2": "표준",
946
+ "option3": "오버",
947
+ "option4": ""
948
+ },
949
+ "class_type": "CustomCombo",
950
+ "_meta": {
951
+ "title": "아우터 핏"
952
+ }
953
+ },
954
+ "932": {
955
+ "inputs": {
956
+ "choice": "골반",
957
+ "index": 1,
958
+ "option1": "허리",
959
+ "option2": "골반",
960
+ "option3": "허벅지",
961
+ "option4": ""
962
+ },
963
+ "class_type": "CustomCombo",
964
+ "_meta": {
965
+ "title": "상의 길이"
966
+ }
967
+ },
968
+ "933": {
969
+ "inputs": {
970
+ "choice": "오버",
971
+ "index": 2,
972
+ "option1": "슬림",
973
+ "option2": "표준",
974
+ "option3": "오버",
975
+ "option4": ""
976
+ },
977
+ "class_type": "CustomCombo",
978
+ "_meta": {
979
+ "title": "상의 핏"
980
+ }
981
+ },
982
+ "934": {
983
+ "inputs": {
984
+ "choice": "신발",
985
+ "index": 4,
986
+ "option1": "허벅지",
987
+ "option2": "무릎",
988
+ "option3": "종아리",
989
+ "option4": "발목",
990
+ "option5": "신발",
991
+ "option6": ""
992
+ },
993
+ "class_type": "CustomCombo",
994
+ "_meta": {
995
+ "title": "하의 길이"
996
+ }
997
+ },
998
+ "935": {
999
+ "inputs": {
1000
+ "choice": "오버",
1001
+ "index": 2,
1002
+ "option1": "슬림",
1003
+ "option2": "표준",
1004
+ "option3": "오버",
1005
+ "option4": ""
1006
+ },
1007
+ "class_type": "CustomCombo",
1008
+ "_meta": {
1009
+ "title": "하의 핏"
1010
+ }
1011
+ },
1012
+ "940": {
1013
+ "inputs": {
1014
+ "prompts": "the outer layer hem ends around the waist\nthe outer layer hem ends around the hip\nthe outer layer hem ends around mid-thigh",
1015
+ "index": [
1016
+ "930",
1017
+ 1
1018
+ ]
1019
+ },
1020
+ "class_type": "FL_PromptSelectorBasic",
1021
+ "_meta": {
1022
+ "title": "FL Prompt Selector Basic"
1023
+ }
1024
+ },
1025
+ "941": {
1026
+ "inputs": {
1027
+ "prompts": "the outer layer has a slim fit\nthe outer layer has a standard regular fit\nthe outer layer has an oversized loose fit",
1028
+ "index": [
1029
+ "931",
1030
+ 1
1031
+ ]
1032
+ },
1033
+ "class_type": "FL_PromptSelectorBasic",
1034
+ "_meta": {
1035
+ "title": "FL Prompt Selector Basic"
1036
+ }
1037
+ },
1038
+ "942": {
1039
+ "inputs": {
1040
+ "prompts": "the top hem ends around the waist\nthe top hem ends around the hip\nthe top hem ends around mid-thigh",
1041
+ "index": [
1042
+ "932",
1043
+ 1
1044
+ ]
1045
+ },
1046
+ "class_type": "FL_PromptSelectorBasic",
1047
+ "_meta": {
1048
+ "title": "FL Prompt Selector Basic"
1049
+ }
1050
+ },
1051
+ "943": {
1052
+ "inputs": {
1053
+ "prompts": "the top has a slim fit\nthe top has a standard regular fit\nthe top has an oversized loose fit",
1054
+ "index": [
1055
+ "933",
1056
+ 1
1057
+ ]
1058
+ },
1059
+ "class_type": "FL_PromptSelectorBasic",
1060
+ "_meta": {
1061
+ "title": "FL Prompt Selector Basic"
1062
+ }
1063
+ },
1064
+ "944": {
1065
+ "inputs": {
1066
+ "prompts": "the bottoms end around mid-thigh (shorts length)\nthe bottoms end at the knee\nthe bottoms end at mid-calf\nthe bottoms end at the ankle (full length)\nthe bottoms fall over the shoes (extra long)",
1067
+ "index": [
1068
+ "934",
1069
+ 1
1070
+ ]
1071
+ },
1072
+ "class_type": "FL_PromptSelectorBasic",
1073
+ "_meta": {
1074
+ "title": "FL Prompt Selector Basic"
1075
+ }
1076
+ },
1077
+ "945": {
1078
+ "inputs": {
1079
+ "prompts": "the bottoms have a slim fit\nthe bottoms have a standard regular fit\nthe bottoms have an oversized loose fit",
1080
+ "index": [
1081
+ "935",
1082
+ 1
1083
+ ]
1084
+ },
1085
+ "class_type": "FL_PromptSelectorBasic",
1086
+ "_meta": {
1087
+ "title": "FL Prompt Selector Basic"
1088
+ }
1089
+ },
1090
+ "946": {
1091
+ "inputs": {
1092
+ "prompts": "Model body proportions: an ADULT fashion model (this subject is an adult, NOT a child). For a MALE subject: a tall, lean high-fashion male runway model about 185cm and 8.5 heads tall, with a DISTINCTLY SMALL HEAD (about one-eighth of body height) on a long neck, BROAD straight square shoulders about 2.3 to 2.5 times the head width, long straight legs and a lean athletic V-shaped torso; strictly avoid a large head, narrow or sloping shoulders, or a short stocky body. For a FEMALE subject: a slender runway build about 8.5 to 9 heads tall, small head, long legs and elegant upright posture. Render clearly ADULT proportions; do NOT render a child or childlike, short, big-headed proportions.\nModel body proportions: a CHILD kids-catalog model, matching the apparent age and gender of the face (a young girl or boy). Render a natural, healthy, well-proportioned child with an age-typical BALANCED build (roughly 6 to 7 heads tall for a school-age child), upright posture and clean, MODEST, age-appropriate kids styling. The head should look natural and balanced, not exaggerated or oversized. Do NOT apply adult runway proportions.",
1093
+ "index": [
1094
+ "918",
1095
+ 1
1096
+ ]
1097
+ },
1098
+ "class_type": "FL_PromptSelectorBasic",
1099
+ "_meta": {
1100
+ "title": "FL Prompt Selector Basic"
1101
+ }
1102
+ },
1103
+ "950": {
1104
+ "inputs": {
1105
+ "string_a": [
1106
+ "940",
1107
+ 0
1108
+ ],
1109
+ "string_b": [
1110
+ "941",
1111
+ 0
1112
+ ],
1113
+ "delimiter": ", "
1114
+ },
1115
+ "class_type": "StringConcatenate",
1116
+ "_meta": {
1117
+ "title": "연결"
1118
+ }
1119
+ },
1120
+ "951": {
1121
+ "inputs": {
1122
+ "string_a": [
1123
+ "950",
1124
+ 0
1125
+ ],
1126
+ "string_b": [
1127
+ "942",
1128
+ 0
1129
+ ],
1130
+ "delimiter": ", "
1131
+ },
1132
+ "class_type": "StringConcatenate",
1133
+ "_meta": {
1134
+ "title": "연결"
1135
+ }
1136
+ },
1137
+ "952": {
1138
+ "inputs": {
1139
+ "string_a": [
1140
+ "951",
1141
+ 0
1142
+ ],
1143
+ "string_b": [
1144
+ "943",
1145
+ 0
1146
+ ],
1147
+ "delimiter": ", "
1148
+ },
1149
+ "class_type": "StringConcatenate",
1150
+ "_meta": {
1151
+ "title": "연결"
1152
+ }
1153
+ },
1154
+ "953": {
1155
+ "inputs": {
1156
+ "string_a": [
1157
+ "952",
1158
+ 0
1159
+ ],
1160
+ "string_b": [
1161
+ "944",
1162
+ 0
1163
+ ],
1164
+ "delimiter": ", "
1165
+ },
1166
+ "class_type": "StringConcatenate",
1167
+ "_meta": {
1168
+ "title": "연결"
1169
+ }
1170
+ },
1171
+ "954": {
1172
+ "inputs": {
1173
+ "string_a": [
1174
+ "953",
1175
+ 0
1176
+ ],
1177
+ "string_b": [
1178
+ "945",
1179
+ 0
1180
+ ],
1181
+ "delimiter": ", "
1182
+ },
1183
+ "class_type": "StringConcatenate",
1184
+ "_meta": {
1185
+ "title": "연결"
1186
+ }
1187
+ },
1188
+ "955": {
1189
+ "inputs": {
1190
+ "string_a": [
1191
+ "914",
1192
+ 0
1193
+ ],
1194
+ "string_b": [
1195
+ "954",
1196
+ 0
1197
+ ],
1198
+ "delimiter": " Garment length and fit: "
1199
+ },
1200
+ "class_type": "StringConcatenate",
1201
+ "_meta": {
1202
+ "title": "연결"
1203
+ }
1204
+ },
1205
+ "956": {
1206
+ "inputs": {
1207
+ "string_a": [
1208
+ "955",
1209
+ 0
1210
+ ],
1211
+ "string_b": [
1212
+ "946",
1213
+ 0
1214
+ ],
1215
+ "delimiter": " Body proportions: "
1216
+ },
1217
+ "class_type": "StringConcatenate",
1218
+ "_meta": {
1219
+ "title": "연결"
1220
+ }
1221
+ },
1222
+ "957": {
1223
+ "inputs": {
1224
+ "image": "39fe1862e41ed99a7aa467d2aff264aa21bd5970d4e20b22d6c8c92668de0e66.png"
1225
+ },
1226
+ "class_type": "LoadImage",
1227
+ "_meta": {
1228
+ "title": "이너웨어"
1229
+ }
1230
+ },
1231
+ "958": {
1232
+ "inputs": {
1233
+ "image": "8aee20214d94656c93395ca378405b6f20935e905cfd244cfe1c87c4e43973b5.jpg"
1234
+ },
1235
+ "class_type": "LoadImage",
1236
+ "_meta": {
1237
+ "title": "벨트"
1238
+ }
1239
+ },
1240
+ "959": {
1241
+ "inputs": {
1242
+ "detect_hand": "enable",
1243
+ "detect_body": "enable",
1244
+ "detect_face": "disable",
1245
+ "resolution": 1024,
1246
+ "bbox_detector": "yolox_l.onnx",
1247
+ "pose_estimator": "dw-ll_ucoco_384_bs5.torchscript.pt",
1248
+ "scale_stick_for_xinsr_cn": "disable",
1249
+ "image": [
1250
+ "916",
1251
+ 0
1252
+ ]
1253
+ },
1254
+ "class_type": "DWPreprocessor",
1255
+ "_meta": {
1256
+ "title": "포즈 스켈레톤 (몸·손만)"
1257
+ }
1258
+ },
1259
+ "961": {
1260
+ "inputs": {
1261
+ "direction": "right",
1262
+ "match_image_size": true,
1263
+ "spacing_width": 16,
1264
+ "spacing_color": "white",
1265
+ "image1": [
1266
+ "916",
1267
+ 0
1268
+ ],
1269
+ "image2": [
1270
+ "959",
1271
+ 0
1272
+ ]
1273
+ },
1274
+ "class_type": "ImageStitch",
1275
+ "_meta": {
1276
+ "title": "포즈사진+스켈레톤 결합"
1277
+ }
1278
+ },
1279
+ "962": {
1280
+ "inputs": {
1281
+ "choice": "반측면",
1282
+ "index": 1,
1283
+ "option1": "정면",
1284
+ "option2": "반측면",
1285
+ "option3": "측면",
1286
+ "option4": "로우앵글",
1287
+ "option5": "하이앵글",
1288
+ "option6": "후면",
1289
+ "option7": ""
1290
+ },
1291
+ "class_type": "CustomCombo",
1292
+ "_meta": {
1293
+ "title": "카메라 앵글"
1294
+ }
1295
+ },
1296
+ "963": {
1297
+ "inputs": {
1298
+ "prompts": "Camera angle: the camera is placed directly in front of the model at eye level. If a pose reference is given, render that exact pose seen from the front; otherwise the model faces the camera directly, eyes into the lens.\nCamera angle: the camera is positioned about 45 degrees to the side of the model (three-quarter viewpoint). Render the model's pose SEEN FROM this 45-degree viewpoint — do NOT change the pose itself; the same stance, limbs and gesture, just viewed from 45 degrees off-axis. Head anchors from this viewpoint: the nose points off-camera, one ear is mostly hidden, the face looks clearly asymmetric; NEVER a straight-on frontal face. This viewpoint instruction OVERRIDES any other head or gaze instruction.\nCamera angle: the camera is positioned 90 degrees to the side of the model (full profile viewpoint). Render the model's pose SEEN FROM the side — do NOT change the pose itself. The head is in full profile like the body, only ONE eye visible, gaze directed slightly downward and forward, NEVER turned toward the camera. This viewpoint instruction OVERRIDES any other head or gaze instruction.\nCamera angle: low-angle viewpoint — the camera is placed clearly BELOW the subject's eye level, pointing upward (for a full-body shot: around the model's knee height; for an upper-body shot: around chest height). Render the model's pose SEEN FROM below with natural upward perspective — do NOT change the pose itself; chin level or slightly down, eyes looking down toward the camera. This viewpoint instruction OVERRIDES any other head or gaze instruction.\nCamera angle: high-angle viewpoint — the camera is placed clearly ABOVE the subject's eye level, pointing downward (for a full-body shot: well above the head; for an upper-body shot: slightly above the head). Render the model's pose SEEN FROM above — do NOT change the pose itself; the face tilts slightly up, eyes looking up toward the camera. This viewpoint instruction OVERRIDES any other head or gaze instruction.\nBACK VIEW: render the scene from DIRECTLY BEHIND the model — the camera sees the model's back: the back of the head and hair, and the BACK of every garment (back panels, back prints, the hood hanging down over the back if present). The face is NOT visible — no eyes, nose or mouth; at most a hint of cheek contour. Render the reference pose as seen from behind. This instruction OVERRIDES every gaze and head-direction instruction in this prompt — the model faces away from the camera. STANCE (back view default): a clean, square standing back view — shoulders LEVEL and parallel to the camera, hips square, spine upright, NO body twist and NO three-quarter turn; the back of the head faces the camera centered, with NO cheek or face contour visible. Arms hang relaxed at the sides with hands visible and empty (NOT in pockets), feet about hip-width apart with weight even on both legs. Deviate from this stance ONLY if a pose reference image explicitly shows a different pose.",
1299
+ "index": [
1300
+ "962",
1301
+ 1
1302
+ ]
1303
+ },
1304
+ "class_type": "FL_PromptSelectorBasic",
1305
+ "_meta": {
1306
+ "title": "앵글 프롬프트 (GPT)"
1307
+ }
1308
+ },
1309
+ "964": {
1310
+ "inputs": {
1311
+ "string_a": [
1312
+ "912",
1313
+ 0
1314
+ ],
1315
+ "string_b": [
1316
+ "963",
1317
+ 0
1318
+ ],
1319
+ "delimiter": " "
1320
+ },
1321
+ "class_type": "StringConcatenate",
1322
+ "_meta": {
1323
+ "title": "샷+앵글 결합"
1324
+ }
1325
+ },
1326
+ "965": {
1327
+ "inputs": {
1328
+ "prompts": "(side-view:1.5), (head turned:1.2)\n(direct front-facing head:1.5), (looking straight at camera:1.4), (symmetrical frontal face:1.4), (full side profile:1.3)\n(front-facing head:1.5), (looking at viewer:1.5), (eye contact:1.4), (head turned toward camera:1.4), (both eyes visible:1.4)\n(side-view:1.3), (looking away:1.3)\n(side-view:1.3), (looking away:1.3)\n(frontal face:1.5), (face visible:1.4), (looking at camera:1.5), (turned head:1.3)",
1329
+ "index": [
1330
+ "962",
1331
+ 1
1332
+ ]
1333
+ },
1334
+ "class_type": "FL_PromptSelectorBasic",
1335
+ "_meta": {
1336
+ "title": "앵글 네거티브 (Qwen)"
1337
+ }
1338
+ },
1339
+ "966": {
1340
+ "inputs": {
1341
+ "string_a": [
1342
+ "997",
1343
+ 0
1344
+ ],
1345
+ "string_b": "",
1346
+ "delimiter": ", "
1347
+ },
1348
+ "class_type": "StringConcatenate",
1349
+ "_meta": {
1350
+ "title": "네거티브 조립"
1351
+ }
1352
+ },
1353
+ "967": {
1354
+ "inputs": {
1355
+ "prompts": "The head faces the camera directly as in the second image.\nIMPORTANT: keep the head rotated about 45 degrees exactly as in the first image — nose pointing to the side, one ear mostly hidden, clearly asymmetric face; use the second image ONLY for facial identity and features, NOT for head orientation; do NOT rotate the head toward the camera; the eyes may glance toward the camera while the head stays turned.\nIMPORTANT: the head MUST stay in full side profile exactly as in the first image, facing the same direction as the body, with only ONE eye visible and the gaze directed slightly downward and forward; use the second image ONLY for facial identity and features, NEVER for head orientation; do NOT rotate the head or the eyes toward the camera.\nKeep the upward camera perspective on the head exactly as in the first image, eyes looking down toward the camera; use the second image only for facial identity, not head orientation.\nKeep the downward camera perspective on the head exactly as in the first image, eyes looking up toward the camera; use the second image only for facial identity, not head orientation.\nThe subject is seen from directly behind: only the back of the head with natural hair is visible, no facial features are rendered.",
1356
+ "index": [
1357
+ "962",
1358
+ 1
1359
+ ]
1360
+ },
1361
+ "class_type": "FL_PromptSelectorBasic",
1362
+ "_meta": {
1363
+ "title": "앵글 포지티브 (Qwen)"
1364
+ }
1365
+ },
1366
+ "968": {
1367
+ "inputs": {
1368
+ "string_a": "combine the body from first image with the head from the second image to make one coherent person with correct anatomical proportions. Preserve the lighting, environment and background from the first image. \n\n",
1369
+ "string_b": [
1370
+ "998",
1371
+ 0
1372
+ ],
1373
+ "delimiter": " "
1374
+ },
1375
+ "class_type": "StringConcatenate",
1376
+ "_meta": {
1377
+ "title": "포지티브 조립"
1378
+ }
1379
+ },
1380
+ "976": {
1381
+ "inputs": {
1382
+ "model_name": "bbox/face_yolov8m.pt"
1383
+ },
1384
+ "class_type": "UltralyticsDetectorProvider",
1385
+ "_meta": {
1386
+ "title": "YOLO 얼굴 감지기"
1387
+ }
1388
+ },
1389
+ "977": {
1390
+ "inputs": {
1391
+ "threshold": 0.4,
1392
+ "dilation": 15,
1393
+ "crop_factor": 3,
1394
+ "drop_size": 48,
1395
+ "labels": "all",
1396
+ "bbox_detector": [
1397
+ "976",
1398
+ 0
1399
+ ],
1400
+ "image": [
1401
+ "786",
1402
+ 0
1403
+ ]
1404
+ },
1405
+ "class_type": "BboxDetectorSEGS",
1406
+ "_meta": {
1407
+ "title": "얼굴 감지 (생성 이미지)"
1408
+ }
1409
+ },
1410
+ "978": {
1411
+ "inputs": {
1412
+ "segs": [
1413
+ "977",
1414
+ 0
1415
+ ]
1416
+ },
1417
+ "class_type": "SegsToCombinedMask",
1418
+ "_meta": {
1419
+ "title": "SEGS to MASK (combined)"
1420
+ }
1421
+ },
1422
+ "979": {
1423
+ "inputs": {
1424
+ "threshold": 0.4,
1425
+ "dilation": 15,
1426
+ "crop_factor": 3,
1427
+ "drop_size": 40,
1428
+ "labels": "all",
1429
+ "bbox_detector": [
1430
+ "976",
1431
+ 0
1432
+ ],
1433
+ "image": [
1434
+ "787",
1435
+ 0
1436
+ ]
1437
+ },
1438
+ "class_type": "BboxDetectorSEGS",
1439
+ "_meta": {
1440
+ "title": "얼굴 감지 (레퍼런스)"
1441
+ }
1442
+ },
1443
+ "980": {
1444
+ "inputs": {
1445
+ "segs": [
1446
+ "979",
1447
+ 0
1448
+ ]
1449
+ },
1450
+ "class_type": "SegsToCombinedMask",
1451
+ "_meta": {
1452
+ "title": "SEGS to MASK (combined)"
1453
+ }
1454
+ },
1455
+ "981": {
1456
+ "inputs": {
1457
+ "model_name": "bbox/hand_yolov8s.pt"
1458
+ },
1459
+ "class_type": "UltralyticsDetectorProvider",
1460
+ "_meta": {
1461
+ "title": "YOLO 손 감지기"
1462
+ }
1463
+ },
1464
+ "982": {
1465
+ "inputs": {
1466
+ "threshold": 0.35,
1467
+ "dilation": 12,
1468
+ "crop_factor": 3,
1469
+ "drop_size": 40,
1470
+ "labels": "all",
1471
+ "bbox_detector": [
1472
+ "981",
1473
+ 0
1474
+ ],
1475
+ "image": [
1476
+ "716",
1477
+ 0
1478
+ ]
1479
+ },
1480
+ "class_type": "BboxDetectorSEGS",
1481
+ "_meta": {
1482
+ "title": "손 감지"
1483
+ }
1484
+ },
1485
+ "983": {
1486
+ "inputs": {
1487
+ "segs": [
1488
+ "982",
1489
+ 0
1490
+ ]
1491
+ },
1492
+ "class_type": "SegsToCombinedMask",
1493
+ "_meta": {
1494
+ "title": "SEGS to MASK (combined)"
1495
+ }
1496
+ },
1497
+ "984": {
1498
+ "inputs": {
1499
+ "expand": 16,
1500
+ "incremental_expandrate": 0,
1501
+ "tapered_corners": true,
1502
+ "flip_input": false,
1503
+ "blur_radius": 12,
1504
+ "lerp_alpha": 1,
1505
+ "decay_factor": 1,
1506
+ "fill_holes": false,
1507
+ "mask": [
1508
+ "983",
1509
+ 0
1510
+ ]
1511
+ },
1512
+ "class_type": "GrowMaskWithBlur",
1513
+ "_meta": {
1514
+ "title": "손 마스크 확장"
1515
+ }
1516
+ },
1517
+ "985": {
1518
+ "inputs": {
1519
+ "downscale_algorithm": "lanczos",
1520
+ "upscale_algorithm": "lanczos",
1521
+ "preresize": false,
1522
+ "preresize_mode": "ensure minimum resolution",
1523
+ "preresize_min_width": 1024,
1524
+ "preresize_min_height": 1024,
1525
+ "preresize_max_width": 16384,
1526
+ "preresize_max_height": 16384,
1527
+ "mask_fill_holes": true,
1528
+ "mask_expand_pixels": 20,
1529
+ "mask_invert": false,
1530
+ "mask_blend_pixels": 5,
1531
+ "mask_hipass_filter": 0.1,
1532
+ "extend_for_outpainting": true,
1533
+ "extend_up_factor": 1,
1534
+ "extend_down_factor": 1,
1535
+ "extend_left_factor": 1,
1536
+ "extend_right_factor": 1,
1537
+ "context_from_mask_extend_factor": 2.5,
1538
+ "output_resize_to_target_size": true,
1539
+ "output_target_width": 1024,
1540
+ "output_target_height": 1024,
1541
+ "output_padding": "128",
1542
+ "image": [
1543
+ "716",
1544
+ 0
1545
+ ],
1546
+ "mask": [
1547
+ "984",
1548
+ 0
1549
+ ]
1550
+ },
1551
+ "class_type": "InpaintCropImproved",
1552
+ "_meta": {
1553
+ "title": "손 크롭"
1554
+ }
1555
+ },
1556
+ "986": {
1557
+ "inputs": {
1558
+ "prompt": "Repair the hands in this image: each visible hand must have exactly five fingers with natural proportions, correct joint anatomy and realistic skin texture. Keep the same skin tone, lighting, pose, gesture, and any contact with garments or objects unchanged. Do not change anything outside the hands.",
1559
+ "clip": [
1560
+ "38",
1561
+ 0
1562
+ ],
1563
+ "vae": [
1564
+ "39",
1565
+ 0
1566
+ ],
1567
+ "image1": [
1568
+ "985",
1569
+ 1
1570
+ ]
1571
+ },
1572
+ "class_type": "TextEncodeQwenImageEditPlus",
1573
+ "_meta": {
1574
+ "title": "손 보정 (positive)"
1575
+ }
1576
+ },
1577
+ "987": {
1578
+ "inputs": {
1579
+ "prompt": "(extra fingers:1.5), (six fingers:1.5), (missing fingers:1.4), (fused fingers:1.4), (deformed hands:1.4), (extra thumb:1.3), (mangled hands:1.4), (broken wrist:1.2)",
1580
+ "clip": [
1581
+ "38",
1582
+ 0
1583
+ ],
1584
+ "vae": [
1585
+ "39",
1586
+ 0
1587
+ ],
1588
+ "image1": [
1589
+ "985",
1590
+ 1
1591
+ ]
1592
+ },
1593
+ "class_type": "TextEncodeQwenImageEditPlus",
1594
+ "_meta": {
1595
+ "title": "손 보정 (negative)"
1596
+ }
1597
+ },
1598
+ "988": {
1599
+ "inputs": {
1600
+ "noise_mask": true,
1601
+ "positive": [
1602
+ "986",
1603
+ 0
1604
+ ],
1605
+ "negative": [
1606
+ "987",
1607
+ 0
1608
+ ],
1609
+ "vae": [
1610
+ "39",
1611
+ 0
1612
+ ],
1613
+ "pixels": [
1614
+ "985",
1615
+ 1
1616
+ ],
1617
+ "mask": [
1618
+ "985",
1619
+ 2
1620
+ ]
1621
+ },
1622
+ "class_type": "InpaintModelConditioning",
1623
+ "_meta": {
1624
+ "title": "인페인팅 모델 조건 설정"
1625
+ }
1626
+ },
1627
+ "989": {
1628
+ "inputs": {
1629
+ "seed": 777703815891662,
1630
+ "steps": 8,
1631
+ "cfg": 1,
1632
+ "sampler_name": "euler",
1633
+ "scheduler": "simple",
1634
+ "denoise": 0.4,
1635
+ "model": [
1636
+ "387",
1637
+ 0
1638
+ ],
1639
+ "positive": [
1640
+ "988",
1641
+ 0
1642
+ ],
1643
+ "negative": [
1644
+ "988",
1645
+ 1
1646
+ ],
1647
+ "latent_image": [
1648
+ "988",
1649
+ 2
1650
+ ]
1651
+ },
1652
+ "class_type": "KSampler",
1653
+ "_meta": {
1654
+ "title": "손 보정 샘플러"
1655
+ }
1656
+ },
1657
+ "990": {
1658
+ "inputs": {
1659
+ "samples": [
1660
+ "989",
1661
+ 0
1662
+ ],
1663
+ "vae": [
1664
+ "39",
1665
+ 0
1666
+ ]
1667
+ },
1668
+ "class_type": "VAEDecode",
1669
+ "_meta": {
1670
+ "title": "VAE 디코드"
1671
+ }
1672
+ },
1673
+ "991": {
1674
+ "inputs": {
1675
+ "stitcher": [
1676
+ "985",
1677
+ 0
1678
+ ],
1679
+ "inpainted_image": [
1680
+ "990",
1681
+ 0
1682
+ ]
1683
+ },
1684
+ "class_type": "InpaintStitchImproved",
1685
+ "_meta": {
1686
+ "title": "손 보정 합성"
1687
+ }
1688
+ },
1689
+ "992": {
1690
+ "inputs": {
1691
+ "choice": "자연스러운 시선",
1692
+ "index": 1,
1693
+ "option1": "카메라 응시",
1694
+ "option2": "자연스러운 시선",
1695
+ "option3": ""
1696
+ },
1697
+ "class_type": "CustomCombo",
1698
+ "_meta": {
1699
+ "title": "시선"
1700
+ }
1701
+ },
1702
+ "993": {
1703
+ "inputs": {
1704
+ "prompts": " \nGAZE OVERRIDE: the model does NOT look into the camera. If a pose reference is given, the gaze follows the pose reference person's gaze direction; otherwise the gaze is directed naturally downward or into the distance, calm and candid. This gaze instruction OVERRIDES every earlier gaze instruction in this prompt. EXCEPTION: if this prompt specifies a BACK VIEW camera angle, ignore this gaze instruction completely — the model faces directly away from the camera and no eyes, gaze or face are shown.",
1705
+ "index": [
1706
+ "992",
1707
+ 1
1708
+ ]
1709
+ },
1710
+ "class_type": "FL_PromptSelectorBasic",
1711
+ "_meta": {
1712
+ "title": "시선 프롬프트 (GPT)"
1713
+ }
1714
+ },
1715
+ "994": {
1716
+ "inputs": {
1717
+ "prompts": "(looking away:1.4)\n(looking at viewer:1.4), (eye contact:1.3), (staring into camera:1.3)",
1718
+ "index": [
1719
+ "992",
1720
+ 1
1721
+ ]
1722
+ },
1723
+ "class_type": "FL_PromptSelectorBasic",
1724
+ "_meta": {
1725
+ "title": "시선 네거티브 (Qwen)"
1726
+ }
1727
+ },
1728
+ "995": {
1729
+ "inputs": {
1730
+ "prompts": " \nThe gaze does NOT meet the camera; preserve the eye and gaze direction from the first image.",
1731
+ "index": [
1732
+ "992",
1733
+ 1
1734
+ ]
1735
+ },
1736
+ "class_type": "FL_PromptSelectorBasic",
1737
+ "_meta": {
1738
+ "title": "시선 포지티브 (Qwen)"
1739
+ }
1740
+ },
1741
+ "996": {
1742
+ "inputs": {
1743
+ "string_a": [
1744
+ "964",
1745
+ 0
1746
+ ],
1747
+ "string_b": [
1748
+ "993",
1749
+ 0
1750
+ ],
1751
+ "delimiter": " "
1752
+ },
1753
+ "class_type": "StringConcatenate",
1754
+ "_meta": {
1755
+ "title": "샷+앵글+시선 결합"
1756
+ }
1757
+ },
1758
+ "997": {
1759
+ "inputs": {
1760
+ "string_a": [
1761
+ "965",
1762
+ 0
1763
+ ],
1764
+ "string_b": [
1765
+ "994",
1766
+ 0
1767
+ ],
1768
+ "delimiter": ", "
1769
+ },
1770
+ "class_type": "StringConcatenate",
1771
+ "_meta": {
1772
+ "title": "네거티브+시선 결합"
1773
+ }
1774
+ },
1775
+ "998": {
1776
+ "inputs": {
1777
+ "string_a": [
1778
+ "967",
1779
+ 0
1780
+ ],
1781
+ "string_b": [
1782
+ "995",
1783
+ 0
1784
+ ],
1785
+ "delimiter": " "
1786
+ },
1787
+ "class_type": "StringConcatenate",
1788
+ "_meta": {
1789
+ "title": "포지티브+시선 결합"
1790
+ }
1791
+ },
1792
+ "999": {
1793
+ "inputs": {
1794
+ "image1": [
1795
+ "901",
1796
+ 0
1797
+ ],
1798
+ "image2": [
1799
+ "902",
1800
+ 0
1801
+ ]
1802
+ },
1803
+ "class_type": "ImageBatch",
1804
+ "_meta": {
1805
+ "title": "이미지 배치"
1806
+ }
1807
+ },
1808
+ "1000": {
1809
+ "inputs": {
1810
+ "image1": [
1811
+ "999",
1812
+ 0
1813
+ ],
1814
+ "image2": [
1815
+ "908",
1816
+ 0
1817
+ ]
1818
+ },
1819
+ "class_type": "ImageBatch",
1820
+ "_meta": {
1821
+ "title": "이미지 배치"
1822
+ }
1823
+ },
1824
+ "1001": {
1825
+ "inputs": {
1826
+ "prompt": "You are a fashion product analyst. Five garment product photos are provided in this exact order: (1) outerwear, (2) top, (3) bottoms — or a dress/one-piece: if the garment in photo 3 is a dress, explicitly call it a dress, describe its full length (mini/knee/midi/maxi) and note that it is a single garment worn in place of both top and bottoms, (4) innerwear (a base layer worn under the top), (5) a belt. For each, write ONE compact line describing its exact construction so an image generation model can reproduce it faithfully: garment category (e.g., hooded pullover, collared shirt, puffer jacket, skirt, wide pants), neckline or collar type, HOOD present or not, closure type (zipper / buttons / drawstring / none), sleeve or leg length and shape, surface texture or knit pattern (e.g., waffle knit, rib, smooth woven), cuff and hem style, the exact color in plain words, and — REQUIRED for every garment — its logos, prints and graphics: state the exact text/mark, its size and placement if any exist, or state exactly 'no logo, no print' if the garment has none (this refers only to graphics — always describe the fabric texture separately). If a photo is a blank placeholder with no garment, write exactly 'none'. Output format, with no other text: OUTERWEAR: ... | TOP: ... | BOTTOMS: ... | INNERWEAR: ... | BELT: ...",
1827
+ "persist_context": false,
1828
+ "model": "gpt-5-mini",
1829
+ "images": [
1830
+ "1042",
1831
+ 0
1832
+ ]
1833
+ },
1834
+ "class_type": "OpenAIChatNode",
1835
+ "_meta": {
1836
+ "title": "의상 자동 분석 (GPT 비전)"
1837
+ }
1838
+ },
1839
+ "1002": {
1840
+ "inputs": {
1841
+ "string_a": [
1842
+ "956",
1843
+ 0
1844
+ ],
1845
+ "string_b": [
1846
+ "1001",
1847
+ 0
1848
+ ],
1849
+ "delimiter": " GARMENT SPECS (automated analysis of the provided garment photos — reproduce these construction details EXACTLY): "
1850
+ },
1851
+ "class_type": "StringConcatenate",
1852
+ "_meta": {
1853
+ "title": "의상 스펙 주입"
1854
+ }
1855
+ },
1856
+ "1041": {
1857
+ "inputs": {
1858
+ "image1": [
1859
+ "1000",
1860
+ 0
1861
+ ],
1862
+ "image2": [
1863
+ "957",
1864
+ 0
1865
+ ]
1866
+ },
1867
+ "class_type": "ImageBatch",
1868
+ "_meta": {
1869
+ "title": "이미지 배치"
1870
+ }
1871
+ },
1872
+ "1042": {
1873
+ "inputs": {
1874
+ "image1": [
1875
+ "1041",
1876
+ 0
1877
+ ],
1878
+ "image2": [
1879
+ "958",
1880
+ 0
1881
+ ]
1882
+ },
1883
+ "class_type": "ImageBatch",
1884
+ "_meta": {
1885
+ "title": "이미지 배치"
1886
+ }
1887
+ },
1888
+ "1043": {
1889
+ "inputs": {
1890
+ "direction": "right",
1891
+ "match_image_size": true,
1892
+ "spacing_width": 16,
1893
+ "spacing_color": "white",
1894
+ "image1": [
1895
+ "957",
1896
+ 0
1897
+ ],
1898
+ "image2": [
1899
+ "958",
1900
+ 0
1901
+ ]
1902
+ },
1903
+ "class_type": "ImageStitch",
1904
+ "_meta": {
1905
+ "title": "이너웨어+벨트 결합"
1906
+ }
1907
+ },
1908
+ "1044": {
1909
+ "inputs": {
1910
+ "prompts": "FINAL BODY PROPORTION OVERRIDE — this outranks every styling instinct: if the pose reference (image 7, LEFT half) contains a person, COPY that person's body proportions as-is: measure the reference — how many head-lengths tall the person is, where the waistline and crotch line sit as a fraction of total height, how long the legs are relative to the torso — and reproduce those same measurements in the generated model, whatever they are. Never lengthen or shorten the legs, move the waistline, or resize the head relative to the reference. If image 7 is blank, use realistic natural human proportions of about 7 to 7.5 head-lengths, never exaggerated long-legged fashion proportions. Ignore any 'REFERENCE CROP INFO' line elsewhere in this prompt — it applies only in reference-framing mode, and this shot instruction decides the crop.\nFINAL FRAMING OVERRIDE — this outranks every other framing rule in this prompt, INCLUDING the pose reference: this is an UPPER-BODY crop only. The bottom frame edge cuts at mid-thigh; thighs below that point, knees, shins and feet must NOT appear even if the pose reference shows the full body — copy only the upper-body pose from the reference and ignore its framing. Show only the seamless studio backdrop; no floor line. Ignore any 'REFERENCE CROP INFO' line elsewhere in this prompt — it applies only in reference-framing mode, and this shot instruction decides the crop.\nFINAL FRAMING OVERRIDE — this outranks every other framing rule in this prompt, INCLUDING the pose reference: this is a LOWER-BODY crop only. The top frame edge cuts at the mid-torso; the head, face and shoulders must NOT appear even if the pose reference shows the full body — copy only the leg stance from the reference and ignore its framing. Ignore any 'REFERENCE CROP INFO' line elsewhere in this prompt — it applies only in reference-framing mode, and this shot instruction decides the crop.\nFINAL FRAMING OVERRIDE — this outranks every other framing, headroom and margin rule in this prompt: the output frame MUST match the pose reference (image 7, LEFT half) exactly — the same crop boundaries (if the reference is cropped at the thighs or waist, the output is cropped at the SAME point), the same subject scale within the frame, and the same camera distance, height and body orientation. If the reference is a head-to-toe shot with margins above the head and below the feet, the output is ALSO a head-to-toe shot with the same margins; if the reference is a partial crop, the output is the same partial crop. Never zoom in beyond the reference. HEAD RULE: keep the whole head and face inside the frame unless the reference itself clearly cuts them off; never introduce a crop that the reference does not have, and never cut off the head, hands or feet to fit the canvas — if the reference aspect ratio differs from the output canvas, keep the subject fully inside the frame and adjust the surrounding margin instead. FINAL BODY PROPORTION OVERRIDE — this outranks every styling instinct: if the pose reference (image 7, LEFT half) contains a person, COPY that person's body proportions as-is: measure the reference from what is VISIBLE inside its crop — head size, shoulder width, where the waistline and crotch line sit, leg thickness relative to the torso — and reproduce those same measurements in the generated model, whatever they are. This proportion rule NEVER extends the frame: it applies only to body parts the framing override above already shows. Never lengthen or shorten the legs, move the waistline, or resize the head relative to the reference. If image 7 is blank, use realistic natural human proportions of about 7 to 7.5 head-lengths, never exaggerated long-legged fashion proportions. BACKGROUND EXCEPTION — matching the reference applies to framing, pose, gaze and body proportions ONLY: the background must still come exclusively from the provided studio background (image 4). Never reproduce the pose reference's background, wall tone, floor, environment or lighting. CROP WINS OVER CONTENT — highest priority of all: if the reference crop excludes body parts (feet, lower legs) then the output excludes them too, even if garment images or the styling text describe shoes, pant hems or how trousers fall on the feet; a garment outside the crop is worn but not shown, exactly like the reference. PADDING BAND: the reference may carry a thin band of plain background added around it for technical reasons — ignore this band when judging the crop; the TRUE crop boundary is where the person's body is cut off, and if the body is cut mid-limb, crop the output at the SAME point instead of completing the legs or feet into the empty band. OBEY 'REFERENCE CROP INFO' — ABSOLUTE, FINAL RULE: if a REFERENCE CROP INFO line is present in this prompt and is not 'none', it is the authoritative crop measurement of the reference. The output frame ends EXACTLY at the stated body point. NOTHING below that point may appear — no feet, no shoes, no pant hems, no floor contact — regardless of any garment image, garment-length option, or styling text that mentions them. If the info says the person is cut at mid-shin, the output is cut at mid-shin. Rendering any body part below the stated point is a FAILURE. FRAME FILL: the person must occupy the same fraction of the frame as the REFERENCE CROP INFO states — a 'tight' reference means an equally tight output where the person fills the frame; NEVER zoom out to a looser shot. Showing MORE body than the stated cut point (e.g. hips or thighs when the info says waist) is a FAILURE, even if provided garments remain unseen — a garment hidden by the crop stays hidden.",
1911
+ "index": [
1912
+ "917",
1913
+ 1
1914
+ ]
1915
+ },
1916
+ "class_type": "FL_PromptSelectorBasic",
1917
+ "_meta": {
1918
+ "title": "구도 오버라이드 (종단)"
1919
+ }
1920
+ },
1921
+ "1045": {
1922
+ "inputs": {
1923
+ "string_a": [
1924
+ "1051",
1925
+ 0
1926
+ ],
1927
+ "string_b": [
1928
+ "1044",
1929
+ 0
1930
+ ],
1931
+ "delimiter": " "
1932
+ },
1933
+ "class_type": "StringConcatenate",
1934
+ "_meta": {
1935
+ "title": "프롬프트+구도 오버라이드"
1936
+ }
1937
+ },
1938
+ "1050": {
1939
+ "inputs": {
1940
+ "prompt": "You will see ONE image: either a fashion pose reference photo, or a blank placeholder with no person. The photo may carry a thin plain-background band added around its edges for technical reasons — ignore that band completely and judge only where the PHOTO ITSELF cuts the person's body. If there is NO person, reply exactly: REFERENCE CROP INFO: none. If there IS a person, reply in ONE line with no other text: REFERENCE CROP INFO: the reference frame cuts the person at [pick exactly one: full body including feet / ankles / mid-shin / knees / mid-thigh / hips / waist / chest / shoulders]; frame fill: [pick one: tight — person fills the frame edge to edge / medium — small margins around the person / loose — large margins]; everything below the cut point is OUTSIDE the reference frame. RULES: judge ONLY by where the body is visibly cut — a tight close crop (chest or waist) is common and valid, NEVER round it toward a wider shot; if the cut falls between two options, pick the TIGHTER (higher) one; never widen your judgement because clothing would be hidden by the crop.",
1941
+ "persist_context": false,
1942
+ "model": "gpt-5-mini",
1943
+ "images": [
1944
+ "916",
1945
+ 0
1946
+ ]
1947
+ },
1948
+ "class_type": "OpenAIChatNode",
1949
+ "_meta": {
1950
+ "title": "레퍼런스 크롭 분석 (GPT 비전)"
1951
+ }
1952
+ },
1953
+ "1051": {
1954
+ "inputs": {
1955
+ "string_a": [
1956
+ "1002",
1957
+ 0
1958
+ ],
1959
+ "string_b": [
1960
+ "1050",
1961
+ 0
1962
+ ],
1963
+ "delimiter": " "
1964
+ },
1965
+ "class_type": "StringConcatenate",
1966
+ "_meta": {
1967
+ "title": "레퍼런스 크롭 정보 주입"
1968
+ }
1969
+ },
1970
+ "1060": {
1971
+ "inputs": {
1972
+ "image": "detail_placeholder.png"
1973
+ },
1974
+ "class_type": "LoadImage",
1975
+ "_meta": {
1976
+ "title": "디테일 참조 1"
1977
+ }
1978
+ },
1979
+ "1061": {
1980
+ "inputs": {
1981
+ "image": "detail_placeholder.png"
1982
+ },
1983
+ "class_type": "LoadImage",
1984
+ "_meta": {
1985
+ "title": "디테일 참조 2"
1986
+ }
1987
+ },
1988
+ "1062": {
1989
+ "inputs": {
1990
+ "image": "detail_placeholder.png"
1991
+ },
1992
+ "class_type": "LoadImage",
1993
+ "_meta": {
1994
+ "title": "디테일 참조 3"
1995
+ }
1996
+ },
1997
+ "1063": {
1998
+ "inputs": {
1999
+ "image": "detail_placeholder.png"
2000
+ },
2001
+ "class_type": "LoadImage",
2002
+ "_meta": {
2003
+ "title": "디테일 참조 4"
2004
+ }
2005
+ },
2006
+ "1055": {
2007
+ "inputs": {
2008
+ "source": [
2009
+ "1050",
2010
+ 0
2011
+ ]
2012
+ },
2013
+ "class_type": "PreviewAny",
2014
+ "_meta": {
2015
+ "title": "[로그] 크롭 분석 결과"
2016
+ }
2017
+ },
2018
+ "1056": {
2019
+ "inputs": {
2020
+ "source": [
2021
+ "1045",
2022
+ 0
2023
+ ]
2024
+ },
2025
+ "class_type": "PreviewAny",
2026
+ "_meta": {
2027
+ "title": "[로그] 최종 프롬프트"
2028
+ }
2029
+ },
2030
+ "1057": {
2031
+ "inputs": {
2032
+ "images": [
2033
+ "915",
2034
+ 0
2035
+ ]
2036
+ },
2037
+ "class_type": "PreviewImage",
2038
+ "_meta": {
2039
+ "title": "[로그] GPT 원본 (보정 전)"
2040
+ }
2041
+ }
2042
+ }