File size: 36,818 Bytes
5d0bada
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5788c76
 
 
 
 
 
 
 
 
 
 
 
 
 
369d427
5788c76
 
369d427
5788c76
 
5d0bada
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f860bab
 
 
 
 
 
 
 
 
 
 
 
 
5d0bada
 
 
 
 
 
 
 
 
 
 
 
140a849
f7097b9
5d0bada
 
 
 
 
f7097b9
 
 
 
 
 
 
 
 
 
 
5d0bada
 
 
140a849
 
 
 
 
46dcf38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140a849
 
 
5d0bada
 
 
 
 
 
 
 
 
 
140a849
5d0bada
 
 
 
 
 
f860bab
 
5d0bada
 
5cc4c4c
5d0bada
 
f860bab
5d0bada
f860bab
 
 
5cc4c4c
 
f860bab
5d0bada
 
 
f860bab
 
 
 
 
 
 
5d0bada
 
 
f860bab
 
 
 
 
 
 
5d0bada
 
d726390
 
 
 
 
 
 
 
2d2d472
cdb5a3d
 
d726390
 
cdb5a3d
a1a4f4b
 
d726390
 
 
 
 
 
 
 
 
cdb5a3d
0446524
 
 
 
d726390
 
 
cdb5a3d
17e3020
 
 
cdb5a3d
 
5d0bada
 
 
ec440e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d3855ab
 
 
 
 
 
 
 
 
5dbf2b4
d3855ab
 
499adbc
 
 
 
 
 
 
d3855ab
 
ec440e5
 
 
 
 
d3855ab
ec440e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d0bada
 
 
 
 
 
 
 
 
b19d1b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ec440e5
 
 
 
 
499adbc
 
 
 
 
 
 
 
ec440e5
 
 
aeda761
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ec440e5
aeda761
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ec440e5
aeda761
 
 
 
 
 
 
 
 
 
d3855ab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aeda761
 
 
 
 
 
 
 
 
 
 
ec440e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f860bab
ec440e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d0bada
ec440e5
 
5d0bada
ec440e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d0bada
ec440e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d0bada
 
f860bab
 
 
5cc4c4c
f860bab
 
 
 
5d0bada
cdb5a3d
 
2d2d472
 
cdb5a3d
 
5d0bada
f860bab
140a849
f7097b9
5d0bada
 
 
 
ec440e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5d0bada
2e15546
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
"""
Character Studio — a ZeroGPU Hugging Face Space.

A multi-model character generator: pick a model from an editable registry,
type a prompt, optionally drop a reference image, and generate. Supports
SD1.5 / SDXL / FLUX bases and txt2img / img2img / IP-Adapter / FaceID modes.

Add or remove models by editing models.json (no code change needed), then
click "🔄 Reload models" or restart the Space.
"""

import random
import traceback

import spaces  # must be imported before torch on ZeroGPU
import gradio as gr

# --- Workaround for a gradio_client bug: "argument of type 'bool' is not iterable".
# Some component api_info schemas carry a boolean `additionalProperties`, which the
# schema-to-type walker chokes on, crashing get_api_info() and the whole launch.
# Short-circuit any non-dict schema to "Any". Version-independent and harmless. ---
import gradio_client.utils as _gcu

_orig_get_type = _gcu.get_type
def _safe_get_type(schema):
    if not isinstance(schema, dict):
        return "Any"
    return _orig_get_type(schema)
_gcu.get_type = _safe_get_type

_orig_j2pt = _gcu._json_schema_to_python_type
def _safe_j2pt(schema, *args, **kwargs):
    if isinstance(schema, bool):
        return "Any"
    return _orig_j2pt(schema, *args, **kwargs)
_gcu._json_schema_to_python_type = _safe_j2pt

import pipeline_manager as pm

MAX_SEED = 2**31 - 1


# ---------------------------------------------------------------------------
# Registry helpers
# ---------------------------------------------------------------------------
def load_models():
    return pm.load_registry()


MODELS = load_models()


def model_choices(models):
    return [(m["label"], m["id"]) for m in models]


# Placeholder shown in the picker when a model has no `preview` image.
_FALLBACK_PREVIEW = "https://placehold.co/400x520/1e293b/93c5fd/png?text=Model"


def gallery_items(models):
    """List of (image_url, caption) for the model picker gallery."""
    return [(m.get("preview") or _FALLBACK_PREVIEW, m["label"]) for m in models]


def model_ids(models):
    return [m["id"] for m in models]


def modes_for(models, model_id):
    m = pm.get_model(models, model_id)
    if not m:
        return [("Text → Image", "txt2img")]
    return [(pm.MODE_LABELS[k], k) for k in pm.SUPPORTED_MODES[m["base"]]]


# ---------------------------------------------------------------------------
# GPU generation
# ---------------------------------------------------------------------------
@spaces.GPU(duration=120)
def generate(model_id, mode, prompt, negative_prompt, ref_image,
             steps, guidance, denoise, ip_scale, width, height, seed, randomize,
             translator, pose_ref_image=None):
    models = load_models()
    cfg = pm.get_model(models, model_id)
    if cfg is None:
        raise gr.Error("ไม่พบโมเดลที่เลือก โปรด Reload models / Selected model not found.")

    # Prompt Builder pose reference: if the user uploaded a pose image, lock the pose
    # to it via ControlNet OpenPose (reuses the tested 'pose' path) regardless of the
    # mode radio — face/clothes/scene still come from the text prompt.
    if pose_ref_image is not None:
        if "pose" in pm.SUPPORTED_MODES.get(cfg["base"], []):
            mode = "pose"
            ref_image = pose_ref_image
        else:
            raise gr.Error("โมเดลนี้ไม่รองรับ Pose lock (ใช้ได้กับ SD1.5 / SDXL) — "
                           "เลือกโมเดล SD1.5 เช่น majicMIX แล้วลองใหม่")

    if randomize or seed is None or int(seed) < 0:
        seed = random.randint(0, MAX_SEED)

    # Thai → English so the (English) text encoders understand the prompt.
    note = ""
    orig_prompt = prompt
    prompt = pm.translate_prompt(prompt, translator)
    negative_prompt = pm.translate_prompt(negative_prompt, translator)

    # --- Post-translation fixes (the translator often drops/softens these) ---
    # 1) Full-body framing: detect intent from the ORIGINAL Thai (translator tends to
    #    drop "เต็มตัว/ทั้งตัว"); inject an explicit English tag up front so the model AND
    #    the auto-tall-canvas logic in run_generation reliably honor it.
    if pm.wants_full_body(orig_prompt) or pm.wants_full_body(prompt):
        if "full body" not in prompt.lower():
            prompt = "full body shot, head to toe, full body visible, " + prompt
    # 2) Gaze: SD1.5 obeys the booru tag "looking at viewer" far better than "...camera".
    if "looking at the camera" in prompt.lower() or "looking at camera" in prompt.lower():
        prompt = (prompt.replace("looking at the camera", "looking at viewer, eye contact")
                        .replace("looking at camera", "looking at viewer, eye contact"))
    elif "มองกล้อง" in (orig_prompt or "") and "looking at viewer" not in prompt.lower():
        prompt = "looking at viewer, eye contact, " + prompt

    if prompt != orig_prompt:
        note = f"  ·  🌐 {translator}: _{prompt[:120]}_"

    try:
        img = pm.run_generation(
            cfg=cfg, mode=mode, prompt=prompt, negative_prompt=negative_prompt,
            ref_image=ref_image, steps=steps, guidance=guidance, denoise=denoise,
            ip_scale=ip_scale, width=width, height=height, seed=seed,
        )
    except Exception as e:
        traceback.print_exc()
        raise gr.Error(str(e))

    status = f"✅ {cfg['label']} · {pm.MODE_LABELS.get(mode, mode)} · seed {seed}{note}"
    return img, seed, status


# ---------------------------------------------------------------------------
# UI callbacks
# ---------------------------------------------------------------------------
def _apply_model(models, model_id):
    """Shared: given a selected model id, return the dependent UI updates."""
    cfg = pm.get_model(models, model_id)
    if not cfg:
        return (*[gr.update() for _ in range(7)], "ไม่พบโมเดล / model not found")
    choices = modes_for(models, model_id)
    return (
        gr.update(choices=choices, value=choices[0][1]),           # mode radio
        gr.update(placeholder=cfg.get("recommended_prompt", "")),  # prompt
        gr.update(value=cfg.get("negative_prompt", "")),           # negative
        gr.update(value=cfg.get("default_steps", 28)),             # steps
        gr.update(value=cfg.get("default_guidance", 6.0)),         # guidance
        gr.update(value=cfg.get("default_width", 768)),            # width
        gr.update(value=cfg.get("default_height", 768)),           # height
        f"**เลือก / Selected:** {cfg['label']}",                   # selected label
    )


def on_gallery_select(ids, evt: gr.SelectData):
    """A model card was clicked. evt.index → model id."""
    models = load_models()
    mid = ids[evt.index] if ids and 0 <= evt.index < len(ids) else None
    return (mid, *_apply_model(models, mid))


def reload_registry():
    global MODELS
    MODELS = load_models()
    first = MODELS[0]["id"] if MODELS else None
    return (
        gr.update(value=gallery_items(MODELS)),   # gallery
        model_ids(MODELS),                         # ids state
        first,                                     # selected id state
        f"🔄 โหลดแล้ว {len(MODELS)} โมเดล",
    )


def _field(v):
    """Normalise a Prompt Builder field to a clean string. Dropdowns with
    multiselect return a list — join those; plain Textboxes return a string."""
    if isinstance(v, (list, tuple)):
        return ", ".join(str(x).strip() for x in v if x and str(x).strip())
    return str(v).strip() if v is not None else ""


def build_prompt(subject, age, ethnicity, skin, face, body, hair, eyes, outfit, pose,
                 expression, scene, lighting, shot):
    """Assemble form fields into a prompt. Thai values are fine — the auto-translator
    turns the whole thing into English at generate time. Dropdown fields already carry
    model-friendly English tags (e.g. 'hourglass figure')."""
    parts = []
    # Framing/shot goes FIRST so "full body / head to toe" survives CLIP's 77-token cut
    # (it's the key compositional cue; if truncated, SD1.5 defaults to a portrait crop).
    shot = _field(shot)
    if shot:
        parts.append(shot)
    who = _field(subject) or "ผู้หญิง"
    ethnicity, age = _field(ethnicity), _field(age)
    if ethnicity:
        who = f"{ethnicity} {who}"
    if age:
        who = f"{who} อายุ {age} ปี"
    parts.append(who)
    # Priority order for CLIP's 77-token budget: compositional anchors first
    # (location/lighting/outfit/pose), fine appearance details last (least harmful
    # if truncated). Skin texture realism is carried by the model's style_prefix anyway.
    for v in (scene, lighting, outfit, pose, expression, body, hair, skin, face, eyes):
        s = _field(v)
        if s:
            parts.append(s)
    thai = ", ".join(parts)
    # Photographic suffix (NOT "masterpiece/best quality" — those art tokens push the model
    # toward an illustrated/CG look. The model's style_prefix + negative do the heavy lifting).
    return thai + ", realistic candid photograph, natural skin texture, soft natural light"


# ---------------------------------------------------------------------------
# Layout (mirrors the FLUX LoRA DLC reference UI)
# ---------------------------------------------------------------------------
def build_face_prompt(gender, age, ethnicity, faceshape, skin, eyecolor,
                      eyes, brow, nose, mouth, hair, expr):
    """Assemble Tab-1 face fields into an identity-focused prompt (Thai ok)."""
    who = _field(gender) or "ผู้หญิง"
    e, a = _field(ethnicity), _field(age)
    if e:
        who = f"{e} {who}"
    if a:
        who = f"{who} อายุ {a} ปี"
    parts = [who]
    for v in (faceshape, skin, eyecolor, eyes, brow, nose, mouth, hair, expr):
        s = _field(v)
        if s:
            parts.append(s)
    return ", ".join(parts) + ", detailed face, beautiful detailed eyes, detailed skin"


@spaces.GPU(duration=120)
def generate_face(model_id, prompt, translator):
    """Tab 1: generate a close-up identity portrait to lock the face first."""
    models = load_models()
    cfg = pm.get_model(models, model_id)
    if cfg is None:
        raise gr.Error("ไม่พบโมเดล — กด Reload models ในแถบ 2 / model not found")
    if not (prompt and str(prompt).strip()):
        raise gr.Error("กรอกช่องใบหน้าแล้วกด '✨ สร้าง prompt ใบหน้า' ก่อน")
    import random as _r
    seed = _r.randint(0, MAX_SEED)
    p = pm.translate_prompt(prompt, translator)
    # Head-and-shoulders framing with headroom (NOT extreme close-up) + a strong skin-
    # realism push baked in here, because only majicMIX has a style_prefix — this makes
    # ALL models (incl. SDXL base) render real skin in the identity tab. Front-loaded so
    # it survives CLIP truncation.
    p = ("RAW photo, photograph, upper body portrait, head and shoulders, full head in "
         "frame, headroom above the head, centered, looking at viewer, "
         "(photorealistic skin:1.1), detailed skin texture, visible skin pores, "
         "fine vellus hair, film grain, " + p)
    # push away from macro/cropped framings + plastic/CG skin
    neg = (cfg.get("negative_prompt", "") +
           ", (extreme close-up, macro, cropped head, head out of frame, forehead cut off:1.3)"
           ", (plastic skin, airbrushed, smooth skin, waxy skin, doll, 3d, cgi, render:1.2)")
    # Gender lock: "short hair / square jaw / pointed nose" bias SD1.5 toward MALE and can
    # override a lone gender word. Default to female unless the user explicitly picked male,
    # and add strong male negatives so "woman" sticks.
    _raw = (prompt or "").lower()
    _is_male = ("1boy" in _raw) or ("ผู้ชาย" in (prompt or "")) or ("ชาย" in (prompt or "") and "หญิง" not in (prompt or ""))
    if not _is_male:
        neg += ", (man, male, masculine, beard, mustache, facial hair, adam's apple:1.4)"
    # SDXL models need their native ~1024 canvas; SD1.5 stays 512-wide.
    fw, fh = (768, 1024) if cfg["base"] == "sdxl" else (512, 768)
    try:
        img = pm.run_generation(
            cfg=cfg, mode="txt2img", prompt=p, negative_prompt=neg, ref_image=None,
            steps=int(cfg.get("default_steps", 30)),
            guidance=float(cfg.get("default_guidance", 5.0)),
            denoise=0.4, ip_scale=0.7, width=fw, height=fh, seed=seed,
        )
    except Exception as e:  # noqa
        traceback.print_exc()
        raise gr.Error(str(e))
    return img, f"✅ ใบหน้า · {cfg['label']} · seed {seed}"


def send_face_to_scene(face_img):
    """Copy the Tab-1 face into Tab-2's reference image + switch to FaceID."""
    if face_img is None:
        return gr.update(), gr.update(), "⚠️ ยังไม่มีใบหน้า — กด 'สร้างใบหน้า' ก่อน"
    return (
        gr.update(value=face_img),
        gr.update(value="face_id"),
        "✅ ส่งใบหน้าไปแถบ 2 แล้ว (ตั้งเป็น FaceID) — ไปแถบ '🎬 ฉาก/ท่าทาง' ตั้งค่าฉาก/ชุด/ท่า แล้วกด Generate",
    )



CSS = """
#gen-btn {height: 100%; font-size: 1.3rem; font-weight: 700;}
.card {border-radius: 14px;}
footer {visibility: hidden;}
"""

with gr.Blocks(css=CSS, theme=gr.themes.Soft(primary_hue="blue"),
               title="Character Studio") as demo:
    gr.Markdown("## 🎭 Character Studio — multi-model character generator (ZeroGPU)")
    # ---- shared model picker (ใช้ร่วมทั้งแถบ 1 และ 2) ----
    ids_state = gr.State(model_ids(MODELS))
    selected_id = gr.State(MODELS[0]["id"] if MODELS else None)
    with gr.Group():
        gr.Markdown("### 🧩 เลือกโมเดล / Models — ใช้ร่วมกันทั้งแถบ 1 และ 2")
        model_gallery = gr.Gallery(
            value=gallery_items(MODELS),
            label=None, show_label=False, columns=3, height="auto",
            object_fit="cover", allow_preview=False, container=False,
            elem_classes="card",
        )
        selected_md = gr.Markdown(
            f"**เลือก / Selected:** {MODELS[0]['label']}" if MODELS else ""
        )
        reload_btn = gr.Button("🔄 Reload models", size="sm")
        reload_status = gr.Markdown("")

    with gr.Tabs():
        with gr.Tab("👤 แถบ 1 · สร้างใบหน้า / Identity"):
            gr.Markdown("### 👤 แถบ 1 · สร้างใบหน้า/อัตลักษณ์ก่อน — เน้นโคลสอัพให้ได้หน้าที่ต้องการ แล้วส่งไปแถบ 2")
            gr.Markdown("กรอกลักษณะใบหน้า (ไทยได้) → กด สร้าง prompt → กด สร้างใบหน้า · ใช้โมเดลที่เลือกจากแถบ 2")
            with gr.Row():
                f_gender = gr.Dropdown(
                    label="เพศ",
                    choices=[
                        ("หญิง / woman", "woman, 1girl, female, feminine"),
                        ("ชาย / man", "man, 1boy, male, masculine"),
                    ],
                    value="woman, 1girl, female, feminine",
                    multiselect=False, allow_custom_value=True)
                f_age = gr.Textbox(label="อายุ", placeholder="เช่น 22")
                f_ethnicity = gr.Textbox(label="เชื้อชาติ", placeholder="เช่น ไทย, เกาหลี")
            with gr.Row():
                f_faceshape = gr.Dropdown(
                    label="โครงหน้า (เลือกได้/พิมพ์เองได้)",
                    choices=[
                        ("รูปไข่ / oval", "oval face"),
                        ("กลม / round", "round face"),
                        ("หัวใจ / heart", "heart-shaped face"),
                        ("เหลี่ยม / square", "square face, defined jaw"),
                        ("ยาว / long", "long face"),
                        ("เรียว V / v-line", "v-line face, slim jaw"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
                f_skin = gr.Dropdown(
                    label="สีผิว (เลือกได้/พิมพ์เองได้)",
                    choices=[
                        ("ขาว / fair", "fair skin"),
                        ("ขาวอมชมพู / light", "light skin, rosy"),
                        ("สองสี / olive", "olive skin"),
                        ("แทน / tan", "tan skin"),
                        ("แทนเข้ม / dark", "dark skin"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
                f_eyecolor = gr.Dropdown(
                    label="สีตา (เลือกได้/พิมพ์เองได้)",
                    choices=[
                        ("น้ำตาล / brown", "brown eyes"),
                        ("น้ำตาลเข้ม / dark brown", "dark brown eyes"),
                        ("ดำ / black", "black eyes"),
                        ("น้ำผึ้ง / hazel", "hazel eyes"),
                        ("ฟ้า / blue", "blue eyes"),
                        ("เขียว / green", "green eyes"),
                        ("เทา / gray", "gray eyes"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
            with gr.Row():
                f_eyes = gr.Dropdown(
                    label="ดวงตา (เลือกได้/พิมพ์เองได้)",
                    choices=[
                        ("ตากลมโต / round", "big round eyes"),
                        ("ตาอัลมอนด์ / almond", "almond eyes"),
                        ("ตาชั้นเดียว / monolid", "monolid eyes"),
                        ("ตาสองชั้น / double eyelid", "double eyelid"),
                        ("ตาหางชี้ / upturned", "upturned eyes"),
                        ("ตาหางตก / downturned", "downturned eyes"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
                f_brow = gr.Dropdown(
                    label="คิ้ว (เลือกได้/พิมพ์เองได้)",
                    choices=[
                        ("คิ้วหนา / thick", "thick eyebrows"),
                        ("คิ้วบาง / thin", "thin eyebrows"),
                        ("คิ้วโก่ง / arched", "arched eyebrows"),
                        ("คิ้วตรง / straight", "straight eyebrows"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
                f_nose = gr.Dropdown(
                    label="จมูก (เลือกได้/พิมพ์เองได้)",
                    choices=[
                        ("จมูกโด่ง / pointed", "pointed nose, high nose bridge"),
                        ("จมูกเล็ก / small", "small nose"),
                        ("จมูกตรง / straight", "straight nose"),
                        ("จมูกบาน / wide", "wide nose"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
            with gr.Row():
                f_mouth = gr.Dropdown(
                    label="ปาก (เลือกได้/พิมพ์เองได้)",
                    choices=[
                        ("ปากอิ่ม / full", "full lips"),
                        ("ปากบาง / thin", "thin lips"),
                        ("ปากเล็ก / small", "small lips"),
                        ("ปากกระจับ / heart", "heart-shaped lips"),
                        ("ยิ้มมุมปาก / slight smile", "slight smile"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
                f_hair = gr.Dropdown(
                    label="ทรงผม / สีผม (เลือกความยาว+สี · พิมพ์เองได้)",
                    choices=[
                        ("ผมสั้น / short", "short hair"),
                        ("บ๊อบ / bob", "bob cut"),
                        ("ประบ่า / medium", "medium hair, shoulder-length"),
                        ("ผมยาว / long", "long hair"),
                        ("ยาวมาก / very long", "very long hair"),
                        ("หางม้า / ponytail", "ponytail"),
                        ("มัดมวย / bun", "hair bun"),
                        ("ผมม้า / bangs", "blunt bangs"),
                        ("หยิก / curly", "curly hair"),
                        ("ตรง / straight", "straight hair"),
                        ("ดำ / black", "black hair"),
                        ("น้ำตาลเข้ม / dark brown", "dark brown hair"),
                        ("น้ำตาล / brown", "brown hair"),
                        ("บลอนด์ / blonde", "blonde hair"),
                        ("ทอง / golden", "golden blonde hair"),
                        ("แดง / red", "red hair"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
                f_expr = gr.Dropdown(
                    label="สีหน้า / อารมณ์ (เลือกได้/พิมพ์เองได้)",
                    choices=[
                        ("ยิ้มอ่อน / soft smile", "soft smile"),
                        ("ยิ้มกว้าง / grin", "happy grin, smiling"),
                        ("นิ่ง / neutral", "neutral expression"),
                        ("เซ็กซี่ / seductive", "seductive expression"),
                        ("เศร้า / sad", "sad expression"),
                        ("มองกล้อง / look at viewer", "looking at viewer, eye contact"),
                    ],
                    value=None, multiselect=True, allow_custom_value=True)
            face_build_btn = gr.Button("✨ สร้าง prompt ใบหน้า", variant="secondary")
            with gr.Row():
                with gr.Column(scale=3):
                    face_prompt = gr.Textbox(label="Prompt ใบหน้า (แก้ได้)", lines=2, placeholder="กดปุ่มด้านบน หรือพิมพ์เอง")
                with gr.Column(scale=1):
                    face_gen_btn = gr.Button("🎨 สร้างใบหน้า (close-up)", variant="primary")
            face_output = gr.Image(label="ใบหน้าที่ได้", height=420, type="pil", elem_classes="card")
            face_status = gr.Markdown("")
            send_face_btn = gr.Button("➡️ ใช้ใบหน้านี้ในแถบ 2 (FaceID)", variant="secondary")
            send_note = gr.Markdown("")
        with gr.Tab("🎬 แถบ 2 · สร้างฉาก/ท่าทาง / Scene"):

            with gr.Row():
                prompt = gr.Textbox(
                    label="Edit Prompt", lines=2, scale=4,
                    placeholder="✦ เลือกโมเดลแล้วพิมพ์ prompt / Choose a model and type the prompt",
                )
                gen_btn = gr.Button("Generate", variant="primary", scale=1, elem_id="gen-btn")

            # ---- Prompt Builder: fill blanks instead of writing sentences ----
            with gr.Accordion("📝 ตัวช่วยสร้าง prompt / Prompt Builder — กรอกช่อง ไม่ต้องเขียนประโยค",
                              open=False):
                gr.Markdown("กรอกเฉพาะช่องที่ต้องการ (เป็นภาษาไทยได้) แล้วกดปุ่มด้านล่าง "
                            "ระบบจะประกอบเป็น prompt ให้ในช่องด้านบน · เว้นว่างช่องที่ไม่ใช้ได้")
                with gr.Row():
                    b_subject = gr.Textbox(label="ใคร / ตัวละคร", value="ผู้หญิง")
                    b_age = gr.Textbox(label="อายุ", placeholder="เช่น 25")
                    b_ethnicity = gr.Textbox(label="เชื้อชาติ / สัญชาติ", placeholder="เช่น เกาหลี, ไทย")
                with gr.Row():
                    b_body = gr.Dropdown(
                        label="รูปร่าง / สัดส่วน (เลือกได้หลายอัน · พิมพ์เองได้)",
                        choices=[
                            ("ผอมเพรียว / slim", "slim, slender figure"),
                            ("ตัวเล็กบอบบาง / petite", "petite, small frame"),
                            ("ทรงนาฬิกาทราย / hourglass", "hourglass figure, narrow waist"),
                            ("อวบอั๋นเว้าโค้ง / curvy", "curvy, voluptuous"),
                            ("ฟิตมีกล้าม / athletic", "athletic, toned, fit body"),
                            ("อึ๋มอกใหญ่ / busty", "busty, large breasts"),
                            ("ท้วมอวบ / chubby", "chubby, plump"),
                            ("สูงขายาวทรงนางแบบ / tall model", "tall, long legs, model body"),
                            ("สะโพกผาย / wide hips", "wide hips"),
                            ("ต้นขาหนา / thick thighs", "thick thighs"),
                            ("เอวเล็ก / slim waist", "slim waist"),
                            ("อกเล็ก / small breasts", "small breasts"),
                            ("ทรงผู้ใหญ่ / mature", "mature body"),
                        ],
                        value=None, multiselect=True, allow_custom_value=True)
                    b_hair = gr.Textbox(label="ทรงผม / สีผม", placeholder="เช่น ผมยาวสีดำ, ผมบลอนด์")
                    b_eyes = gr.Textbox(label="สีตา", placeholder="เช่น ตาสีน้ำตาล")
                with gr.Row():
                    b_skin = gr.Textbox(label="สีผิว", placeholder="เช่น ผิวขาว, ผิวสองสี, ผิวแทนทอง, ผิวเนียน")
                    b_face = gr.Textbox(label="โครงหน้า", placeholder="เช่น หน้าเรียว, หน้ารูปไข่, หน้ากลม, คางแหลม")
                with gr.Row():
                    b_outfit = gr.Textbox(label="ชุด / เสื้อผ้า", placeholder="เช่น เดรสสีขาว, ชุดนักเรียน")
                    b_pose = gr.Dropdown(
                        label="ท่าโพส (เลือกผสมได้ · พิมพ์เองได้)",
                        choices=[
                            ("ยืนตรง / standing", "standing"),
                            ("ยืนมือเท้าเอว / hands on hips", "standing, hands on hips"),
                            ("ยืนเอียงสะโพก / contrapposto", "standing, contrapposto pose"),
                            ("ยืนพิงกำแพง / leaning on wall", "leaning against wall"),
                            ("นั่งเก้าอี้ / sitting (chair)", "sitting on chair"),
                            ("นั่งพื้น / sitting (floor)", "sitting on floor"),
                            ("นั่งไขว่ห้าง / crossed legs", "sitting, crossed legs"),
                            ("นั่งชันเข่า / knees up", "sitting, knees up, hugging knees"),
                            ("คุกเข่า / kneeling", "kneeling"),
                            ("นอนหงาย / lying on back", "lying on back"),
                            ("นอนคว่ำ / on stomach", "lying on stomach"),
                            ("นอนตะแคง / on side", "lying on side"),
                            ("เดิน / walking", "walking"),
                            ("วิ่ง / running", "running"),
                            ("มือรวบผม / hand in hair", "hand in own hair, arm up"),
                            ("กอดอก / arms crossed", "crossed arms"),
                            ("โบกมือ / waving", "waving hand"),
                        ],
                        value=None, multiselect=True, allow_custom_value=True)
                    b_expr = gr.Textbox(label="สีหน้า / อารมณ์", placeholder="เช่น ยิ้มอ่อนๆ")
                with gr.Row():
                    b_scene = gr.Textbox(label="สถานที่ / ฉาก", placeholder="เช่น คาเฟ่, ริมทะเล, ในสวน")
                    b_light = gr.Textbox(label="แสง", placeholder="เช่น แสงเช้านุ่มๆ, แสงสตูดิโอ")
                    b_shot = gr.Dropdown(
                        label="มุมกล้อง / ช็อต (เลือกได้หลายอัน · พิมพ์เองได้)",
                        choices=[
                            ("โคลสอัพใบหน้า / close-up", "close-up portrait, face focus, headshot"),
                            ("ครึ่งตัวบน / upper body", "upper body shot, bust"),
                            ("ครึ่งตัว เอวขึ้นไป / half body", "half body shot, waist up"),
                            ("เต็มตัว หัวถึงเท้า / full body", "full body shot, head to toe, full body visible"),
                            ("ระยะไกล เห็นรอบตัว / wide shot", "wide shot, full body, distant, environmental"),
                            ("มุมเงย / low angle", "from below, low angle shot"),
                            ("มุมก้ม / high angle", "from above, high angle shot"),
                            ("มุมข้าง โปรไฟล์ / side", "from side, profile view"),
                            ("มุมหลัง / from behind", "from behind, back view"),
                            ("เซลฟี่ / selfie", "selfie, pov, arm extended"),
                        ],
                        value=None, multiselect=True, allow_custom_value=True)
                with gr.Row():
                    b_pose_img = gr.Image(
                        label="📷 รูปอ้างอิงท่าโพส (ใส่รูป → ล็อกท่าตามรูปด้วย ControlNet OpenPose · "
                              "หน้า/ชุด/ฉาก ยังคุมด้วยช่องด้านบน · เว้นว่าง = ใช้ข้อความท่าโพส)",
                        type="pil", sources=["upload"], height=240)
                build_btn = gr.Button("✨ สร้าง prompt → ใส่ในช่องด้านบน", variant="secondary")


            with gr.Row(equal_height=False):
                # ---- left: model picker ----
                with gr.Column(scale=1):
                    mode_radio = gr.Radio(
                        choices=modes_for(MODELS, MODELS[0]["id"]) if MODELS else [],
                        value="txt2img",
                        label="โหมดรูปต้นแบบ / Input mode",
                    )

                    translator = gr.Radio(
                        choices=[("ปิด / Off", "off"),
                                 ("NLLB-200 (เร็ว)", "nllb"),
                                 ("Typhoon 2 (ไทยแน่น)", "typhoon")],
                        value="typhoon",
                        label="แปลไทย→อังกฤษ / Auto-translate (พิมพ์ไทยได้เลย)",
                    )

                # ---- right: output ----
                with gr.Column(scale=1):
                    output = gr.Image(label="Generated Image", height=560, elem_classes="card")
                    status = gr.Markdown("")

            # ---- advanced ----
            with gr.Accordion("Advanced Settings", open=False):
                with gr.Row():
                    with gr.Column():
                        ref_image = gr.Image(label="Input image (รูปต้นแบบ)", type="pil", height=240)
                        ip_scale = gr.Slider(0.0, 1.5, value=0.7, step=0.05,
                                             label="Reference strength (IP-Adapter / FaceID)")
                        denoise = gr.Slider(0.1, 1.0, value=0.65, step=0.01,
                                            label="Denoise strength (img2img · ต่ำ = อิงรูปมาก)")
                    with gr.Column():
                        negative_prompt = gr.Textbox(label="Negative prompt", lines=2)
                        with gr.Row():
                            steps = gr.Slider(1, 50, value=28, step=1, label="Steps")
                            guidance = gr.Slider(0.0, 15.0, value=6.5, step=0.1, label="Guidance (CFG)")
                        with gr.Row():
                            width = gr.Slider(384, 1280, value=512, step=64, label="Width")
                            height = gr.Slider(384, 1280, value=768, step=64, label="Height")
                        with gr.Row():
                            seed = gr.Number(value=-1, label="Seed (-1 = random)", precision=0)
                            randomize = gr.Checkbox(value=True, label="Randomize seed")

    # ---- wiring ----
    model_gallery.select(
        on_gallery_select, inputs=[ids_state],
        outputs=[selected_id, mode_radio, prompt, negative_prompt,
                 steps, guidance, width, height, selected_md],
    )
    reload_btn.click(
        reload_registry,
        outputs=[model_gallery, ids_state, selected_id, reload_status],
    )
    build_btn.click(
        build_prompt,
        inputs=[b_subject, b_age, b_ethnicity, b_skin, b_face, b_body, b_hair, b_eyes,
                b_outfit, b_pose, b_expr, b_scene, b_light, b_shot],
        outputs=prompt,
    )

    gen_inputs = [selected_id, mode_radio, prompt, negative_prompt, ref_image,
                  steps, guidance, denoise, ip_scale, width, height, seed, randomize,
                  translator, b_pose_img]
    gen_btn.click(generate, inputs=gen_inputs, outputs=[output, seed, status])
    prompt.submit(generate, inputs=gen_inputs, outputs=[output, seed, status])



    # ---- Tab 1 (identity) wiring ----
    face_build_btn.click(
        build_face_prompt,
        inputs=[f_gender, f_age, f_ethnicity, f_faceshape, f_skin, f_eyecolor,
                f_eyes, f_brow, f_nose, f_mouth, f_hair, f_expr],
        outputs=face_prompt,
    )
    face_gen_btn.click(
        generate_face,
        inputs=[selected_id, face_prompt, translator],
        outputs=[face_output, face_status],
    )
    send_face_btn.click(
        send_face_to_scene,
        inputs=[face_output],
        outputs=[ref_image, mode_radio, send_note],
    )


if __name__ == "__main__":
    # allowed_paths lets Gradio serve the local model preview thumbnails.
    demo.queue(max_size=12).launch(allowed_paths=["previews"])