1
File size: 52,661 Bytes
aa9ae0d
 
 
 
bda6f07
aa9ae0d
 
bda6f07
 
 
 
 
 
 
 
10e21d5
 
 
 
37e3825
 
 
bda6f07
 
aa9ae0d
 
 
 
bda6f07
aa9ae0d
 
bda6f07
 
aa9ae0d
fa5b005
 
aa9ae0d
fa5b005
aa9ae0d
 
 
c1bf20f
 
aa9ae0d
bda6f07
 
aa9ae0d
bda6f07
 
 
fa5b005
d8824c4
bda6f07
 
 
 
 
 
 
c190ef9
bda6f07
 
 
 
4d383bb
bda6f07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d383bb
bda6f07
 
 
 
 
 
10e21d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bda6f07
 
 
 
9f1096f
bda6f07
 
 
 
9f1096f
bda6f07
 
 
 
9f1096f
bda6f07
 
 
 
9f1096f
bda6f07
 
 
 
ecd94b4
bda6f07
91d0229
cdd6457
bda6f07
28fa4c1
4a68986
aa9ae0d
bda6f07
cdd6457
bda6f07
 
de82d04
 
cdd6457
bda6f07
de82d04
 
 
 
 
 
 
 
 
bda6f07
 
 
 
 
 
 
 
 
 
 
aa05d04
bda6f07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de82d04
bda6f07
 
 
 
 
 
 
 
 
6a640b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10e21d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a640b1
37e3825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de82d04
bda6f07
 
 
 
10e21d5
 
 
de82d04
bda6f07
 
de82d04
376bab9
aa9ae0d
615fa71
 
10e21d5
 
 
 
 
 
 
615fa71
c1bf20f
9f1096f
 
bda6f07
d8824c4
aa05d04
d8824c4
bda6f07
de82d04
 
aa9ae0d
 
 
 
 
 
 
bda6f07
aa9ae0d
 
 
fa5b005
aa9ae0d
9f1096f
aa9ae0d
c1bf20f
9f1096f
 
c1bf20f
aa9ae0d
bda6f07
aa9ae0d
c1bf20f
fa5b005
bda6f07
aa9ae0d
 
 
bda6f07
aa9ae0d
 
 
 
c1bf20f
bda6f07
 
 
 
 
de82d04
bda6f07
 
 
 
10e21d5
 
 
 
 
 
 
 
 
 
 
 
bda6f07
10e21d5
 
bda6f07
 
10e21d5
 
bda6f07
 
10e21d5
 
 
bda6f07
 
 
10e21d5
 
 
 
bda6f07
 
 
 
10e21d5
 
 
 
 
 
 
 
bda6f07
 
10e21d5
bda6f07
de82d04
 
bda6f07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10e21d5
 
 
 
 
 
 
bda6f07
615fa71
 
10e21d5
 
 
 
615fa71
bda6f07
 
615fa71
10e21d5
615fa71
10e21d5
 
 
aa05d04
bda6f07
10e21d5
 
 
de82d04
10e21d5
 
de82d04
aa05d04
10e21d5
615fa71
10e21d5
615fa71
 
10e21d5
615fa71
bda6f07
 
 
 
 
 
 
de82d04
bda6f07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d8824c4
bda6f07
d8824c4
 
fa5b005
d8824c4
bda6f07
d8824c4
aa9ae0d
fa5b005
aa9ae0d
 
fa5b005
 
 
 
 
aa9ae0d
9f1096f
aa9ae0d
 
c1bf20f
b1fdbd1
fa5b005
 
aa9ae0d
 
fa5b005
 
aa9ae0d
fa5b005
aa9ae0d
fa5b005
 
aa9ae0d
 
fa5b005
 
 
 
aa9ae0d
 
bda6f07
aa9ae0d
 
bda6f07
c1bf20f
aa9ae0d
fa5b005
aa9ae0d
 
c1bf20f
bda6f07
 
 
4d383bb
bda6f07
6a640b1
 
37e3825
fa5b005
 
4d383bb
 
fa5b005
 
bda6f07
37e3825
4d383bb
bda6f07
6a640b1
 
37e3825
8cc80ba
 
 
 
 
bda6f07
6a640b1
bda6f07
4d383bb
bda6f07
fa5b005
bda6f07
 
fa5b005
bda6f07
 
 
 
6a640b1
 
 
fa5b005
 
bda6f07
 
 
fa5b005
 
 
6a640b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37e3825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bda6f07
37e3825
 
 
 
 
 
 
 
bda6f07
 
10e21d5
 
 
 
 
 
 
 
615fa71
10e21d5
615fa71
10e21d5
 
 
615fa71
10e21d5
 
 
 
 
 
 
 
 
 
 
 
615fa71
 
 
 
 
 
10e21d5
 
 
615fa71
 
 
 
 
 
 
 
6a640b1
 
10e21d5
6a640b1
10e21d5
 
6a640b1
 
bda6f07
10e21d5
 
37e3825
 
 
10e21d5
6a640b1
37e3825
 
 
 
 
 
6a640b1
bda6f07
 
37e3825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6a640b1
bda6f07
10e21d5
6a640b1
10e21d5
6a640b1
10e21d5
 
 
37e3825
10e21d5
37e3825
 
 
 
 
6a640b1
 
 
 
37e3825
 
 
6a640b1
 
 
 
 
10e21d5
 
 
 
 
6a640b1
 
37e3825
6a640b1
 
 
 
 
 
10e21d5
 
6a640b1
 
 
 
37e3825
fa5b005
 
bda6f07
 
 
 
 
3be379f
 
bda6f07
4d383bb
bda6f07
4d383bb
bda6f07
 
3be379f
 
6a640b1
10e21d5
37e3825
6a640b1
bda6f07
fa5b005
bda6f07
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10e21d5
 
6a640b1
 
 
10e21d5
 
 
 
 
37e3825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa5b005
4d383bb
aa9ae0d
37e3825
10e21d5
 
6a640b1
37e3825
 
6a640b1
fa5b005
bda6f07
fa5b005
 
bda6f07
 
 
 
10e21d5
 
bda6f07
 
10e21d5
bda6f07
 
 
 
 
10e21d5
 
 
 
 
 
 
 
 
 
37e3825
 
 
 
 
 
 
 
 
 
 
6a640b1
 
 
bda6f07
4d383bb
bda6f07
 
 
 
 
 
 
4d383bb
 
 
10e21d5
 
37e3825
bda6f07
10e21d5
 
bda6f07
10e21d5
37e3825
 
bda6f07
 
 
10e21d5
37e3825
bda6f07
37e3825
bda6f07
 
 
 
 
 
10e21d5
37e3825
 
bda6f07
aa05d04
bda6f07
 
 
 
 
 
 
aa05d04
bda6f07
10e21d5
bda6f07
10e21d5
 
bda6f07
 
aa05d04
 
37e3825
 
 
 
 
 
 
 
 
10e21d5
 
 
 
 
 
 
fa5b005
aa9ae0d
fa5b005
 
 
 
 
 
aa9ae0d
 
 
c1bf20f
fa5b005
bda6f07
8cc80ba
bda6f07
10e21d5
bda6f07
 
8cc80ba
bda6f07
 
 
 
 
aa9ae0d
bda6f07
 
10e21d5
 
 
bda6f07
 
 
de82d04
bda6f07
de82d04
10e21d5
 
 
bda6f07
37e3825
 
 
 
 
 
 
 
6a640b1
 
 
 
 
 
bda6f07
 
 
 
 
 
 
10e21d5
bda6f07
 
aa05d04
bda6f07
 
 
37e3825
 
bda6f07
 
 
 
10e21d5
 
bda6f07
 
 
 
6a640b1
 
bda6f07
4d383bb
bda6f07
 
 
10e21d5
bda6f07
aa9ae0d
37e3825
 
10e21d5
bda6f07
fa5b005
aa9ae0d
c1bf20f
aa9ae0d
 
fa5b005
 
6a640b1
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
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
# ===== LINE 1: MUST BE SPACES FOR ZEROGPU =====
try:
    import spaces
    SPACES_AVAILABLE = True
    print("ZeroGPU mode enabled")
except ImportError:
    SPACES_AVAILABLE = False
    print("Running without Spaces (no ZeroGPU)")

"""
Roulette Lab for JustinD/1.

One button rolls a random recipe (chef, style, scene, outfit, pose, light,
camera, woman). Lock any field and the next roll only spins the rest.
Optional "Tease then reveal" renders the same seed twice.
Art mode: Cartoon (default, anime and illustration chefs only) or Photo.
Nude frame mode: fresh render same seed (default), fresh render new seed,
or reveal from the clothed picture (img2img). LoRA slot and sampler dropdown.
Compel weights the clothed and nude words so the two frames really differ.
Picture upload (optional): "Copy the look" steers style, outfit and mood with
IP-Adapter; "Redraw my pic" repaints your picture with img2img. Faces always
come from the random deck, never from the uploaded picture.
Fiction only. Adult women 21+ (late 20s) only.
"""

import sys
import os
import re
import io
import gc
import time
import random
import logging
import threading

import torch
import gradio as gr
from huggingface_hub import hf_hub_download, HfApi, CommitOperationAdd

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger("RouletteLab")
logger.info("Roulette Lab boot (21+ adult women only, fiction only)")

# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
BACKUP_DATASET_ID = "JustinD/generation-images"
LOG_VISITOR_UPLOADS = os.environ.get("LOG_VISITOR_UPLOADS", "0").strip().lower() in {"1", "true", "yes"}
COOLDOWN_SECONDS = 12
LAST_GEN_TIME = {}
HISTORY_SIZE = 10
GALLERY_SIZE = 12

DEFAULT_STEPS = 28
DEFAULT_GUIDANCE = 6.0

SIZE_PRESETS = {
    "Portrait 832x1216": (832, 1216),
    "Landscape 1344x768": (1344, 768),
    "Square 1024x1024": (1024, 1024),
}

# ---------------------------------------------------------------------------
# Chefs: diffusers SDXL repos, tagged by what they draw best
# ---------------------------------------------------------------------------
CHEFS = {
    # photoreal
    "Lustify GGWP v7": {"repo": "John6666/lustify-sdxl-nsfw-checkpoint-ggwp-v7-sdxl", "kind": "photo"},
    "Lustify Endgame": {"repo": "John6666/lustify-sdxl-nsfwsfw-endgame-sdxl", "kind": "photo"},
    "URPM XL v6": {"repo": "John6666/uber-realistic-porn-merge-xl-urpmxl-v6final-sdxl", "kind": "photo"},
    "PornWorks Sexy Beauty v04": {"repo": "John6666/pornworks-sexy-beauty-v04-sdxl", "kind": "photo"},
    "OhMySister Asian Woman": {"repo": "John6666/oms-oh-my-sister-beautiful-asian-woman-v10-sdxl", "kind": "photo"},
    # anime / illustration
    "WAI NSFW Illustrious v150": {"repo": "John6666/wai-nsfw-illustrious-sdxl-v150-sdxl", "kind": "anime"},
    "PornMaster Anime Illustrious": {"repo": "John6666/pornmaster-anime-illustrious-v2-sdxl", "kind": "anime"},
    "Perfection Asian Illustrious": {"repo": "John6666/perfection-asian-ilxl-illustrious-xl-nsfw-sfw-checkpoint-v10-sdxl", "kind": "anime"},
    "Janku v5 Illustrious": {"repo": "John6666/janku-v5-nsfw-trained-noobai-rou-wei-illustrious-xl-v50-sdxl", "kind": "anime"},
    "Unearthly NSFW Illustrious": {"repo": "John6666/unearthly-nsfw-illustrious-sdxl-20250610b-sdxl", "kind": "anime"},
}

# ---------------------------------------------------------------------------
# Decks
# ---------------------------------------------------------------------------
# Each style says which chef kinds suit it, so an unlocked chef is drawn from a matching pool.
STYLES = {
    # Justin's favourite, listed first so it is easy to pick in the Style dropdown
    "Glossy mono anime": {
        "kinds": ["anime"],
        "prefix": "monochrome, grayscale, glossy anime illustration, oily shiny skin, wet skin, water droplets, "
                  "soft studio light, long wavy dark hair, sultry half lidded eyes, slim tall body, tiny waist, "
                  "toned stomach, long legs, standing pose, minimal plain room background",
        "neg": "color, photo, realistic, 3d render",
    },
    "Modern anime key visual": {
        "kinds": ["anime"],
        "prefix": "modern anime key visual, crisp clean lineart, vibrant cel shading, detailed glossy eyes, bloom lighting, high contrast",
        "neg": "photo, realistic, 3d render, sketch",
    },
    "Hentai doujin cel": {
        "kinds": ["anime"],
        "prefix": "hentai doujin style, bold ink lineart, flat cel shading, blush stickers, glossy skin highlights, manga panel energy",
        "neg": "photo, realistic, 3d render, painterly",
    },
    "Western cartoon pin up": {
        "kinds": ["anime"],
        "prefix": "western cartoon pin up, bold outlines, exaggerated curves, saturated flat colors, retro 1950s pin up poster",
        "neg": "photo, realistic, anime eyes, 3d render",
    },
    "Manhwa webtoon glossy": {
        "kinds": ["anime"],
        "prefix": "korean manhwa webtoon art, glossy digital painting, soft gradient shading, sharp elegant lineart, shiny highlights on skin",
        "neg": "photo, realistic, 3d render, rough sketch",
    },
    "Photoreal": {
        "kinds": ["photo"],
        "prefix": "photorealistic photo, RAW photo, 85mm lens, natural skin texture, sharp focus",
        "neg": "anime, cartoon, illustration, drawing, painting, 3d render",
    },
    "90s anime cel": {
        "kinds": ["anime"],
        "prefix": "retro 90s anime cel shading, vintage anime key visual, airbrushed highlights, film grain, glossy reflective surfaces",
        "neg": "photo, realistic, 3d render",
    },
    "Painted PVC figure": {
        "kinds": ["anime"],
        "prefix": "anime figurine, painted PVC 1/7 scale statue, smooth matte skin finish, round display base, product photography, soft studio light",
        "neg": "flat illustration, sketch, real skin pores",
    },
    "Pop art engraving": {
        "kinds": ["anime", "photo"],
        "prefix": "pop art, Victorian engraving linework, crosshatching, halftone dots, neon pink outline, flat magenta cyan and orange colors",
        "neg": "photo, soft gradient, 3d render",
    },
    "Flat vector pin up": {
        "kinds": ["anime"],
        "prefix": "flat vector illustration, modern pin up, clean linework, flat color blocks with one soft shade, muted dark background",
        "neg": "photo, realistic, 3d render, heavy texture",
    },
    "Oil painting impasto": {
        "kinds": ["anime"],
        "prefix": "oil painting, thick impasto brushstrokes, visible canvas texture, rich chiaroscuro, classical figure painting, nice tits", 
        "neg": "photo, flat vector, cel shading",
    },
}

SCENES = [
    "",
]

# Every spin makes a pair with the same seed: one clothed, one nude
CLOTHED_OUTFITS = [
    "",
]
NUDE_OUTFITS = [
    "fully nude, bare breasts, nipples",
    "fully nude, gold body chain, bare breasts",
    "topless, bare breasts, denim shorts",
    "completely naked, bare breasts, nipples, nothing covering her",
    "nude except black thigh high stockings, bare breasts",
    "fully nude, wet skin, bare breasts",
]
OUTFITS = CLOTHED_OUTFITS

POSES = [
    "caught mid task, glancing at the camera",
    "leaning forward on the counter",
    "kneeling on the bed",
    "sitting on the edge of a desk, legs crossed",
    "stretching arms overhead",
    "lying on her stomach, looking back over her shoulder",
    "standing contrapposto, hand on hip",
    "reclining, propped on one elbow",
]

LIGHTS = [
    "golden hour window light",
    "pink and blue neon glow",
    "soft overcast window light",
    "warm candlelight",
    "studio softbox with rim light",
    "moonlight through blinds",
    "direct on-camera flash",
    "warm lamp light on her collarbone",
]

CAMERAS = [
    "full body wide shot",
    "three quarter shot",
    "low angle full body",
    "eye level 50mm",
    "high angle looking down",
    "mirror reflection shot",
    "cowboy shot",
]

# Face randomizer (body lock never changes)
ETHNICITIES = ["Japanese", "Korean", "Chinese", "Taiwanese", "Vietnamese", "Thai", "Filipino", "Indonesian", "Malaysian"]
HAIRS = [
    "long straight black hair", "messy bun", "chin length bob", "high ponytail",
    "long wavy dark brown hair", "twin braids", "wet slicked back hair", "curtain bangs, shoulder length hair",
]
FACES = [
    "soft almond eyes, full lips", "sharp cheekbones, confident smirk", "round face, playful smile",
    "monolid eyes, glossy lips", "heavy lashes, sultry stare", "freckles, bright eyes",
    "beauty mark under one eye", "strong brows, calm gaze",
]
DEFAULT_FACE = "adult Japanese Asian woman late 20s, long straight black hair, soft almond eyes, full lips"

# Tease then reveal: frame 1 states
TEASE_STATES = [
    "shirt pulled halfway up, underboob showing",
    "bra strap slipping off one shoulder",
    "silk robe falling open",
    "towel slipping down",
]

# ---------------------------------------------------------------------------
# LoRA slot (every repo id and file name checked on the HF Hub API, Oct 24 2026)
# ---------------------------------------------------------------------------
LORA_NONE = "None"
LORA_CUSTOM = "Custom (repo id + file below)"
LORAS = {
    "Breast size slider (anime chefs, Illustrious)": {
        "repo": "Muapi/breast-size-slider-lora-illustriousxl",
        "file": "breast-size-slider-lora-illustriousxl.safetensors",
    },
    "Oppai large breasts slim body (anime chefs, Pony/Illustrious)": {
        "repo": "Muapi/oppai-large-breasts-slim-body",
        "file": "oppai-large-breasts-slim-body.safetensors",
    },
    "Skinny to voluptuous slider (photo chefs, SDXL)": {
        "repo": "Muapi/skinny-voluptous-slider-sdxl",
        "file": "skinny-voluptous-slider-sdxl.safetensors",
    },
    "Asian features slider (photo chefs, SDXL)": {
        "repo": "ntc-ai/SDXL-LoRA-slider.asian",
        "file": "asian.safetensors",
    },
    "Add detail XL (photo chefs, SDXL)": {
        "repo": "highscoregames12018/add-detail-xl",
        "file": "add-detail-xl.safetensors",
    },
}
LORA_CHOICES = [LORA_NONE] + list(LORAS) + [LORA_CUSTOM]
DEFAULT_LORA_STRENGTH = 0.8
LORA_ADAPTER = "roulette_lora"
REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9][\w.\-]*/[\w.\-]+$")

# ---------------------------------------------------------------------------
# Samplers (set per spin from the chef's own scheduler config)
# ---------------------------------------------------------------------------
SAMPLERS = ["Euler a", "DPM++ 2M Karras", "DPM++ SDE Karras", "UniPC"]
DEFAULT_SAMPLER = "Euler a"

# Nude frame: how frame 2 is made
NUDE_MODE_SAME = "Fresh render same seed"
NUDE_MODE_NEW = "Fresh render new seed"
NUDE_MODE_REVEAL = "Reveal from clothed picture"
NUDE_MODES = [NUDE_MODE_SAME, NUDE_MODE_NEW, NUDE_MODE_REVEAL]
DEFAULT_NUDE_MODE = NUDE_MODE_SAME
# Reveal strength: 0.55 kept the clothes, so start high
DEFAULT_REVEAL_STRENGTH = 0.72
REVEAL_MIN, REVEAL_MAX = 0.4, 0.9

# Art mode: which styles and chefs a spin may draw
ART_CARTOON = "Cartoon"
ART_PHOTO = "Photo"
ART_MODES = [ART_CARTOON, ART_PHOTO]
DEFAULT_ART_MODE = ART_CARTOON
ART_KIND = {ART_CARTOON: "anime", ART_PHOTO: "photo"}
STYLE_RANDOM = "Roll random"

# Reference picture upload
PIC_OFF = "Off"
PIC_LOOK = "Copy the look"
PIC_REDRAW = "Redraw my pic"
PIC_MODES = [PIC_OFF, PIC_LOOK, PIC_REDRAW]
DEFAULT_PIC_MODE = PIC_OFF
DEFAULT_LOOK_STRENGTH = 0.6
LOOK_MIN, LOOK_MAX = 0.2, 1.0
DEFAULT_REDRAW_STRENGTH = 0.6
REDRAW_MIN, REDRAW_MAX = 0.3, 0.9
# IP-Adapter for SDXL, ViT-H variant (file list checked on the HF Hub API, Oct 24 2026).
# General look adapter only: the face adapters in that repo are deliberately not used.
IP_REPO = "h94/IP-Adapter"
IP_SUBFOLDER = "sdxl_models"
IP_WEIGHT = "ip-adapter_sdxl_vit-h.safetensors"
IP_ENCODER_FOLDER = "models/image_encoder"   # ViT-H encoder, 2.5 GB (smaller than the bigG one)
IP_FILES = [
    f"{IP_SUBFOLDER}/{IP_WEIGHT}",
    f"{IP_ENCODER_FOLDER}/config.json",
    f"{IP_ENCODER_FOLDER}/model.safetensors",
]

FIELDS = ["chef", "style", "scene", "outfit", "nude_look", "pose", "light", "camera", "woman"]

# ---------------------------------------------------------------------------
# Hard locks
# ---------------------------------------------------------------------------
ADULT_TAGS = "masterpiece, best quality, 1girl, solo, adult woman over 21, late 20s, fictional character"
# No "21+" here: in compel a trailing + means "weight up", so plain words only
FRAME_HEAD = "1girl, solo, adult woman over 21, late 20s, Asian woman"
ASIAN_LOCK = "beautiful Asian woman, Asian facial features, Asian eyes, dark hair"
BODY_LOCK = (
    "tall, slim body, small frame, tiny waist, "
    "huge breasts, very large breasts, heavy full round breasts, busty, deep cleavage, slender thighs"
)

# Short versions so the important words fit inside the first 75 tokens
BODY_LOCK_SHORT = "huge breasts, very large breasts, busty, tall, slim, tiny waist"
NUDE_FRONT = "(completely nude, naked, bare breasts, nipples)1.4, nsfw, explicit, no clothes"
# Weighted negatives so each frame pushes away the other frame's state
CLOTHED_FRAME_NEG = "(nude, naked, topless, nipples, bare breasts)1.3"
NUDE_FRAME_NEG = (
    "(clothes, clothing, shirt, dress, robe, bra, panties, lingerie, bikini, swimsuit, "
    "towel, corset, blazer, sweater, covered breasts, covered nipples)1.4"
)

BASE_NEGATIVES = (
    "child, loli, underage, teen, young teen, barely legal, "
    "18, eighteen, 19, nineteen, "
    "man, men, male, boy, guy, guys, male focus, masculine, "
    "old, elderly, aged, grandma, wrinkly, "
    "fat, chubby, obese, overweight, belly fat, thick thighs, thick body, "
    "beard, mustache, penis, dick, balls, "
    "bad hands, extra fingers, deformed, "
    "blurry, lowres, watermark, text, censored, ugly, celebrity, real person, "
    "caucasian, white woman, european, blonde hair, small breasts, flat chest"
)

FORBIDDEN_KEYWORDS = []
if os.path.exists("restricted_words.txt"):
    try:
        with open("restricted_words.txt", "r", encoding="utf-8") as f:
            FORBIDDEN_KEYWORDS = [
                line.strip().lower() for line in f
                if line.strip() and not line.strip().startswith("#")
            ]
    except Exception as e:
        logger.error(f"restricted words load failed: {e}")

# 21+: block ages under 21 in prompt text (includes 19 / nineteen)
AGE_PATTERN = re.compile(
    r"\b(one|two|three|four|five|six|seven|eight|nine|ten|"
    r"eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|"
    r"1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|18|19)"
    r"\s*[-\s]?\s*(year|yr|y\/o|yo)\b"
)
MINOR_WORDS = re.compile(r"\b(child|kid|loli|teen|teenage|underage|minor|schoolgirl|young girl|little girl)\b")


def check_prompt_safety(prompt: str):
    clean = str(prompt).lower().replace("-", " ").replace("_", " ")
    for word in FORBIDDEN_KEYWORDS:
        if word in clean:
            return False, word
    match = AGE_PATTERN.search(clean) or MINOR_WORDS.search(clean)
    if match:
        return False, match.group(0)
    return True, None


# ---------------------------------------------------------------------------
# Pure recipe logic (no GPU)
# ---------------------------------------------------------------------------
def random_woman(rng=random):
    return (
        f"adult {rng.choice(ETHNICITIES)} Asian woman late 20s, "
        f"{rng.choice(HAIRS)}, {rng.choice(FACES)}"
    )


def styles_for_mode(art_mode):
    """Cartoon: anime styles only (no Photoreal). Photo: styles a photo chef can draw."""
    kind = ART_KIND.get(art_mode, "anime")
    return [name for name, spec in STYLES.items() if kind in spec["kinds"]]


def chefs_for_mode(art_mode):
    kind = ART_KIND.get(art_mode, "anime")
    return [name for name, spec in CHEFS.items() if spec["kind"] == kind]


def chef_pool_for_style(style_name, art_mode=DEFAULT_ART_MODE):
    kinds = STYLES.get(style_name, STYLES["Photoreal"])["kinds"]
    pool = [n for n in chefs_for_mode(art_mode) if CHEFS[n]["kind"] in kinds]
    return pool or chefs_for_mode(art_mode)


def roll_recipe(prev=None, locks=None, new_woman=True, rng=random,
                art_mode=DEFAULT_ART_MODE, style_pick=STYLE_RANDOM):
    """Slot machine roll. Locked fields keep prev values; the rest spin.

    Art mode decides the style and chef pools. A locked style or chef that does
    not belong to the current art mode is re-rolled inside that mode.
    The Style dropdown (style_pick) forces one style when it is not "Roll random".
    """
    prev = dict(prev or {})
    locks = locks or {}
    if art_mode not in ART_MODES:
        art_mode = DEFAULT_ART_MODE
    style_pool = styles_for_mode(art_mode)
    chef_pool = chefs_for_mode(art_mode)

    def keep(field):
        return bool(locks.get(field)) and prev.get(field)

    recipe = {"art": art_mode}
    if style_pick in style_pool:
        recipe["style"] = style_pick
    elif keep("style") and prev["style"] in style_pool:
        recipe["style"] = prev["style"]
    else:
        recipe["style"] = rng.choice(style_pool)
    if keep("chef") and prev["chef"] in chef_pool:
        recipe["chef"] = prev["chef"]
    else:
        recipe["chef"] = rng.choice(chef_pool_for_style(recipe["style"], art_mode))
    recipe["scene"] = prev["scene"] if keep("scene") else rng.choice(SCENES)
    recipe["outfit"] = prev["outfit"] if keep("outfit") else rng.choice(CLOTHED_OUTFITS)
    recipe["nude_look"] = prev["nude_look"] if keep("nude_look") else rng.choice(NUDE_OUTFITS)
    recipe["pose"] = prev["pose"] if keep("pose") else rng.choice(POSES)
    recipe["light"] = prev["light"] if keep("light") else rng.choice(LIGHTS)
    recipe["camera"] = prev["camera"] if keep("camera") else rng.choice(CAMERAS)
    if keep("woman"):
        recipe["woman"] = prev["woman"]
    elif new_woman:
        recipe["woman"] = random_woman(rng)
    else:
        recipe["woman"] = prev.get("woman") or DEFAULT_FACE
    recipe["tease"] = rng.choice(TEASE_STATES)
    return recipe


def _join(*parts):
    return ", ".join(p.strip(" ,") for p in parts if p and str(p).strip(" ,"))


def _outfit_for_negative(outfit):
    """Clothed outfit words for the nude negative, minus words that are fine nude."""
    keep_ok = {"deep cleavage", "sheer stockings"}
    parts = [w.strip() for w in str(outfit or "").split(",")]
    return ", ".join(w for w in parts if w and w not in keep_ok)


def build_frames(recipe, tease_reveal=False, extra=""):
    """Return list of (label, positive, negative).

    The words that make the two frames different go FIRST and are weighted
    with compel syntax, e.g. (completely nude, naked)1.4. The nude frame also
    carries the clothed frame's outfit in its negative, weighted, so the
    clothes are pushed away instead of copied.
    """
    style = STYLES.get(recipe.get("style"), STYLES["Photoreal"])
    negative = _join(BASE_NEGATIVES, style["neg"])
    woman = recipe.get("woman") or DEFAULT_FACE
    after = _join(woman, BODY_LOCK_SHORT, style["prefix"], recipe.get("pose"), recipe.get("camera"),
                  recipe.get("scene"), recipe.get("light"), extra)
    outfit = recipe.get("outfit") or CLOTHED_OUTFITS[0]
    outfit_neg = _outfit_for_negative(outfit)
    nude_neg = _join(negative, NUDE_FRAME_NEG, f"({outfit_neg})1.3" if outfit_neg else "")

    if not tease_reveal:
        # Pair: frame 1 clothed, frame 2 nude
        clothed = _join(f"(wearing {outfit}, clothed)1.3", FRAME_HEAD, after)
        nude = _join(NUDE_FRONT, recipe.get("nude_look") or NUDE_OUTFITS[0], FRAME_HEAD, after)
        return [
            ("Clothed", clothed, _join(negative, CLOTHED_FRAME_NEG)),
            ("Nude", nude, nude_neg),
        ]

    frame1 = _join(f"({recipe.get('tease')}, partially covered, teasing)1.2", FRAME_HEAD,
                   "full body shot", after)
    frame2 = _join(NUDE_FRONT, FRAME_HEAD, "full body shot", after)
    return [
        ("Tease", frame1, negative),
        ("Reveal", frame2, nude_neg),
    ]


def recipe_card(recipe, seed=None):
    if not recipe:
        return "_No roll yet. Hit **Spin**._"
    lines = ["### Recipe"]
    for field in FIELDS:
        lines.append(f"- **{field.replace('_', ' ').title()}**: {recipe.get(field, '')}")
    if seed is not None:
        lines.append(f"- **Seed**: {seed}")
    return "\n".join(lines)


def history_label(i, entry):
    r = entry["recipe"]
    return f"{i + 1}. {r['style']} | {r['scene']} | {r['outfit']} | seed {entry['seed']}"


def push_history(history, recipe, seed):
    history = list(history or [])
    history.insert(0, {"recipe": dict(recipe), "seed": seed})
    return history[:HISTORY_SIZE]


# ---------------------------------------------------------------------------
# Visitor logging (off by default)
# ---------------------------------------------------------------------------
def should_log_upload(username) -> bool:
    return bool(LOG_VISITOR_UPLOADS)


def async_log_upload(pil_img, seed, model_name, prompt, username, width, height, steps, cfg_scale):
    if not should_log_upload(username):
        logger.info("visitor log upload skipped (LOG_VISITOR_UPLOADS not enabled)")
        return
    hf_token = os.environ.get("HF_TOKEN")
    if not username or not str(username).strip():
        username = "Unknown_Visitor"
    timestamp = int(time.time())
    clean = str(model_name).replace("/", "_")[:18]
    filename_base = f"gen_{timestamp}_seed_{seed}_{clean}"
    buf = io.BytesIO()
    pil_img.save(buf, format="WEBP", quality=95)
    raw = buf.getvalue()
    if not hf_token:
        logger.error("HF_TOKEN missing - log upload skipped")
        return
    api = HfApi(token=hf_token)
    date_str = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(timestamp))
    log_entry = f"[{date_str}] | User: {username} | Model: {model_name} | Seed: {seed} | Prompt: {prompt[:150]}...\n"
    user_log = f"user_logs/{username}_history.txt"
    for attempt in range(1, 4):
        try:
            try:
                path = hf_hub_download(BACKUP_DATASET_ID, "traffic_logs.txt", repo_type="dataset", token=hf_token)
                current = open(path, encoding="utf-8").read()
            except Exception:
                current = "=== TRAFFIC LOGS ===\n"
            try:
                upath = hf_hub_download(BACKUP_DATASET_ID, user_log, repo_type="dataset", token=hf_token)
                user_logs = open(upath, encoding="utf-8").read()
            except Exception:
                user_logs = f"=== HISTORY FOR {username} ===\n"
            ops = [
                CommitOperationAdd(path_in_repo="traffic_logs.txt", path_or_fileobj=(current + log_entry).encode()),
                CommitOperationAdd(path_in_repo=user_log, path_or_fileobj=(user_logs + log_entry).encode()),
                CommitOperationAdd(path_in_repo=f"data/{filename_base}.webp", path_or_fileobj=raw),
            ]
            api.create_commit(
                repo_id=BACKUP_DATASET_ID, repo_type="dataset", operations=ops,
                commit_message=f"gen for {username} @ {timestamp}",
            )
            logger.info(f"logged {username} attempt {attempt}")
            return
        except Exception as e:
            logger.error(f"log fail {attempt}: {e}")
            time.sleep(1.5 * attempt)


# ---------------------------------------------------------------------------
# Pipeline (one chef cached at a time)
# ---------------------------------------------------------------------------
_pipe = None
_pipe_chef = None
_img2img = None
_base_sched_config = None
_ip_loaded = False


def _hf_token():
    return os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")


def unload_pipe():
    global _pipe, _pipe_chef, _img2img, _base_sched_config, _ip_loaded
    _pipe = None
    _pipe_chef = None
    _img2img = None
    _base_sched_config = None
    _ip_loaded = False
    gc.collect()
    if torch.cuda.is_available():
        torch.cuda.empty_cache()


def get_pipe(chef_name):
    global _pipe, _pipe_chef, _base_sched_config
    if _pipe is not None and _pipe_chef == chef_name:
        return _pipe
    from diffusers import StableDiffusionXLPipeline, EulerAncestralDiscreteScheduler

    unload_pipe()
    spec = CHEFS[chef_name]
    dtype = torch.float16 if torch.cuda.is_available() else torch.float32
    logger.info(f"Loading chef {chef_name} ({spec['repo']})")
    pipe = StableDiffusionXLPipeline.from_pretrained(
        spec["repo"], torch_dtype=dtype, use_safetensors=True, token=_hf_token(),
    )
    # Keep the chef's own scheduler config so every sampler swap starts clean
    _base_sched_config = dict(pipe.scheduler.config)
    pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(_base_sched_config)
    if torch.cuda.is_available():
        pipe.to("cuda")
    pipe.vae.enable_slicing()
    pipe.vae.enable_tiling()
    _pipe, _pipe_chef = pipe, chef_name
    return pipe


def get_img2img(pipe):
    """Img2img pipe that reuses the SAME unet, vae and text encoders (no extra VRAM)."""
    global _img2img
    if _img2img is not None:
        return _img2img
    from diffusers import StableDiffusionXLImg2ImgPipeline
    try:
        _img2img = StableDiffusionXLImg2ImgPipeline.from_pipe(pipe)
    except Exception as e:
        logger.warning(f"from_pipe failed ({e}), sharing components by hand")
        _img2img = StableDiffusionXLImg2ImgPipeline(**pipe.components)
    return _img2img


def make_scheduler(sampler_name):
    """Fresh scheduler for this spin, built from the chef's original config."""
    from diffusers import (
        EulerAncestralDiscreteScheduler,
        DPMSolverMultistepScheduler,
        UniPCMultistepScheduler,
    )
    config = _base_sched_config or dict(_pipe.scheduler.config)
    if sampler_name == "DPM++ 2M Karras":
        return DPMSolverMultistepScheduler.from_config(
            config, algorithm_type="dpmsolver++", solver_order=2, use_karras_sigmas=True)
    if sampler_name == "DPM++ SDE Karras":
        # sde-dpmsolver++ gives the SDE flavour without needing torchsde
        return DPMSolverMultistepScheduler.from_config(
            config, algorithm_type="sde-dpmsolver++", solver_order=2, use_karras_sigmas=True)
    if sampler_name == "UniPC":
        return UniPCMultistepScheduler.from_config(config)
    return EulerAncestralDiscreteScheduler.from_config(config)


def resolve_lora(lora_choice, custom_repo, custom_file):
    """Return (repo, weight_file) or None."""
    if not lora_choice or lora_choice == LORA_NONE:
        return None
    if lora_choice == LORA_CUSTOM:
        repo = (custom_repo or "").strip()
        if not repo:
            return None
        if not REPO_ID_PATTERN.match(repo):
            logger.warning(f"custom LoRA repo id looks wrong, skipping: {repo!r}")
            return None
        return repo, ((custom_file or "").strip() or None)
    spec = LORAS.get(lora_choice)
    return (spec["repo"], spec["file"]) if spec else None


def clear_lora(pipe):
    """Drop any LoRA left from an earlier spin so they never stack."""
    try:
        pipe.unfuse_lora()
    except Exception:
        pass
    try:
        pipe.unload_lora_weights()
    except Exception as e:
        logger.warning(f"LoRA unload warning: {e}")


def apply_lora(pipe, lora, strength):
    """Load one LoRA by adapter name. A bad LoRA logs a warning and the spin goes on."""
    clear_lora(pipe)
    if not lora or float(strength) <= 0:
        return None
    repo, weight_file = lora
    try:
        kwargs = {"adapter_name": LORA_ADAPTER, "token": _hf_token()}
        if weight_file:
            kwargs["weight_name"] = weight_file
        pipe.load_lora_weights(repo, **kwargs)
        pipe.set_adapters([LORA_ADAPTER], adapter_weights=[float(strength)])
        logger.info(f"LoRA on: {repo} ({weight_file}) at {strength}")
        return f"{repo} @ {float(strength):.2f}"
    except Exception as e:
        logger.warning(f"LoRA {repo} failed, rendering without it: {e}")
        clear_lora(pipe)
        return None


def prefetch_ip_adapter():
    """Download the IP-Adapter files on the CPU side, before the GPU clock starts.

    Returns None when ready, or an error string.
    """
    try:
        for f in IP_FILES:
            hf_hub_download(IP_REPO, f, token=_hf_token())
        return None
    except Exception as e:
        logger.warning(f"IP-Adapter download failed: {e}")
        return str(e)


def ensure_ip_adapter(pipe, want):
    """Load IP-Adapter once per cached chef, or unload it when not wanted.

    Returns (ok, error_text). The img2img twin is rebuilt after any change so it
    picks up the new image encoder and attention processors.
    """
    global _ip_loaded, _img2img
    if want and not _ip_loaded:
        try:
            pipe.load_ip_adapter(
                IP_REPO, subfolder=IP_SUBFOLDER, weight_name=IP_WEIGHT,
                image_encoder_folder=IP_ENCODER_FOLDER, token=_hf_token(),
            )
            _ip_loaded = True
            _img2img = None
            logger.info("IP-Adapter loaded")
        except Exception as e:
            logger.warning(f"IP-Adapter load failed, rendering without it: {e}")
            try:
                pipe.unload_ip_adapter()
            except Exception:
                pass
            _ip_loaded = False
            _img2img = None
            return False, str(e)
    elif not want and _ip_loaded:
        try:
            pipe.unload_ip_adapter()
        except Exception as e:
            logger.warning(f"IP-Adapter unload warning: {e}")
        _ip_loaded = False
        _img2img = None
    return (_ip_loaded if want else True), None


def fit_picture(img, width, height):
    """Resize and center crop the upload to the chosen size preset."""
    from PIL import Image, ImageOps
    if img is None:
        return None
    if not isinstance(img, Image.Image):
        img = Image.fromarray(img)
    return ImageOps.fit(img.convert("RGB"), (int(width), int(height)), Image.LANCZOS)


def _gpu_duration(*args, **kwargs):
    # Copy the look may load IP-Adapter onto the GPU, so it gets a little more room
    return 120 if kwargs.get("picture_mode") == PIC_LOOK else 90


def _gpu(fn):
    # 90 s reservation (120 s with Copy the look): ZeroGPU checks this against your remaining daily time
    if not SPACES_AVAILABLE:
        return fn
    try:
        return spaces.GPU(duration=_gpu_duration)(fn)
    except Exception as e:
        logger.warning(f"dynamic GPU duration not supported ({e}), using 120 s")
        return spaces.GPU(duration=120)(fn)


WEIGHT_SYNTAX = re.compile(r"\(([^()]*)\)[0-9.]+")


def strip_weights(text):
    """(words)1.4 becomes words, for the plain fallback without compel."""
    return WEIGHT_SYNTAX.sub(r"\1", str(text))


def _long_prompt_kwargs(pipe, positive, negative):
    """Encode the WHOLE prompt with compel, weights included.

    Plain diffusers cuts every prompt at 77 tokens and ignores (words)1.4.
    Compel reads past 77 tokens and applies the weights. A NEW encoder runs for
    every frame, so frame 2 never reuses frame 1's embeddings.
    """
    import compel as compel_pkg
    if hasattr(compel_pkg, "CompelForSDXL"):
        enc = compel_pkg.CompelForSDXL(pipe)
        out = enc(positive, negative_prompt=negative)
        return {
            "prompt_embeds": out.embeds,
            "pooled_prompt_embeds": out.pooled_embeds,
            "negative_prompt_embeds": out.negative_embeds,
            "negative_pooled_prompt_embeds": out.negative_pooled_embeds,
        }
    from compel import Compel, ReturnedEmbeddingsType
    enc = Compel(
        tokenizer=[pipe.tokenizer, pipe.tokenizer_2],
        text_encoder=[pipe.text_encoder, pipe.text_encoder_2],
        returned_embeddings_type=ReturnedEmbeddingsType.PENULTIMATE_HIDDEN_STATES_NON_NORMALIZED,
        requires_pooled=[False, True],
        truncate_long_prompts=False,
    )
    pos_embeds, pos_pooled = enc(positive)
    neg_embeds, neg_pooled = enc(negative)
    pos_embeds, neg_embeds = enc.pad_conditioning_tensors_to_same_length([pos_embeds, neg_embeds])
    return {
        "prompt_embeds": pos_embeds,
        "pooled_prompt_embeds": pos_pooled,
        "negative_prompt_embeds": neg_embeds,
        "negative_pooled_prompt_embeds": neg_pooled,
    }


def _prompt_kwargs(pipe, positive, negative):
    try:
        return _long_prompt_kwargs(pipe, positive, negative), "compel"
    except Exception as e:
        logger.warning(f"compel encode failed, using plain prompt cut at 77 tokens: {e}")
        return {"prompt": strip_weights(positive), "negative_prompt": strip_weights(negative)}, "plain"


@_gpu
def render_frames(chef_name, frames, seeds, steps, guidance_scale, width, height,
                  nude_mode=DEFAULT_NUDE_MODE, reveal_strength=DEFAULT_REVEAL_STRENGTH,
                  lora=None, lora_strength=DEFAULT_LORA_STRENGTH, sampler=DEFAULT_SAMPLER,
                  ref_image=None, picture_mode=DEFAULT_PIC_MODE,
                  look_strength=DEFAULT_LOOK_STRENGTH, redraw_strength=DEFAULT_REDRAW_STRENGTH):
    """frames: list of (label, positive, negative); seeds: one seed per frame.

    No picture: frame 1 is txt2img; later frames are txt2img (fresh modes) or
    img2img on frame 1 (reveal mode).
    Copy the look: same as above, with IP-Adapter steering every frame.
    Redraw my pic: frame 1 is img2img from the upload; the nude frame is img2img
    from the upload too (strength + 0.1), unless reveal mode is on.
    ref_image arrives already fitted to width x height.
    """
    pipe = get_pipe(chef_name)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    use_look = picture_mode == PIC_LOOK and ref_image is not None
    use_redraw = picture_mode == PIC_REDRAW and ref_image is not None
    ip_ok, ip_error = ensure_ip_adapter(pipe, use_look)
    ip_kwargs = {}
    if use_look and ip_ok:
        try:
            pipe.set_ip_adapter_scale(float(look_strength))
            ip_kwargs = {"ip_adapter_image": ref_image}
        except Exception as e:
            logger.warning(f"IP-Adapter scale failed, rendering without it: {e}")
            ip_error = str(e)
            ensure_ip_adapter(pipe, False)
            ip_ok = False
    if use_look:
        ip_note = (f"on at {float(look_strength):.2f}" if ip_kwargs
                   else f"failed, rendered without it ({str(ip_error)[:160]})")
    else:
        ip_note = None
    lora_note = apply_lora(pipe, lora, lora_strength)
    images = []
    encoders = []
    try:
        first_image = None
        for i, (label, positive, negative) in enumerate(frames):
            gen = torch.Generator(device=device).manual_seed(int(seeds[i]))
            prompt_kwargs, enc_name = _prompt_kwargs(pipe, positive, negative)
            encoders.append(enc_name)
            source, strength = None, None
            if nude_mode == NUDE_MODE_REVEAL and i > 0 and first_image is not None:
                source, strength = first_image, float(reveal_strength)
            elif use_redraw:
                bump = 0.0 if i == 0 else 0.1
                source, strength = ref_image, min(REDRAW_MAX, float(redraw_strength) + bump)
            if source is not None:
                i2i = get_img2img(pipe)
                i2i.scheduler = make_scheduler(sampler)
                out = i2i(
                    **prompt_kwargs,
                    **ip_kwargs,
                    image=source.copy(),
                    strength=strength,
                    num_inference_steps=int(steps),
                    guidance_scale=float(guidance_scale),
                    generator=gen,
                ).images[0]
            else:
                # fresh scheduler every frame so no state carries over
                try:
                    pipe.scheduler = make_scheduler(sampler)
                except Exception as e:
                    logger.warning(f"sampler {sampler} failed, keeping current: {e}")
                out = pipe(
                    **prompt_kwargs,
                    **ip_kwargs,
                    num_inference_steps=int(steps),
                    guidance_scale=float(guidance_scale),
                    width=int(width),
                    height=int(height),
                    generator=gen,
                ).images[0]
            if i == 0:
                first_image = out
            images.append((label, out))
    finally:
        if lora_note:
            clear_lora(pipe)
    return images, lora_note, encoders, ip_note


# ---------------------------------------------------------------------------
# UI handlers
# ---------------------------------------------------------------------------
def _locks_from_flags(flags):
    return {field: bool(flag) for field, flag in zip(FIELDS, flags)}


def _resolve_seed(seed_value):
    try:
        s = int(float(seed_value))
    except (TypeError, ValueError):
        s = -1
    return random.randint(0, 2**31 - 1) if s < 0 else s


def _cook(recipe, seed, tease_reveal, extra, size_name, steps, guidance_scale,
          nude_mode, reveal_strength, lora_choice, lora_strength, lora_repo, lora_file, sampler,
          ref_image, picture_mode, look_strength, redraw_strength,
          username, gallery_items, history):
    """Shared render path for Spin and Re-roll. Returns UI outputs."""
    user_key = username or "anonymous"
    now = time.time()
    last = LAST_GEN_TIME.get(user_key)
    if last and now - last < COOLDOWN_SECONDS:
        left = int(COOLDOWN_SECONDS - (now - last))
        raise gr.Error(f"Cooldown {left}s")

    frames = build_frames(recipe, tease_reveal=bool(tease_reveal), extra=extra or "")
    for _, positive, _ in frames:
        ok, bad = check_prompt_safety(positive)
        if not ok:
            raise gr.Error("Restricted content in prompt")

    width, height = SIZE_PRESETS.get(size_name, SIZE_PRESETS["Portrait 832x1216"])
    use_steps = int(max(10, min(40, int(steps or DEFAULT_STEPS))))
    use_guidance = float(guidance_scale if guidance_scale is not None else DEFAULT_GUIDANCE)
    use_mode = nude_mode if nude_mode in NUDE_MODES else DEFAULT_NUDE_MODE
    use_reveal_strength = float(max(REVEAL_MIN, min(REVEAL_MAX, float(reveal_strength or DEFAULT_REVEAL_STRENGTH))))
    use_lora_strength = float(max(0.0, min(1.5, float(lora_strength if lora_strength is not None else DEFAULT_LORA_STRENGTH))))
    use_sampler = sampler if sampler in SAMPLERS else DEFAULT_SAMPLER
    lora = resolve_lora(lora_choice, lora_repo, lora_file)
    seed = int(seed)
    if use_mode == NUDE_MODE_NEW:
        seeds = [seed] + [random.randint(0, 2**31 - 1) for _ in frames[1:]]
    else:
        seeds = [seed] * len(frames)
    use_pic_mode = picture_mode if picture_mode in PIC_MODES else DEFAULT_PIC_MODE
    pic_note = None
    if use_pic_mode != PIC_OFF and ref_image is None:
        pic_note = f"Picture mode {use_pic_mode} picked but no picture uploaded, so it ran Off"
        use_pic_mode = PIC_OFF
    use_look_strength = float(max(LOOK_MIN, min(LOOK_MAX, float(look_strength or DEFAULT_LOOK_STRENGTH))))
    use_redraw_strength = float(max(REDRAW_MIN, min(REDRAW_MAX, float(redraw_strength or DEFAULT_REDRAW_STRENGTH))))
    fitted = None
    if use_pic_mode != PIC_OFF:
        try:
            fitted = fit_picture(ref_image, width, height)
        except Exception as e:
            logger.warning(f"could not read the uploaded picture: {e}")
            pic_note = f"Could not read the uploaded picture, so it ran Off ({e})"
            use_pic_mode = PIC_OFF
    if use_pic_mode == PIC_LOOK:
        # download before the GPU clock starts
        err = prefetch_ip_adapter()
        if err:
            pic_note = f"Copy the look: IP-Adapter download failed, rendered without it ({err[:160]})"
            use_pic_mode, fitted = PIC_OFF, None
    LAST_GEN_TIME[user_key] = now

    try:
        results, lora_note, encoders, ip_note = render_frames(
            recipe["chef"], frames, seeds, use_steps, use_guidance, width, height,
            nude_mode=use_mode, reveal_strength=use_reveal_strength,
            lora=lora, lora_strength=use_lora_strength, sampler=use_sampler,
            ref_image=fitted, picture_mode=use_pic_mode,
            look_strength=use_look_strength, redraw_strength=use_redraw_strength,
        )
    except Exception as e:
        logger.exception("render failed")
        msg = str(e)
        if "gated" in msg.lower() or "401" in msg or "403" in msg:
            msg += " (accept the model license and make sure the HF_TOKEN secret can read it)"
        raise gr.Error(msg)

    new_items = []
    for (label, img), (_, positive, _), frame_seed in zip(results, frames, seeds):
        new_items.append((img, f"{label} | {recipe['chef']} | seed {frame_seed}"))
        threading.Thread(
            target=async_log_upload,
            args=(img, frame_seed, recipe["chef"], positive, username, width, height, use_steps, use_guidance),
            daemon=True,
        ).start()

    gallery_items = (new_items + list(gallery_items or []))[:GALLERY_SIZE]
    history = push_history(history, recipe, seed)
    prompt_text = "\n\n".join(f"[{label}]\n{pos}\n[{label} negative]\n{neg}" for label, pos, neg in frames)
    notes = [
        f"Art mode: {recipe.get('art', DEFAULT_ART_MODE)}",
        f"Sampler: {use_sampler}",
        f"Nude frame mode: {use_mode}",
        f"Seeds: {', '.join(str(x) for x in seeds)}",
        f"Prompt encoder per frame: {', '.join(encoders)} (compel = weights on, plain = weights off, cut at 77 tokens)",
    ]
    if use_mode == NUDE_MODE_REVEAL:
        notes.append(f"Reveal strength {use_reveal_strength:.2f}")
    notes.append(f"Picture mode: {use_pic_mode}")
    if use_pic_mode == PIC_LOOK:
        notes.append(f"Copy the look (IP-Adapter {IP_WEIGHT}): {ip_note}")
    if use_pic_mode == PIC_REDRAW:
        if use_mode == NUDE_MODE_REVEAL:
            notes.append(f"Redraw strength {use_redraw_strength:.2f} on the clothed picture, nude picture revealed from it")
        else:
            notes.append(f"Redraw strength {use_redraw_strength:.2f} clothed, "
                         f"{min(REDRAW_MAX, use_redraw_strength + 0.1):.2f} nude, both from your picture")
    if pic_note:
        notes.append(pic_note)
    if lora:
        notes.append(f"LoRA: {lora_note}" if lora_note else f"LoRA {lora[0]} failed to load, rendered without it")
    prompt_text = prompt_text + "\n\n[Settings]\n" + "\n".join(notes)
    choices = [history_label(i, h) for i, h in enumerate(history)]
    return (
        gallery_items,
        gallery_items,
        recipe_card(recipe, seed),
        prompt_text,
        recipe,
        history,
        gr.update(choices=choices, value=choices[0] if choices else None),
    )


def on_spin(recipe, art_mode, style_pick, new_woman, tease_reveal, extra, size_name, steps, guidance_scale,
            seed_value, nude_mode, reveal_strength, lora_choice, lora_strength, lora_repo, lora_file, sampler,
            ref_image, picture_mode, look_strength, redraw_strength,
            username, gallery_items, history, *lock_flags):
    rolled = roll_recipe(recipe, _locks_from_flags(lock_flags), new_woman=bool(new_woman),
                         art_mode=art_mode, style_pick=style_pick)
    return _cook(rolled, _resolve_seed(seed_value), tease_reveal, extra, size_name, steps,
                 guidance_scale, nude_mode, reveal_strength, lora_choice, lora_strength,
                 lora_repo, lora_file, sampler, ref_image, picture_mode, look_strength,
                 redraw_strength, username, gallery_items, history)


def on_reroll_history(choice, tease_reveal, extra, size_name, steps, guidance_scale,
                      nude_mode, reveal_strength, lora_choice, lora_strength, lora_repo, lora_file, sampler,
                      ref_image, picture_mode, look_strength, redraw_strength,
                      username, gallery_items, history):
    """Re-roll uses whatever picture is in the upload box right now."""
    history = list(history or [])
    labels = [history_label(i, h) for i, h in enumerate(history)]
    if not choice or choice not in labels:
        raise gr.Error("Pick a recipe from history first")
    picked = history[labels.index(choice)]["recipe"]
    return _cook(dict(picked), _resolve_seed(-1), tease_reveal, extra, size_name, steps,
                 guidance_scale, nude_mode, reveal_strength, lora_choice, lora_strength,
                 lora_repo, lora_file, sampler, ref_image, picture_mode, look_strength,
                 redraw_strength, username, gallery_items, history)


def on_load_history(choice, history):
    history = list(history or [])
    labels = [history_label(i, h) for i, h in enumerate(history)]
    if not choice or choice not in labels:
        return gr.update(), gr.update()
    entry = history[labels.index(choice)]
    return dict(entry["recipe"]), recipe_card(entry["recipe"], entry["seed"])


def on_preview(recipe, art_mode, style_pick, new_woman, tease_reveal, extra, *lock_flags):
    """Spin the card without rendering (free, no GPU)."""
    rolled = roll_recipe(recipe, _locks_from_flags(lock_flags), new_woman=bool(new_woman),
                         art_mode=art_mode, style_pick=style_pick)
    frames = build_frames(rolled, tease_reveal=bool(tease_reveal), extra=extra or "")
    return rolled, recipe_card(rolled), "\n\n".join(f"[{l}]\n{p}" for l, p, _ in frames)


def on_picture_upload(picture_mode):
    """A fresh upload switches Off to Copy the look; any other pick is kept."""
    return gr.update(value=PIC_LOOK if picture_mode in (None, PIC_OFF) else picture_mode)


def on_picture_clear():
    return gr.update(value=PIC_OFF)


def on_art_mode(art_mode, style_pick):
    """Refill the Style dropdown with the styles that suit the art mode."""
    pool = styles_for_mode(art_mode)
    value = style_pick if style_pick in pool else STYLE_RANDOM
    return gr.update(choices=[STYLE_RANDOM] + pool, value=value)


js_visitor = """
function() {
  let id = localStorage.getItem('adult_ai_visitor_id');
  if (!id) {
    id = 'Visitor_' + Math.floor(1000 + Math.random() * 9000);
    localStorage.setItem('adult_ai_visitor_id', id);
  }
  return id;
}
"""


def create_ui():
    with gr.Blocks(title="Roulette Lab") as demo:
        gr.Markdown(
            "# Roulette Lab\n"
            "Spin a random recipe. Every spin makes two pictures: one clothed, one nude. Cartoon art is the default; switch Art mode to Photo for real looking pictures. Lock what you like, spin again, and only the unlocked slots change. "
            "Fictional adult women 21+ only. Visitor images are not saved unless the Space secret "
            "`LOG_VISITOR_UPLOADS=1` is set."
        )
        recipe_state = gr.State({})
        history_state = gr.State([])
        gallery_state = gr.State([])
        user_id = gr.Textbox(visible=False)

        with gr.Row():
            with gr.Column(scale=1):
                spin_btn = gr.Button("Spin", variant="primary", size="lg")
                art_mode = gr.Radio(ART_MODES, value=DEFAULT_ART_MODE, label="Art mode")
                style_pick = gr.Dropdown([STYLE_RANDOM] + styles_for_mode(DEFAULT_ART_MODE), value=STYLE_RANDOM,
                                         label="Style (Roll random, or pick one to keep every spin)")
                preview_btn = gr.Button("Spin card only (no render)")
                card = gr.Markdown(recipe_card({}))
                gr.Markdown("**Lock slots** (locked slots survive the next spin)")
                lock_boxes = [gr.Checkbox(label=f"Lock {f.replace('_', ' ')}", value=False) for f in FIELDS]
                new_woman = gr.Checkbox(label="New woman each roll", value=True)
                tease_reveal = gr.Checkbox(label="Tease then reveal instead of clothed and nude (same seed, 2 frames)", value=False)
                nude_mode = gr.Radio(NUDE_MODES, value=DEFAULT_NUDE_MODE, label="Nude frame mode")
                reveal_strength = gr.Slider(REVEAL_MIN, REVEAL_MAX, value=DEFAULT_REVEAL_STRENGTH, step=0.05,
                                            label="Reveal strength (reveal mode only; low keeps more of the clothed picture)")
                extra = gr.Textbox(label="Extra tags (optional)", placeholder="e.g. glossy lips, rain on skin")
                with gr.Accordion("Your picture (optional)", open=False):
                    ref_image = gr.Image(type="pil", label="Reference picture", sources=["upload", "clipboard"])
                    gr.Markdown("Use for pose, outfit, style and mood. Faces come from the random deck, not your pic.")
                    picture_mode = gr.Radio(PIC_MODES, value=DEFAULT_PIC_MODE, label="Picture mode")
                    look_strength = gr.Slider(LOOK_MIN, LOOK_MAX, value=DEFAULT_LOOK_STRENGTH, step=0.05,
                                              label="Look strength (Copy the look; high copies more of your pic)")
                    redraw_strength = gr.Slider(REDRAW_MIN, REDRAW_MAX, value=DEFAULT_REDRAW_STRENGTH, step=0.05,
                                                label="Redraw strength (Redraw my pic; low stays close to your pic)")
                with gr.Accordion("LoRA and sampler", open=False):
                    lora_choice = gr.Dropdown(LORA_CHOICES, value=LORA_NONE, label="LoRA")
                    lora_strength = gr.Slider(0.0, 1.5, value=DEFAULT_LORA_STRENGTH, step=0.05, label="LoRA strength")
                    lora_repo = gr.Textbox(label="Custom LoRA repo id (pick Custom above)", placeholder="user/repo")
                    lora_file = gr.Textbox(label="Custom LoRA weight file name", placeholder="name.safetensors")
                    sampler = gr.Dropdown(SAMPLERS, value=DEFAULT_SAMPLER, label="Sampler")
                with gr.Accordion("Settings", open=False):
                    size_name = gr.Radio(list(SIZE_PRESETS), value="Portrait 832x1216", label="Size")
                    steps = gr.Slider(10, 40, value=DEFAULT_STEPS, step=1, label="Steps")
                    guidance_scale = gr.Slider(1.0, 12.0, value=DEFAULT_GUIDANCE, step=0.5, label="CFG")
                    seed_value = gr.Number(value=-1, precision=0, label="Seed (-1 = random)")
            with gr.Column(scale=2):
                gallery = gr.Gallery(label="Output", columns=2, height=720)
                prompt_box = gr.Textbox(label="Prompt sent", lines=8, interactive=False)
                gr.Markdown("**History** (last 10 recipes)")
                history_dd = gr.Dropdown(choices=[], label="Pick a recipe", interactive=True)
                with gr.Row():
                    reroll_btn = gr.Button("Re-roll this recipe, new seed")
                    load_btn = gr.Button("Load into card (then lock and spin)")

        advanced = [nude_mode, reveal_strength, lora_choice, lora_strength, lora_repo, lora_file, sampler,
                    ref_image, picture_mode, look_strength, redraw_strength]
        outputs = [gallery, gallery_state, card, prompt_box, recipe_state, history_state, history_dd]

        spin_btn.click(
            on_spin,
            inputs=[recipe_state, art_mode, style_pick, new_woman, tease_reveal, extra, size_name, steps,
                    guidance_scale, seed_value] + advanced + [user_id, gallery_state, history_state] + lock_boxes,
            outputs=outputs,
        )
        reroll_btn.click(
            on_reroll_history,
            inputs=[history_dd, tease_reveal, extra, size_name, steps, guidance_scale]
                   + advanced + [user_id, gallery_state, history_state],
            outputs=outputs,
        )
        load_btn.click(on_load_history, inputs=[history_dd, history_state], outputs=[recipe_state, card])
        preview_btn.click(
            on_preview,
            inputs=[recipe_state, art_mode, style_pick, new_woman, tease_reveal, extra] + lock_boxes,
            outputs=[recipe_state, card, prompt_box],
        )
        ref_image.upload(on_picture_upload, inputs=[picture_mode], outputs=[picture_mode])
        ref_image.clear(on_picture_clear, inputs=None, outputs=[picture_mode])
        art_mode.change(on_art_mode, inputs=[art_mode, style_pick], outputs=[style_pick])
        demo.load(fn=None, inputs=None, outputs=[user_id], js=js_visitor)
    return demo


if __name__ == "__main__":
    os.environ["GRADIO_ANALYTICS_ENABLED"] = "False"
    app = create_ui()
    app.queue(max_size=12)
    app.launch(ssr_mode=False)