nenae18 commited on
Commit
c6bf392
ยท
verified ยท
1 Parent(s): f77ca77

Deploy LeeChard

Browse files
app/api/v1/endpoints/photos.py CHANGED
@@ -42,6 +42,17 @@ ALLOWED_MIME = {"image/jpeg", "image/png", "image/webp"}
42
  MAX_BYTES = 20 * 1024 * 1024
43
  LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
44
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  def _utcnow() -> datetime:
47
  return datetime.now(timezone.utc)
@@ -141,6 +152,7 @@ async def upload_photo(
141
  qr_delivery_allowed: bool = Form(default=True),
142
  marketing_allowed: bool = Form(default=False),
143
  client_name: str | None = Form(default="Guest"),
 
144
  access_code: str | None = Form(default=None),
145
  idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
146
  db: Session = Depends(get_db),
@@ -186,6 +198,7 @@ async def upload_photo(
186
  shop_id=effective_shop,
187
  tablet_id=tablet_id,
188
  idempotency_key=idempotency_key,
 
189
  )
190
  except IntegrityError:
191
  db.rollback()
 
42
  MAX_BYTES = 20 * 1024 * 1024
43
  LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1"}
44
 
45
+ # Operator gender pick (upload screen) -> canonical "man"/"woman". Anything else
46
+ # (blank, "auto", garbage) -> None so the engine falls back to InsightFace auto-detect.
47
+ _GENDER_ALIASES = {
48
+ "man": "man", "male": "man", "m": "man", "๋‚จ": "man", "๋‚จ์ž": "man", "๋‚จ์„ฑ": "man",
49
+ "woman": "woman", "female": "woman", "f": "woman", "์—ฌ": "woman", "์—ฌ์ž": "woman", "์—ฌ์„ฑ": "woman",
50
+ }
51
+
52
+
53
+ def _normalize_gender(value: str | None) -> str | None:
54
+ return _GENDER_ALIASES.get((value or "").strip().lower())
55
+
56
 
57
  def _utcnow() -> datetime:
58
  return datetime.now(timezone.utc)
 
152
  qr_delivery_allowed: bool = Form(default=True),
153
  marketing_allowed: bool = Form(default=False),
154
  client_name: str | None = Form(default="Guest"),
155
+ gender: str | None = Form(default=None),
156
  access_code: str | None = Form(default=None),
157
  idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
158
  db: Session = Depends(get_db),
 
198
  shop_id=effective_shop,
199
  tablet_id=tablet_id,
200
  idempotency_key=idempotency_key,
201
+ requested_gender=_normalize_gender(gender),
202
  )
203
  except IntegrityError:
204
  db.rollback()
app/models/photo_task.py CHANGED
@@ -76,6 +76,10 @@ class PhotoTask(Base):
76
  TaskStatusType, default=TaskStatus.UPLOADED, nullable=False, index=True
77
  )
78
 
 
 
 
 
79
  # โ”€โ”€ ๋””์ŠคํŒจ์น˜ / enqueue ๋ณต๊ตฌ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
80
  celery_job_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
81
 
 
76
  TaskStatusType, default=TaskStatus.UPLOADED, nullable=False, index=True
77
  )
78
 
79
+ # ์ง์›์ด ์—…๋กœ๋“œ ํ™”๋ฉด์—์„œ ๊ณ ๋ฅธ ์„ฑ๋ณ„("man"/"woman"). NULL=์ž๋™ํŒ์ •.
80
+ # InsightFace ์˜คํŒ(์—ฌ์„ฑโ†’๋‚จ์„ฑ ๋“ฑ)์œผ๋กœ ์ž˜๋ชป๋œ ์„ฑ๋ณ„ ๊ฒฝ๋กœ/ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์ ์šฉ๋˜๋Š” ๊ฒƒ์„ ๋ง‰๋Š”๋‹ค.
81
+ requested_gender: Mapped[str | None] = mapped_column(String(8), nullable=True)
82
+
83
  # โ”€โ”€ ๋””์ŠคํŒจ์น˜ / enqueue ๋ณต๊ตฌ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
84
  celery_job_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
85
 
app/services/comfyui_client.py CHANGED
@@ -93,6 +93,9 @@ class FaceSwapRequest:
93
  mood: str | None = None
94
  prompt: str | None = None
95
  simulate: str | None = None
 
 
 
96
 
97
 
98
  @dataclass
 
93
  mood: str | None = None
94
  prompt: str | None = None
95
  simulate: str | None = None
96
+ # Operator-picked gender ("man"/"woman") from the upload screen; None -> auto-detect.
97
+ # Overrides InsightFace so a misread face still gets the correct gender-locked route.
98
+ requested_gender: str | None = None
99
 
100
 
101
  @dataclass
app/services/falinpaint_beautify.py CHANGED
@@ -535,7 +535,9 @@ def nanobanana_prompt_for(gender: str) -> str:
535
  )
536
 
537
 
538
- def beautify_with_nanobanana_fal(source_bytes: bytes, *, prompt: str | None = None, **_opts):
 
 
539
  """APPROVED production engine: fal Nano Banana instruction-edit beautify.
540
 
541
  Full-frame (no mask): the model itself keeps hair/clothing/background because the
@@ -544,6 +546,9 @@ def beautify_with_nanobanana_fal(source_bytes: bytes, *, prompt: str | None = No
544
  (final_png_bytes, metrics). Raises FalUnavailable when FAL_KEY is absent or the
545
  hosted call fails. Extra **_opts are accepted/ignored so the worker can pass the
546
  shared settings dict unchanged.
 
 
 
547
  """
548
  from PIL import Image
549
 
@@ -551,7 +556,13 @@ def beautify_with_nanobanana_fal(source_bytes: bytes, *, prompt: str | None = No
551
  raise FalUnavailable("FAL_KEY not set; no network call made")
552
  src = normalize_image_orientation_bytes(source_bytes)
553
  # Gender for a gender-locked prompt (best-effort; full-frame edit needs no mask).
554
- _box, gender, _eyes = detect_face_and_gender(_downscale(src, int(DEFAULTS["max_size"])))
 
 
 
 
 
 
555
  used_prompt = prompt or nanobanana_prompt_for(gender)
556
 
557
  raw = run_fal_nanobanana_edit(src, used_prompt)
@@ -568,6 +579,7 @@ def beautify_with_nanobanana_fal(source_bytes: bytes, *, prompt: str | None = No
568
  "engine": "fal-nanobanana-beautify",
569
  "hosted_model": fal_nanobanana_model(),
570
  "gender_detected": gender or "unknown",
 
571
  "edit_mode": "full_frame_instruction_edit",
572
  "human_qa_required": "YES",
573
  "pilot_ready": "NOT CONFIRMED",
@@ -575,7 +587,9 @@ def beautify_with_nanobanana_fal(source_bytes: bytes, *, prompt: str | None = No
575
  return final, metrics
576
 
577
 
578
- def beautify_with_kontext_composite(source_bytes: bytes, *, prompt: str | None = None, **_opts):
 
 
579
  """SALON engine: strong FLUX-Kontext face transform, then composite the ORIGINAL
580
  hair / background / clothing / glasses back over it so they are preserved 100%.
581
 
@@ -583,13 +597,20 @@ def beautify_with_kontext_composite(source_bytes: bytes, *, prompt: str | None =
583
  Nano Banana leaves unchanged) but it restyles hair/background โ€” so we keep only
584
  the new face-skin region and take everything else from the original. Returns
585
  (final_png_bytes, metrics). Raises FalUnavailable if FAL_KEY is absent / the call
586
- fails. If segmentation is unavailable the Kontext image is returned as-is."""
 
 
587
  from PIL import Image
588
 
589
  if not fal_real_enabled():
590
  raise FalUnavailable("FAL_KEY not set; no network call made")
591
  src = normalize_image_orientation_bytes(source_bytes)
592
- _box, gender, _eyes = detect_face_and_gender(_downscale(src, int(DEFAULTS["max_size"])))
 
 
 
 
 
593
  used_prompt = prompt or nanobanana_prompt_for(gender)
594
 
595
  raw = run_fal_kontext_edit(src, used_prompt) # strong face transform (may restyle hair/bg)
@@ -609,6 +630,7 @@ def beautify_with_kontext_composite(source_bytes: bytes, *, prompt: str | None =
609
  "engine": "fal-kontext-composite",
610
  "hosted_model": fal_kontext_model(),
611
  "gender_detected": gender or "unknown",
 
612
  "edit_mode": "kontext_face_then_original_hair_composite",
613
  "human_qa_required": "YES",
614
  "pilot_ready": "NOT CONFIRMED",
@@ -632,19 +654,31 @@ KONTEXT_MAN_NATURAL_PROMPT = (
632
  )
633
 
634
 
635
- def beautify_hybrid(source_bytes: bytes, *, prompt: str | None = None, **_opts):
 
 
636
  """Per-gender routing (Option 1: keep hair/glasses + natural light glow-up):
637
  - MEN -> Kontext composite with a NATURAL prompt (nano-banana barely moves male faces;
638
  a strong prompt melts the composite, so men stay photoreal + subtle).
639
  - WOMEN/unknown -> nano-banana (more dynamic + natural on female faces).
640
- Both preserve the original hair/glasses. Returns (final_png_bytes, metrics)."""
 
 
 
 
641
  if not fal_real_enabled():
642
  raise FalUnavailable("FAL_KEY not set; no network call made")
643
  src = normalize_image_orientation_bytes(source_bytes)
644
- _box, gender, _eyes = detect_face_and_gender(_downscale(src, int(DEFAULTS["max_size"])))
645
- if gender == "man":
646
- return beautify_with_kontext_composite(source_bytes, prompt=prompt or KONTEXT_MAN_NATURAL_PROMPT)
647
- return beautify_with_nanobanana_fal(source_bytes, prompt=prompt)
 
 
 
 
 
 
648
 
649
 
650
  def settings_from_env() -> dict:
 
535
  )
536
 
537
 
538
+ def beautify_with_nanobanana_fal(
539
+ source_bytes: bytes, *, prompt: str | None = None, gender: str | None = None, **_opts
540
+ ):
541
  """APPROVED production engine: fal Nano Banana instruction-edit beautify.
542
 
543
  Full-frame (no mask): the model itself keeps hair/clothing/background because the
 
546
  (final_png_bytes, metrics). Raises FalUnavailable when FAL_KEY is absent or the
547
  hosted call fails. Extra **_opts are accepted/ignored so the worker can pass the
548
  shared settings dict unchanged.
549
+
550
+ `gender` ("man"/"woman") lets the operator pin the gender; when given it overrides
551
+ auto-detection so a misread face still gets the correct gender-locked prompt.
552
  """
553
  from PIL import Image
554
 
 
556
  raise FalUnavailable("FAL_KEY not set; no network call made")
557
  src = normalize_image_orientation_bytes(source_bytes)
558
  # Gender for a gender-locked prompt (best-effort; full-frame edit needs no mask).
559
+ # Operator-supplied gender wins; otherwise fall back to InsightFace auto-detect.
560
+ known = (gender or "").strip().lower()
561
+ if known in ("man", "woman"):
562
+ gender, gender_source = known, "operator"
563
+ else:
564
+ _box, gender, _eyes = detect_face_and_gender(_downscale(src, int(DEFAULTS["max_size"])))
565
+ gender_source = "auto"
566
  used_prompt = prompt or nanobanana_prompt_for(gender)
567
 
568
  raw = run_fal_nanobanana_edit(src, used_prompt)
 
579
  "engine": "fal-nanobanana-beautify",
580
  "hosted_model": fal_nanobanana_model(),
581
  "gender_detected": gender or "unknown",
582
+ "gender_source": gender_source,
583
  "edit_mode": "full_frame_instruction_edit",
584
  "human_qa_required": "YES",
585
  "pilot_ready": "NOT CONFIRMED",
 
587
  return final, metrics
588
 
589
 
590
+ def beautify_with_kontext_composite(
591
+ source_bytes: bytes, *, prompt: str | None = None, gender: str | None = None, **_opts
592
+ ):
593
  """SALON engine: strong FLUX-Kontext face transform, then composite the ORIGINAL
594
  hair / background / clothing / glasses back over it so they are preserved 100%.
595
 
 
597
  Nano Banana leaves unchanged) but it restyles hair/background โ€” so we keep only
598
  the new face-skin region and take everything else from the original. Returns
599
  (final_png_bytes, metrics). Raises FalUnavailable if FAL_KEY is absent / the call
600
+ fails. If segmentation is unavailable the Kontext image is returned as-is.
601
+
602
+ `gender` ("man"/"woman") pins the gender (operator override); otherwise auto-detect."""
603
  from PIL import Image
604
 
605
  if not fal_real_enabled():
606
  raise FalUnavailable("FAL_KEY not set; no network call made")
607
  src = normalize_image_orientation_bytes(source_bytes)
608
+ known = (gender or "").strip().lower()
609
+ if known in ("man", "woman"):
610
+ gender, gender_source = known, "operator"
611
+ else:
612
+ _box, gender, _eyes = detect_face_and_gender(_downscale(src, int(DEFAULTS["max_size"])))
613
+ gender_source = "auto"
614
  used_prompt = prompt or nanobanana_prompt_for(gender)
615
 
616
  raw = run_fal_kontext_edit(src, used_prompt) # strong face transform (may restyle hair/bg)
 
630
  "engine": "fal-kontext-composite",
631
  "hosted_model": fal_kontext_model(),
632
  "gender_detected": gender or "unknown",
633
+ "gender_source": gender_source,
634
  "edit_mode": "kontext_face_then_original_hair_composite",
635
  "human_qa_required": "YES",
636
  "pilot_ready": "NOT CONFIRMED",
 
654
  )
655
 
656
 
657
+ def beautify_hybrid(
658
+ source_bytes: bytes, *, prompt: str | None = None, gender: str | None = None, **_opts
659
+ ):
660
  """Per-gender routing (Option 1: keep hair/glasses + natural light glow-up):
661
  - MEN -> Kontext composite with a NATURAL prompt (nano-banana barely moves male faces;
662
  a strong prompt melts the composite, so men stay photoreal + subtle).
663
  - WOMEN/unknown -> nano-banana (more dynamic + natural on female faces).
664
+ Both preserve the original hair/glasses. Returns (final_png_bytes, metrics).
665
+
666
+ `gender` ("man"/"woman") is the operator's pick from the upload screen. When given it
667
+ decides the route AND the prompt โ€” so a face InsightFace misreads (e.g. a woman read as
668
+ a man, then masculinized by the man prompt) no longer happens. Empty/None -> auto-detect."""
669
  if not fal_real_enabled():
670
  raise FalUnavailable("FAL_KEY not set; no network call made")
671
  src = normalize_image_orientation_bytes(source_bytes)
672
+ known = (gender or "").strip().lower()
673
+ if known in ("man", "woman"):
674
+ routed = known
675
+ else:
676
+ _box, routed, _eyes = detect_face_and_gender(_downscale(src, int(DEFAULTS["max_size"])))
677
+ if routed == "man":
678
+ return beautify_with_kontext_composite(
679
+ source_bytes, prompt=prompt or KONTEXT_MAN_NATURAL_PROMPT, gender=routed
680
+ )
681
+ return beautify_with_nanobanana_fal(source_bytes, prompt=prompt, gender=routed)
682
 
683
 
684
  def settings_from_env() -> dict:
app/services/hybrid_client.py CHANGED
@@ -40,7 +40,7 @@ class HybridBeautifyClient(AIClient):
40
  )
41
 
42
  try:
43
- final, metrics = beautify_hybrid(req.image_bytes)
44
  except FalUnavailable as exc:
45
  raise AIProviderError(f"Hybrid beautify failed: {exc}", code="FAL_ERROR") from exc
46
 
@@ -59,6 +59,7 @@ class HybridBeautifyClient(AIClient):
59
  "denied_operations": DENIED_OPERATIONS,
60
  "engine_used": metrics.get("engine"),
61
  "gender_detected": metrics.get("gender_detected"),
 
62
  },
63
  qa=qa,
64
  )
 
40
  )
41
 
42
  try:
43
+ final, metrics = beautify_hybrid(req.image_bytes, gender=req.requested_gender)
44
  except FalUnavailable as exc:
45
  raise AIProviderError(f"Hybrid beautify failed: {exc}", code="FAL_ERROR") from exc
46
 
 
59
  "denied_operations": DENIED_OPERATIONS,
60
  "engine_used": metrics.get("engine"),
61
  "gender_detected": metrics.get("gender_detected"),
62
+ "gender_source": metrics.get("gender_source"),
63
  },
64
  qa=qa,
65
  )
app/services/kontext_client.py CHANGED
@@ -41,7 +41,9 @@ class KontextCompositeClient(AIClient):
41
  )
42
 
43
  try:
44
- final, metrics = beautify_with_kontext_composite(req.image_bytes)
 
 
45
  except FalUnavailable as exc:
46
  raise AIProviderError(f"Kontext beautify failed: {exc}", code="FAL_ERROR") from exc
47
 
 
41
  )
42
 
43
  try:
44
+ final, metrics = beautify_with_kontext_composite(
45
+ req.image_bytes, gender=req.requested_gender
46
+ )
47
  except FalUnavailable as exc:
48
  raise AIProviderError(f"Kontext beautify failed: {exc}", code="FAL_ERROR") from exc
49
 
app/services/nanobanana_client.py CHANGED
@@ -43,7 +43,9 @@ class NanoBananaFalClient(AIClient):
43
  )
44
 
45
  try:
46
- final, metrics = beautify_with_nanobanana_fal(req.image_bytes)
 
 
47
  except FalUnavailable as exc:
48
  raise AIProviderError(f"Nano Banana beautify failed: {exc}", code="FAL_ERROR") from exc
49
 
 
43
  )
44
 
45
  try:
46
+ final, metrics = beautify_with_nanobanana_fal(
47
+ req.image_bytes, gender=req.requested_gender
48
+ )
49
  except FalUnavailable as exc:
50
  raise AIProviderError(f"Nano Banana beautify failed: {exc}", code="FAL_ERROR") from exc
51
 
app/services/task_repo.py CHANGED
@@ -129,6 +129,7 @@ def create_task(
129
  tablet_id: str | None = None,
130
  idempotency_key: str | None = None,
131
  task_id: str | None = None,
 
132
  ) -> PhotoTask:
133
  """task + outbox ์ด๋ฒคํŠธ๋ฅผ ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜์—์„œ ์ƒ์„ฑ.
134
 
@@ -144,6 +145,7 @@ def create_task(
144
  shop_id=shop_id or salon_id,
145
  tablet_id=tablet_id,
146
  idempotency_key=idempotency_key,
 
147
  original_key=original_key,
148
  original_mime=original_mime,
149
  original_expires_at=_utcnow() + timedelta(hours=settings.original_ttl_hours),
 
129
  tablet_id: str | None = None,
130
  idempotency_key: str | None = None,
131
  task_id: str | None = None,
132
+ requested_gender: str | None = None,
133
  ) -> PhotoTask:
134
  """task + outbox ์ด๋ฒคํŠธ๋ฅผ ๊ฐ™์€ ํŠธ๋žœ์žญ์…˜์—์„œ ์ƒ์„ฑ.
135
 
 
145
  shop_id=shop_id or salon_id,
146
  tablet_id=tablet_id,
147
  idempotency_key=idempotency_key,
148
+ requested_gender=requested_gender,
149
  original_key=original_key,
150
  original_mime=original_mime,
151
  original_expires_at=_utcnow() + timedelta(hours=settings.original_ttl_hours),
app/workers/tasks.py CHANGED
@@ -112,6 +112,7 @@ def process_photo(self, task_id: str, dispatch_version: int = 0) -> str:
112
  image_bytes=original,
113
  mime=task.original_mime or "image/jpeg",
114
  face_boxes=in_qc.face_boxes,
 
115
  )
116
  )
117
  except Exception as exc: # noqa: BLE001
 
112
  image_bytes=original,
113
  mime=task.original_mime or "image/jpeg",
114
  face_boxes=in_qc.face_boxes,
115
+ requested_gender=task.requested_gender,
116
  )
117
  )
118
  except Exception as exc: # noqa: BLE001
static/css/styles.css CHANGED
@@ -98,6 +98,13 @@ body {
98
  .chip { background: var(--card); border: 1px solid var(--line); color: var(--muted); font-size: 12.5px; padding: 7px 13px; border-radius: 999px; }
99
  .chip.gold { color: var(--gold-deep); border-color: #e7d6c4; background: #fbf3ea; font-weight: 600; }
100
 
 
 
 
 
 
 
 
101
  /* --- loading --- */
102
  #screen-loading { background: var(--charcoal); }
103
  .loading-bg { position: absolute; inset: 0; background-size: cover; background-position: center; filter: blur(26px) brightness(.5); transform: scale(1.15); }
 
98
  .chip { background: var(--card); border: 1px solid var(--line); color: var(--muted); font-size: 12.5px; padding: 7px 13px; border-radius: 999px; }
99
  .chip.gold { color: var(--gold-deep); border-color: #e7d6c4; background: #fbf3ea; font-weight: 600; }
100
 
101
+ /* --- gender pick (capture confirm) --- */
102
+ .gender-pick { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
103
+ .gender-pick-label { font-size: 13px; color: var(--muted); font-weight: 600; flex: none; }
104
+ .gender-seg { display: flex; flex: 1; gap: 6px; padding: 4px; background: var(--ivory); border: 1px solid var(--line); border-radius: 999px; }
105
+ .gender-opt { flex: 1; padding: 9px 0; border: none; background: none; border-radius: 999px; font-family: var(--sans); font-size: 14px; font-weight: 600; color: var(--muted); cursor: pointer; transition: background .15s ease, color .15s ease; }
106
+ .gender-opt.active { background: var(--gold); color: #fff; box-shadow: 0 3px 10px rgba(200, 169, 138, .35); }
107
+
108
  /* --- loading --- */
109
  #screen-loading { background: var(--charcoal); }
110
  .loading-bg { position: absolute; inset: 0; background-size: cover; background-position: center; filter: blur(26px) brightness(.5); transform: scale(1.15); }
static/index.html CHANGED
@@ -70,6 +70,14 @@
70
  <button class="btn btn-text" id="btn-gallery">๊ฐค๋Ÿฌ๋ฆฌ์—์„œ ์„ ํƒ</button>
71
  </div>
72
  <div id="capture-confirm" hidden>
 
 
 
 
 
 
 
 
73
  <button class="btn btn-primary" id="btn-use">์ด ์‚ฌ์ง„์œผ๋กœ</button>
74
  <button class="btn btn-text" id="btn-retake">๋‹ค์‹œ ์ฐ๊ธฐ</button>
75
  </div>
 
70
  <button class="btn btn-text" id="btn-gallery">๊ฐค๋Ÿฌ๋ฆฌ์—์„œ ์„ ํƒ</button>
71
  </div>
72
  <div id="capture-confirm" hidden>
73
+ <div class="gender-pick" id="gender-pick" role="group" aria-label="์„ฑ๋ณ„ ์„ ํƒ">
74
+ <span class="gender-pick-label">์„ฑ๋ณ„</span>
75
+ <div class="gender-seg">
76
+ <button type="button" class="gender-opt active" data-gender="auto">์ž๋™</button>
77
+ <button type="button" class="gender-opt" data-gender="woman">์—ฌ์„ฑ</button>
78
+ <button type="button" class="gender-opt" data-gender="man">๋‚จ์„ฑ</button>
79
+ </div>
80
+ </div>
81
  <button class="btn btn-primary" id="btn-use">์ด ์‚ฌ์ง„์œผ๋กœ</button>
82
  <button class="btn btn-text" id="btn-retake">๋‹ค์‹œ ์ฐ๊ธฐ</button>
83
  </div>
static/js/app.js CHANGED
@@ -11,6 +11,7 @@ const state = {
11
  shareUrl: null,
12
  deliveryAvailable: true,
13
  accessCode: "",
 
14
  retryTo: "capture",
15
  };
16
 
@@ -86,6 +87,21 @@ $("#btn-shoot").addEventListener("click", () => { input.setAttribute("capture",
86
  $("#btn-gallery").addEventListener("click", () => { input.removeAttribute("capture"); input.click(); });
87
  $("#btn-retake").addEventListener("click", resetCapture);
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  input.addEventListener("change", (ev) => {
90
  const f = ev.target.files && ev.target.files[0];
91
  if (!f) return;
@@ -108,6 +124,7 @@ function resetCapture() {
108
  $("#capture-hint").textContent = "์–ผ๊ตด์ด ์ž˜ ๋ณด์ด๊ฒŒ ์ •๋ฉด์œผ๋กœ ๋งž์ถฐ ์ฃผ์„ธ์š”";
109
  $("#capture-actions").hidden = false;
110
  $("#capture-confirm").hidden = true;
 
111
  input.value = "";
112
  }
113
 
@@ -125,6 +142,7 @@ async function startProcessing() {
125
  data.append("salon_id", "leechard_pro");
126
  data.append("qr_delivery_allowed", $("#c-qr").checked ? "true" : "false");
127
  data.append("marketing_allowed", $("#c-mkt").checked ? "true" : "false");
 
128
  data.append("access_code", state.accessCode || "");
129
  try {
130
  const up = await api(`${API}/upload`, { method: "POST", body: data });
 
11
  shareUrl: null,
12
  deliveryAvailable: true,
13
  accessCode: "",
14
+ gender: "auto", // operator pick on capture screen: "auto" | "woman" | "man"
15
  retryTo: "capture",
16
  };
17
 
 
87
  $("#btn-gallery").addEventListener("click", () => { input.removeAttribute("capture"); input.click(); });
88
  $("#btn-retake").addEventListener("click", resetCapture);
89
 
90
+ // operator picks gender (overrides auto-detect so a misread face routes correctly)
91
+ document.querySelectorAll("#gender-pick .gender-opt").forEach((b) => {
92
+ b.addEventListener("click", () => {
93
+ document.querySelectorAll("#gender-pick .gender-opt").forEach((o) => o.classList.remove("active"));
94
+ b.classList.add("active");
95
+ state.gender = b.dataset.gender || "auto";
96
+ });
97
+ });
98
+
99
+ function resetGenderPick() {
100
+ state.gender = "auto";
101
+ document.querySelectorAll("#gender-pick .gender-opt").forEach((o) =>
102
+ o.classList.toggle("active", o.dataset.gender === "auto"));
103
+ }
104
+
105
  input.addEventListener("change", (ev) => {
106
  const f = ev.target.files && ev.target.files[0];
107
  if (!f) return;
 
124
  $("#capture-hint").textContent = "์–ผ๊ตด์ด ์ž˜ ๋ณด์ด๊ฒŒ ์ •๋ฉด์œผ๋กœ ๋งž์ถฐ ์ฃผ์„ธ์š”";
125
  $("#capture-actions").hidden = false;
126
  $("#capture-confirm").hidden = true;
127
+ resetGenderPick();
128
  input.value = "";
129
  }
130
 
 
142
  data.append("salon_id", "leechard_pro");
143
  data.append("qr_delivery_allowed", $("#c-qr").checked ? "true" : "false");
144
  data.append("marketing_allowed", $("#c-mkt").checked ? "true" : "false");
145
+ if (state.gender && state.gender !== "auto") data.append("gender", state.gender);
146
  data.append("access_code", state.accessCode || "");
147
  try {
148
  const up = await api(`${API}/upload`, { method: "POST", body: data });