saliacoel commited on
Commit
b377f38
·
verified ·
1 Parent(s): 74c001c

Upload Bam_GPT_Parser.py

Browse files
Files changed (1) hide show
  1. Bam_GPT_Parser.py +599 -599
Bam_GPT_Parser.py CHANGED
@@ -1,600 +1,600 @@
1
- from __future__ import annotations
2
-
3
- import re
4
- from typing import Dict, List, Tuple
5
-
6
-
7
- # =============================================================================
8
- # Outputs (order matters)
9
- # =============================================================================
10
- _OUTPUT_DEFS: List[Tuple[str, str]] = [
11
- # Core
12
- ("gender_str", "STRING"),
13
- ("gender_int", "INT"),
14
- ("age_str", "STRING"),
15
- ("age_int", "INT"),
16
- ("identity_str", "STRING"),
17
- ("eyecolor_str", "STRING"),
18
- ("hairstyle_str", "STRING"),
19
-
20
- # Equipment
21
- ("topwear_str", "STRING"),
22
- ("bellywear_str", "STRING"),
23
- ("breastwear_str", "STRING"),
24
-
25
- ("handwear_left_str", "STRING"),
26
- ("handwear_right_str", "STRING"),
27
- ("wristwear_left_str", "STRING"),
28
- ("wristwear_right_str", "STRING"),
29
- ("forearm_left_str", "STRING"),
30
- ("forearm_right_str", "STRING"),
31
- ("elbow_left_str", "STRING"),
32
- ("elbow_right_str", "STRING"),
33
- ("upperarm_left_str", "STRING"),
34
- ("upperarm_right_str", "STRING"),
35
- ("shoulder_left_str", "STRING"),
36
- ("shoulder_right_str", "STRING"),
37
-
38
- ("shank_left_str", "STRING"),
39
- ("shank_right_str", "STRING"),
40
-
41
- ("knee_left_str", "STRING"),
42
- ("knee_right_str", "STRING"),
43
-
44
- ("foot_left_str", "STRING"),
45
- ("foot_right_str", "STRING"),
46
-
47
- ("necklace_str", "STRING"),
48
- ("earring_left_str", "STRING"),
49
- ("earring_right_str", "STRING"),
50
-
51
- ("kneewear_str", "STRING"),
52
- ("headwear_str", "STRING"),
53
- ("facemask_str", "STRING"),
54
- ("sunglasses_str", "STRING"),
55
- ("glasses_str", "STRING"),
56
-
57
- ("crotch_str", "STRING"),
58
- ("belt_str", "STRING"),
59
- ("skirt_str", "STRING"),
60
- ("one_piece_str", "STRING"),
61
-
62
- # Tags
63
- ("aesthetic_tag1", "STRING"),
64
- ("aesthetic_tag2", "STRING"),
65
- ("aesthetic_tag3", "STRING"),
66
- ("aesthetic_tag4", "STRING"),
67
- ("aesthetic_tag5", "STRING"),
68
-
69
- ("skin_tag1", "STRING"),
70
- ("skin_tag2", "STRING"),
71
- ("skin_tag3", "STRING"),
72
- ("skin_tag4", "STRING"),
73
- ("skin_tag5", "STRING"),
74
-
75
- ("expression_tag1", "STRING"),
76
- ("expression_tag2", "STRING"),
77
- ("expression_tag3", "STRING"),
78
- ("expression_tag4", "STRING"),
79
- ("expression_tag5", "STRING"),
80
-
81
- # Unique extra headwear slot
82
- ("headwear_str_2", "STRING"),
83
-
84
- # Flattened equipment values (equip.* values only, in order, unique)
85
- ("all_equip", "STRING"),
86
-
87
- # Converted ancient BAM string
88
- ("bam_ancient", "STRING"),
89
- ]
90
-
91
- RETURN_NAMES_TUPLE = tuple(n for n, _t in _OUTPUT_DEFS)
92
- RETURN_TYPES_TUPLE = tuple(_t for _n, _t in _OUTPUT_DEFS)
93
-
94
-
95
- # =============================================================================
96
- # Constants
97
- # =============================================================================
98
- _NEGATIVE_PROMPT_1 = (
99
- "monochrome, sketch, colorless, (asymmetrical face:1.5), "
100
- "(asymmetrical tail-arched eyebrows:1.0), (terribly drawn eyes:1.2), "
101
- "(heterochromia:1.5), watermark, text, visible background objects, visible floor, "
102
- "(floor-effects:1.5), (background-effects:1.5), non-character, character-shadow, floor-shadow"
103
- )
104
-
105
-
106
- # =============================================================================
107
- # Helpers
108
- # =============================================================================
109
- def _strip_quotes(v: str) -> str:
110
- v = (v or "").strip()
111
- if len(v) >= 2 and ((v[0] == v[-1] == '"') or (v[0] == v[-1] == "'")):
112
- return v[1:-1].strip()
113
- return v
114
-
115
-
116
- def _norm_key(k: str) -> str:
117
- k = (k or "").strip().lower()
118
- k = k.replace(" ", "_").replace("-", "_")
119
- k = re.sub(r"_+", "_", k)
120
- return k
121
-
122
-
123
- def _safe_int(s: str, default: int = 0) -> int:
124
- try:
125
- return int((s or "").strip())
126
- except Exception:
127
- return default
128
-
129
-
130
- def _norm_spaces(s: str) -> str:
131
- s = (s or "").replace("\r", " ").replace("\n", " ")
132
- s = re.sub(r"\s+", " ", s).strip()
133
- return s
134
-
135
-
136
- def _extract_gpt_bam_block(text: str) -> str:
137
- """
138
- Extract first GPT_BAM block payload (between markers).
139
- If markers are missing, returns the whole text (still attempts key=value parsing).
140
- """
141
- text = text or ""
142
- m = re.search(r"GPT_BAM_START###(.*?)###GPT_BAM_END", text, flags=re.S | re.I)
143
- return m.group(1) if m else text
144
-
145
-
146
- # =============================================================================
147
- # Ancient conversion cleaners (match your expected example)
148
- # - Underscores -> spaces for non-equip textual fields
149
- # - Equipment keeps underscores and internal comma formatting as provided
150
- # =============================================================================
151
- def _clean_identity_like(s: str) -> str:
152
- s = (s or "").strip().replace("_", " ")
153
- return _norm_spaces(s)
154
-
155
-
156
- def _clean_eyes(s: str) -> str:
157
- s = (s or "").strip()
158
- s = s.replace("_eyes", "")
159
- s = s.replace("_", " ")
160
- return _norm_spaces(s)
161
-
162
-
163
- def _clean_hair(s: str) -> str:
164
- s = (s or "").strip()
165
- parts = [p.strip() for p in s.split(",") if p.strip()]
166
- cleaned: List[str] = []
167
- for p in parts:
168
- # remove common suffixes
169
- for suf in ("_hairstyle", "_hairsyle", "_hair"):
170
- if p.endswith(suf):
171
- p = p[: -len(suf)]
172
- # also remove occurrences inside
173
- p = p.replace("_hairstyle", "").replace("_hairsyle", "").replace("_hair", "")
174
- p = p.replace("_", " ")
175
- p = _norm_spaces(p)
176
- if p:
177
- cleaned.append(p)
178
- return ", ".join(cleaned)
179
-
180
-
181
- def _clean_tag(s: str, kind: str) -> str:
182
- s = (s or "").strip()
183
- if kind == "aesthetic":
184
- s = s.replace("_aesthetic", "").replace("aesthetic_", "").replace("aesthetic", "")
185
- elif kind == "skin":
186
- s = s.replace("_skin", "").replace("skin_", "").replace("skin", "")
187
- elif kind == "expression":
188
- s = s.replace("_expression", "").replace("expression_", "").replace("expression", "")
189
- s = s.replace("_", " ")
190
- return _norm_spaces(s)
191
-
192
-
193
- def _zero_if_empty(s: str) -> str:
194
- s = _norm_spaces(s)
195
- return s if s else "0"
196
-
197
-
198
- # =============================================================================
199
- # Equipment mapping
200
- # =============================================================================
201
- _KEY_CANONICAL: Dict[str, str] = {
202
- "topwear": "topwear",
203
- "belly": "bellywear",
204
- "bellywear": "bellywear",
205
- "breast": "breastwear",
206
- "breastwear": "breastwear",
207
-
208
- "hand": "handwear",
209
- "handwear": "handwear",
210
- "wrist": "wristwear",
211
- "wristwear": "wristwear",
212
-
213
- "forearm": "forearm",
214
- "elbow": "elbow",
215
- "upperarm": "upperarm",
216
- "upper_arm": "upperarm",
217
- "shoulder": "shoulder",
218
-
219
- "shank": "shank",
220
- "knee": "knee",
221
-
222
- "foot": "foot",
223
- "footwear": "foot",
224
- "shoe": "foot",
225
- "shoes": "foot",
226
-
227
- "necklace": "necklace",
228
-
229
- "earring": "earring",
230
- "earrings": "earring",
231
-
232
- "kneewear": "kneewear",
233
- "headwear": "headwear",
234
- "headwear2": "headwear2",
235
-
236
- "facemask": "facemask",
237
- "face_mask": "facemask",
238
- "mask": "facemask",
239
-
240
- "sunglasses": "sunglasses",
241
- "glasses": "glasses",
242
-
243
- "crotch": "crotch",
244
- "belt": "belt",
245
- "skirt": "skirt",
246
-
247
- "onepiece": "one_piece",
248
- "one_piece": "one_piece",
249
- "one_piecewear": "one_piece",
250
- }
251
-
252
- _SIDE_FIELDS: Dict[str, Tuple[str, str]] = {
253
- "handwear": ("handwear_left_str", "handwear_right_str"),
254
- "wristwear": ("wristwear_left_str", "wristwear_right_str"),
255
- "forearm": ("forearm_left_str", "forearm_right_str"),
256
- "elbow": ("elbow_left_str", "elbow_right_str"),
257
- "upperarm": ("upperarm_left_str", "upperarm_right_str"),
258
- "shoulder": ("shoulder_left_str", "shoulder_right_str"),
259
- "shank": ("shank_left_str", "shank_right_str"),
260
- "knee": ("knee_left_str", "knee_right_str"),
261
- "foot": ("foot_left_str", "foot_right_str"),
262
- "earring": ("earring_left_str", "earring_right_str"),
263
- }
264
-
265
- _SINGLE_FIELDS: Dict[str, str] = {
266
- "topwear": "topwear_str",
267
- "bellywear": "bellywear_str",
268
- "breastwear": "breastwear_str",
269
- "necklace": "necklace_str",
270
- "kneewear": "kneewear_str",
271
- "headwear": "headwear_str",
272
- "facemask": "facemask_str",
273
- "sunglasses": "sunglasses_str",
274
- "glasses": "glasses_str",
275
- "crotch": "crotch_str",
276
- "belt": "belt_str",
277
- "skirt": "skirt_str",
278
- "one_piece": "one_piece_str",
279
- "headwear2": "headwear_str_2",
280
- }
281
-
282
- _ALL_EQUIP_OUTPUTS = set(_SINGLE_FIELDS.values())
283
- for lf, rf in _SIDE_FIELDS.values():
284
- _ALL_EQUIP_OUTPUTS.add(lf)
285
- _ALL_EQUIP_OUTPUTS.add(rf)
286
-
287
-
288
- def _assign_equip(
289
- out: Dict[str, object],
290
- equip_values_in_order: List[str],
291
- raw_key: str,
292
- val: str,
293
- ) -> None:
294
- """
295
- Assign equipment into structured outputs.
296
-
297
- Precedence rule:
298
- - sided keys (.left/.right or _left/_right) overwrite that side
299
- - unsided keys fill only empty sides (so sided values win even if unsided appears later)
300
-
301
- Also collects ALL equip values (even unknown keys) into equip_values_in_order.
302
- """
303
- val = (val or "").strip()
304
- k = _norm_key(raw_key)
305
-
306
- # detect side
307
- side = None
308
- base = k
309
-
310
- if base.endswith(".left"):
311
- side = "left"
312
- base = base[:-5]
313
- elif base.endswith(".right"):
314
- side = "right"
315
- base = base[:-6]
316
-
317
- if base.endswith("_left"):
318
- side = "left"
319
- base = base[:-5]
320
- elif base.endswith("_right"):
321
- side = "right"
322
- base = base[:-6]
323
-
324
- base = base.strip("._")
325
- base_for_lookup = base.replace(".", "_")
326
- canonical = _KEY_CANONICAL.get(base_for_lookup, base_for_lookup)
327
-
328
- # collect equip values (even unknown keys) for all_equip
329
- if val:
330
- equip_values_in_order.append(val)
331
-
332
- if canonical in _SIDE_FIELDS:
333
- left_name, right_name = _SIDE_FIELDS[canonical]
334
- if side == "left":
335
- out[left_name] = val
336
- elif side == "right":
337
- out[right_name] = val
338
- else:
339
- # unsided: fill only empties
340
- if not out.get(left_name, ""):
341
- out[left_name] = val
342
- if not out.get(right_name, ""):
343
- out[right_name] = val
344
-
345
- elif canonical in _SINGLE_FIELDS:
346
- out[_SINGLE_FIELDS[canonical]] = val
347
-
348
- else:
349
- # unknown equip key -> ignored for structured outputs
350
- pass
351
-
352
-
353
- # =============================================================================
354
- # GPT_BAM parsing + ancient conversion
355
- # =============================================================================
356
- def _parse_gpt_bam(text: str) -> Dict[str, object]:
357
- payload = _extract_gpt_bam_block(text)
358
- segments = [s.strip() for s in payload.split("###") if s.strip()]
359
-
360
- # defaults
361
- out: Dict[str, object] = {name: (0 if t == "INT" else "") for name, t in _OUTPUT_DEFS}
362
- for k in _ALL_EQUIP_OUTPUTS:
363
- out[k] = ""
364
-
365
- equip_values_in_order: List[str] = []
366
-
367
- g_int = None
368
-
369
- for seg in segments:
370
- if "=" in seg:
371
- k, v = seg.split("=", 1)
372
- elif ":" in seg:
373
- k, v = seg.split(":", 1)
374
- else:
375
- continue
376
-
377
- k = _norm_key(k)
378
- v = _strip_quotes(v)
379
-
380
- # core
381
- if k in ("gender", "sex", "gender_int", "gender_num"):
382
- vv = v.strip().lower()
383
- if vv in ("1", "boy", "male", "m"):
384
- g_int = 1
385
- elif vv in ("2", "girl", "female", "f"):
386
- g_int = 2
387
-
388
- elif k in ("age", "age_str"):
389
- out["age_str"] = v.strip()
390
- out["age_int"] = _safe_int(out["age_str"], 0)
391
-
392
- elif k in ("identity", "identity_str", "job", "role"):
393
- out["identity_str"] = v.strip()
394
-
395
- elif k in ("eyecolor", "eye_color", "eye", "eyecolor_str"):
396
- out["eyecolor_str"] = v.strip()
397
-
398
- elif k in ("hairstyle", "hair", "hairstyle_str"):
399
- out["hairstyle_str"] = v.strip()
400
-
401
- # equipment
402
- elif k.startswith("equip.") or k.startswith("equipment."):
403
- raw_equip_key = k.split(".", 1)[1] # remove equip.
404
- _assign_equip(out, equip_values_in_order, raw_equip_key, v)
405
-
406
- # tag slots
407
- elif k.startswith("aesthetic.") or k.startswith("aesthetic_tag"):
408
- num = None
409
- if k.startswith("aesthetic."):
410
- suf = k.split(".", 1)[1]
411
- if suf.isdigit():
412
- num = int(suf)
413
- else:
414
- m = re.search(r"aesthetic_tag(\d+)", k)
415
- if m:
416
- num = int(m.group(1))
417
- if num and 1 <= num <= 5:
418
- out[f"aesthetic_tag{num}"] = v.strip()
419
-
420
- elif k.startswith("skin.") or k.startswith("skin_tag"):
421
- num = None
422
- if k.startswith("skin."):
423
- suf = k.split(".", 1)[1]
424
- if suf.isdigit():
425
- num = int(suf)
426
- else:
427
- m = re.search(r"skin_tag(\d+)", k)
428
- if m:
429
- num = int(m.group(1))
430
- if num and 1 <= num <= 5:
431
- out[f"skin_tag{num}"] = v.strip()
432
-
433
- elif k.startswith("expression.") or k.startswith("expression_tag"):
434
- num = None
435
- if k.startswith("expression."):
436
- suf = k.split(".", 1)[1]
437
- if suf.isdigit():
438
- num = int(suf)
439
- else:
440
- m = re.search(r"expression_tag(\d+)", k)
441
- if m:
442
- num = int(m.group(1))
443
- if num and 1 <= num <= 5:
444
- out[f"expression_tag{num}"] = v.strip()
445
-
446
- # headwear2 aliases
447
- elif k in ("headwear2", "headwear_tag2", "headwear_str_2", "equip_headwear2"):
448
- out["headwear_str_2"] = v.strip()
449
-
450
- else:
451
- # explicitly ignore name (and anything else not recognized)
452
- # e.g. name=mirela_vance should not affect anything
453
- pass
454
-
455
- # finalize gender
456
- if g_int is None:
457
- g_int = 2 # default: if not "1" => girl (matches your ancient rule)
458
- out["gender_int"] = int(g_int)
459
- out["gender_str"] = "boy" if g_int == 1 else "girl"
460
-
461
- # all_equip = unique equip values, in order (includes unknown equip keys)
462
- seen = set()
463
- equip_unique: List[str] = []
464
- for v in equip_values_in_order:
465
- v = (v or "").strip()
466
- if v and v not in seen:
467
- equip_unique.append(v)
468
- seen.add(v)
469
- out["all_equip"] = ", ".join(equip_unique)
470
-
471
- # bam_ancient conversion
472
- out["bam_ancient"] = _convert_to_ancient(out, equip_unique)
473
-
474
- return out
475
-
476
-
477
- def _convert_to_ancient(parsed: Dict[str, object], equip_unique: List[str]) -> str:
478
- gender_int = int(parsed.get("gender_int", 2) or 2)
479
-
480
- age_str = str(parsed.get("age_str", "") or "").strip()
481
- if not age_str:
482
- age_str = str(parsed.get("age_int", 0) or 0)
483
-
484
- identity = _clean_identity_like(str(parsed.get("identity_str", "") or ""))
485
- eyes = _clean_eyes(str(parsed.get("eyecolor_str", "") or ""))
486
- hair = _clean_hair(str(parsed.get("hairstyle_str", "") or ""))
487
-
488
- # Equipment defaults / additions
489
- equip_list: List[str] = list(equip_unique)
490
-
491
- def add_unique(val: str) -> None:
492
- val = (val or "").strip()
493
- if not val:
494
- return
495
- if val not in equip_list:
496
- equip_list.append(val)
497
-
498
- # (No footwear parsed) => add ",bare foot"
499
- foot_l = str(parsed.get("foot_left_str", "") or "").strip()
500
- foot_r = str(parsed.get("foot_right_str", "") or "").strip()
501
- if not foot_l and not foot_r:
502
- add_unique("bare foot")
503
-
504
- # (No handwear parsed) => add ",bare hands"
505
- hand_l = str(parsed.get("handwear_left_str", "") or "").strip()
506
- hand_r = str(parsed.get("handwear_right_str", "") or "").strip()
507
- if not hand_l and not hand_r:
508
- add_unique("bare hands")
509
-
510
- # (No topwear AND no breastwear AND no one_piece) => add ",naked breasts"
511
- top = str(parsed.get("topwear_str", "") or "").strip()
512
- breast = str(parsed.get("breastwear_str", "") or "").strip()
513
- one_piece = str(parsed.get("one_piece_str", "") or "").strip()
514
- if not top and not breast and not one_piece:
515
- add_unique("naked breasts")
516
-
517
- # (No topwear AND no one_piece AND no crotch AND no skirt) => exposed crotch (gendered)
518
- crotch = str(parsed.get("crotch_str", "") or "").strip()
519
- skirt = str(parsed.get("skirt_str", "") or "").strip()
520
- if not top and not one_piece and not crotch and not skirt:
521
- if gender_int == 1:
522
- add_unique("naked crotch exposed penis")
523
- else:
524
- add_unique("naked crotch exposed vagina")
525
-
526
- equip_str = ", ".join([e for e in equip_list if (e or "").strip()])
527
-
528
- # Tags
529
- aest = [_clean_tag(str(parsed.get(f"aesthetic_tag{i}", "") or ""), "aesthetic") for i in range(1, 6)]
530
- skin = [_clean_tag(str(parsed.get(f"skin_tag{i}", "") or ""), "skin") for i in range(1, 6)]
531
- expr = [_clean_tag(str(parsed.get(f"expression_tag{i}", "") or ""), "expression") for i in range(1, 6)]
532
-
533
- hw_extra = _clean_identity_like(str(parsed.get("headwear_str_2", "") or ""))
534
-
535
- # Fill missing with 0 according to your template
536
- fields = [
537
- "START",
538
- str(gender_int),
539
- _zero_if_empty(age_str),
540
- _zero_if_empty(identity),
541
- _zero_if_empty(eyes),
542
- _zero_if_empty(hair),
543
- _zero_if_empty(equip_str),
544
- *(_zero_if_empty(a) for a in aest),
545
- *(_zero_if_empty(s) for s in skin),
546
- *(_zero_if_empty(e) for e in expr),
547
- _zero_if_empty(hw_extra),
548
- "0", # POSITIVE_PROMPT_0
549
- "0", # POSITIVE_PROMPT_1
550
- "0", # NEGATIVE_PROMPT_0
551
- _NEGATIVE_PROMPT_1, # NEGATIVE_PROMPT_1 (constant)
552
- "0", # NEGATIVE_PROMPT_2
553
- "END",
554
- ]
555
-
556
- # Build exactly: START###...###END###
557
- out = "###".join(fields[:-1]) + "###" + fields[-1] + "###"
558
- out = _norm_spaces(out) # remove linebreaks, collapse double spaces
559
- return out
560
-
561
-
562
- # =============================================================================
563
- # ComfyUI Node
564
- # =============================================================================
565
- class GPTBAMParser:
566
- """
567
- Parses GPT_BAM v1 (key=value fields separated by ###) and also outputs bam_ancient.
568
- """
569
-
570
- @classmethod
571
- def INPUT_TYPES(cls):
572
- return {
573
- "required": {
574
- "gpt_bam_string": ("STRING", {"multiline": True, "default": ""}),
575
- }
576
- }
577
-
578
- RETURN_TYPES = RETURN_TYPES_TUPLE
579
- RETURN_NAMES = RETURN_NAMES_TUPLE
580
- FUNCTION = "parse"
581
- CATEGORY = "BAM"
582
-
583
- def parse(self, gpt_bam_string: str):
584
- parsed = _parse_gpt_bam(gpt_bam_string)
585
-
586
- # ensure all outputs exist
587
- for name, t in _OUTPUT_DEFS:
588
- if name not in parsed:
589
- parsed[name] = 0 if t == "INT" else ""
590
-
591
- return tuple(parsed[name] for name in RETURN_NAMES_TUPLE)
592
-
593
-
594
- NODE_CLASS_MAPPINGS = {
595
- "GPTBAMParser": GPTBAMParser,
596
- }
597
-
598
- NODE_DISPLAY_NAME_MAPPINGS = {
599
- "GPTBAMParser": "GPT_BAM Parser",
600
  }
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Dict, List, Tuple
5
+
6
+
7
+ # =============================================================================
8
+ # Outputs (order matters)
9
+ # =============================================================================
10
+ _OUTPUT_DEFS: List[Tuple[str, str]] = [
11
+ # Core
12
+ ("gender_str", "STRING"),
13
+ ("gender_int", "INT"),
14
+ ("age_str", "STRING"),
15
+ ("age_int", "INT"),
16
+ ("identity_str", "STRING"),
17
+ ("eyecolor_str", "STRING"),
18
+ ("hairstyle_str", "STRING"),
19
+
20
+ # Equipment
21
+ ("topwear_str", "STRING"),
22
+ ("bellywear_str", "STRING"),
23
+ ("breastwear_str", "STRING"),
24
+
25
+ ("handwear_left_str", "STRING"),
26
+ ("handwear_right_str", "STRING"),
27
+ ("wristwear_left_str", "STRING"),
28
+ ("wristwear_right_str", "STRING"),
29
+ ("forearm_left_str", "STRING"),
30
+ ("forearm_right_str", "STRING"),
31
+ ("elbow_left_str", "STRING"),
32
+ ("elbow_right_str", "STRING"),
33
+ ("upperarm_left_str", "STRING"),
34
+ ("upperarm_right_str", "STRING"),
35
+ ("shoulder_left_str", "STRING"),
36
+ ("shoulder_right_str", "STRING"),
37
+
38
+ ("shank_left_str", "STRING"),
39
+ ("shank_right_str", "STRING"),
40
+
41
+ ("knee_left_str", "STRING"),
42
+ ("knee_right_str", "STRING"),
43
+
44
+ ("foot_left_str", "STRING"),
45
+ ("foot_right_str", "STRING"),
46
+
47
+ ("necklace_str", "STRING"),
48
+ ("earring_left_str", "STRING"),
49
+ ("earring_right_str", "STRING"),
50
+
51
+ ("kneewear_str", "STRING"),
52
+ ("headwear_str", "STRING"),
53
+ ("facemask_str", "STRING"),
54
+ ("sunglasses_str", "STRING"),
55
+ ("glasses_str", "STRING"),
56
+
57
+ ("crotch_str", "STRING"),
58
+ ("belt_str", "STRING"),
59
+ ("skirt_str", "STRING"),
60
+ ("one_piece_str", "STRING"),
61
+
62
+ # Tags
63
+ ("aesthetic_tag1", "STRING"),
64
+ ("aesthetic_tag2", "STRING"),
65
+ ("aesthetic_tag3", "STRING"),
66
+ ("aesthetic_tag4", "STRING"),
67
+ ("aesthetic_tag5", "STRING"),
68
+
69
+ ("skin_tag1", "STRING"),
70
+ ("skin_tag2", "STRING"),
71
+ ("skin_tag3", "STRING"),
72
+ ("skin_tag4", "STRING"),
73
+ ("skin_tag5", "STRING"),
74
+
75
+ ("expression_tag1", "STRING"),
76
+ ("expression_tag2", "STRING"),
77
+ ("expression_tag3", "STRING"),
78
+ ("expression_tag4", "STRING"),
79
+ ("expression_tag5", "STRING"),
80
+
81
+ # Unique extra headwear slot
82
+ ("headwear_str_2", "STRING"),
83
+
84
+ # Flattened equipment values (equip.* values only, in order, unique)
85
+ ("all_equip", "STRING"),
86
+
87
+ # Converted ancient BAM string
88
+ ("bam_ancient", "STRING"),
89
+ ]
90
+
91
+ RETURN_NAMES_TUPLE = tuple(n for n, _t in _OUTPUT_DEFS)
92
+ RETURN_TYPES_TUPLE = tuple(_t for _n, _t in _OUTPUT_DEFS)
93
+
94
+
95
+ # =============================================================================
96
+ # Constants
97
+ # =============================================================================
98
+ _NEGATIVE_PROMPT_1 = (
99
+ "monochrome, sketch, colorless, (asymmetrical face:1.5), "
100
+ "(asymmetrical tail-arched eyebrows:1.0), (terribly drawn eyes:1.2), "
101
+ "(heterochromia:1.5), watermark, text, visible background objects, visible floor, "
102
+ "(floor-effects:1.5), (background-effects:1.5), non-character, character-shadow, floor-shadow"
103
+ )
104
+
105
+
106
+ # =============================================================================
107
+ # Helpers
108
+ # =============================================================================
109
+ def _strip_quotes(v: str) -> str:
110
+ v = (v or "").strip()
111
+ if len(v) >= 2 and ((v[0] == v[-1] == '"') or (v[0] == v[-1] == "'")):
112
+ return v[1:-1].strip()
113
+ return v
114
+
115
+
116
+ def _norm_key(k: str) -> str:
117
+ k = (k or "").strip().lower()
118
+ k = k.replace(" ", "_").replace("-", "_")
119
+ k = re.sub(r"_+", "_", k)
120
+ return k
121
+
122
+
123
+ def _safe_int(s: str, default: int = 0) -> int:
124
+ try:
125
+ return int((s or "").strip())
126
+ except Exception:
127
+ return default
128
+
129
+
130
+ def _norm_spaces(s: str) -> str:
131
+ s = (s or "").replace("\r", " ").replace("\n", " ")
132
+ s = re.sub(r"\s+", " ", s).strip()
133
+ return s
134
+
135
+
136
+ def _extract_gpt_bam_block(text: str) -> str:
137
+ """
138
+ Extract first GPT_BAM block payload (between markers).
139
+ If markers are missing, returns the whole text (still attempts key=value parsing).
140
+ """
141
+ text = text or ""
142
+ m = re.search(r"GPT_BAM_START###(.*?)###GPT_BAM_END", text, flags=re.S | re.I)
143
+ return m.group(1) if m else text
144
+
145
+
146
+ # =============================================================================
147
+ # Ancient conversion cleaners (match your expected example)
148
+ # - Underscores -> spaces for non-equip textual fields
149
+ # - Equipment keeps underscores and internal comma formatting as provided
150
+ # =============================================================================
151
+ def _clean_identity_like(s: str) -> str:
152
+ s = (s or "").strip().replace("_", " ")
153
+ return _norm_spaces(s)
154
+
155
+
156
+ def _clean_eyes(s: str) -> str:
157
+ s = (s or "").strip()
158
+ s = s.replace("_eyes", "")
159
+ s = s.replace("_", " ")
160
+ return _norm_spaces(s)
161
+
162
+
163
+ def _clean_hair(s: str) -> str:
164
+ s = (s or "").strip()
165
+ parts = [p.strip() for p in s.split(",") if p.strip()]
166
+ cleaned: List[str] = []
167
+ for p in parts:
168
+ # remove common suffixes
169
+ for suf in ("_hairstyle", "_hairsyle", "_hair"):
170
+ if p.endswith(suf):
171
+ p = p[: -len(suf)]
172
+ # also remove occurrences inside
173
+ p = p.replace("_hairstyle", "").replace("_hairsyle", "").replace("_hair", "")
174
+ p = p.replace("_", " ")
175
+ p = _norm_spaces(p)
176
+ if p:
177
+ cleaned.append(p)
178
+ return ", ".join(cleaned)
179
+
180
+
181
+ def _clean_tag(s: str, kind: str) -> str:
182
+ s = (s or "").strip()
183
+ if kind == "aesthetic":
184
+ s = s.replace("_aesthetic", "").replace("aesthetic_", "").replace("aesthetic", "")
185
+ elif kind == "skin":
186
+ s = s.replace("_skin", "").replace("skin_", "").replace("skin", "")
187
+ elif kind == "expression":
188
+ s = s.replace("_expression", "").replace("expression_", "").replace("expression", "")
189
+ s = s.replace("_", " ")
190
+ return _norm_spaces(s)
191
+
192
+
193
+ def _zero_if_empty(s: str) -> str:
194
+ s = _norm_spaces(s)
195
+ return s if s else "0"
196
+
197
+
198
+ # =============================================================================
199
+ # Equipment mapping
200
+ # =============================================================================
201
+ _KEY_CANONICAL: Dict[str, str] = {
202
+ "topwear": "topwear",
203
+ "belly": "bellywear",
204
+ "bellywear": "bellywear",
205
+ "breast": "breastwear",
206
+ "breastwear": "breastwear",
207
+
208
+ "hand": "handwear",
209
+ "handwear": "handwear",
210
+ "wrist": "wristwear",
211
+ "wristwear": "wristwear",
212
+
213
+ "forearm": "forearm",
214
+ "elbow": "elbow",
215
+ "upperarm": "upperarm",
216
+ "upper_arm": "upperarm",
217
+ "shoulder": "shoulder",
218
+
219
+ "shank": "shank",
220
+ "knee": "knee",
221
+
222
+ "foot": "foot",
223
+ "footwear": "foot",
224
+ "shoe": "foot",
225
+ "shoes": "foot",
226
+
227
+ "necklace": "necklace",
228
+
229
+ "earring": "earring",
230
+ "earrings": "earring",
231
+
232
+ "kneewear": "kneewear",
233
+ "headwear": "headwear",
234
+ "headwear2": "headwear2",
235
+
236
+ "facemask": "facemask",
237
+ "face_mask": "facemask",
238
+ "mask": "facemask",
239
+
240
+ "sunglasses": "sunglasses",
241
+ "glasses": "glasses",
242
+
243
+ "crotch": "crotch",
244
+ "belt": "belt",
245
+ "skirt": "skirt",
246
+
247
+ "onepiece": "one_piece",
248
+ "one_piece": "one_piece",
249
+ "one_piecewear": "one_piece",
250
+ }
251
+
252
+ _SIDE_FIELDS: Dict[str, Tuple[str, str]] = {
253
+ "handwear": ("handwear_left_str", "handwear_right_str"),
254
+ "wristwear": ("wristwear_left_str", "wristwear_right_str"),
255
+ "forearm": ("forearm_left_str", "forearm_right_str"),
256
+ "elbow": ("elbow_left_str", "elbow_right_str"),
257
+ "upperarm": ("upperarm_left_str", "upperarm_right_str"),
258
+ "shoulder": ("shoulder_left_str", "shoulder_right_str"),
259
+ "shank": ("shank_left_str", "shank_right_str"),
260
+ "knee": ("knee_left_str", "knee_right_str"),
261
+ "foot": ("foot_left_str", "foot_right_str"),
262
+ "earring": ("earring_left_str", "earring_right_str"),
263
+ }
264
+
265
+ _SINGLE_FIELDS: Dict[str, str] = {
266
+ "topwear": "topwear_str",
267
+ "bellywear": "bellywear_str",
268
+ "breastwear": "breastwear_str",
269
+ "necklace": "necklace_str",
270
+ "kneewear": "kneewear_str",
271
+ "headwear": "headwear_str",
272
+ "facemask": "facemask_str",
273
+ "sunglasses": "sunglasses_str",
274
+ "glasses": "glasses_str",
275
+ "crotch": "crotch_str",
276
+ "belt": "belt_str",
277
+ "skirt": "skirt_str",
278
+ "one_piece": "one_piece_str",
279
+ "headwear2": "headwear_str_2",
280
+ }
281
+
282
+ _ALL_EQUIP_OUTPUTS = set(_SINGLE_FIELDS.values())
283
+ for lf, rf in _SIDE_FIELDS.values():
284
+ _ALL_EQUIP_OUTPUTS.add(lf)
285
+ _ALL_EQUIP_OUTPUTS.add(rf)
286
+
287
+
288
+ def _assign_equip(
289
+ out: Dict[str, object],
290
+ equip_values_in_order: List[str],
291
+ raw_key: str,
292
+ val: str,
293
+ ) -> None:
294
+ """
295
+ Assign equipment into structured outputs.
296
+
297
+ Precedence rule:
298
+ - sided keys (.left/.right or _left/_right) overwrite that side
299
+ - unsided keys fill only empty sides (so sided values win even if unsided appears later)
300
+
301
+ Also collects ALL equip values (even unknown keys) into equip_values_in_order.
302
+ """
303
+ val = (val or "").strip()
304
+ k = _norm_key(raw_key)
305
+
306
+ # detect side
307
+ side = None
308
+ base = k
309
+
310
+ if base.endswith(".left"):
311
+ side = "left"
312
+ base = base[:-5]
313
+ elif base.endswith(".right"):
314
+ side = "right"
315
+ base = base[:-6]
316
+
317
+ if base.endswith("_left"):
318
+ side = "left"
319
+ base = base[:-5]
320
+ elif base.endswith("_right"):
321
+ side = "right"
322
+ base = base[:-6]
323
+
324
+ base = base.strip("._")
325
+ base_for_lookup = base.replace(".", "_")
326
+ canonical = _KEY_CANONICAL.get(base_for_lookup, base_for_lookup)
327
+
328
+ # collect equip values (even unknown keys) for all_equip
329
+ if val:
330
+ equip_values_in_order.append(val)
331
+
332
+ if canonical in _SIDE_FIELDS:
333
+ left_name, right_name = _SIDE_FIELDS[canonical]
334
+ if side == "left":
335
+ out[left_name] = val
336
+ elif side == "right":
337
+ out[right_name] = val
338
+ else:
339
+ # unsided: fill only empties
340
+ if not out.get(left_name, ""):
341
+ out[left_name] = val
342
+ if not out.get(right_name, ""):
343
+ out[right_name] = val
344
+
345
+ elif canonical in _SINGLE_FIELDS:
346
+ out[_SINGLE_FIELDS[canonical]] = val
347
+
348
+ else:
349
+ # unknown equip key -> ignored for structured outputs
350
+ pass
351
+
352
+
353
+ # =============================================================================
354
+ # GPT_BAM parsing + ancient conversion
355
+ # =============================================================================
356
+ def _parse_gpt_bam(text: str) -> Dict[str, object]:
357
+ payload = _extract_gpt_bam_block(text)
358
+ segments = [s.strip() for s in payload.split("###") if s.strip()]
359
+
360
+ # defaults
361
+ out: Dict[str, object] = {name: (0 if t == "INT" else "") for name, t in _OUTPUT_DEFS}
362
+ for k in _ALL_EQUIP_OUTPUTS:
363
+ out[k] = ""
364
+
365
+ equip_values_in_order: List[str] = []
366
+
367
+ g_int = None
368
+
369
+ for seg in segments:
370
+ if "=" in seg:
371
+ k, v = seg.split("=", 1)
372
+ elif ":" in seg:
373
+ k, v = seg.split(":", 1)
374
+ else:
375
+ continue
376
+
377
+ k = _norm_key(k)
378
+ v = _strip_quotes(v)
379
+
380
+ # core
381
+ if k in ("gender", "sex", "gender_int", "gender_num"):
382
+ vv = v.strip().lower()
383
+ if vv in ("1", "boy", "male", "m"):
384
+ g_int = 1
385
+ elif vv in ("2", "girl", "female", "f"):
386
+ g_int = 2
387
+
388
+ elif k in ("age", "age_str"):
389
+ out["age_str"] = v.strip()
390
+ out["age_int"] = _safe_int(out["age_str"], 0)
391
+
392
+ elif k in ("identity", "identity_str", "job", "role"):
393
+ out["identity_str"] = v.strip()
394
+
395
+ elif k in ("eyecolor", "eye_color", "eye", "eyecolor_str"):
396
+ out["eyecolor_str"] = v.strip()
397
+
398
+ elif k in ("hairstyle", "hair", "hairstyle_str"):
399
+ out["hairstyle_str"] = v.strip()
400
+
401
+ # equipment
402
+ elif k.startswith("equip.") or k.startswith("equipment."):
403
+ raw_equip_key = k.split(".", 1)[1] # remove equip.
404
+ _assign_equip(out, equip_values_in_order, raw_equip_key, v)
405
+
406
+ # tag slots
407
+ elif k.startswith("aesthetic.") or k.startswith("aesthetic_tag"):
408
+ num = None
409
+ if k.startswith("aesthetic."):
410
+ suf = k.split(".", 1)[1]
411
+ if suf.isdigit():
412
+ num = int(suf)
413
+ else:
414
+ m = re.search(r"aesthetic_tag(\d+)", k)
415
+ if m:
416
+ num = int(m.group(1))
417
+ if num and 1 <= num <= 5:
418
+ out[f"aesthetic_tag{num}"] = v.strip()
419
+
420
+ elif k.startswith("skin.") or k.startswith("skin_tag"):
421
+ num = None
422
+ if k.startswith("skin."):
423
+ suf = k.split(".", 1)[1]
424
+ if suf.isdigit():
425
+ num = int(suf)
426
+ else:
427
+ m = re.search(r"skin_tag(\d+)", k)
428
+ if m:
429
+ num = int(m.group(1))
430
+ if num and 1 <= num <= 5:
431
+ out[f"skin_tag{num}"] = v.strip()
432
+
433
+ elif k.startswith("expression.") or k.startswith("expression_tag"):
434
+ num = None
435
+ if k.startswith("expression."):
436
+ suf = k.split(".", 1)[1]
437
+ if suf.isdigit():
438
+ num = int(suf)
439
+ else:
440
+ m = re.search(r"expression_tag(\d+)", k)
441
+ if m:
442
+ num = int(m.group(1))
443
+ if num and 1 <= num <= 5:
444
+ out[f"expression_tag{num}"] = v.strip()
445
+
446
+ # headwear2 aliases
447
+ elif k in ("headwear2", "headwear_tag2", "headwear_str_2", "equip_headwear2"):
448
+ out["headwear_str_2"] = v.strip()
449
+
450
+ else:
451
+ # explicitly ignore name (and anything else not recognized)
452
+ # e.g. name=mirela_vance should not affect anything
453
+ pass
454
+
455
+ # finalize gender
456
+ if g_int is None:
457
+ g_int = 2 # default: if not "1" => girl (matches your ancient rule)
458
+ out["gender_int"] = int(g_int)
459
+ out["gender_str"] = "boy" if g_int == 1 else "girl"
460
+
461
+ # all_equip = unique equip values, in order (includes unknown equip keys)
462
+ seen = set()
463
+ equip_unique: List[str] = []
464
+ for v in equip_values_in_order:
465
+ v = (v or "").strip()
466
+ if v and v not in seen:
467
+ equip_unique.append(v)
468
+ seen.add(v)
469
+ out["all_equip"] = ", ".join(equip_unique)
470
+
471
+ # bam_ancient conversion
472
+ out["bam_ancient"] = _convert_to_ancient(out, equip_unique)
473
+
474
+ return out
475
+
476
+
477
+ def _convert_to_ancient(parsed: Dict[str, object], equip_unique: List[str]) -> str:
478
+ gender_int = int(parsed.get("gender_int", 2) or 2)
479
+
480
+ age_str = str(parsed.get("age_str", "") or "").strip()
481
+ if not age_str:
482
+ age_str = str(parsed.get("age_int", 0) or 0)
483
+
484
+ identity = _clean_identity_like(str(parsed.get("identity_str", "") or ""))
485
+ eyes = _clean_eyes(str(parsed.get("eyecolor_str", "") or ""))
486
+ hair = _clean_hair(str(parsed.get("hairstyle_str", "") or ""))
487
+
488
+ # Equipment defaults / additions
489
+ equip_list: List[str] = list(equip_unique)
490
+
491
+ def add_unique(val: str) -> None:
492
+ val = (val or "").strip()
493
+ if not val:
494
+ return
495
+ if val not in equip_list:
496
+ equip_list.append(val)
497
+
498
+ # (No footwear parsed) => add ",bare foot"
499
+ foot_l = str(parsed.get("foot_left_str", "") or "").strip()
500
+ foot_r = str(parsed.get("foot_right_str", "") or "").strip()
501
+ if not foot_l and not foot_r:
502
+ add_unique("bare foot")
503
+
504
+ # (No handwear parsed) => add ",bare hands"
505
+ hand_l = str(parsed.get("handwear_left_str", "") or "").strip()
506
+ hand_r = str(parsed.get("handwear_right_str", "") or "").strip()
507
+ if not hand_l and not hand_r:
508
+ add_unique("bare hands")
509
+
510
+ # (No topwear AND no breastwear AND no one_piece) => add ",naked breasts"
511
+ top = str(parsed.get("topwear_str", "") or "").strip()
512
+ breast = str(parsed.get("breastwear_str", "") or "").strip()
513
+ one_piece = str(parsed.get("one_piece_str", "") or "").strip()
514
+ if not top and not breast and not one_piece:
515
+ add_unique("naked breasts")
516
+
517
+ # (No topwear AND no one_piece AND no crotch AND no skirt) => exposed crotch (gendered)
518
+ crotch = str(parsed.get("crotch_str", "") or "").strip()
519
+ skirt = str(parsed.get("skirt_str", "") or "").strip()
520
+ if not top and not one_piece and not crotch and not skirt:
521
+ if gender_int == 1:
522
+ add_unique("naked crotch exposed penis")
523
+ else:
524
+ add_unique("naked crotch exposed vagina")
525
+
526
+ equip_str = ", ".join([e for e in equip_list if (e or "").strip()])
527
+
528
+ # Tags
529
+ aest = [_clean_tag(str(parsed.get(f"aesthetic_tag{i}", "") or ""), "aesthetic") for i in range(1, 6)]
530
+ skin = [_clean_tag(str(parsed.get(f"skin_tag{i}", "") or ""), "skin") for i in range(1, 6)]
531
+ expr = [_clean_tag(str(parsed.get(f"expression_tag{i}", "") or ""), "expression") for i in range(1, 6)]
532
+
533
+ hw_extra = _clean_identity_like(str(parsed.get("headwear_str_2", "") or ""))
534
+
535
+ # Fill missing with 0 according to your template
536
+ fields = [
537
+ "START",
538
+ str(gender_int),
539
+ _zero_if_empty(age_str),
540
+ _zero_if_empty(identity),
541
+ _zero_if_empty(eyes),
542
+ _zero_if_empty(hair),
543
+ _zero_if_empty(equip_str),
544
+ *(_zero_if_empty(a) for a in aest),
545
+ *(_zero_if_empty(s) for s in skin),
546
+ *(_zero_if_empty(e) for e in expr),
547
+ _zero_if_empty(hw_extra),
548
+ "0", # POSITIVE_PROMPT_0
549
+ "0", # POSITIVE_PROMPT_1
550
+ "0", # NEGATIVE_PROMPT_0
551
+ _NEGATIVE_PROMPT_1, # NEGATIVE_PROMPT_1 (constant)
552
+ "0", # NEGATIVE_PROMPT_2
553
+ "END",
554
+ ]
555
+
556
+ # Build exactly: START###...###END###
557
+ out = "###".join(fields[:-1]) + "###" + fields[-1] + "###"
558
+ out = _norm_spaces(out) # remove linebreaks, collapse double spaces
559
+ return out
560
+
561
+
562
+ # =============================================================================
563
+ # ComfyUI Node
564
+ # =============================================================================
565
+ class BAMParser_Ancestral:
566
+ """
567
+ Parses GPT_BAM v1 (key=value fields separated by ###) and also outputs bam_ancient.
568
+ """
569
+
570
+ @classmethod
571
+ def INPUT_TYPES(cls):
572
+ return {
573
+ "required": {
574
+ "gpt_bam_string": ("STRING", {"multiline": True, "default": ""}),
575
+ }
576
+ }
577
+
578
+ RETURN_TYPES = RETURN_TYPES_TUPLE
579
+ RETURN_NAMES = RETURN_NAMES_TUPLE
580
+ FUNCTION = "parse"
581
+ CATEGORY = "BAM"
582
+
583
+ def parse(self, gpt_bam_string: str):
584
+ parsed = _parse_gpt_bam(gpt_bam_string)
585
+
586
+ # ensure all outputs exist
587
+ for name, t in _OUTPUT_DEFS:
588
+ if name not in parsed:
589
+ parsed[name] = 0 if t == "INT" else ""
590
+
591
+ return tuple(parsed[name] for name in RETURN_NAMES_TUPLE)
592
+
593
+
594
+ NODE_CLASS_MAPPINGS = {
595
+ "BAMParser_Ancestral": BAMParser_Ancestral,
596
+ }
597
+
598
+ NODE_DISPLAY_NAME_MAPPINGS = {
599
+ "BAMParser_Ancestral": "BAMParser_Ancestral",
600
  }