sunyoung00 commited on
Commit
3f1f6c7
ยท
verified ยท
1 Parent(s): a52eaae

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1332 -0
app.py ADDED
@@ -0,0 +1,1332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ๏ปฟfrom __future__ import annotations
2
+
3
+ import base64
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import re
8
+ import threading
9
+ import time
10
+ import traceback
11
+ import uuid
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from datetime import datetime, timezone
14
+ from io import BytesIO
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import uvicorn
19
+ from fastapi import FastAPI, File, Form, UploadFile
20
+ from fastapi.responses import FileResponse, JSONResponse, Response
21
+ from fastapi.staticfiles import StaticFiles
22
+ from google import genai
23
+ from google.genai import types
24
+ from huggingface_hub import HfApi
25
+ from openai import OpenAI
26
+ from PIL import Image, ImageChops, ImageDraw, ImageFilter, ImageOps
27
+
28
+
29
+ APP_TITLE = "AI ModelCut Studio"
30
+ BASE_DIR = Path(__file__).parent
31
+ ASSETS_DIR = BASE_DIR / "assets"
32
+ PRESET_FACE_CANDIDATES = [
33
+ ASSETS_DIR / "model_face_preset.png",
34
+ BASE_DIR / "model_face_preset.png",
35
+ ]
36
+ OPENAI_DEFAULT_IMAGE_MODEL = os.environ.get("OPENAI_IMAGE_MODEL", "gpt-image-2")
37
+ GEMINI_DEFAULT_IMAGE_MODEL = os.environ.get("GEMINI_IMAGE_MODEL", "gemini-3.1-flash-image-preview")
38
+ TARGET_SIZES = {
39
+ "1K": (1024, 1536),
40
+ "2K": (2048, 3072),
41
+ }
42
+ DEMO_FALLBACK = os.environ.get("DEMO_FALLBACK", "").lower() == "true"
43
+ API_INPUT_MAX_SIDE = int(os.environ.get("API_INPUT_MAX_SIDE", "2048"))
44
+ # Max concurrent image-generation calls (batch shots / Gemini candidates run in parallel).
45
+ GEN_MAX_WORKERS = max(1, int(os.environ.get("GEN_MAX_WORKERS", "4")))
46
+ # Proportion policy. When a body reference exists, its proportions are always matched.
47
+ # With NO body reference: IDEALIZE_PROPORTIONS=true โ†’ force 8.2-8.5 heads editorial look;
48
+ # otherwise leave proportions neutral (no forced head-shrink / leg elongation).
49
+ IDEALIZE_PROPORTIONS = os.environ.get("IDEALIZE_PROPORTIONS", "").lower() == "true"
50
+ # Post-process: re-crop full-body output so the subject occupies the same vertical band
51
+ # (head-top / feet-bottom margins) as the body reference image. Set to "false" to disable.
52
+ MATCH_REFERENCE_FRAMING = os.environ.get("MATCH_REFERENCE_FRAMING", "true").lower() != "false"
53
+ # Color distance (0-255) above which a pixel counts as subject vs background.
54
+ SUBJECT_BG_TOLERANCE = max(1, int(os.environ.get("SUBJECT_BG_TOLERANCE", "32")))
55
+ # Optional manual framing override (fractions of height, e.g. "0.10"). If BOTH are set they
56
+ # replace the reference-derived margins โ€” head sits at TOP, feet at (1 - BOTTOM).
57
+ FRAMING_TOP_MARGIN = os.environ.get("FRAMING_TOP_MARGIN", "").strip()
58
+ FRAMING_BOTTOM_MARGIN = os.environ.get("FRAMING_BOTTOM_MARGIN", "").strip()
59
+ HF_DATASET_REPO_DEFAULT = "sunyoung00/ROEM_TEST"
60
+ STUDIO_BACKGROUND_PROMPT = (
61
+ "Use a clean seamless studio background in solid warm light gray color #E8E7E2. "
62
+ "Ignore the background from all reference images. "
63
+ "Keep only a natural soft floor shadow. "
64
+ "Do not add props, walls, patterns, gradients, or colored lighting."
65
+ )
66
+ FULL_BODY_PROPORTION_PROMPT = (
67
+ "Use elegant fashion model proportions with a naturally smaller head-to-body ratio, "
68
+ "approximately 8.2 to 8.5 heads tall. Keep the face identity exactly the same, but scale "
69
+ "the head naturally smaller relative to the full body. Use long legs, balanced shoulders, "
70
+ "and realistic runway/editorial model proportions. Do not distort the face, neck, hands, "
71
+ "feet, or garment shape."
72
+ )
73
+ PROPORTION_MATCH_PROMPT = (
74
+ "Match the model's head SIZE, face size, neck length, torso-to-leg ratio, and overall "
75
+ "head-to-body proportions to the body-type reference image. Reproduce the natural proportions "
76
+ "shown in that reference. Do NOT elongate the legs, do NOT shrink the head, and do NOT apply "
77
+ "exaggerated runway/editorial proportions. Do not distort the face, neck, hands, feet, or garment shape."
78
+ )
79
+ FACE_ARTIFACT_PREVENTION_PROMPT = (
80
+ "Keep facial features clean, smooth, and natural. Do not over-sharpen the face, add skin "
81
+ "texture noise, mottling, patchy artifacts, speckles, blotches, or uneven discoloration. "
82
+ "Preserve clear eyes, nose, lips, brows, and natural skin tone without repainting the identity."
83
+ )
84
+ FULL_BODY_FRAMING_LOCK_PROMPT = (
85
+ "Preserve the subject scale, crop, and camera distance from the selected base image. "
86
+ "The selected base image controls the final framing, not the pose reference image. "
87
+ "Match the selected base image subject bounding box: keep the head top, shoe bottom, body center, "
88
+ "and full-body height in nearly the same pixel positions. Do not zoom out, do not make the model "
89
+ "smaller in the frame, and do not copy the margins from the pose reference image. If pose and "
90
+ "framing conflict, prioritize the selected base image framing."
91
+ )
92
+ SKIN_TONE_LOCK_PROMPT = (
93
+ "Preserve the original skin tone and facial exposure from the selected base image. "
94
+ "Do not whiten, pale, brighten, over-smooth, or overexpose the face."
95
+ )
96
+ DETAIL_SHOT_PROMPT = (
97
+ "Create an EXTREME close-up macro detail shot of the garment only. "
98
+ "NO MODEL, NO FACE, NO BODY PARTS, NO HAIR, NO SKIN. "
99
+ "Zoom in tightly to showcase the fabric texture, stitching, and construction quality of the target area. "
100
+ "Keep the exact same garment color, material, and design as the source image."
101
+ )
102
+ FULL_BODY_FRAMING_BLOCK = (
103
+ "FRAMING (FULL BODY, NON-NEGOTIABLE): wide full-length shot. "
104
+ "The standing figure must occupy approximately 75-80% of the frame height, with clear empty space on all four sides. "
105
+ "Leave at least 8% empty space above the top of the hair/head, at least 8% below the soles of the shoes, and "
106
+ "about 5% on the left and right. Every body part must be visible: head, face, shoulders, torso, waist, knees, "
107
+ "ankles, and feet with complete shoes. If ANY body part is cropped, the result is wrong. "
108
+ "No bags, no phones, no extra accessories held in the hands."
109
+ )
110
+ # Precise English transform instruction per Korean shot label.
111
+ # Used to image-to-image transform the selected base cut while keeping identity/outfit locked.
112
+ SHOT_TRANSFORM_INSTRUCTIONS = {
113
+ "์ „์‹ (์ •๋ฉด)": "Front-facing FULL-BODY standing shot of the exact same model and outfit.",
114
+ "์ „์‹ (์•ž๋ฉด)": "Front-facing FULL-BODY standing shot of the exact same model and outfit.",
115
+ "์ „์‹ (์ž์œ ํฌ์ฆˆ)": (
116
+ "FULL-BODY shot of the exact same model and outfit in a natural, relaxed editorial pose. "
117
+ "Keep both feet and the complete standing figure visible."
118
+ ),
119
+ "์ „์‹ (์ธก๋ฉด)": (
120
+ "Rotate the model to a SIDE PROFILE (about 90 degrees) to show the silhouette of the exact same outfit "
121
+ "as a full-body shot."
122
+ ),
123
+ "์ „์‹ (ํ›„๋ฉด)": (
124
+ "Rotate the model 180 degrees to show the BACK of the exact same outfit as a full-body shot. "
125
+ "Show the back construction details of the garment clearly."
126
+ ),
127
+ "์ƒ๋ฐ˜์‹ ": (
128
+ "MEDIUM CLOSE-UP UPPER-BODY portrait, framed from approximately the waist up to above the top of the head. "
129
+ "The entire head including the complete crown of hair MUST be fully visible โ€” leave at least 8% empty space "
130
+ "above the hair, never crop the top of the head. Sharp focus on the upper garment."
131
+ ),
132
+ "์ƒ๋ฐ˜์‹ (์•ž๋ฉด)": (
133
+ "Front-facing MEDIUM CLOSE-UP UPPER-BODY portrait, framed from approximately the waist up to above the top "
134
+ "of the head. The entire head and hair crown MUST be fully visible โ€” leave at least 8% empty space above "
135
+ "the hair, never crop the top of the head. Sharp focus on the upper garment."
136
+ ),
137
+ "์ƒ๋ฐ˜์‹ (์ธก๋ฉด)": (
138
+ "SIDE-PROFILE (about 90 degrees) UPPER-BODY portrait, framed from approximately the waist up to above the "
139
+ "top of the head. Keep the whole head and hair crown visible. Show the side silhouette of the upper garment."
140
+ ),
141
+ "์ƒ๋ฐ˜์‹ (ํ›„๋ฉด)": (
142
+ "Rotate the model 180 degrees and frame an UPPER-BODY BACK portrait from the waist up. "
143
+ "Keep the whole head and hair crown visible. Show the back neckline and upper-back construction of the same garment."
144
+ ),
145
+ "์ƒ๋ฐ˜์‹ (ํด๋กœ์ฆˆ์—…)": (
146
+ "TIGHT CLOSE-UP of the upper chest, neckline, and collar/tie area of the same garment, including the lower "
147
+ "face and shoulders. Show the fabric texture and neckline construction in sharp detail. Keep the same model identity."
148
+ ),
149
+ "ํ•˜๋ฐ˜์‹ ": (
150
+ "LOWER-BODY shot framed from the waist down to the soles of the shoes. "
151
+ "Keep both feet and the complete shoes fully visible. Sharp focus on the lower garment, hem, and shoes."
152
+ ),
153
+ "ํ•˜๋ฐ˜์‹ (์ž์œ ํฌ์ฆˆ)": (
154
+ "LOWER-BODY shot from the waist down in a natural, relaxed stance. "
155
+ "Both feet and complete shoes must be fully visible. Sharp focus on the lower garment and footwear."
156
+ ),
157
+ "ํ•˜๋ฐ˜์‹ (ํด๋กœ์ฆˆ์—…)": (
158
+ "EXTREME CLOSE-UP macro of the lower-garment detail (waistband, tie, hem, or fabric texture). "
159
+ "Garment only โ€” no face. Show the construction and texture in sharp detail."
160
+ ),
161
+ "๋””ํ…Œ์ผ(์ƒ์˜)": "Focus the detail shot on the TOP garment area (collar, placket, sleeve, or main fabric texture).",
162
+ "๋””ํ…Œ์ผ(ํฌ์ผ“)": "Focus the detail shot on the POCKET area, showing stitching and construction.",
163
+ "๋””ํ…Œ์ผ(์‹ ๋ฐœ)": "Focus the detail shot on the SHOES / footwear.",
164
+ "๋””ํ…Œ์ผ(ํ›„๋ฉด)": (
165
+ "Focus the detail shot on the BACK construction of the garment (back neckline, zipper, seams, or fabric "
166
+ "texture from behind)."
167
+ ),
168
+ }
169
+ # Garment-only macro shots (no model/face/skin).
170
+ _DETAIL_SHOTS = {"๋””ํ…Œ์ผ(์ƒ์˜)", "๋””ํ…Œ์ผ(ํฌ์ผ“)", "๋””ํ…Œ์ผ(์‹ ๋ฐœ)", "๋””ํ…Œ์ผ(ํ›„๋ฉด)", "ํ•˜๋ฐ˜์‹ (ํด๋กœ์ฆˆ์—…)"}
171
+ # Shots where the deterministic crop-to-reference is skipped (extreme crops / no clear full subject).
172
+ _NO_REFRAME_SHOTS = _DETAIL_SHOTS | {"์ƒ๋ฐ˜์‹ (ํด๋กœ์ฆˆ์—…)"}
173
+
174
+ # ---- Per-shot reference library + body-type reference --------------------------
175
+ # Each shot button maps 1:1 to a reference image in assets/poses/ whose filename is the
176
+ # shot label with parentheses turned into underscores, e.g.:
177
+ # "์ „์‹ (์•ž๋ฉด)" -> assets/poses/์ „์‹ _์•ž๋ฉด_.(png|jpg|jpeg|webp)
178
+ # "์ƒ๋ฐ˜์‹ (ํด๋กœ์ฆˆ์—…)" -> assets/poses/์ƒ๋ฐ˜์‹ _ํด๋กœ์ฆˆ์—…_.(...)
179
+ # "ํ•˜๋ฐ˜์‹ " -> assets/poses/ํ•˜๋ฐ˜์‹ .(...)
180
+ # The reference defines pose, camera angle, and crop/framing for that shot.
181
+ POSES_DIR = ASSETS_DIR / "poses"
182
+ POSE_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp")
183
+ # Model body-type reference (physique only; face stays from the face preset).
184
+ BODY_PRESET_CANDIDATES = [
185
+ ASSETS_DIR / "model_body_preset.png",
186
+ BASE_DIR / "model_body_preset.png",
187
+ ]
188
+
189
+
190
+ def _shot_reference_stems(shot_type: str) -> list[str]:
191
+ """Candidate filename stems for a shot label, in priority order.
192
+
193
+ Supports both naming styles so references resolve regardless of how they were saved:
194
+ 1) label as-is, with parentheses kept -> "์ „์‹ (์•ž๋ฉด)" -> ์ „์‹ (์•ž๋ฉด).jpeg
195
+ 2) parentheses replaced with underscores -> "์ „์‹ _์•ž๋ฉด_" -> ์ „์‹ _์•ž๋ฉด_.jpeg
196
+ """
197
+ label = (shot_type or "").strip()
198
+ if not label:
199
+ return []
200
+ underscore = label.replace("(", "_").replace(")", "_")
201
+ stems = [label]
202
+ if underscore != label:
203
+ stems.append(underscore)
204
+ return stems
205
+
206
+
207
+ def _shot_reference_stem(shot_type: str) -> str:
208
+ """Primary (parens-kept) filename stem for a shot label."""
209
+ stems = _shot_reference_stems(shot_type)
210
+ return stems[0] if stems else ""
211
+
212
+
213
+ BODY_REFERENCE_PROMPT = (
214
+ "A BODY-TYPE reference image is provided. Match the model's physique to it: overall height "
215
+ "impression, body build, shoulder width, limb proportions, AND the head-to-body ratio โ€” i.e. how "
216
+ "large the head and face appear relative to the full body. Use ONLY the body type and proportions "
217
+ "from that image. Do NOT copy its face, hairstyle, skin tone, clothing, pose, or background โ€” those "
218
+ "come from the other reference images."
219
+ )
220
+
221
+
222
+ def _reference_legend(has_face: bool, has_body: bool, product_count: int, has_pose: bool) -> str:
223
+ """Describe each reference image by its position so the model never confuses roles."""
224
+ roles: list[str] = []
225
+ if has_face:
226
+ roles.append("FACE identity (copy this exact face, hairline, and features)")
227
+ if has_body:
228
+ roles.append("BODY-TYPE physique (match build/proportions only; ignore its face, hair, clothing, pose)")
229
+ if has_pose:
230
+ roles.append("POSE/FRAMING guide (follow its body pose, camera angle, viewing direction and crop only; ignore its face, clothing, body type, background)")
231
+ for index in range(product_count):
232
+ roles.append(f"PRODUCT garment {index + 1} (preserve its design, color, logo, and texture exactly)")
233
+ if not roles:
234
+ return ""
235
+ legend = "; ".join(f"image {index + 1} = {role}" for index, role in enumerate(roles))
236
+ return "REFERENCE IMAGE ROLES (in this exact order): " + legend + "."
237
+
238
+
239
+ app = FastAPI(title=APP_TITLE)
240
+ ASSETS_DIR.mkdir(exist_ok=True)
241
+ app.mount("/assets", StaticFiles(directory=ASSETS_DIR), name="assets")
242
+ _OPENAI_CLIENT: Optional[OpenAI] = None
243
+ _GEMINI_CLIENT: Optional[genai.Client] = None
244
+ _CLIENT_LOCK = threading.Lock()
245
+
246
+
247
+ def _log(message: str, request_id: str = "-") -> None:
248
+ print(f"[MODEL-CUT][{request_id}] {message}", flush=True)
249
+
250
+
251
+ def _create_fallback_face() -> Image.Image:
252
+ canvas = Image.new("RGB", (768, 1024), (248, 248, 248))
253
+ draw = ImageDraw.Draw(canvas)
254
+ draw.ellipse((210, 120, 558, 468), fill=(36, 28, 26))
255
+ draw.rounded_rectangle((258, 210, 510, 560), radius=118, fill=(238, 211, 195))
256
+ draw.ellipse((276, 315, 332, 344), fill=(74, 64, 58))
257
+ draw.ellipse((436, 315, 492, 344), fill=(74, 64, 58))
258
+ draw.arc((340, 378, 428, 438), 15, 165, fill=(170, 116, 110), width=5)
259
+ draw.line((300, 283, 348, 272), fill=(72, 52, 45), width=7)
260
+ draw.line((420, 272, 468, 283), fill=(72, 52, 45), width=7)
261
+ draw.rounded_rectangle((120, 558, 648, 980), radius=140, fill=(238, 211, 195))
262
+ draw.rectangle((188, 782, 580, 1024), fill=(255, 255, 255))
263
+ draw.line((384, 104, 384, 258), fill=(82, 70, 66), width=5)
264
+ return canvas
265
+
266
+
267
+ def load_preset_face() -> Image.Image:
268
+ for preset_path in PRESET_FACE_CANDIDATES:
269
+ if preset_path.exists():
270
+ return ImageOps.exif_transpose(Image.open(preset_path)).convert("RGB")
271
+
272
+ return _create_fallback_face()
273
+
274
+
275
+ def load_body_reference() -> Optional[Image.Image]:
276
+ """Optional model body-type reference. Returns None if no preset is present."""
277
+ for preset_path in BODY_PRESET_CANDIDATES:
278
+ if preset_path.exists():
279
+ return ImageOps.exif_transpose(Image.open(preset_path)).convert("RGB")
280
+ return None
281
+
282
+
283
+ def load_shot_reference(shot_type: str) -> Optional[Image.Image]:
284
+ """Load the reference image that defines pose/angle/crop for the given shot label.
285
+
286
+ Looks in assets/poses/ (then assets/) for a file whose name matches the shot label,
287
+ accepting both parens-kept ("์ „์‹ (์•ž๋ฉด).jpeg") and underscore ("์ „์‹ _์•ž๋ฉด_.jpeg")
288
+ naming. Returns None if no matching reference file is present.
289
+ """
290
+ stems = _shot_reference_stems(shot_type)
291
+ if not stems:
292
+ return None
293
+ for directory in (POSES_DIR, ASSETS_DIR):
294
+ if not directory.exists():
295
+ continue
296
+ for stem in stems:
297
+ for ext in POSE_IMAGE_EXTENSIONS:
298
+ candidate = directory / f"{stem}{ext}"
299
+ if candidate.exists():
300
+ return ImageOps.exif_transpose(Image.open(candidate)).convert("RGB")
301
+ return None
302
+
303
+
304
+ async def _read_upload(upload: Optional[UploadFile]) -> Optional[Image.Image]:
305
+ if upload is None or not upload.filename:
306
+ return None
307
+
308
+ content = await upload.read()
309
+ if not content:
310
+ return None
311
+
312
+ return ImageOps.exif_transpose(Image.open(BytesIO(content))).convert("RGB")
313
+
314
+
315
+ def _read_data_url_image(data_url: str) -> Optional[Image.Image]:
316
+ if not data_url or not data_url.startswith("data:image/") or ";base64," not in data_url:
317
+ return None
318
+
319
+ encoded = data_url.split(";base64,", 1)[1]
320
+ raw = base64.b64decode(encoded)
321
+ return ImageOps.exif_transpose(Image.open(BytesIO(raw))).convert("RGB")
322
+
323
+
324
+ def _get_openai_client() -> OpenAI:
325
+ global _OPENAI_CLIENT
326
+ if _OPENAI_CLIENT is None:
327
+ with _CLIENT_LOCK:
328
+ if _OPENAI_CLIENT is None:
329
+ _OPENAI_CLIENT = OpenAI()
330
+ return _OPENAI_CLIENT
331
+
332
+
333
+ def _get_gemini_client(api_key: str) -> genai.Client:
334
+ global _GEMINI_CLIENT
335
+ if _GEMINI_CLIENT is None:
336
+ with _CLIENT_LOCK:
337
+ if _GEMINI_CLIENT is None:
338
+ _GEMINI_CLIENT = genai.Client(api_key=api_key)
339
+ return _GEMINI_CLIENT
340
+
341
+
342
+ def _prepare_api_reference(image: Image.Image) -> Image.Image:
343
+ prepared = ImageOps.exif_transpose(image).convert("RGB")
344
+ prepared.thumbnail((API_INPUT_MAX_SIDE, API_INPUT_MAX_SIDE), Image.Resampling.LANCZOS)
345
+ return prepared
346
+
347
+
348
+ def _image_summary(image: Optional[Image.Image]) -> str:
349
+ if image is None:
350
+ return "none"
351
+ return f"{image.width}x{image.height}"
352
+
353
+
354
+ def _fit_image(image: Image.Image, size: tuple[int, int]) -> Image.Image:
355
+ image = ImageOps.exif_transpose(image).convert("RGBA")
356
+ image.thumbnail(size, Image.Resampling.LANCZOS)
357
+ canvas = Image.new("RGBA", size, (246, 243, 239, 255))
358
+ x = (size[0] - image.width) // 2
359
+ y = (size[1] - image.height) // 2
360
+ canvas.alpha_composite(image, (x, y))
361
+ return canvas
362
+
363
+
364
+ def _draw_model_cut(
365
+ product_image: Optional[Image.Image],
366
+ model_face: Image.Image,
367
+ label: str,
368
+ resolution: str,
369
+ pose_shift: int,
370
+ shot_type: str = "?๊พฉ๋–Š(?๋บฃใˆƒ)",
371
+ ) -> Image.Image:
372
+ size = (1024, 1280) if resolution == "1K" else (1536, 1920)
373
+ canvas = Image.new("RGB", size, (246, 243, 239))
374
+ draw = ImageDraw.Draw(canvas)
375
+
376
+ grid = max(size[0] // 24, 36)
377
+ for x in range(0, size[0], grid):
378
+ draw.line((x, 0, x, size[1]), fill=(235, 232, 226), width=1)
379
+ for y in range(0, size[1], grid):
380
+ draw.line((0, y, size[0], y), fill=(235, 232, 226), width=1)
381
+
382
+ cx = size[0] // 2 + pose_shift
383
+ head_r = size[0] // 15
384
+ is_upper = "์ƒ๋ฐ˜์‹ " in shot_type or "?๊ณท์ปฒ" in shot_type
385
+ is_lower = "ํ•˜๋ฐ˜์‹ " in shot_type or "?์„Ž์ปฒ" in shot_type
386
+ is_detail = "๋””ํ…Œ์ผ" in shot_type or "?๋ท€๋€’" in shot_type
387
+ is_back = "ํ›„๋ฉด" in shot_type or "?๊พจใˆƒ" in shot_type
388
+
389
+ if is_upper:
390
+ head_r = size[0] // 11
391
+ if is_detail:
392
+ head_r = size[0] // 18
393
+
394
+ draw.ellipse((cx - head_r, size[1] // 8, cx + head_r, size[1] // 8 + head_r * 2), fill=(232, 204, 184))
395
+ draw.arc(
396
+ (cx - head_r - 8, size[1] // 8 - 6, cx + head_r + 8, size[1] // 8 + head_r * 2),
397
+ 190,
398
+ 350,
399
+ fill=(24, 24, 26),
400
+ width=max(8, size[0] // 70),
401
+ )
402
+
403
+ face = ImageOps.fit(model_face, (head_r * 2, head_r * 2), method=Image.Resampling.LANCZOS, centering=(0.5, 0.34))
404
+ face_mask = Image.new("L", face.size, 0)
405
+ mask_draw = ImageDraw.Draw(face_mask)
406
+ mask_draw.ellipse((0, 0, face.width, face.height), fill=230)
407
+ canvas.paste(face, (cx - head_r, size[1] // 8), face_mask.filter(ImageFilter.GaussianBlur(0.6)))
408
+
409
+ shoulder_y = size[1] // 4
410
+ hem_y = int(size[1] * 0.72)
411
+ if is_upper:
412
+ shoulder_y = size[1] // 3
413
+ hem_y = int(size[1] * 0.92)
414
+ if is_lower:
415
+ shoulder_y = size[1] // 7
416
+ hem_y = int(size[1] * 0.82)
417
+ if is_back:
418
+ draw.rectangle((cx - head_r, size[1] // 8, cx + head_r, size[1] // 8 + head_r * 2), fill=(31, 28, 27))
419
+
420
+ body = [
421
+ (cx - size[0] // 6, shoulder_y),
422
+ (cx + size[0] // 6, shoulder_y),
423
+ (cx + size[0] // 8, hem_y),
424
+ (cx - size[0] // 8, hem_y),
425
+ ]
426
+ draw.polygon(body, fill=(29, 32, 36))
427
+
428
+ if product_image:
429
+ product_box = (size[0] // 3, int(size[1] * 0.44))
430
+ if is_upper:
431
+ product_box = (size[0] // 2, int(size[1] * 0.5))
432
+ if is_lower:
433
+ product_box = (size[0] // 2, int(size[1] * 0.58))
434
+ if is_detail:
435
+ product_box = (int(size[0] * 0.72), int(size[1] * 0.55))
436
+
437
+ product = _fit_image(product_image, product_box)
438
+ product_mask = Image.new("L", product.size, 0)
439
+ product_mask_draw = ImageDraw.Draw(product_mask)
440
+ product_mask_draw.rounded_rectangle((0, 0, product.width, product.height), radius=18, fill=210)
441
+ px = cx - product.width // 2
442
+ py = shoulder_y + size[1] // 18
443
+ if is_lower:
444
+ py = int(size[1] * 0.36)
445
+ if is_detail:
446
+ py = int(size[1] * 0.26)
447
+ canvas.paste(product.convert("RGB"), (px, py), product_mask.filter(ImageFilter.GaussianBlur(1.2)))
448
+
449
+ leg_y = hem_y
450
+ if not is_upper and not is_detail:
451
+ draw.line((cx - size[0] // 14, leg_y, cx - size[0] // 9, int(size[1] * 0.9)), fill=(24, 26, 29), width=size[0] // 34)
452
+ draw.line((cx + size[0] // 14, leg_y, cx + size[0] // 9, int(size[1] * 0.9)), fill=(24, 26, 29), width=size[0] // 34)
453
+ draw.ellipse((24, 24, 82, 82), fill=(20, 22, 24))
454
+ draw.text((41, 42), "AI", fill=(255, 255, 255))
455
+ draw.text((24, size[1] - 64), label, fill=(30, 34, 38))
456
+
457
+ return canvas
458
+
459
+
460
+ def _image_to_data_url(image: Image.Image, resolution: str = "1K") -> str:
461
+ output = BytesIO()
462
+ if resolution == "2K":
463
+ image.convert("RGB").save(output, format="JPEG", quality=92, optimize=True, progressive=True, subsampling=0)
464
+ encoded = base64.b64encode(output.getvalue()).decode("ascii")
465
+ return f"data:image/jpeg;base64,{encoded}"
466
+
467
+ image.save(output, format="PNG", optimize=True)
468
+ encoded = base64.b64encode(output.getvalue()).decode("ascii")
469
+ return f"data:image/png;base64,{encoded}"
470
+
471
+
472
+ def _image_to_png_bytes(image: Image.Image) -> bytes:
473
+ output = BytesIO()
474
+ image.save(output, format="PNG")
475
+ output.seek(0)
476
+ return output.getvalue()
477
+
478
+
479
+ def _image_to_jpeg_bytes(image: Image.Image) -> bytes:
480
+ output = BytesIO()
481
+ image.convert("RGB").save(output, format="JPEG", quality=95, optimize=True, subsampling=0)
482
+ output.seek(0)
483
+ return output.getvalue()
484
+
485
+
486
+ def _safe_dataset_name(value: str) -> str:
487
+ cleaned = re.sub(r"[^0-9A-Za-z๊ฐ€-ํžฃ_.()-]+", "_", value.strip())
488
+ return cleaned.strip("_")[:80] or "modelcut"
489
+
490
+
491
+ def _upload_generation_to_dataset(
492
+ images: list[Image.Image],
493
+ labels: list[str],
494
+ metadata: dict,
495
+ request_id: str,
496
+ ) -> None:
497
+ dataset_repo = os.environ.get("HF_DATASET_REPO", HF_DATASET_REPO_DEFAULT).strip()
498
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
499
+ if not dataset_repo:
500
+ _log("dataset upload skipped: HF_DATASET_REPO is empty", request_id)
501
+ return
502
+ if not token:
503
+ _log("dataset upload skipped: HF_TOKEN is not set", request_id)
504
+ return
505
+
506
+ try:
507
+ api = HfApi(token=token)
508
+ api.create_repo(repo_id=dataset_repo, repo_type="dataset", exist_ok=True)
509
+
510
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
511
+ folder = f"generated/{timestamp}_{request_id}"
512
+ uploaded_files = []
513
+
514
+ for index, image in enumerate(images, start=1):
515
+ label = labels[index - 1] if index - 1 < len(labels) else f"image-{index}"
516
+ filename = f"{index:02d}_{_safe_dataset_name(label)}.png"
517
+ path_in_repo = f"{folder}/{filename}"
518
+ api.upload_file(
519
+ path_or_fileobj=_image_to_png_bytes(image),
520
+ path_in_repo=path_in_repo,
521
+ repo_id=dataset_repo,
522
+ repo_type="dataset",
523
+ commit_message=f"Add generated model cut {request_id}",
524
+ )
525
+ uploaded_files.append(path_in_repo)
526
+
527
+ metadata_payload = {
528
+ **metadata,
529
+ "request_id": request_id,
530
+ "created_at": timestamp,
531
+ "files": uploaded_files,
532
+ }
533
+ api.upload_file(
534
+ path_or_fileobj=json.dumps(metadata_payload, ensure_ascii=False, indent=2).encode("utf-8"),
535
+ path_in_repo=f"{folder}/metadata.json",
536
+ repo_id=dataset_repo,
537
+ repo_type="dataset",
538
+ commit_message=f"Add model cut metadata {request_id}",
539
+ )
540
+ _log(f"dataset upload done repo={dataset_repo} files={len(uploaded_files)} folder={folder}", request_id)
541
+ except Exception as error:
542
+ _log(f"dataset upload failed repo={dataset_repo} error={error}", request_id)
543
+
544
+
545
+ def _normalize_output_size(image: Image.Image, resolution: str) -> Image.Image:
546
+ target = TARGET_SIZES.get(resolution, TARGET_SIZES["1K"])
547
+ image = ImageOps.exif_transpose(image).convert("RGB")
548
+ if image.size == target:
549
+ return image
550
+
551
+ fitted = ImageOps.contain(image, target, method=Image.Resampling.LANCZOS)
552
+ canvas = Image.new("RGB", target, (246, 243, 239))
553
+ x = (target[0] - fitted.width) // 2
554
+ y = (target[1] - fitted.height) // 2
555
+ canvas.paste(fitted, (x, y))
556
+ return canvas
557
+
558
+
559
+ def _estimate_bg_color(image: Image.Image) -> tuple[int, int, int]:
560
+ """Estimate the (solid studio) background color from the image corners."""
561
+ rgb = image.convert("RGB")
562
+ w, h = rgb.size
563
+ patch = max(4, min(w, h) // 50)
564
+ samples: list[tuple[int, int, int]] = []
565
+ for cx, cy in [(0, 0), (w - patch, 0), (0, h - patch), (w - patch, h - patch)]:
566
+ region = rgb.crop((cx, cy, cx + patch, cy + patch))
567
+ samples.append(tuple(int(v) for v in region.resize((1, 1), Image.Resampling.LANCZOS).getpixel((0, 0))))
568
+ samples.sort(key=lambda c: c[0] + c[1] + c[2])
569
+ return samples[len(samples) // 2] # median-ish corner
570
+
571
+
572
+ def _subject_bbox(image: Image.Image, tolerance: int) -> Optional[tuple[int, int, int, int]]:
573
+ """Bounding box (left, top, right, bottom) of the subject vs a near-solid background."""
574
+ rgb = image.convert("RGB")
575
+ bg = Image.new("RGB", rgb.size, _estimate_bg_color(rgb))
576
+ diff = ImageChops.difference(rgb, bg)
577
+ r, g, b = diff.split()
578
+ per_pixel_max = ImageChops.lighter(ImageChops.lighter(r, g), b) # strongest channel diff
579
+ mask = per_pixel_max.point(lambda p: 255 if p >= tolerance else 0)
580
+ return mask.getbbox()
581
+
582
+
583
+ def _reframe_to_reference(
584
+ image: Image.Image,
585
+ reference: Optional[Image.Image],
586
+ target_size: tuple[int, int],
587
+ ) -> Image.Image:
588
+ """Re-crop/scale a full-body output so the subject occupies the same vertical band
589
+ (head-top and feet-bottom margins) as the reference image โ€” or as explicit env margins.
590
+ Falls back to the unchanged image (at target size) if detection looks unreliable."""
591
+ width, height = target_size
592
+ base = image.convert("RGB")
593
+
594
+ # 1) Determine the target vertical band (fractions of final height).
595
+ top_frac: Optional[float] = None
596
+ bottom_frac: Optional[float] = None
597
+ if FRAMING_TOP_MARGIN and FRAMING_BOTTOM_MARGIN:
598
+ try:
599
+ top_frac = float(FRAMING_TOP_MARGIN)
600
+ bottom_frac = 1.0 - float(FRAMING_BOTTOM_MARGIN)
601
+ except ValueError:
602
+ top_frac = bottom_frac = None
603
+ if top_frac is None and reference is not None:
604
+ ref_box = _subject_bbox(reference, SUBJECT_BG_TOLERANCE)
605
+ if ref_box:
606
+ top_frac = ref_box[1] / reference.height
607
+ bottom_frac = ref_box[3] / reference.height
608
+ if top_frac is None or bottom_frac is None:
609
+ return _normalize_output_size(base, "1K" if target_size == TARGET_SIZES["1K"] else "2K")
610
+
611
+ subject_frac = bottom_frac - top_frac
612
+ if not (0.2 < subject_frac < 0.98): # sanity: reference detection failed
613
+ return _normalize_output_size(base, "1K" if target_size == TARGET_SIZES["1K"] else "2K")
614
+
615
+ # 2) Find the subject in the generated image.
616
+ gen_box = _subject_bbox(base, SUBJECT_BG_TOLERANCE)
617
+ if not gen_box:
618
+ return _normalize_output_size(base, "1K" if target_size == TARGET_SIZES["1K"] else "2K")
619
+ gen_subject_h = gen_box[3] - gen_box[1]
620
+ if gen_subject_h <= 0:
621
+ return _normalize_output_size(base, "1K" if target_size == TARGET_SIZES["1K"] else "2K")
622
+
623
+ # 3) Scale so the subject height matches the target band, then place it.
624
+ scale = (subject_frac * height) / gen_subject_h
625
+ new_w = max(1, round(base.width * scale))
626
+ new_h = max(1, round(base.height * scale))
627
+ scaled = base.resize((new_w, new_h), Image.Resampling.LANCZOS)
628
+
629
+ subject_cx = ((gen_box[0] + gen_box[2]) / 2) * scale
630
+ subject_top = gen_box[1] * scale
631
+ paste_x = round(width / 2 - subject_cx)
632
+ paste_y = round(top_frac * height - subject_top)
633
+
634
+ canvas = Image.new("RGB", (width, height), _estimate_bg_color(base))
635
+ canvas.paste(scaled, (paste_x, paste_y))
636
+ return canvas
637
+
638
+
639
+ def _openai_size_for_model(model: str, resolution: str) -> str:
640
+ if model == "gpt-image-2":
641
+ return "2048x3072" if resolution == "2K" else "1024x1536"
642
+
643
+ return "1024x1536"
644
+
645
+
646
+ def _gemini_image_config(model: str, resolution: str) -> types.ImageConfig:
647
+ image_config = {"aspect_ratio": "2:3"}
648
+ if model in {"gemini-3.1-flash-image-preview", "gemini-3-pro-image-preview"}:
649
+ image_config["image_size"] = resolution
650
+ return types.ImageConfig(**image_config)
651
+
652
+
653
+ def _compose_generation_prompt(
654
+ category: str,
655
+ fit: str,
656
+ length: str,
657
+ style: str,
658
+ prompt: str,
659
+ pose: str,
660
+ total_length_cm: str,
661
+ generation_mode: str,
662
+ shot_type: str,
663
+ selected_base_index: int,
664
+ has_body_reference: bool = False,
665
+ has_pose_reference: bool = False,
666
+ product_count: int = 0,
667
+ ) -> str:
668
+ shot_instruction = "?๊พฉ๋–Š(?๋บฃใˆƒ) ่€Œใ…ปใ‰ง??๏งโ‘ค๋œฝ่€Œ??๊พจ๋‚ซ็‘œ??์•น๊ฝฆ?์„๊ฝญ??"
669
+ is_full_body = generation_mode != "shot_variant" or "์ „์‹ " in shot_type or "?๊พฉ๋–Š" in shot_type
670
+ if generation_mode == "shot_variant":
671
+ shot_instruction = (
672
+ f"?์ข๊นฎ??ๆนฒ๊ณ—? ่€Œ?{selected_base_index + 1}??๏งโ‘ค๋œฝ ?์‡จ๋Žฌ, ?ใ…ผ๋ผฑ, ๏งฃ๋Œ„์‚Ž, ?์„๊ธฝ, ?๋ฑ๊ธฝ, ?๋šฏ์˜ฑ, ๆฟก์’“ํ€ฌ, "
673
+ f"?ใ…ป๏ผˆ?๏ฝŒ? ?์ขŽ??์„ํ€ฌ ??ๆดั‰๋ฃ„๏ง?'{shot_type}'ๆฟก?่น‚ย€ๅฏƒ์€๋ธฏ?๋ช„์Š‚. "
674
+ "Use the selected base image reference as the source photo to transform; do not create a new unrelated model."
675
+ )
676
+
677
+ length_text = f"{length}, ?๋‰๊ธฝ ็ฅ์•น์˜ฃ {total_length_cm}cm" if total_length_cm else length
678
+ legend = _reference_legend(
679
+ has_face=True,
680
+ has_body=has_body_reference,
681
+ product_count=product_count,
682
+ has_pose=has_pose_reference,
683
+ )
684
+ # Proportion policy: a body reference always wins (match it). Without one, only
685
+ # apply the idealized 8.2-8.5 head look when explicitly enabled.
686
+ if not is_full_body:
687
+ proportion_prompt = ""
688
+ elif has_body_reference:
689
+ proportion_prompt = PROPORTION_MATCH_PROMPT
690
+ elif IDEALIZE_PROPORTIONS:
691
+ proportion_prompt = FULL_BODY_PROPORTION_PROMPT
692
+ else:
693
+ proportion_prompt = ""
694
+ return "\n".join(
695
+ [
696
+ "Create a high-resolution fashion ecommerce AI model photo.",
697
+ legend,
698
+ "CRITICAL IDENTITY LOCK: Use the face reference (image 1) as the exact persona model.",
699
+ "All generated candidates must show the same person, not a similar-looking new model.",
700
+ "Preserve the same face shape, jawline, eye shape, eye spacing, nose, lips, eyebrows, skin tone, and hairline from the face reference.",
701
+ "Do not beautify, age-shift, ethnicity-shift, change makeup style, or invent a different face.",
702
+ "If generating multiple candidates, keep the face identity identical across every candidate.",
703
+ BODY_REFERENCE_PROMPT if has_body_reference else "",
704
+ proportion_prompt,
705
+ FACE_ARTIFACT_PREVENTION_PROMPT if is_full_body else "",
706
+ FULL_BODY_FRAMING_BLOCK if is_full_body else "",
707
+ "Preserve the original skin tone and facial exposure from the face reference. Do not whiten, pale, brighten, over-smooth, or overexpose the face.",
708
+ shot_instruction,
709
+ f"Garment category: {category}. Fit: {fit}. Length: {length_text}.",
710
+ f"Style: {style}. Pose reference: {pose}.",
711
+ STUDIO_BACKGROUND_PROMPT,
712
+ "Use sharp fabric texture and accurate garment edges.",
713
+ "Preserve the uploaded product image details as faithfully as possible.",
714
+ "Do not alter logos, buttons, patterns, colors, or silhouette.",
715
+ "Output should be suitable for a shopping mall product detail page.",
716
+ prompt.strip(),
717
+ ]
718
+ ).strip()
719
+
720
+
721
+ def _compose_transform_prompt(
722
+ shot_type: str,
723
+ prompt: str,
724
+ total_length_cm: str,
725
+ selected_base_index: int,
726
+ has_pose_reference: bool = False,
727
+ ) -> str:
728
+ is_detail = shot_type in _DETAIL_SHOTS
729
+ is_full_body = "์ „์‹ " in shot_type
730
+ shot_instruction = SHOT_TRANSFORM_INSTRUCTIONS.get(
731
+ shot_type, f"Create this shot composition: {shot_type}."
732
+ )
733
+ extra = f"Additional instruction: {prompt.strip()}" if prompt.strip() else ""
734
+ pose_reference = (
735
+ "A POSE/FRAMING reference image is also provided. Match its body pose, camera angle, viewing "
736
+ "direction (front / side / back), and crop/framing as closely as possible. Take ONLY pose, angle "
737
+ "and framing from it โ€” identity, face, outfit, garment color and texture must come from the source "
738
+ "(first) image, never from the pose reference."
739
+ if has_pose_reference
740
+ else ""
741
+ )
742
+
743
+ lines = [
744
+ "Edit the FIRST image. Use it as the source photo to transform; do NOT create a new, unrelated model.",
745
+ ]
746
+ if is_detail:
747
+ lines.append(
748
+ "Keep the exact same garment color, fabric texture, material, silhouette, logos, and design as the first image."
749
+ )
750
+ else:
751
+ lines.append(
752
+ "Keep the exact same person, face, skin tone, hair style, outfit, garment color, fabric texture, "
753
+ "silhouette, shoes, and background from the first image."
754
+ )
755
+ lines.append("Do not repaint the face, do not beautify, and do not change the clothing design.")
756
+ lines.append(f"TARGET SHOT: {shot_type}.")
757
+ lines.append(shot_instruction)
758
+ if is_detail:
759
+ lines.append(DETAIL_SHOT_PROMPT)
760
+ else:
761
+ # Person is in frame โ†’ preserve skin tone; lock scale/crop only for full-body shots.
762
+ lines.append(SKIN_TONE_LOCK_PROMPT)
763
+ if is_full_body:
764
+ lines.append(FULL_BODY_FRAMING_LOCK_PROMPT)
765
+ lines.append(FULL_BODY_FRAMING_BLOCK)
766
+ lines.append(pose_reference)
767
+ lines.append("Keep the edit natural and close to the source image.")
768
+ lines.append(extra)
769
+ return "\n".join(line for line in lines if line).strip()
770
+
771
+
772
+ def _split_provider_model(image_model: str) -> tuple[str, str]:
773
+ if ":" not in image_model:
774
+ return "openai", image_model
775
+
776
+ provider, model = image_model.split(":", 1)
777
+ return provider, model
778
+
779
+
780
+ def _resolve_model(provider: str, model: str) -> str:
781
+ if provider == "openai":
782
+ return os.environ.get("OPENAI_IMAGE_MODEL", model or OPENAI_DEFAULT_IMAGE_MODEL)
783
+ if provider == "gemini":
784
+ return os.environ.get("GEMINI_IMAGE_MODEL", model or GEMINI_DEFAULT_IMAGE_MODEL)
785
+ return model
786
+
787
+
788
+ def _generate_with_openai(
789
+ references: list[Optional[Image.Image]],
790
+ model: str,
791
+ prompt: str,
792
+ resolution: str,
793
+ count: int,
794
+ request_id: str = "-",
795
+ ) -> list[Image.Image]:
796
+ if not os.environ.get("OPENAI_API_KEY"):
797
+ raise RuntimeError("OPENAI_API_KEY is not set.")
798
+
799
+ client = _get_openai_client()
800
+ references = [_prepare_api_reference(image) for image in references if image is not None]
801
+ size = _openai_size_for_model(model, resolution)
802
+ image_files = []
803
+ try:
804
+ started = time.perf_counter()
805
+ _log(
806
+ f"openai start model={model} size={size} count={count} refs={len(references)} "
807
+ f"ref_sizes={[f'{image.width}x{image.height}' for image in references]} prompt_chars={len(prompt)}",
808
+ request_id,
809
+ )
810
+ for index, image in enumerate(references):
811
+ payload = BytesIO(_image_to_jpeg_bytes(image))
812
+ payload.name = f"reference_{index}.jpg"
813
+ image_files.append(payload)
814
+
815
+ if image_files:
816
+ response = client.images.edit(
817
+ model=model,
818
+ image=image_files,
819
+ prompt=prompt,
820
+ size=size,
821
+ quality="high",
822
+ n=count,
823
+ )
824
+ else:
825
+ response = client.images.generate(
826
+ model=model,
827
+ prompt=prompt,
828
+ size=size,
829
+ quality="high",
830
+ n=count,
831
+ )
832
+
833
+ images = []
834
+ for item in response.data:
835
+ if getattr(item, "b64_json", None):
836
+ raw = base64.b64decode(item.b64_json)
837
+ images.append(_normalize_output_size(Image.open(BytesIO(raw)), resolution))
838
+ elif getattr(item, "url", None):
839
+ raise RuntimeError("OpenAI returned an image URL, but URL fetching is disabled in this container.")
840
+
841
+ if not images:
842
+ raise RuntimeError("OpenAI did not return image data.")
843
+ _log(f"openai done images={len(images)} elapsed={time.perf_counter() - started:.1f}s", request_id)
844
+ return images
845
+ finally:
846
+ for file in image_files:
847
+ file.close()
848
+
849
+
850
+ def _generate_with_gemini(
851
+ references: list[Optional[Image.Image]],
852
+ model: str,
853
+ prompt: str,
854
+ resolution: str,
855
+ count: int,
856
+ request_id: str = "-",
857
+ ) -> list[Image.Image]:
858
+ api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
859
+ if not api_key:
860
+ raise RuntimeError("GEMINI_API_KEY or GOOGLE_API_KEY is not set.")
861
+
862
+ client = _get_gemini_client(api_key)
863
+ references = [_prepare_api_reference(image) for image in references if image is not None]
864
+ contents = [*references, prompt]
865
+ started = time.perf_counter()
866
+ _log(
867
+ f"gemini start model={model} count={count} refs={len(references)} "
868
+ f"ref_sizes={[f'{image.width}x{image.height}' for image in references]} prompt_chars={len(prompt)}",
869
+ request_id,
870
+ )
871
+
872
+ def _one_candidate(_index: int) -> Optional[Image.Image]:
873
+ response = client.models.generate_content(
874
+ model=model,
875
+ contents=contents,
876
+ config=types.GenerateContentConfig(
877
+ response_modalities=["TEXT", "IMAGE"],
878
+ image_config=_gemini_image_config(model, resolution),
879
+ ),
880
+ )
881
+ parts = getattr(response, "parts", None)
882
+ if parts is None and getattr(response, "candidates", None):
883
+ parts = response.candidates[0].content.parts
884
+ for part in parts or []:
885
+ inline_data = getattr(part, "inline_data", None)
886
+ if inline_data and inline_data.data:
887
+ raw = inline_data.data
888
+ if isinstance(raw, str):
889
+ raw = base64.b64decode(raw)
890
+ return _normalize_output_size(Image.open(BytesIO(raw)), resolution)
891
+ return None
892
+
893
+ if count <= 1:
894
+ images = [image for image in [_one_candidate(0)] if image is not None]
895
+ else:
896
+ # Fan out the candidate calls; executor.map preserves input order.
897
+ with ThreadPoolExecutor(max_workers=min(count, GEN_MAX_WORKERS)) as executor:
898
+ images = [image for image in executor.map(_one_candidate, range(count)) if image is not None]
899
+
900
+ if not images:
901
+ raise RuntimeError("Gemini did not return image data.")
902
+ _log(f"gemini done images={len(images)} elapsed={time.perf_counter() - started:.1f}s", request_id)
903
+ return images
904
+
905
+
906
+ def generate_model_cuts(
907
+ product_images: list[Optional[Image.Image]],
908
+ model_face: Image.Image,
909
+ selected_reference_image: Optional[Image.Image],
910
+ pose_reference_image: Optional[Image.Image],
911
+ image_model: str,
912
+ selected_product: str,
913
+ category: str,
914
+ fit: str,
915
+ length: str,
916
+ style: str,
917
+ prompt: str,
918
+ pose: str,
919
+ resolution: str,
920
+ total_length_cm: str,
921
+ generation_mode: str,
922
+ shot_type: str,
923
+ shot_types: list[str],
924
+ selected_base_index: int,
925
+ only_selected_cut: bool,
926
+ model_body: Optional[Image.Image] = None,
927
+ request_id: str = "-",
928
+ ) -> tuple[list[Image.Image], list[str]]:
929
+ product_match = re.search(r"\d+", selected_product or "")
930
+ product_index = max(0, min(3, int(product_match.group(0)) - 1 if product_match else 0))
931
+ selected_pair = product_images[product_index * 2 : product_index * 2 + 2]
932
+ primary_product = next((image for image in selected_pair + product_images if image is not None), None)
933
+ length_label = f"{length} / {total_length_cm}cm" if total_length_cm else length
934
+ provider, requested_model = _split_provider_model(image_model)
935
+ model = _resolve_model(provider, requested_model)
936
+ # Body-type reference: explicit upload wins, otherwise fall back to assets preset (may be None).
937
+ body_reference = model_body or load_body_reference()
938
+ front_products = [image for image in product_images if image is not None]
939
+ _log(
940
+ f"compose mode={generation_mode} provider={provider} model={model} resolution={resolution} "
941
+ f"selected_product={selected_product} selected_pair={[_image_summary(image) for image in selected_pair]} "
942
+ f"selected_reference={_image_summary(selected_reference_image)} pose_reference={_image_summary(pose_reference_image)} "
943
+ f"face={_image_summary(model_face)} body_reference={_image_summary(body_reference)} "
944
+ f"shot_type={shot_type or '-'} shot_types={shot_types or []}",
945
+ request_id,
946
+ )
947
+ composed_prompt = _compose_generation_prompt(
948
+ category=category,
949
+ fit=fit,
950
+ length=length,
951
+ style=style,
952
+ prompt=prompt,
953
+ pose=pose,
954
+ total_length_cm=total_length_cm,
955
+ generation_mode=generation_mode,
956
+ shot_type=shot_type,
957
+ selected_base_index=selected_base_index,
958
+ has_body_reference=body_reference is not None,
959
+ has_pose_reference=False,
960
+ product_count=len(front_products),
961
+ )
962
+
963
+ if generation_mode in {"shot_variant", "shot_batch"}:
964
+ selected_shots = shot_types if generation_mode == "shot_batch" and shot_types else [shot_type or "?๊พฉ๋–Š(?๋จฏ์‘€?ัŠ์ซฐ)"]
965
+ reference_face = None if selected_reference_image is not None else model_face
966
+
967
+ def _render_shot(selected_shot: str) -> list[Image.Image]:
968
+ # User-uploaded pose wins; otherwise load the named reference for this exact shot.
969
+ shot_pose = pose_reference_image or load_shot_reference(selected_shot)
970
+ # Order matters: base image first, then the pose/framing reference.
971
+ references = [
972
+ image
973
+ for image in [reference_face, selected_reference_image, shot_pose]
974
+ if image is not None
975
+ ]
976
+ _log(
977
+ f"transform shot={selected_shot} refs={len(references)} "
978
+ f"ref_sizes={[_image_summary(image) for image in references]} "
979
+ f"pose={'upload' if pose_reference_image is not None else ('named' if shot_pose is not None else 'none')}",
980
+ request_id,
981
+ )
982
+ shot_prompt = _compose_transform_prompt(
983
+ shot_type=selected_shot,
984
+ prompt=prompt,
985
+ total_length_cm=total_length_cm,
986
+ selected_base_index=selected_base_index,
987
+ has_pose_reference=shot_pose is not None,
988
+ )
989
+ if provider == "openai":
990
+ shot_images = _generate_with_openai(references, model, shot_prompt, resolution, 1, request_id)
991
+ else:
992
+ shot_images = _generate_with_gemini(references, model, shot_prompt, resolution, 1, request_id)
993
+ # Crop/scale the output to match the reference's framing โ€” except for extreme
994
+ # garment crops (detail / close-up) where subject detection is unreliable.
995
+ if MATCH_REFERENCE_FRAMING and shot_pose is not None and selected_shot not in _NO_REFRAME_SHOTS:
996
+ target_size = TARGET_SIZES.get(resolution, TARGET_SIZES["1K"])
997
+ shot_images = [_reframe_to_reference(image, shot_pose, target_size) for image in shot_images]
998
+ return shot_images
999
+
1000
+ try:
1001
+ if provider in {"openai", "gemini"}:
1002
+ if len(selected_shots) <= 1:
1003
+ results = [_render_shot(selected_shots[0])]
1004
+ else:
1005
+ # Shots are independent โ†’ fan out. executor.map keeps the input order,
1006
+ # so images stay aligned with their labels.
1007
+ with ThreadPoolExecutor(max_workers=min(len(selected_shots), GEN_MAX_WORKERS)) as executor:
1008
+ results = list(executor.map(_render_shot, selected_shots))
1009
+ images = [image for shot_images in results for image in shot_images]
1010
+ labels = list(selected_shots)
1011
+ return images, labels
1012
+ except Exception as error:
1013
+ if not DEMO_FALLBACK:
1014
+ raise
1015
+ print(f"Real image generation failed, using demo renderer: {error}")
1016
+
1017
+ elif generation_mode in {"front_candidates", "front_candidate"}:
1018
+ front_count = 1 if generation_mode == "front_candidate" else 3
1019
+ # Reference order: face (identity) โ†’ body-type (physique) โ†’ product garments.
1020
+ front_references = [model_face]
1021
+ if body_reference is not None:
1022
+ front_references.append(body_reference)
1023
+ front_references.extend(front_products)
1024
+ try:
1025
+ if provider == "openai":
1026
+ images = _generate_with_openai(front_references, model, composed_prompt, resolution, front_count, request_id)
1027
+ elif provider == "gemini":
1028
+ images = _generate_with_gemini(front_references, model, composed_prompt, resolution, front_count, request_id)
1029
+ else:
1030
+ images = None
1031
+ if images is not None:
1032
+ # Re-crop so the subject sits in the same vertical band as the framing reference.
1033
+ # Prefer the dedicated ์ „์‹ (์•ž๋ฉด) reference, else fall back to the body reference.
1034
+ framing_ref = load_shot_reference("์ „์‹ (์•ž๋ฉด)") or body_reference
1035
+ if MATCH_REFERENCE_FRAMING and (framing_ref is not None or (FRAMING_TOP_MARGIN and FRAMING_BOTTOM_MARGIN)):
1036
+ target_size = TARGET_SIZES.get(resolution, TARGET_SIZES["1K"])
1037
+ reframed = [_reframe_to_reference(image, framing_ref, target_size) for image in images]
1038
+ _log(f"reframe applied to {len(reframed)} front candidate(s) target={target_size}", request_id)
1039
+ images = reframed
1040
+ return images, [f"์ „์‹ (์ •๋ฉด) ํ›„๋ณด {index + 1}" for index in range(front_count)]
1041
+ except Exception as error:
1042
+ if not DEMO_FALLBACK:
1043
+ raise
1044
+ print(f"Real image generation failed, using demo renderer: {error}")
1045
+
1046
+ if generation_mode in {"shot_variant", "shot_batch"}:
1047
+ selected_shots = shot_types if generation_mode == "shot_batch" and shot_types else [shot_type or "?๊พฉ๋–Š(?๋จฏ์‘€?ัŠ์ซฐ)"]
1048
+ images = []
1049
+ labels = []
1050
+ base_label = f"?์ข๊นฎ ่€Œ?{selected_base_index + 1}"
1051
+ shift_map = {
1052
+ "์ „์‹ (์ž์œ ํฌ์ฆˆ)": -36,
1053
+ "์ „์‹ (์ธก๋ฉด)": 42,
1054
+ "์ „์‹ (ํ›„๋ฉด)": 0,
1055
+ "์ƒ๋ฐ˜์‹ ": 0,
1056
+ "์ƒ๋ฐ˜์‹ (ํ›„๋ฉด)": 18,
1057
+ "ํ•˜๋ฐ˜์‹ ": -18,
1058
+ "ํ•˜๋ฐ˜์‹ (์ž์œ ํฌ์ฆˆ)": 34,
1059
+ "๋””ํ…Œ์ผ(์ƒ์˜)": 0,
1060
+ "๋””ํ…Œ์ผ(ํฌ์ผ“)": -22,
1061
+ "๋””ํ…Œ์ผ(์‹ ๋ฐœ)": 22,
1062
+ }
1063
+ for shot_label in selected_shots:
1064
+ label = f"{shot_label} / {base_label}"
1065
+ image = _draw_model_cut(primary_product, model_face, label, resolution, shift_map.get(shot_label, 0), shot_label)
1066
+ images.append(image)
1067
+ labels.append(shot_label)
1068
+ return images, labels
1069
+
1070
+ fallback_count = 1 if generation_mode == "front_candidate" else 3
1071
+ labels = [
1072
+ f"์ „์‹ (์ •๋ฉด) ํ›„๋ณด 1 / {category} / {fit} / {length_label}",
1073
+ f"์ „์‹ (์ •๋ฉด) ํ›„๋ณด 2 / {style}",
1074
+ f"์ „์‹ (์ •๋ฉด) ํ›„๋ณด 3 / {pose}",
1075
+ ][:fallback_count]
1076
+ shifts = [0, -18, 18][:fallback_count]
1077
+ images = [
1078
+ _draw_model_cut(primary_product, model_face, label, resolution, shift, "์ „์‹ (์ •๋ฉด)")
1079
+ for label, shift in zip(labels, shifts)
1080
+ ]
1081
+ return images, [f"์ „์‹ (์ •๋ฉด) ํ›„๋ณด {index + 1}" for index in range(fallback_count)]
1082
+
1083
+
1084
+ @app.get("/")
1085
+ def index() -> FileResponse:
1086
+ return FileResponse(BASE_DIR / "index.html")
1087
+
1088
+
1089
+ @app.get("/styles.css")
1090
+ def styles() -> FileResponse:
1091
+ return FileResponse(BASE_DIR / "styles.css")
1092
+
1093
+
1094
+ @app.get("/script.js")
1095
+ def script() -> FileResponse:
1096
+ return FileResponse(BASE_DIR / "script.js")
1097
+
1098
+
1099
+ @app.get("/model_face_preset.png")
1100
+ def model_face_preset() -> Response:
1101
+ for preset_path in PRESET_FACE_CANDIDATES:
1102
+ if preset_path.exists():
1103
+ return FileResponse(preset_path)
1104
+
1105
+ return Response(content=_image_to_png_bytes(_create_fallback_face()), media_type="image/png")
1106
+
1107
+
1108
+ @app.get("/health")
1109
+ def health() -> dict[str, str]:
1110
+ return {"status": "ok"}
1111
+
1112
+
1113
+ @app.post("/api/generate")
1114
+ async def generate(
1115
+ product_1_front: Optional[UploadFile] = File(None),
1116
+ product_1_back: Optional[UploadFile] = File(None),
1117
+ product_2_front: Optional[UploadFile] = File(None),
1118
+ product_2_back: Optional[UploadFile] = File(None),
1119
+ product_3_front: Optional[UploadFile] = File(None),
1120
+ product_3_back: Optional[UploadFile] = File(None),
1121
+ product_4_front: Optional[UploadFile] = File(None),
1122
+ product_4_back: Optional[UploadFile] = File(None),
1123
+ model_face: Optional[UploadFile] = File(None),
1124
+ model_body: Optional[UploadFile] = File(None),
1125
+ face_source: str = Form("์ฒจ๋ถ€ ์–ผ๊ตด ํ”„๋ฆฌ์…‹"),
1126
+ image_model: str = Form("openai:gpt-image-2"),
1127
+ selected_product: str = Form("์ œํ’ˆ 1"),
1128
+ category: str = Form("์•„์šฐํ„ฐ"),
1129
+ fit: str = Form("ํ‘œ๏ฟฝ๏ฟฝ"),
1130
+ length: str = Form("๋ฌด๋ฆŽ"),
1131
+ style: str = Form("์ปค๋จธ์Šค ๋ฃฉ๋ถ"),
1132
+ prompt: str = Form(""),
1133
+ pose: str = Form("์ •๋ฉด"),
1134
+ resolution: str = Form("1K"),
1135
+ total_length_cm: str = Form(""),
1136
+ generation_mode: str = Form("front_candidates"),
1137
+ shot_type: str = Form(""),
1138
+ shot_types: str = Form(""),
1139
+ selected_base_index: int = Form(0),
1140
+ selected_reference_image: Optional[UploadFile] = File(None),
1141
+ pose_reference_image: Optional[UploadFile] = File(None),
1142
+ only_selected_cut: bool = Form(False),
1143
+ ) -> JSONResponse:
1144
+ request_id = uuid.uuid4().hex[:8]
1145
+ request_started = time.perf_counter()
1146
+ _log(
1147
+ f"request start mode={generation_mode} shot_type={shot_type or '-'} shot_types={shot_types or '-'} "
1148
+ f"model={image_model} resolution={resolution} selected_product={selected_product}",
1149
+ request_id,
1150
+ )
1151
+ uploads = [
1152
+ product_1_front,
1153
+ product_1_back,
1154
+ product_2_front,
1155
+ product_2_back,
1156
+ product_3_front,
1157
+ product_3_back,
1158
+ product_4_front,
1159
+ product_4_back,
1160
+ ]
1161
+ product_images = [await _read_upload(upload) for upload in uploads]
1162
+ selected_reference = await _read_upload(selected_reference_image)
1163
+ pose_reference = await _read_upload(pose_reference_image)
1164
+ uploaded_face = await _read_upload(model_face)
1165
+ uploaded_body = await _read_upload(model_body)
1166
+ _log(
1167
+ f"uploads products={sum(image is not None for image in product_images)}/8 "
1168
+ f"product_sizes={[_image_summary(image) for image in product_images if image is not None]} "
1169
+ f"selected_reference={_image_summary(selected_reference)} pose_reference={_image_summary(pose_reference)} "
1170
+ f"uploaded_face={_image_summary(uploaded_face)}",
1171
+ request_id,
1172
+ )
1173
+ if face_source == "?๋‚…์คˆ???์‡จ๋Žฌ" and uploaded_face:
1174
+ selected_face = uploaded_face
1175
+ elif any(preset_path.exists() for preset_path in PRESET_FACE_CANDIDATES):
1176
+ selected_face = load_preset_face()
1177
+ elif DEMO_FALLBACK:
1178
+ selected_face = load_preset_face()
1179
+ else:
1180
+ return JSONResponse(
1181
+ {
1182
+ "error": "?์„Žโ…ค?๋šฎ๊ตน ?์‡จ๋Žฌ ?๊พจโ”?๋—ญ์”  ?๋†๋’ฟ?๋ˆ๋–Ž. assets/model_face_preset.png ?๋จฎ๋’— ็Œทโ‘ฆ๋“ƒ model_face_preset.png็‘œ??ั‰โ”ๅซ„๊ณ•๊ตน ?๋ถพใˆƒ?๋จฏ๊ฝŒ ๏งโ‘ค๋œฝ ?์‡จ๋Žฌ???๋‚…์คˆ?์’—๋ธฏ?๋ช„์Š‚.",
1183
+ "provider": _split_provider_model(image_model)[0],
1184
+ "model": _resolve_model(*_split_provider_model(image_model)),
1185
+ "generation_mode": generation_mode,
1186
+ "resolution": resolution,
1187
+ },
1188
+ status_code=400,
1189
+ )
1190
+ try:
1191
+ images, labels = await asyncio.to_thread(
1192
+ generate_model_cuts,
1193
+ product_images=product_images,
1194
+ model_face=selected_face,
1195
+ selected_reference_image=selected_reference,
1196
+ pose_reference_image=pose_reference,
1197
+ image_model=image_model,
1198
+ selected_product=selected_product,
1199
+ category=category,
1200
+ fit=fit,
1201
+ length=length,
1202
+ style=style,
1203
+ prompt=prompt,
1204
+ pose=pose,
1205
+ resolution=resolution,
1206
+ total_length_cm=total_length_cm,
1207
+ generation_mode=generation_mode,
1208
+ shot_type=shot_type,
1209
+ shot_types=[item for item in shot_types.split("|") if item],
1210
+ selected_base_index=selected_base_index,
1211
+ only_selected_cut=only_selected_cut,
1212
+ model_body=uploaded_body,
1213
+ request_id=request_id,
1214
+ )
1215
+ _log(f"request done images={len(images)} labels={labels} elapsed={time.perf_counter() - request_started:.1f}s", request_id)
1216
+ asyncio.create_task(
1217
+ asyncio.to_thread(
1218
+ _upload_generation_to_dataset,
1219
+ images,
1220
+ labels,
1221
+ {
1222
+ "kind": "generate",
1223
+ "image_model": image_model,
1224
+ "selected_product": selected_product,
1225
+ "category": category,
1226
+ "fit": fit,
1227
+ "length": length,
1228
+ "style": style,
1229
+ "pose": pose,
1230
+ "resolution": resolution,
1231
+ "total_length_cm": total_length_cm,
1232
+ "generation_mode": generation_mode,
1233
+ "shot_type": shot_type,
1234
+ "shot_types": [item for item in shot_types.split("|") if item],
1235
+ "selected_base_index": selected_base_index,
1236
+ "labels": labels,
1237
+ },
1238
+ request_id,
1239
+ )
1240
+ )
1241
+ except Exception as error:
1242
+ provider, requested_model = _split_provider_model(image_model)
1243
+ resolved_model = _resolve_model(provider, requested_model)
1244
+ traceback.print_exc()
1245
+ _log(f"request failed error={error} elapsed={time.perf_counter() - request_started:.1f}s", request_id)
1246
+ return JSONResponse(
1247
+ {
1248
+ "error": str(error),
1249
+ "provider": provider,
1250
+ "model": resolved_model,
1251
+ "generation_mode": generation_mode,
1252
+ "resolution": resolution,
1253
+ },
1254
+ status_code=500,
1255
+ )
1256
+
1257
+ return JSONResponse({"images": [_image_to_data_url(image, resolution) for image in images], "labels": labels})
1258
+
1259
+
1260
+ @app.post("/api/edit")
1261
+ async def edit_image(
1262
+ base_image: UploadFile = File(...),
1263
+ reference_images: Optional[list[UploadFile]] = File(None),
1264
+ image_model: str = Form("openai:gpt-image-2"),
1265
+ instruction: str = Form(""),
1266
+ background: str = Form(""),
1267
+ resolution: str = Form("1K"),
1268
+ ) -> JSONResponse:
1269
+ try:
1270
+ base = await _read_upload(base_image)
1271
+ if base is None:
1272
+ return JSONResponse({"error": "?์„์ ™??ๆนฒ๊ณ—? ?๋Œ€?๏งžย€ๅช›ย€ ?๋†๋’ฟ?๋ˆ๋–Ž."}, status_code=400)
1273
+
1274
+ refs = []
1275
+ for upload in reference_images or []:
1276
+ image = await _read_upload(upload)
1277
+ if image is not None:
1278
+ refs.append(image)
1279
+
1280
+ provider, requested_model = _split_provider_model(image_model)
1281
+ model = _resolve_model(provider, requested_model)
1282
+ edit_prompt = "\n".join(
1283
+ [
1284
+ "Edit this fashion model image while preserving the same model identity, outfit, garment color, fabric texture, silhouette, and product details.",
1285
+ "Only apply the requested changes. Do not change the face or clothing unless explicitly requested.",
1286
+ f"Background preset: {background or 'keep current background'}",
1287
+ f"User edit instruction: {instruction or 'Regenerate naturally with the same settings.'}",
1288
+ ]
1289
+ )
1290
+
1291
+ if provider == "openai":
1292
+ images = _generate_with_openai([base, *refs], model, edit_prompt, resolution, 1)
1293
+ elif provider == "gemini":
1294
+ images = _generate_with_gemini([base, *refs], model, edit_prompt, resolution, 1)
1295
+ else:
1296
+ return JSONResponse({"error": f"๏งžย€?๋จฐ๋ธฏ๏งžย€ ?๋”…๋’— provider?๋‚…๋•ฒ?? {provider}"}, status_code=400)
1297
+
1298
+ edit_request_id = uuid.uuid4().hex[:8]
1299
+ asyncio.create_task(
1300
+ asyncio.to_thread(
1301
+ _upload_generation_to_dataset,
1302
+ images,
1303
+ ["์ˆ˜์ • ์ด๋ฏธ์ง€"],
1304
+ {
1305
+ "kind": "edit",
1306
+ "image_model": image_model,
1307
+ "resolution": resolution,
1308
+ "background": background,
1309
+ "instruction": instruction,
1310
+ "labels": ["์ˆ˜์ • ์ด๋ฏธ์ง€"],
1311
+ },
1312
+ edit_request_id,
1313
+ )
1314
+ )
1315
+ return JSONResponse({"images": [_image_to_data_url(image, resolution) for image in images], "labels": ["?์„์ ™ ?๋Œ€?๏งžย€"]})
1316
+ except Exception as error:
1317
+ provider, requested_model = _split_provider_model(image_model)
1318
+ traceback.print_exc()
1319
+ return JSONResponse(
1320
+ {
1321
+ "error": str(error),
1322
+ "provider": provider,
1323
+ "model": _resolve_model(provider, requested_model),
1324
+ "resolution": resolution,
1325
+ },
1326
+ status_code=500,
1327
+ )
1328
+
1329
+
1330
+ if __name__ == "__main__":
1331
+ port = int(os.environ.get("PORT", "7860"))
1332
+ uvicorn.run("app:app", host="0.0.0.0", port=port)