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)
|