From 0f60721162ea9a6dbf2f24fba3944ecb21408f91 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Oct 2025 18:09:30 +0300 Subject: [PATCH] Enhance AIImages mod with settings support and improved UI for image generation. Update localized strings in English and Russian for better clarity. Refactor code for better organization and maintainability. --- Assemblies/AIImages.dll | Bin 18432 -> 62976 bytes Languages/English/Keyed/AIImages.xml | 48 +- Languages/Russian/Keyed/AIImages.xml | 62 ++- Source/AIImages/AIImagesMod.cs | 48 +- Source/AIImages/Models/GenerationRequest.cs | 50 ++ Source/AIImages/Models/PawnAppearanceData.cs | 46 ++ .../Models/StableDiffusionSettings.cs | 62 +++ .../Services/AdvancedPromptGenerator.cs | 353 ++++++++++++ .../Services/ColorDescriptionService.cs | 165 ++++++ .../Services/IPawnDescriptionService.cs | 26 + .../Services/IPromptGeneratorService.cs | 31 ++ .../Services/IStableDiffusionApiService.cs | 32 ++ .../Services/PawnDescriptionService.cs | 183 ++++++ .../Services/StableDiffusionApiService.cs | 234 ++++++++ .../AIImages/Settings/AIImagesModSettings.cs | 94 ++++ Source/AIImages/UI/AIImagesSettingsUI.cs | 284 ++++++++++ Source/AIImages/Window_AIImage.cs | 524 +++++++----------- 17 files changed, 1907 insertions(+), 335 deletions(-) create mode 100644 Source/AIImages/Models/GenerationRequest.cs create mode 100644 Source/AIImages/Models/PawnAppearanceData.cs create mode 100644 Source/AIImages/Models/StableDiffusionSettings.cs create mode 100644 Source/AIImages/Services/AdvancedPromptGenerator.cs create mode 100644 Source/AIImages/Services/ColorDescriptionService.cs create mode 100644 Source/AIImages/Services/IPawnDescriptionService.cs create mode 100644 Source/AIImages/Services/IPromptGeneratorService.cs create mode 100644 Source/AIImages/Services/IStableDiffusionApiService.cs create mode 100644 Source/AIImages/Services/PawnDescriptionService.cs create mode 100644 Source/AIImages/Services/StableDiffusionApiService.cs create mode 100644 Source/AIImages/Settings/AIImagesModSettings.cs create mode 100644 Source/AIImages/UI/AIImagesSettingsUI.cs diff --git a/Assemblies/AIImages.dll b/Assemblies/AIImages.dll index 3ae3f5581ae7c21b073d86c49ea4bd5e9cc01656..35453bd45674f46c52f163c67c5ed18d86e3feca 100644 GIT binary patch literal 62976 zcmce9d0_0bxqzqcmVThq3py{5F78 zHO)@k=>#RV!A9w*4^+MjThLw2PPE&GAlpi?S$yzkS3;#NMDvDo;3M`c{@6B?eDqu| zQPHhLiYrdpwSEs3qp|ou zI5JM9V^V>YEb?FoB+ z20QHtyDfvA_Jh43gPnGRy)c8F_JX~L*%*6%`i3D>DOBVD>8lQYF%M&o1jA1zjt{mf9uFM?dQIb<&)^EvYIR>sA z<65p<4V>HpPyLn<%Q0}}7}s(Yri`CLZ-KXdO92Zqa0MCHf@4h?P=&q%U;UOs7G&TG zGOh(HO&MW@R)JN&rHBO?xPpvp!75XRT%o_fU%#c81sS-4jB7ze2p)rlfr3E&mJ$|Z z;0iLX1*@}68I^@u1zGi5N?DSDE6KQ$3@?GcNp)uU6r?n1u0{|rvJp2YRqRA?^dd41 zNm{WpSCf)f>_opwuA~(^(LF*+iaB7?Qkel551#O;TmzY542s4u{Ap2q1z;GC0E+2m zh0y|w!sry4Q{MPWW|lG2S&g+OyiANdi##F|9>+P*yj<9*&mdUMYjcR4Cr}{RNY*6b zBmD6)0RGRw=Zb?t%ZC0SIUi@Ju09-p5L)ZaWG@QTdy;+JB)Ds8Hhnihi1rkUe;*P- z%acs7&YdvV8(sxXg;vOFYK860-+>yv;nTn#Vgb#>h}BFuz-1z?X(k4bm*VXxlwDwg zO(b9pg;#?Id{66Iz#H4br-L>>hhgpwl!ng$LH>Y!rlih!CS0{f6&U)VbCXMk*MeuC z1<>G!mX=ogY*pZ(E8$dnQ1l$PXps_Dw)<5wScy7I?QdjPM9($N{Wi?b!>80C~MaJXdT5POa|$vEE8VO&FciPyO^Ebcy=h; z#vl#e#o2{9@yB3kVQwhbN^XT_3gw1!lKsF!!GfHOdP2E}tsg)%9!1%TB6b$`5k;jX z@eQneBLD_lwvG88oP6tnl2CR(hL2I07s_i2gz{|6n;^Xz-h?C+jK`4J=Q9f~?HjF` zh#Y7p3?0$TF`+S;&CI8tvdqY4hS1EAY9?nF7Q`RtW)_ADt>kuTW?`rxxs98dUr>-y zPpI&)^}~PL%uu?Sp`$l5e^@iwVX2XKx7%drXTbDW^4A1{j$at5lR^-tf6J3EE{YjXo>=J2`}? znK;C#&;_1w59}^13Kca~=&6%c-BQCF>o7uEkhgwb43V?`opz2zT05gV85DSx_To@+ z(^#!N3Cgb4Yu7R4N?89Xz1`dx1zLoLt$(*>H3iWjd^R1QtO?g*IE`T z`;=O-(C`jAv(|^X{t>lKnt!xf9~-^a@=$q3ty~Ltn%mY?tx^~Fypyg~2X-AFpNIZW z8A3{<8`#G|lMd{R4%dW*Hgipx=aNT_wAZ>4qmSOOHoBtb4*wVY2 z7#-e-27hKF^y;QX^isIV6_JK1D|g@sjchSiOSDT~K71$qP9tP7ZOhgLwrMHnEXYL>Mc8I(M=TKkL|+q2mIjV^HZB z5FN~e4h&}KU_$9=4kJ4_#2Xtq$6`g3KeHrR2A=73!68Wm2CpfS$qGi{GE9=Oj24BV z42vLkuvj3yW@!@D29fOGq$HVC$xg-moT7#}31jG4G>KzTqzzzkTO17yPP!e7A!f8? z4zfZ%aal>M$uIyLJIiin8b0(+nwP3ntHO4ji@^1A9z@j~hC|SIArw>iB0&3MfSs2? ztbTH7!_G^A9JZc=&iLn%Q9r~vVK2NCw3_m?4Tp`A!v@Zp+5V;=nN}Dh9sOnwvIE2w zhs~ToQxTbp6^7E_T5%5#1KF2zKV@-0{Rm~)QKr>?cuACFE!P)t_iv(z3_a_$!BG|)STdJbI;0gsV_b8 zm)U3K7AP-Tez3m;71mm{-wt{C{BB`E@n@~E0wZ&MVN7U@pu(v>+6CWavu_HNL&Pq( z7TL#}d>3MgVvo6lhq0x1@*9Y{#BI^iW&rSkgyt8IakQ%pV=(6)@tF%`@TvqRZUS%{5cbbEuEwV2T9X#jS*kK76H=Tsf3Fn|dV zEKyMG{g51=l(3}PJR$AJ^-RyLTx>;#OrpMla41P*u&iN^0D0`4Twpdpq1Ot@2pDc0Dm#vId4*mD%XkUo*jdV}^C%i&!^N!fcrJvi+f*SA zf?Q}6yRpN!FX2Kba3Nm5-I_Q$a-q?@=C!dpr1j^A^7-~eaYr%CGF5-@Gbz^wxtZjF z9;|M%?_$4aHBu-`&PW)e{J0i#M|#w9x>8+WIq#)#k92ZVwn|I+1W}3;L_S$gUtX$* z8^@~rvcm?>5V(DHUP)(qY{ckPLvpj4#*n3Wsu?hMF>D)Vrx$keSU z7V{PTXnMT-l+V(n{Wak_OEV_`hZ-v#cqF+;8NouAg{JTG~e9#;C6&dvL+ z4C-|o&;56oF`B|jCKYm zAD`tspCYDZIX_DgOHMyotNBtg?nr+EiH5K{HjkJ z!=W(fG%x=DZAr>5?8>xV@L`VgGKv;@ zXO4l4^8xVqcTh*rGd7IXyfF+NjoI->z{(-N{V1E{n~9;sce3AO3Qzv_aOy|2{Svz$ z!7Ld5KC^hinn*5x4l;3~@rU%{5b`Vk%NoT}U*OViH0C92JV9FVrPEX=j3~zvm%H7n zBm#)x62%^a6=LZRfSh>BC!c%*&-o6QU`2O5rxwRR_;E<+PL{H%j#d6#L_hEv;!oZ~ zoQ?M5=!j>LW+9$YdYUXxDjFl8Itg$kX5=&;=Rq7+an|`)nmx`_j>V17tt`K>C?)U1 z?v{fK9_V6wfZY|x-DlA^{1^WrSoO{e5O-b!u=lY@y)&&Cj0FHL<6dSwnZal*0pnx< zSK`aexF>@VDg~n!z?Im=jB7F&|73>0EG-cS?%-B0Rpc#Z{4RrWYB?BZ1Gq{bV#bph zjPVsZdp z-hHXF$*JhiG9E)_0TmdS+E24mf8EWy%jX{U@*b!un*!mvJZr4RS}S}qu-t$@+`=4; zoGi5}@s0ulm_V%8r2m25a?v~Z46@9@X93rkgU@k-f#1?{HqKQy?_PWIVIQIs_7B<4 zD=;u;Cg!Xh4qH?X25q+UC&}3>IU6=Ks2mx$*^X7ir9Lltd?hFP#PEPq$$5?4R^5gT z@61&h9LWOC49RG>>ZVVBNN4ba<$!aFWSq!HzagE$@gv}zBN?@9Gd#mK%e_dzaU>() zTq`Lr$UIjN&Z%M)zw#Xb{K{73xqkJ}9AfL0YxQEnJ+Pa5fNRfk?uT;3f?_}4?VNnr zl)C-SW5QNNd;zxz2dymU$DHSDES5VVF2WmZzw4IX2f!+l9TS>>1j)$jlQUY1aMbpZCDWFfH3z zD(o6zt067pX}z$sgniDJ=CPmIv}~tOGCqI<%)0v)MN(71*(n*XNrsFW)rZ;6m6CzD zjS@1-yz+`dwsW^+V4H`GN5=V1)_s}nJSZ7gO2(lJia3LB*aFUDoYAGnvcX7;xSc?kQ$n(S3`U%O{?I^MP<@a8F=jdF9v=a1IN1oLN`>`OVCg z2mek^9oL3AA}KqY=j~3IrTd<*5)XT&;~PX~BkScsDZM^hcTJ zqMVt3L-)lm!^Elg*gr;2y}Xs=v;R|YtHJ4pT6-G=sNOU9JdgDOO}xOwiwp)ehgA>$ z1VC$%ZIGSJ$hCjU@}JA#48D{h|1#(9%E-0K*sB_H5(k-ey-OWp>PDt` zPgW2J{|XEl=po)u1>$eBz*Q`O_G7cpN8Vq9C#L^5kP~O6c1jb{J0-m_QTw5(#*2s5 zeu&4*$rvx=MjtQ5&JGNQ^czJvGkI{ymN95K40Re`pFhc9g4Wie#~^WQ)qz@#tv?%6Cz!I=<}(#03RP#0EuNH!MF@q( z&dQXvY-sMx2OzA*mYTdWIeClY$1&bL^+@mH)f#7LSZQr)<3{9s7dd|Wx6qj$YvLby z11Zxhem(UYi_`Po#3qAZX~YBrlV{ZAU#Ww~u<0ZXm&qq7{24M9zgIu{9KuB?M;r`G_$>_ihI-b?Jp1=t=no7= zOeqxN(Bic@q#X7lVlLCH&5E~~ABH?P@2ps%*W;We8o7Y@Oi2ie2YNDN|Hye6Bg!kW zF5|$+i&7J+;1C#|0|ypb9_L!taaTEZWKv=POFl$?Scxf%W+;GXcG0h3diF~~6d7tq*iVI|JPqZDEN6|4qz=WPx-EpEsEF`KD52JW)&&DYag1-) z^$*sC1%!3o%!wzto0H<6>s=$DDYQ2so_kSx$Cv7IYPfE+$gCJcI#2 z4bVk$p-?Y(?e=4+3TN2o(9JIXN(TOB2L4V4{y_$QI0G+gNb70Hz)#G;muBD_GVmQ4 z`1Kk1{TcWx8Tg+w@REt?dTUa+Jb1ucB-7vsj8ach5HOB)8G?W@-DL;@#tfGs2pGq? z3_-v+-em{^#tAM%5HM!C3_-w{<5LBN>pG6VqwGgLK75HRMt z3_*Ayno6T{y~@gzr-_{OFh;?t>=}{&+=0*QSQ2*n!Ta zFwX;d-`h5M2oxho@>eGCY8X~#tmGkn*&&8T*a*26GRl;=V#Z;yrC$-BbiQs7VhUyy zmN0yME11M&!Tr)$oF_2_=@`^!4sv$Ts{U7V08c?no6W`?I^Z|M-+{6>@vOHfxrNIY zCE+Pf;+#|;7Oj);&S5^z(YVz%OZl}xf^HvOWDqZbT6ea80UX8-BriTNn;Nqk^BT9$ zTmre0;9F-P!9!9pGk!lLRE>jD@1TuhNp3TQ%)uTYb~&$CVovqHq*&u@WHG~rw}N6K z3$v-o%>a0O%GyhVZ~c}n(WSU;(i&A1-VRRw57ltak_yBK<#IgT3N|&G85}=z@bx1$ zRLKOm+i-mml-KvLm)e5qjBiQeR(2ACe)^n~#8)iATs@qL?;V191(>DbT;Rz(W~d4d zTMxiB@?C-?F5`o$VP(2u2a2=@;m`vCGyD)c`c2&VwN}D7G@Abg;As8?6p8C;?R9ZL zQ;zmw5wEx$dDwN6D&lwzFnEDW@?8YtAID?eztxAQC0fFzeK)l z6U3-RIQJ&G_ki~?&i!hkmhhu6H9Z+pv*J8+a4SUQyEf)vKNo2LNJ~Osds<){3rrj) zAkSPLMthsY)&XQNdOmZINEzbutn}dU_l)YvnzFKw&t>@=`1xP2) zH%sb+N!Dcglb_=x$CQ^_<%$;N8|keD&~6TLX12M5lf%Vu)>yOHEeT~s+<{3hZw_+8 zFR$|C64oFSHoky~&83N5^%imv8t|56il07p$bNZe31mydrGThe?5~0fd05hvpHU9_ z+$*1QEva%#>@v29OQ8)_Cw8gJY4}AS0v^hm{RC7L%!rrF68VK3mLa7AS@@5 zVZ{#6feI8rCzyqK1N$gtBTVOJ2<7%|T9*7coA_5Pe@w z&Oe5CMMhssPt7~#=skrl@|q!U68j2t(PfY{2O0mgE|TCb#z|{%ILqjwG+%cSW%ki; z(A>Y_GqR6r(Fgpkz;quq$Df5mhc1OJ=?Q&_$Cemd>aiuVj`7&T<7rxy*BLJ@#JbWf zeS0|+>ApP)9{&PJd0`Q2rthshh$&vFddFPyotuU^PF^{esNkEq7`EaaIV=bW2cioo z`b1EyFzPMC)GMf7Gl{_38*bi_IKT1})Cegz!7mPa3D zTpwfxRBD`~t92HlkOV~sFTkmvj7O3za0b*i86@qsE{6O--E5#>0$FXC$4)$QudXgGR zA1AMOa|D6t4K70?Y0(?q9F6Fl)JB10frk*2IqqOj@@2@6jPsqm9o&r!=L7D!d*Hp~ zEg;Q7?907_CTIjtMCv{U)RZJkrr#toe7q1Lh!SVx)6& zxOkd7(xoJnO@gu{<~fgaPLj7WUZhxqOxW0%1hX+FBxvIlf}ma2$ODz}^k@h-L5@nY z(Zv$2r3Sh2LA1u}KUh$Uc|lF{{o!V2Pg36z;uZDbr)ZK|NB#7hKTrkZ!`IGiYV}Fq z!>4;tf6yvF-rYPcz&1g4^xXA1_=NCjESh;LdST|T7~G3-GV>J-2y)JlCfv?VI0JL7 zr21HMFh$u@U|1Mm><-$~07kCC@Ks7wqd3eVEDQInG%-ahM-JjM)fjgKKgG<=Q zqwHuyY7dtQBp>Dk!E-NC8B$|qNIeVRnS-2(A;n3?kdl(_kh+h{n}eJrzs`73Qdomb z$dKY^;{06dCG-Y#3V$XWHH*QC0P>y9lYmTKV3uN^DivUF_Z`;f+UCP>#~Oj*-YB+l zCc@VaP7fE)aL2nc&KI5yb;Le!`dcHBj(vmT!k*d6LNWogV%(%qMc#Yr0-+$z+oxLlAFUP zbH(3erOtgU*uoCOa;m|+AAqmMN#A3>VhnL+VZhwM>EYt}+ zJwUxaGUKT0BiDdn_B1CLG>;&aU{)=Ircx#tXCjz!k`c_Lq#Mlkad~r)ljLKJ7bS%? z$V6r^Yf^6=X210<8IO|v=`l^}K}>sw)w(8fOsf$S2Sg8Na!lhiBc>@EbxhmO3d})H zl0RU)D5+UYg=;wmJkET1jFJ2yql3)QPkm=yM*IgA%(PFz5J_V6dmCF!a|a_~ETh5X zlfW?3RB+q?sz)Nc$7Gf+z7)kLKXjlJyTSs+I$Ux*!n1xGRI`ei zMNXSc39d!19MW%AyYrQ^vJWgS^qV1n@+lBF>`NKOGd3kMZKTP(G4b;Z5x2=$z>D;q2RW#cd*w$Ea-KmG)zbc-AkD!a1L}FejHAv2t^xDlRZcL-osojLXj9``Q;Yn+Cuz({x{v;GShnx)DzVZSnL>@!QtuY&yKC)}~mDh`*- z%pCi|DrOcr@5?x5wMQBIoRxiGxf=VZ6JvkU6kb9Y&lvl%A7WE2da0cn0} z2H>|&^c?sGoIej^@qMHt=Ky~xe-=J7M$CZ?;Pv2>o&(U)ehLw) zb^5ef%<)k-pm!RcEAJ%gL%MV#@b-ig>)jYFqQFK`S4;@rzDh(uTAR z^iK;x&+E3=voQWpy>Vvs?b%s*ApT)Y=a1mLH^d%jbj&aO?05pGCjUXPG|XlovB+Zovap1@xcwjGt#b z?F-V)$hGLIJl6Tk%4@1EdKr9+F3h{8I*-0+vd&Xbi$#Y-{?$^(+l|*sN@#6mTfRl( zq0^#s#KPYeS3$$deC9l5Fg&J=H6N&Wpejg@*R!3kdRgb5BG%IlJwe(#j`1Or>-|f` zhWa2?kL7m#wl+8CHd+Bs=FuZk*LI_-I!HH`KA+Qg&jkQyZko(l4(zA#-X(G6vrU>DwaX7kk3vn3FGf;Y7xL zXd`q=OW%dxg7in}sSArg_F6Qnf#H?q3=fJwzgWjI-KAW1E!q{NapH%)27BP2;x)hq}=}8gH=DmH+<@Z+C8pk+#4u0S*WwBZiAL4><3uOM>$MQ zp_6DYG=CEm-wL$~70Ojqn^0HlyhWnrSGwG(LS3V&l|mKQpHw{y`{*--s;Oh@M2acO z$JEKRRjA8_T1=M<_3d$-w}N&Pp6Ao0V@|5BHO`{@g?c!jsZLNHSk19-7Bngf2T4%p z(}R+?NT>vTUnuOwkk?O-2~~rDI}6{Weq5*~^vWz6pnXC;>S1b#o+M9gKJCXTVHRCV z&q&@sCGT?jkx*G7&byAD7it}1_AI)dej?QGaThU*ZlqU)ib%OH(`!O~D7EjQp9}Sp zG8UYh`zwz(?D9qk{FtB2%=#5#{Nj{zR^FkItN+L|Hh zwsa=&iS=KGZK_nU(F)16V$bvDD&R{ztmhKZknd%DgZB*ZFZ1&FyhAvK)b$_4wOCU2 zE`eoIc8BL|@IOq|Zr};TI&easO&ON-e2V?|inbch1t@ichx?+#%XMv++Hdk+4E#aw zRjiZTHmX|EE&ccUCws1ghPy}6{}u2b_kL{@{VQt_w&{|S{jVq4C~>NzCTGjvtWkP)JrG*!^hY824@ zs$ALjs#oznx?c!&BfVT7%q^flYwD_+s@x)arCBVa>r3l%OQ;N|9@t6u7dPdW(m9F} zEoHPxD6V}*ZW$dA>Y2cQ7M_?}PJa|?7u|q4S59S9S<5AKTKNp*9iyngx#can6*NPr zXJ|S0XBD(hs9p44?uTZDoXNOXl4wx{)$sO!c2Pm;kTI6-pRTA{P?faq1Vuep`>Ihz zzZU8l+KJk$Xr-L;E}?tJEy%5+zbi`muZr$Gk;}bf+%(}7mRn6T<|t}x z`6;PFB=l*?GC+>06q56?qeAb&JaDs_D$Fr}s5=XPKSbK(ptm zyz|CgkUNp?5K4`uFnvp?Ys9-@8fs(78_82XlpChkHPux%lzR-lzCh)@3u+QwzmTbG zXldD{cuRf)Zhfsd5SD zy<@N~C(+VVHHF$|)7O_Nifx%gdzLe`i=HldDt9iuw1O$+vlecLtA&!At`)T?7#jZ^6@O&uDWPp8s5n!4DUVlAaV zXzGjD;Vh-UX=-UntFeqe(bO3w`Lv7z?b3Fc9m^@Cs6b&&GcBibO;y*tmb;wlG_}U} zX6_1_q^S+QL!hQ>sx+_FSV<>oDx8;3E2&LW#}>C5t7xgFT8i^=+llKH@%+Cg{3iD_ zI#*L!_3wk)B-D-cm;68Fw$s&`npXGs+|~4~k`$}Yq@U_?w~c8v&ZO5h_4P6NbS53r zRQ^~qZwj|fHFZhln7p&8UQ=hmmUC#brZ&NrbLeSvD zrWVv1O>HfioYz6?G<9Lg37}$%5>Kq7FX+5V^z=HqPE!r&>2-9grv8Y2h|ryy`WXEX zp?fve*)T6}J$*-0cEd7Ik87$6R(H}fn(Bqso%9n;T?#LC(QBHz0bc5&gPMAzVTu){ zcQy5FgF#XHP*Xoc-Uj+eQ|}^g1DUwb<=Ggid)3&88#tk)eH*Ds=XGESo%d zDW0j_bh)N@rgqb{n&O$-LpN)RXKD}Kp(&oJz4T2@@l5Tdhc(4B)uzWZ#WU5Wr!}>8 z%-Xy@dO=ecjX4k0&op&MeplWWI-sfh^JAdiRg`$pp$%uG{pnDjP#4iHxHoj@sZ<_z zhjdV=Lq<8)={U_klgk}4&aH0-^)Bv5IYR&4;5^@2$;%3kxAWqZt0?2bTFxudd8dqT z0adBrCr+eXVZRW$Akpw3j3^w@S!IgsShD}0o|G*IY@U6qwW_secx>HufqPTito~Y4`|R2 z3O@~g9cKW8%AkSI&s+5)wS?hr*pHKK9J|Fs5}iU3-)MWXc< ziPDvFhgvCjsFiYuS}Av^m2!t#DR-!qa)(+ecc_(ehlf-e<(wcx7-KS%I$1n&~OOYm;Ny9J*>c5bcFByfhn z*#g@HE)}?1;5h=j1a=GDLVWYE$_&-6Gp>sZ74Pe%*V?u;7mza6zy z*WP13N>A6boxcIxVmkGYLh=FYKapF3TdytVrS-3xmm6n?4gkI};n#rg6n|(QW9%t6 zJlCLJKj0W&9$*{pp06?HhZx>l&v1Jl!~Laso?{>x0?zjiKQcd8HO}*qd1v(m&nsxx zLeubs>W=qhdHykGg(u|sukk&eEoMte!t(~bU?n|!jVB9Rjatt~wHJAg@vIMBf!wT; z>phRsFLD16^8BFoE|LGHXPW1RhM#!eGd-nmcxHM2mH$i6A=LXW%lrQb&C4tQE}S=v zC7z$xf8tr?Ic@BqG0XFKrQdtVxGI$AUF$hiJl6Yw!FL%CpsYpv1-?lS6h^)KjfR|F z@2>@4Wgfsjag|w;vlIMd@_Pl|;AL;$0vN2lUEo82jg>5UdBc9-XXm`+H9WqC-+JH2 z9Q67AEOYZEV^&d_FXnk*OqK6r(f?=S-5%GTPg^|m@>cn_c&5~^_j$#h9L!vwuLyJb zBc?KJJq+su*nPLTG#HG_=5Tw z{`WkQ;*U}?)}K~PR&n^k37>GPWI2hELaW5vtR~hL6?8FaApf3 zcM^HU1Fhcs$M*(Sd8g&Y1772?k{y9o@2dLVz%Ju&xvj?KMyTw8jET4jE>D3WzOG;hs{d8gN2m9@qDIq!8@v(P)cvkY^J zb$iwf(V1mlIR1xO175>=I_p-c@lL7nPF>ev?z_-1zxEGVmwUfd^v|qoym!?b!QJ?M zCVwyn`NCki$zCcqPeyxp8J#&iH-oix!5L!P46&_E>iU+9(uZY~J}e`xO=Q|c<}t~A zN^+l)Ts*Be#%2fbmc#!91Jq~y9WY`13vjz(g1^J?0}dHEfEODfz{~KjEd=Olk-uK# zZxZ=iMSc(DYjN5gTQHF>q^Ap;@l3mQOf$X(aSGsAiUE!%2e6U$0ZykE0Z*bg0O!*? zfTs$-9k5mO%*U?;{yjLKI%z^dL^z$o>BLFo@4;Qdxkxw{3FjjGdgO71dxgJW;FEL_ zBo7JxHhqE4DKreWuvTEJz;#9(GD8A)8C>Ho;qMX7Ug7K&&OYJn7tVg+91{GH;58;`61c~69o{9_A zZQ+pj7j!XR3t*1ND>?<f-zE57!S@QjU-12c9}@hK z;6_072SmT%wSu<_-YR%R@QC2M1m7k2UcvVYzF+YDf*%t6kl;p^=+6@Ug4YV(DtN2l z5y2yZ?-G2M;Clt%EBJoF_X~bV@I!(dLD3&%{WZZ~&>!<_g4`pu!mky63;44_Ey8aV zeyi};f&cl?I^joz9})f#_&*5^34fRHcL{&5;Clt%C-{C1x%PvCAJULHlr6StC>9D{ zt08k*1Yaj`NZ=lU`ve{oNI9&hMqrD;bpnS3?h&|8;6Z^kxh&rzaGk&*fqMk*6L?S{ z<%wp2EduumJSdRHNUp#Zf$Ibg3EU%apTL6xDPQCTwg_A&a7f@Df%^m=6j&1yI|Z&2 zI3#e7zUrh14ssMc|OYJ&He8N(nqDu%(hYchW@T65|TvOU4`K z-^@}^yQjl*i|0X4k+;>m(0h^hi{7t$zvq3{`x?G&|5e|8zNdY!`hM;Ez3&s>*R5Yz z|FVwpFZB2Nclmew@ABX0f5`ui|F`~O02Bv0B3hoM;ICcB5f@R??tpc2d z3vp^L!il*UC*%^E3hE@hYc&Te+FYC&TX4tp?1XD@qx8^(8v$Rez6G#1Zx7&Y#diU| zUHc8dUyS)S;LX;JvlD}{-p3Vz)1O9fM1{R zE5MZkpQ?Tb_?o=m0^U%}`0KR{KN-t1*I11Al`uZO<`019`~C#jl*gR5VuscAe*=7Y zjNoHG2L6LwF7;YIbFQf5Uk~e++`9$0YXachSW*bsBAQ9yt_HkLiuWH`ez%nUV*~4X zUtqM3E!^{evi#C9{L9G?=T8RwTHUdLZ`aKPEE_i)@O5d+^J3v&0J*F%9?z?34^VwU`b(M#lj^AJD{}<^h08Td9p6ZgTbzy|K4%H=3yI$Vya>>wi}760z>U%};FsV%K7%d=G;!y%3iuU(CVhe0fnNz| z(iiD;;8y{f^dZh+4WJn23c_?O``lXl}7l!1RK zBLe&uc*~$$0r}sghywpAph>sETL$d`G;!|~1AaT8Nq4|!2Hgp0(p~Tw-cbQG>1*&A z-Y5e!=^psZpl<-0^iACT8~AnlIPiP%MxjCX0h;t(c+teacs~I6JUnO63xFox2TTJ0 zDWFL&!E*+^3~1twrd_~a1+1VA#xa1Kj7Gq2V=`c`F$J*Cm6)wNFU(@kY$97a$~Nsz*uaYYAiQa8LN#mjkAn%jmhTyX0zuUZ@>3r z?-*Z=FYdd*ccHJ_>bGvQmiiO^OZ^}Frv(-Tb_ezZo({Ya$j>Uz+L-lX)~i`|aC`8X z;7@|yA-=oJ{5y#IbZ-XZ_274KOUqIg-7=v;-cP{K;o)w4#r}tHGE4=i% z3Xh~!6FE`_X{+%^df)sbeQf3$r+EHI zYdt3!6TK%HjrdH#XF5JJ@tNa2*Z3*3YPVeiAB%*WIyWBRrJ(@`HS2tAte7iRh?M=j40)>`yb8jp=8p%%v=^tY_hxN^~buqq7E{&PZq*~LND0TAL))I2Ij-u zh$Ad6*19bcO9;Dx z5oZ9z8PQHyee4Khb;)VdU7hK=!P*$RpvPvDfp@C$qbVodgUD0E8pxt(FM1>qUD6ZT z7!|kA#gIYIr4U^dP3Wq+qs!F5 zR~g(sr;{0L8Q>1kl9ZcDouEL8%?QajS6R4&A9LFHUX6s4Sgi0$t zL3#TG+89lAoW6SDOqw0z?r4i7BHVkRxgaOA5TRn-QEoZsfE4FwT>C{+u&p1Ho>oV< zC;FY}v2BEJei1qxuyu)ac-)ORV6_j#6VaaLB`fH(Me|nMi`&njE_;5r9gnV|tj zXh#RFjBM*|?dyw1oJen{*hdStC!9zpd_RmE8BTJ;q(W{I#fMR;ra~3MkJ1Wtn9kiI zep++`hSnyoTj(yd3KA$n0v3t8dWBY@jn8M3UC-?s)@=Xk4*)OmToB4#^7Zif)Kt@V7AmsDhUki3 zj+))kgqB?rPt$EqWZOJ@d%N8ogVkL5+!#m5XgA2t4I4Y+2%OOjo*sG~y*x70qT6C! zSWDA%9C4vTdOV)VjckpM!c9c`GNm>}V;eUmGK%%7MJlb`jl3O4q>GQ>N*q$%e?;EW$UxNL zEz^;9VQ?l!&zWa;4UC@Cj^WZJo3tZpI#b5m5$t7H;T+7lBkPxmb0k~h`jKoMmX6Fo zCu2i$Bwxq>BQs<^9?5RUz&uJ8_ux^o^n^O1*=}?nr5>4EM;7Ss?8J~eQhv3A4b>?8 z#cEC-p-Ar^kKivKzBf7|9}zWk|9M2dyM;O;W0l$m9%*@7KQ^3dn{h;;Ws&Wll6_jN z=S(atU9{SX^(^Q`u(f-7B1n0yL&BTT)Iy2@135TpwY|&+&Kpg{n2z=YN<^G4tSmSp zbVgNpPapz8T9fHmBQZaNT!P}*JGaF)Z0L_8)MRFj9)(2zQL=bJIZBocEMC2a$23}^ zI&x$TQ?-m5-lPdfDK&fU^bY*RdgJg48jpo^aU{NJKK5mK>Jm*jPA zNxWSR&k=ltPvtkH>g3d+w>QLFGztAsulBi`)D2SGUa4D$TB-Ony`gt`Tzh6bmg4gESBb!xQ+z2hQDFREU#BHe(gjz?b%ZY5<$UU8TMxrHruC(LAKdrwv5$lO^ zKwOLw1xYxMDc*Z(PCEu1G8STkDEeh7kopyNIyN2y3wk%kdZW!tmSHofc8HY8pO0O_ zC~V$yoF*|2b&QfPPR8bYD~aeSF*NdCsWnqX*T{D4IBfkEiO8igh+_cW+g5Xo)&$wZ|@qk~^6(zuKZ3R%{r- zAa{-+lU8G*Mm9FBFUG`M{;yVjE+L z2yQcRy~p&5SQ^*c6I!C?C{KMBS!!>TGkyo0x`?lm;@B`GIybq;ZAG+ijxvv1sT%F> z$5lK^Q^PRcu&2b^aE4!K^QE4!EslM?h&+g41zC6rwEXdqRk2zCfZ^~JUmy8!wk-KttXc8jE(8+Z*?2>PGT z|BC1qDK;3^XnBRTs|zD&;HwF&Fp|%bdZdYN7iT_ppnT)Pmmw6kJZqU~? z@G75yTb@OOycLUGZx@`0Sv4X{#jRmlwT_V+f3}0A zN8>N#XCt!d&s4uWx(%07aeG6e`4n7oyO)OQu8Pz{4nB2Ar|Ww$p?jm-B<lVV7BNyNULP<0d6&FMuo8(^d^gdz8q?;e<-lKm^V-6zgZD;{~@BSucLSt-03|t-~9T?kuUfv8kl15dQii&Qw@Y>O(11@JH zOwji*(h3)E?{YCW9J_SJBP2FTKTX22?L?!vXh4W?Xtll6-iDO5FO9?z`fRQ- zOzpUVW;_gYrvyrlyd`gT#jsPJ&9Ob&EVFMeuF(_v5gzjHlN-eU%z_ZE*Kc?x`S^ulAf6-n`PL6-Ym2 zOiM+lXQa^TR!uyvQN4$R9yR9Bn7*i!h(R-N)v$EpXab{fi?Tl2A-mEz51=%ruc@$) zOB>USMMfU`O06k^M^B1yXAGK=;;qL1Z`kcnDhCsk-HY`x#ex+nJnq(qZs79+Z^7c2 z_%k{>5}RUiYyq&SNPVxvgqcyUpiBqIMff?dHA(Lt3 zK8My4n4fYKv>g?vu<)u-ghxuzUOv&J>dY9W+DU5v#FIU)I2?e~%n^)-k_08mZQU51 z7sJfh$g9g*Jid}{Nj(_U^In+P&GSw!#gZ|p?wyo+jbm4eEVSD_->4&;vIb4Vb*ac} z6i=2lhMwRXLLM-hlTgxaw%V!c{cD_#_IzEMv^bVn$q(*Tg}BK~Gu-PFHP_%oeKFwD z3%28OlFOw=iCnaaT4h3lwHYz9H!YLV;i+(hK+%lT9faWc4t4V^F4pM$NOxxj+RwKP z+LxNm;+WJ{}Zkh3hgH!VG;QsVVV~ z;@0pm%G`v3q-%&6iwVt>JnWD4j9^28;sUDb0*XmBw z8xxn}jhl;isv{mhfR)Du3wYCy^HFSpew~G|MUnSfWPmJ$ICh6UIAa2lx^3L2lDeP7 z>=2%s9#TM~EIIs%h2)N4t;Y#{1WS*Tk!;rInw(kNu!kPR`bJ2(`bMx>--tb+w4_h= zi86>RvN-SizxpYc%b##c&9h;h~5m69X9Mymk12!;Az0= z33*J$K@O(}m(muwWcnPv(uv`6Br$;A!sCSjS`z11y;e92@Z#72UZta5s|NAnX$@ZX zb@1|P57GpvIrt3X74;;(z+8iuf^k!a*JTrU1$Ha&j!~tjY3VrRqL7HE^c<^mI`Ja% zMwE`@719V^JC3F#rfZ39sG$qxH>GlB=$uWEj-m9X4Ev5t)ff@WS)&8}z0ls1q5b%j zTol}{l4CNxI1(fkab|Yjj~*OALs<^;yz%%M8N42%Rk+2n#Hzd zdZ8Jwa1Xb?8NTX+UCi&4c5$t==>J+0UvtNQ-J+?F=%mlCX94Q%f-N?z=|y?+;Mdz) zKTG-5;+F(oe`niywq&&AGx-2t(T7*N^azQ%|o$}rYU!MM1+VJo7 zjPUGnpG9jc?7~ul&wz|a`b?$TG16?5S&dSCXd6xWEOl`$D(+DBi5R6SetotY=SyU2 zN584KL?KthiTLR?j%=UJhBGmCRQ#f8pT*|Iu-J`iH0C(ebS%DDp;GeeG{rxGj^`h1 z_v>_wO1I!)cLF|L+;7Jc*3 zia6|83A^I(pt~|<)j)5 za%S(`$GPWq&pCJQ?433)tqa0ISp17<|HZ9=j-1OFIh4`Uz+^en=?SDy0rvVVBDI8` zejM%T&sU)V2p85ldbRB|V9;mb*B0B_`Nxq$d%FtXHVAwvL+Yf-(Bjs*gnJonC-J?2 z7I?kF!FKrcDzqK+Wb>#^ud3cB*4;#}8Gb?`us!>@MccjYy=PfdZgRMq@P=95F}q!B zE_ifcy*;+JB*#xt0B4C_09bzZceY4{{8C7)lwd zT_aSD)I_&7rOQ$lwD*X_Y4pI1j7)bN|C)+Mya~V3eHpk$9-=21)0z_d5sJ1f0M6%H zw`Q$dTCQ(ig2Wj^GLByaeA)oUcI=6H*aQfpsfHh`T|%#nM(L|rvVf~gsLPSB*Njj| zBjanzb|osgEHFuT8jh)O{Io;>70S59yCWxMF}9>NU?+Y;n2d9TPXqI!WgYD0z1q($ zZQ(ZCb1QVU;VIkqWk!aih4zG8s^R*S@PiRD<$nP=(DwqlWlFZ()c@%JRtSPl{o5@;IKAZtK|yZV6F}a)KJfSp^>K7cE&4nDoiCHR)1w zk`WfU$SD1+w4go_!jfEZuEDnSq=dlU7SVz>Kh9Z3uhdTJTr%<%Qjd}n)n%5`12X?y zq<5>StJ4C3rK;z6@9GvRcU!JIfE;%u3JasBj&>`;Hq!f*H1Y@wcue4)gs zUCo~QXC#kvY#XN{LLE4SSoJ7s#r7sqde;m%!I@^9cLg!B#J1H|o5mUj+u*7_(yK>- zkNWEs*tn~Z9yX_he9Y}}nzmsP`J8>3?nL@_YQ-Y{Xk&Mv)@gh%8mEV|?TeA_nO@pk z+9=Ll5p8%Z#dw0vq^*&3Ps>Qlahx&6pk|oT)6zF~ar90JT+?$3(AZKUi&8T+c0qDT zkJ{}8w4_d1n@IgR3ofWuP@8EV;dzg96!HEAsicHxCzEYeBdOce{&ailp+-f-NOhAC zcwYb%KR}N_TX+?HusjR;DtdngHQJ$@0i~DR;KBPVF?MDX>9@FNl;5RRnB$4^X=$4~ z0ga$V)jIII{frOk8+cEG7M-KoK%%Qs>oRaO_av`slD~|48S^ zTv{30AiW==_Q!0}Ioe96@&QWA&Twkr6+IkvgiuW7GQKG{(oYGQuTy(?UrFz@P$O`Y z8hMnE>Vw)r@|O2y6c1uZX(~^3gfb3c455~cu_-BS^NIC8R&zuX(hf9fsM4ls6ilsw zMNdbF;pay{H?8J6D1d5DKSQSMfn9{=n8Or%2 zZaf1jmE;1>Doo-)#U_3fZnlE47K2fWlDMi`EJ0yKfvOJ*RUEL^0h!aJhbq8{AK`K$ zNmHw^2cVjNu51Ws?82C{z|S62av!;OwordOwx({$JtOZH2{N5xlu zl3pq=X`2^ZSVxDFHD;`*Qc)#(TZwMD0e=5Yp4>7F$lR1Ruc`!HtjI{D+&jAn^C zWBFz(b~4@U=`?lA-YQ8;Rr$f-mS(v#m-QAVrD1DwxYNaQ6i`=9FP-;!XfqugH1`Bc zt_T---5GXL{;hH&t?fLC^r`m{`lj3!BeNWjPnw_3{ZabbC*`L4V)>`C)mt{yAcfS5 zmFa#E{i-=P&rpYWgMe;YM@Z+M)kKm@ln#wH@l5tjY0|n`C)lFJB~Jcmpv?(dKT>a{ zkiXC>N0;gS zL!pK?Pc-Lo@{v|iuUk2^fP9r7Lx`;L#$J*-Wyh`QJ&4}vWJvi*&_(&GWxbV6i``~g z=h>6qDk0RgEDS6jrKHwA(o#G>L#veJrpnUVDxD0dj#A6?ri*GRC+ylEOZ3(ZaZqm; z<6uh1kSC$w1ht8?C@z6y^`vfH-8^|gZOH1!^e}WM?-+uZrECnddeB>@v{b~I)+!4= z>7bQS@1vS#uEtXj_D$_l`)Q{poO)#!pVcRk@mx)(Bqao%a5ZSd3T)?g!Rh8pJ)ajGmaq06Ev&~-aCiO&e^ z7|&P{%raM=&Y)ckO%;f>H7X`9mL?5-vg&fE)AUeT*_LLV=G5}WoS?FC;Aww&IRdVA zg^%^Az6^chnYYf=ll3et)~7nRz@MkYs74exp^>8+OP@k6<2J z9;vY{STD|{`KN+QICfNL92y9+z1G#Bi>++<&HVP^*EI8EgvJ5K8MEG=M2O%GeYBxixfU1QZm(yynag1uB|s~V%AQnErSahNJkL*8Q9y9qglJc z@y2kznbS}G=iSAY+)az9cU~qkndJ%Ws>X1ddAN_OCY_do{hMyy9i>cpw}DdPjF=ip9&nPO2AB6ZlDVem zG$5!p!nX77O-Dcbqn*ldvxArIixBTMPP1%@io1_HfeI%)|W? z-sw|krW_S5?APi+eWG7}Jx~E7N;S zpfS**nMC(@>YY^G3jsj;|0dXZe(|45hxr6q;4hjdd)=08@c@Wg_ z-pwoyLR){!nP^1L0s^gP*2-LVpX1ORV$_0nhC9zw4=JwJ@T8Bm%W_SRZAcV|V{ zIi@g4!oBXA`BJTBoQ{0vn5V9yWV~nQk$K`f8n*rR92XJS=08tC;k|QC}S;5Yv_+yY6@u%6OAl zznXzmNIQdcQ#y?M$y0c?!1UM@{kQ4Yt8LgwvWC(boRM2;&BfdDs>z%gYyo(rS&JS` zr|)Ki)*=krP+bkDFEag#Q4JeDk_uW$Nbz>9zYD##m(avs;;pUgyN7zxw>>KcntI+C6&=jpkXGkzv(~dWKsQ8Hi zbKf!axKo}XT8Tnt-rnPDwUf_KnW;`sZA}6Xa$ch*hTXJG9CLcxhrXD8{2+8;0vPiz zYbALGZ8$wrI2TZBDgnv%A|CI7R@_*(y){?8lMo)lHMJ5RbH@?U26{+ciR^^GtU(;< zMENW*=2*9sYdlYz8n5+hdt5lX0Dhd(>cmfP@@3f`^@j9?@vMl_YM~{=u6Al?*8%9gvb;uiyB3;vlG5jZ z5wX@)qd`~}%ZDk+=XCotQdr+Q)JgtXeetG>g{1xPX;yRJPV}YLX&GbdS*(9)&W@Jx z;vDGImyRBtJ4#yMpyc| zhgOzxmpGM@eQy@M7iIN!7h0{LPczdATT3e?r0ZWCW$pCplXf-4yp$(b z6rBOvHkM~X3VUf2c&owEP7B3e^!5>Xz?h!5;gdFtvp#+H=Y%}nw-3Hq|E6GJxW0G- z{(c-#S3v&-;4uj9FdjFnooJkB;*jRQ;?Vh-TsudmhSJLsW6ohkGRGcG@r<(Je2TX3 zBJirEo@mhf6m}L&t@ z@HOU#)$H^R#^X=JNiSvt7@uF)ve~L`D&qB zta??yTFMpCzF2i|xHxh+@;C}Oia0zRK916zp3?~gx^8T7s@R$sT}#TVxh^_0QHc|M zyU}+p`p!q+h3LB&eS6WjAAOg=1;2{du4Phyp1^>>tvYTM-R9hC-mMngYSFEFZq;|I zrIN`7VPi)yu#xNxpDYYH*i|{-<%C=F#!$&}0(_Thxyo^Tmjg<@j1A{;!SX`Q4FbPd%i*isRV&~Nz>#H<@eFjL(m2BJ#zeS` z2F}JizEtaSKjr&((d2x5pof*?<+5){t(YP*f&}rUTEPkW+@M^>meUT|`8+zR)Zg)j9LMQX zu0JoT`4g62p%ku2l~<(y##OiIk`(mq<-!%$6-2M8UPB5iP>TBd_^$Rj?%h=OA2{;$ zKE4QT1wEGwfL;Gq0hje}^>`jh0h`Ha@ZG62zV90syumea(cDVor9jyqAPI^ox{_A} z#R2}n7M#HkGz&I*eYgNaamZIf`YwQg(XZj3ZNM&R6E@xiPYKg@y(XyC-|`@H;0D;h ztiC49SSxz@G6dh<=j6$@;BMpQAXywfjxq?X%<=RTu3U2RiGHsV);|pN<5DR6P+dN7 zD|3M_W3sR6fX%EI6+A_~3vwl|fK1d6JaE1Kp%49o`S=iQfHG_pod~+TBB1+~`bW@S zsAfbOWNd% zVdF2|VnB(1fDZ2Ux`C234`<~IeQD*{aF+N7E;V2luMI^n*F-OP_K~4#PEBD>QJWJo zbZXX%pip!W0sfH+0@ejX3h2glVbp+J)hUTOkxUuZ^|ih-|H@uhxyg06SPn$#z#Pr= zy`m;6UT<7YM0BUu6;8YOrxKCy&-g!Eso!+Tfg6-W?`DbR#{X8Dao)ER61O#1j4lOIU(!!A+cCtT*yC6w05!9Lt{~CtYNIPRd^i^lSBShNU~9K!bv(KreNr31YTA9@hPua@8zPD zx>ESEWWC-kS*jS4|GMPAA?Y`KIzT9k_=w4Z=Z2eI;p_DR;JsTG4Wnv>^_QrGo2lzo z-GHVKphUi^$h^<9xO{0UW_7mlcy;T>Zkj?BH4c<>N~sw(^7xC z8<>0=W$C!N0m6WPvtrkf>(XxY-R<;tQBpAk0KdM)hj|O|4P4L?hMiHu+r3^vdvn9* z*$?&UQ?OjxyIm0=6ox=cp@x&Nu&m3W^eGt6p>zY^<*2q)ph1WKpib4_8z_Y6Cu-h+ z-FcZL)kqHjGzwyxB&En8tH%P9>c8ie{cuwIVaBTpi>Ba)6S$Q|Po+@_8-VvVEED>r zFZF#4XTJ;ftBj=Yy0^nqW<^Bs-T@3Agfr&DV1r9zK~SUBQtZO(2U^g1Rm6F~mofIK z6DZU0CD?RFmd7DX=t(W>tjFk33XexhKa+raYvBp4_Fdsc1q_+2E1T5a+) zx4|aQ*8!}wf-_aJPsMHQb701ji_j{j`>^AhCjD8pjjR`ucmhVr34;Q$A%xk0KUR z+@npwA7bz!93DUj9F7ytnpCHoKqU?GZW3IB`PVRR8mPG_03;^I)fu*X$746N7^wVq zSrtNaP(tnCXs_1={QC@Cis5D)lgQ991S|qpg&jk19v&uY1wjHMtWjV+F?PM^D5=`4 zH$d3mWePS#-~kV46Y)SJ#Y2z=W%U;XDxJ>@Y{yISjP)0r1uwGT#XKe)4o@ymIiB)7 z6?iK0%i&ap?t ziby^sNu1%M8VX(%7TK;aa6qP*13`W(CT~1BJh?pOc*^rs;Hk)y$CJ-fDeptbJdN-) z%G3S=-be%CY=v-gTTOt&$5F;Xh~NiG#--9na$f7&s^L5X7@M4BrX>de=gPtt#)eLg?iss(VehFu3kze53!@9eql-f$dv>23-gWBK=*Y;Pks)LngNf=k z3wcoX&B6`@$!#CEHD5Y|*H@sEQ3vZ)(VHU%ZNl!5aBO1R{Zj4`nEKk>b2Z&Lq;{b8oTjdA zucbB^VOc5*m1L_W7D)mb&AqLe*}Oo3>>9^RNgzWv@Y!BV?K`-=W(WT8;GV&;-Ft@zhevk}k8H0U z0yylseBkWTC2UJ_W_xY=(gJoLdU)xo?815AEVj}pHAf@jdk60cj6Z+>&ardmlDlEy7MdZ=gwW2JBhvUSI^Gb?VIMHqZj7pmv`t+X>&8H zm$0?boE<)Ms|#P76SLLK8a%hSU}nY-?z(@EiMsxWV$?Os+wvd&ueNO1dE&Dp-&y`8 zNU)y@%$}*)tJd!v%bFkTvUmRQnKM(^|5o1-v$P}|rAmeW-U1+LHZ+_5Hu3+71C9v# z4m|bQN)_EX#!f~)>y6`hqK0>Ea`q5?Zp)1z^%xd6PvCsSJc`r=-YNMAt|xGQ2x)$9 z%u&UBfJuDvI5>8bpToFt0F{e)4`E4%i+)^!nzx4R+y^jwK~ z`=pKSqr>9|9oh~ds)C1&D18jnaw$a@s|b4-%To3y)x>Wf-jV7ENAzJB{#5u(0>dFXf{y zobxA(d{E%?_;?*l#ZI)?Zrh|e67ypl-lRGP-kg*lnU9(KA;G=SlRfxeK*=J`qd10f zUPQeSl{w M;`dv;{x>=BKj^7;1^@s6 literal 18432 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 a@cR6Qe)h`W%-)bs`dZX1OFG1!!{xS diff --git a/Languages/English/Keyed/AIImages.xml b/Languages/English/Keyed/AIImages.xml index 7091f44..28a0bce 100644 --- a/Languages/English/Keyed/AIImages.xml +++ b/Languages/English/Keyed/AIImages.xml @@ -2,19 +2,21 @@ AI Image - Open AI Image window + Open AI Image window to generate character portraits - AI Image Window + AI Image Generator Character: {0} + Refresh Appearance Appearance information unavailable Gender: {0} Age: {0} years Body type: {0} - Skin color: RGB({0}, {1}, {2}) + Skin tone: {0} Hairstyle: {0} - Hair color: RGB({0}, {1}, {2}) + Hair color: {0} + Beard: {0} Traits: Apparel @@ -24,9 +26,45 @@ Quality: {0} Material: {0} Durability: {0}/{1} ({2}%) - Color: RGB({0}, {1}, {2}) + Color: {0} Stable Diffusion Prompt Copy Prompt Copied! + + Generate Image + Generating... + Generating image, please wait... + Image generated successfully! + Generation failed + Image saved to: {0} + No image generated yet.\nClick "Generate Image" to start. + + API Settings + Configure connection to Stable Diffusion API + API Endpoint + Test Connection + Load Available Models + Successfully connected to API! + Failed to connect to API. Check endpoint and ensure Stable Diffusion WebUI is running. + Loaded {0} models from API + No models found. Check API connection. + Generation Settings + Configure image generation parameters + Art Style + Shot Type + Sampling Steps + CFG Scale + Width + Height + Sampler + Prompts + Base prompts that will be added to all generations + Base Positive Prompt + Base Negative Prompt + Options + Auto-load models on startup + Show technical information + Save generation history + Save Path diff --git a/Languages/Russian/Keyed/AIImages.xml b/Languages/Russian/Keyed/AIImages.xml index dc4375d..fcdd1d7 100644 --- a/Languages/Russian/Keyed/AIImages.xml +++ b/Languages/Russian/Keyed/AIImages.xml @@ -1,22 +1,24 @@ - - AI Изображение - Открыть окно AI изображения - - Окно AI изображения + + AI Портрет + Открыть окно генерации AI портретов персонажа + + Генератор AI Изображений Персонаж: {0} - + Обновить + Внешность Информация о внешности недоступна Пол: {0} Возраст: {0} лет Тип тела: {0} - Цвет кожи: RGB({0}, {1}, {2}) + Тон кожи: {0} Прическа: {0} - Цвет волос: RGB({0}, {1}, {2}) + Цвет волос: {0} + Борода: {0} Черты характера: - + Одежда Информация об одежде недоступна Персонаж ничего не носит @@ -24,9 +26,45 @@ Качество: {0} Материал: {0} Прочность: {0}/{1} ({2}%) - Цвет: RGB({0}, {1}, {2}) - - Промпт для Stable Diffusion + Цвет: {0} + + Промпт Stable Diffusion Копировать промпт Скопировано! + + Сгенерировать изображение + Генерация... + Генерируется изображение, пожалуйста подождите... + Изображение успешно сгенерировано! + Ошибка генерации + Изображение сохранено в: {0} + Изображение еще не сгенерировано.\nНажмите "Сгенерировать изображение" для начала. + + Настройки API + Настройка подключения к API Stable Diffusion + Адрес API + Проверить соединение + Загрузить доступные модели + Успешное подключение к API! + Не удалось подключиться к API. Проверьте адрес и убедитесь, что Stable Diffusion WebUI запущен. + Загружено {0} моделей из API + Модели не найдены. Проверьте подключение к API. + Настройки генерации + Настройка параметров генерации изображений + Художественный стиль + Тип кадра + Количество шагов сэмплирования + CFG Scale + Ширина + Высота + Сэмплер + Промпты + Базовые промпты, которые будут добавлены ко всем генерациям + Базовый позитивный промпт + Базовый негативный промпт + Опции + Автоматически загружать модели при запуске + Показывать техническую информацию + Сохранять историю генераций + Путь для сохранения diff --git a/Source/AIImages/AIImagesMod.cs b/Source/AIImages/AIImagesMod.cs index 4aac809..8700b50 100644 --- a/Source/AIImages/AIImagesMod.cs +++ b/Source/AIImages/AIImagesMod.cs @@ -1,19 +1,59 @@ +using AIImages.Services; +using AIImages.Settings; using HarmonyLib; +using UnityEngine; using Verse; namespace AIImages { /// - /// Main mod class that initializes Harmony patches + /// Main mod class with settings support + /// + public class AIImagesMod : Mod + { + public static AIImagesModSettings Settings { get; private set; } + + // Singleton сервисы + public static IPawnDescriptionService PawnDescriptionService { get; private set; } + public static IPromptGeneratorService PromptGeneratorService { get; private set; } + public static IStableDiffusionApiService ApiService { get; private set; } + + public AIImagesMod(ModContentPack content) + : base(content) + { + Settings = GetSettings(); + + // Инициализируем сервисы + PawnDescriptionService = new PawnDescriptionService(); + PromptGeneratorService = new AdvancedPromptGenerator(); + ApiService = new StableDiffusionApiService(Settings.savePath); + + Log.Message("[AI Images] Mod initialized successfully with settings"); + } + + public override void DoSettingsWindowContents(Rect inRect) + { + AIImagesSettingsUI.DoSettingsWindowContents(inRect, Settings); + base.DoSettingsWindowContents(inRect); + } + + public override string SettingsCategory() + { + return "AI Images"; + } + } + + /// + /// Static constructor for Harmony patches /// [StaticConstructorOnStartup] - public static class AIImagesMod + public static class AIImagesHarmonyPatcher { - static AIImagesMod() + static AIImagesHarmonyPatcher() { var harmony = new Harmony("Mrleo1nid.aiimages"); harmony.PatchAll(); - Log.Message("[AI Images] Mod initialized successfully"); + Log.Message("[AI Images] Harmony patches applied successfully"); } } } diff --git a/Source/AIImages/Models/GenerationRequest.cs b/Source/AIImages/Models/GenerationRequest.cs new file mode 100644 index 0000000..3843729 --- /dev/null +++ b/Source/AIImages/Models/GenerationRequest.cs @@ -0,0 +1,50 @@ +namespace AIImages.Models +{ + /// + /// Запрос на генерацию изображения + /// + public class GenerationRequest + { + public string Prompt { get; set; } + public string NegativePrompt { get; set; } + public int Steps { get; set; } + public float CfgScale { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public string Sampler { get; set; } + public int Seed { get; set; } + public string Model { get; set; } + } + + /// + /// Результат генерации изображения + /// + public class GenerationResult + { + public bool Success { get; set; } + public byte[] ImageData { get; set; } + public string ErrorMessage { get; set; } + public string SavedPath { get; set; } + public GenerationRequest Request { get; set; } + + public static GenerationResult Failure(string error) + { + return new GenerationResult { Success = false, ErrorMessage = error }; + } + + public static GenerationResult SuccessResult( + byte[] imageData, + string savedPath, + GenerationRequest request + ) + { + return new GenerationResult + { + Success = true, + ImageData = imageData, + SavedPath = savedPath, + Request = request, + }; + } + } +} diff --git a/Source/AIImages/Models/PawnAppearanceData.cs b/Source/AIImages/Models/PawnAppearanceData.cs new file mode 100644 index 0000000..41c3e91 --- /dev/null +++ b/Source/AIImages/Models/PawnAppearanceData.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using RimWorld; +using UnityEngine; +using Verse; + +namespace AIImages.Models +{ + /// + /// Модель данных внешности персонажа для генерации промптов + /// + public class PawnAppearanceData + { + public string Name { get; set; } + public Gender Gender { get; set; } + public int Age { get; set; } + public string BodyType { get; set; } + public Color SkinColor { get; set; } + public string HairStyle { get; set; } + public Color HairColor { get; set; } + public List Traits { get; set; } + public List Apparel { get; set; } + + public PawnAppearanceData() + { + Traits = new List(); + Apparel = new List(); + } + } + + /// + /// Данные об одежде персонажа + /// + public class ApparelData + { + public string Label { get; set; } + public string Material { get; set; } + public QualityCategory? Quality { get; set; } + public Color Color { get; set; } + public string LayerType { get; set; } + public int Durability { get; set; } + public int MaxDurability { get; set; } + + public float DurabilityPercent => + MaxDurability > 0 ? (float)Durability / MaxDurability : 1f; + } +} diff --git a/Source/AIImages/Models/StableDiffusionSettings.cs b/Source/AIImages/Models/StableDiffusionSettings.cs new file mode 100644 index 0000000..0b684de --- /dev/null +++ b/Source/AIImages/Models/StableDiffusionSettings.cs @@ -0,0 +1,62 @@ +namespace AIImages.Models +{ + /// + /// Настройки для генерации изображений через Stable Diffusion + /// + public class StableDiffusionSettings + { + public string PositivePrompt { get; set; } + public string NegativePrompt { get; set; } + public int Steps { get; set; } + public float CfgScale { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public string Sampler { get; set; } + public int Seed { get; set; } + public string Model { get; set; } + public ArtStyle ArtStyle { get; set; } + public ShotType ShotType { get; set; } + + public StableDiffusionSettings() + { + // Значения по умолчанию + Steps = 30; + CfgScale = 7.5f; + Width = 512; + Height = 768; + Sampler = "Euler a"; + Seed = -1; // Случайный seed + ArtStyle = ArtStyle.Realistic; + ShotType = ShotType.Portrait; + PositivePrompt = ""; + NegativePrompt = "ugly, deformed, low quality, blurry, bad anatomy, worst quality"; + } + } + + /// + /// Художественный стиль изображения + /// + public enum ArtStyle + { + Realistic, + SemiRealistic, + Anime, + ConceptArt, + DigitalPainting, + OilPainting, + Sketch, + CellShaded, + } + + /// + /// Тип кадра/композиции + /// + public enum ShotType + { + Portrait, // Портрет (голова и плечи) + HalfBody, // Половина тела + FullBody, // Полное тело + CloseUp, // Крупный план + ThreeQuarter, // Три четверти + } +} diff --git a/Source/AIImages/Services/AdvancedPromptGenerator.cs b/Source/AIImages/Services/AdvancedPromptGenerator.cs new file mode 100644 index 0000000..cda4066 --- /dev/null +++ b/Source/AIImages/Services/AdvancedPromptGenerator.cs @@ -0,0 +1,353 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using AIImages.Models; +using RimWorld; +using Verse; + +namespace AIImages.Services +{ + /// + /// Продвинутый генератор промптов для Stable Diffusion + /// + public class AdvancedPromptGenerator : IPromptGeneratorService + { + private static readonly Dictionary TraitToMood = new Dictionary< + string, + string + > + { + { "Kind", "warm smile, gentle expression, friendly eyes" }, + { "Bloodlust", "intense gaze, fierce expression, aggressive posture" }, + { "Psychopath", "cold eyes, emotionless face, calculating expression" }, + { "Pessimist", "sad eyes, worried expression, downcast gaze" }, + { "Optimist", "bright smile, hopeful expression, cheerful demeanor" }, + { "Nervous", "anxious expression, tense posture, worried eyes" }, + { "Careful", "focused expression, attentive gaze, composed posture" }, + { "Brave", "confident expression, determined gaze, strong posture" }, + { "Wimp", "fearful eyes, nervous expression, timid posture" }, + { "Greedy", "cunning expression, calculating eyes, sly smile" }, + { "Jealous", "envious gaze, bitter expression, tense face" }, + { "Ascetic", "serene expression, calm demeanor, peaceful eyes" }, + { "Beautiful", "stunning features, attractive appearance, graceful" }, + { "Ugly", "rough features, weathered appearance" }, + { "Pretty", "attractive features, pleasant appearance, charming" }, + }; + + private static readonly Dictionary ArtStylePrompts = new Dictionary< + ArtStyle, + string + > + { + { ArtStyle.Realistic, "photorealistic, hyperrealistic, realistic photo, photography" }, + { ArtStyle.SemiRealistic, "semi-realistic, detailed illustration, realistic art" }, + { ArtStyle.Anime, "anime style, manga style, anime character" }, + { + ArtStyle.ConceptArt, + "concept art, digital art, artstation, professional concept design" + }, + { ArtStyle.DigitalPainting, "digital painting, painterly, brush strokes, artistic" }, + { ArtStyle.OilPainting, "oil painting, traditional painting, canvas, fine art" }, + { ArtStyle.Sketch, "pencil sketch, hand drawn, sketch art, line art" }, + { ArtStyle.CellShaded, "cell shaded, flat colors, toon shading, stylized" }, + }; + + private static readonly Dictionary ShotTypePrompts = new Dictionary< + ShotType, + string + > + { + { ShotType.Portrait, "portrait, head and shoulders" }, + { ShotType.HalfBody, "half body shot, waist up" }, + { ShotType.FullBody, "full body, full length" }, + { ShotType.CloseUp, "close up, face focus, detailed face" }, + { ShotType.ThreeQuarter, "three-quarter view, 3/4 view" }, + }; + + public string GeneratePositivePrompt( + PawnAppearanceData appearanceData, + StableDiffusionSettings settings + ) + { + if (appearanceData == null) + return "portrait of a person"; + + StringBuilder prompt = new StringBuilder(); + + // 1. Художественный стиль + if (ArtStylePrompts.TryGetValue(settings.ArtStyle, out string stylePrompt)) + { + prompt.Append(stylePrompt); + prompt.Append(", "); + } + + // 2. Тип кадра + if (ShotTypePrompts.TryGetValue(settings.ShotType, out string shotPrompt)) + { + prompt.Append(shotPrompt); + prompt.Append(" of "); + } + + // 3. Базовое описание (возраст и пол) + prompt.Append(GetAgeAndGenderDescription(appearanceData)); + prompt.Append(", "); + + // 4. Тип тела + string bodyType = GetBodyTypeDescription(appearanceData.BodyType); + if (!string.IsNullOrEmpty(bodyType)) + { + prompt.Append(bodyType); + prompt.Append(", "); + } + + // 5. Цвет кожи + string skinTone = ColorDescriptionService.GetSkinToneDescription( + appearanceData.SkinColor + ); + prompt.Append(skinTone); + prompt.Append(", "); + + // 6. Волосы + string hairDescription = GetHairDescription(appearanceData); + if (!string.IsNullOrEmpty(hairDescription)) + { + prompt.Append(hairDescription); + prompt.Append(", "); + } + + // 7. Настроение и выражение на основе черт характера + string moodDescription = GetMoodFromTraits(appearanceData.Traits); + if (!string.IsNullOrEmpty(moodDescription)) + { + prompt.Append(moodDescription); + prompt.Append(", "); + } + + // 8. Одежда + string apparelDescription = GetApparelDescription(appearanceData.Apparel); + if (!string.IsNullOrEmpty(apparelDescription)) + { + prompt.Append(apparelDescription); + prompt.Append(", "); + } + + // 9. Базовый пользовательский промпт (если указан) + if (!string.IsNullOrEmpty(settings.PositivePrompt)) + { + prompt.Append(settings.PositivePrompt); + prompt.Append(", "); + } + + // 10. Качественные теги + prompt.Append(GetQualityTags(settings.ArtStyle)); + + return prompt.ToString().Trim().TrimEnd(','); + } + + public string GenerateNegativePrompt(StableDiffusionSettings settings) + { + StringBuilder negativePrompt = new StringBuilder(); + + // Базовые негативные промпты + negativePrompt.Append( + "ugly, deformed, low quality, blurry, bad anatomy, worst quality, " + ); + negativePrompt.Append( + "mutated, disfigured, bad proportions, extra limbs, missing limbs, " + ); + + // Специфичные для стиля негативы + if ( + settings.ArtStyle == ArtStyle.Realistic + || settings.ArtStyle == ArtStyle.SemiRealistic + ) + { + negativePrompt.Append("cartoon, anime, painting, drawing, illustration, "); + } + else if (settings.ArtStyle == ArtStyle.Anime) + { + negativePrompt.Append("realistic, photo, photography, 3d, "); + } + + // Пользовательский негативный промпт + if (!string.IsNullOrEmpty(settings.NegativePrompt)) + { + negativePrompt.Append(settings.NegativePrompt); + } + + return negativePrompt.ToString().Trim().TrimEnd(','); + } + + public string GetFullPromptDescription( + PawnAppearanceData appearanceData, + StableDiffusionSettings settings + ) + { + StringBuilder description = new StringBuilder(); + + description.AppendLine("=== Positive Prompt ==="); + description.AppendLine(GeneratePositivePrompt(appearanceData, settings)); + description.AppendLine(); + + description.AppendLine("=== Negative Prompt ==="); + description.AppendLine(GenerateNegativePrompt(settings)); + description.AppendLine(); + + description.AppendLine("=== Technical Parameters ==="); + description.AppendLine($"Steps: {settings.Steps}"); + description.AppendLine($"CFG Scale: {settings.CfgScale}"); + description.AppendLine($"Size: {settings.Width}x{settings.Height}"); + description.AppendLine($"Sampler: {settings.Sampler}"); + description.AppendLine( + $"Seed: {(settings.Seed == -1 ? "Random" : settings.Seed.ToString())}" + ); + + return description.ToString(); + } + + private string GetAgeAndGenderDescription(PawnAppearanceData data) + { + string ageGroup = data.Age switch + { + < 18 => "young", + < 25 => "young adult", + < 35 => "adult", + < 50 => "middle-aged", + < 65 => "mature", + _ => "elderly", + }; + + string genderLabel = data.Gender switch + { + Gender.Male => "man", + Gender.Female => "woman", + _ => "person", + }; + return $"{ageGroup} {genderLabel}"; + } + + private string GetBodyTypeDescription(string bodyType) + { + if (string.IsNullOrEmpty(bodyType)) + return ""; + + return bodyType.ToLower() switch + { + "thin" => "slender build, lean physique", + "hulk" => "muscular build, strong physique, athletic body", + "fat" => "heavyset build, stocky physique", + "female" => "feminine build", + "male" => "masculine build", + _ => "average build", + }; + } + + private string GetHairDescription(PawnAppearanceData data) + { + if (string.IsNullOrEmpty(data.HairStyle)) + return ""; + + StringBuilder hair = new StringBuilder(); + + // Цвет волос + string hairColor = ColorDescriptionService.GetHairColorDescription(data.HairColor); + hair.Append(hairColor); + hair.Append(" "); + + // Стиль прически (упрощаем сложные названия) + string style = data + .HairStyle.ToLower() + .Replace("_", " ") + .Replace("shaved", "very short") + .Replace("mohawk", "mohawk hairstyle"); + + hair.Append(style); + hair.Append(" hair"); + + return hair.ToString(); + } + + private string GetMoodFromTraits(List traits) + { + if (traits == null || !traits.Any()) + return "neutral expression"; + + // Ищем черты, которые влияют на внешний вид + foreach (var trait in traits) + { + string traitDefName = trait.def.defName; + if (TraitToMood.TryGetValue(traitDefName, out string mood)) + { + return mood; + } + } + + return "calm expression"; + } + + private string GetApparelDescription(List apparel) + { + if (apparel == null || !apparel.Any()) + return "simple clothes"; + + StringBuilder apparelDesc = new StringBuilder("wearing "); + + // Берем топ 5 наиболее заметных предметов одежды + var visibleApparel = apparel.Take(5).ToList(); + + List items = new List(); + foreach (var item in visibleApparel) + { + StringBuilder itemDesc = new StringBuilder(); + + // Цвет (если не белый) + if (item.Color != UnityEngine.Color.white) + { + string colorDesc = ColorDescriptionService.GetApparelColorDescription( + item.Color + ); + itemDesc.Append(colorDesc); + itemDesc.Append(" "); + } + + // Материал + if (!string.IsNullOrEmpty(item.Material)) + { + itemDesc.Append(item.Material.ToLower()); + itemDesc.Append(" "); + } + + // Название предмета + itemDesc.Append(item.Label.ToLower()); + + items.Add(itemDesc.ToString()); + } + + apparelDesc.Append(string.Join(", ", items)); + + return apparelDesc.ToString(); + } + + private string GetQualityTags(ArtStyle style) + { + var baseTags = "highly detailed, professional, masterpiece, best quality"; + + if (style == ArtStyle.Realistic || style == ArtStyle.SemiRealistic) + { + return $"{baseTags}, professional photography, 8k uhd, dslr, high quality, sharp focus"; + } + else if (style == ArtStyle.Anime) + { + return $"{baseTags}, anime masterpiece, high resolution, vibrant colors"; + } + else if (style == ArtStyle.ConceptArt) + { + return $"{baseTags}, trending on artstation, professional digital art"; + } + else + { + return baseTags; + } + } + } +} diff --git a/Source/AIImages/Services/ColorDescriptionService.cs b/Source/AIImages/Services/ColorDescriptionService.cs new file mode 100644 index 0000000..5e1c5c7 --- /dev/null +++ b/Source/AIImages/Services/ColorDescriptionService.cs @@ -0,0 +1,165 @@ +using UnityEngine; + +namespace AIImages.Services +{ + /// + /// Сервис для умного определения цветов (вместо RGB значений) + /// + public static class ColorDescriptionService + { + /// + /// Получает текстовое описание цвета волос + /// + public static string GetHairColorDescription(Color color) + { + float h, + s, + v; + Color.RGBToHSV(color, out h, out s, out v); + + // Проверяем на оттенки серого + if (s < 0.15f) + { + return GetGrayscaleDescription(v); + } + + // Определяем оттенок + string hueDescription = GetHueDescription(h); + string brightnessModifier = GetBrightnessModifier(v, s); + + return $"{brightnessModifier}{hueDescription}".Trim(); + } + + /// + /// Получает текстовое описание цвета кожи + /// + public static string GetSkinToneDescription(Color color) + { + // Вычисляем яркость + float brightness = (color.r + color.g + color.b) / 3f; + + // Определяем оттенок кожи + if (brightness >= 0.85f) + return "very fair skin"; + else if (brightness >= 0.75f) + return "fair skin"; + else if (brightness >= 0.65f) + return "light skin"; + else if (brightness >= 0.55f) + return "medium skin"; + else if (brightness >= 0.45f) + return "olive skin"; + else if (brightness >= 0.35f) + return "tan skin"; + else if (brightness >= 0.25f) + return "brown skin"; + else if (brightness >= 0.15f) + return "dark brown skin"; + else + return "very dark skin"; + } + + /// + /// Получает описание цвета одежды + /// + public static string GetApparelColorDescription(Color color) + { + float h, + s, + v; + Color.RGBToHSV(color, out h, out s, out v); + + // Проверяем на оттенки серого + if (s < 0.1f) + { + return GetGrayscaleDescription(v); + } + + // Определяем оттенок + string hueDescription = GetHueDescription(h); + + // Модификаторы насыщенности и яркости + string saturationModifier = ""; + if (s < 0.3f) + saturationModifier = "pale "; + else if (s > 0.8f) + saturationModifier = "vivid "; + + string brightnessModifier = ""; + if (v < 0.3f) + brightnessModifier = "dark "; + else if (v > 0.8f) + brightnessModifier = "bright "; + + return $"{brightnessModifier}{saturationModifier}{hueDescription}".Trim(); + } + + private static string GetGrayscaleDescription(float value) + { + if (value >= 0.95f) + return "white"; + else if (value >= 0.8f) + return "light gray"; + else if (value >= 0.6f) + return "gray"; + else if (value >= 0.4f) + return "dark gray"; + else if (value >= 0.2f) + return "charcoal"; + else + return "black"; + } + + private static string GetHueDescription(float hue) + { + // Hue from 0 to 1 + if (hue < 0.05f || hue >= 0.95f) + return "red"; + if (hue < 0.083f) + return "orange-red"; + if (hue < 0.15f) + return "orange"; + if (hue < 0.19f) + return "golden"; + if (hue < 0.22f) + return "yellow"; + if (hue < 0.35f) + return "yellow-green"; + if (hue < 0.45f) + return "green"; + if (hue < 0.52f) + return "cyan"; + if (hue < 0.58f) + return "light blue"; + if (hue < 0.65f) + return "blue"; + if (hue < 0.72f) + return "deep blue"; + if (hue < 0.78f) + return "purple"; + if (hue < 0.85f) + return "violet"; + if (hue < 0.92f) + return "magenta"; + + return "pink"; + } + + private static string GetBrightnessModifier(float value, float saturation) + { + // Для волос используем специальные термины + if (value < 0.2f) + return "jet black "; + else if (value < 0.3f) + return "black "; + else if (value < 0.45f) + return "dark "; + else if (value > 0.85f && saturation < 0.3f) + return "platinum "; + else if (value > 0.75f) + return "light "; + else + return ""; + } + } +} diff --git a/Source/AIImages/Services/IPawnDescriptionService.cs b/Source/AIImages/Services/IPawnDescriptionService.cs new file mode 100644 index 0000000..d8948f4 --- /dev/null +++ b/Source/AIImages/Services/IPawnDescriptionService.cs @@ -0,0 +1,26 @@ +using AIImages.Models; +using Verse; + +namespace AIImages.Services +{ + /// + /// Интерфейс сервиса для извлечения данных о внешности персонажа + /// + public interface IPawnDescriptionService + { + /// + /// Извлекает данные о внешности персонажа + /// + PawnAppearanceData ExtractAppearanceData(Pawn pawn); + + /// + /// Получает текстовое описание внешности для отображения в UI + /// + string GetAppearanceDescription(Pawn pawn); + + /// + /// Получает текстовое описание одежды для отображения в UI + /// + string GetApparelDescription(Pawn pawn); + } +} diff --git a/Source/AIImages/Services/IPromptGeneratorService.cs b/Source/AIImages/Services/IPromptGeneratorService.cs new file mode 100644 index 0000000..34e4301 --- /dev/null +++ b/Source/AIImages/Services/IPromptGeneratorService.cs @@ -0,0 +1,31 @@ +using AIImages.Models; + +namespace AIImages.Services +{ + /// + /// Интерфейс сервиса для генерации промптов Stable Diffusion + /// + public interface IPromptGeneratorService + { + /// + /// Генерирует позитивный промпт на основе данных о персонаже + /// + string GeneratePositivePrompt( + PawnAppearanceData appearanceData, + StableDiffusionSettings settings + ); + + /// + /// Генерирует негативный промпт на основе настроек + /// + string GenerateNegativePrompt(StableDiffusionSettings settings); + + /// + /// Получает полное описание промпта (позитивный + негативный) для отображения + /// + string GetFullPromptDescription( + PawnAppearanceData appearanceData, + StableDiffusionSettings settings + ); + } +} diff --git a/Source/AIImages/Services/IStableDiffusionApiService.cs b/Source/AIImages/Services/IStableDiffusionApiService.cs new file mode 100644 index 0000000..247175c --- /dev/null +++ b/Source/AIImages/Services/IStableDiffusionApiService.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using AIImages.Models; + +namespace AIImages.Services +{ + /// + /// Интерфейс сервиса для работы с Stable Diffusion API + /// + public interface IStableDiffusionApiService + { + /// + /// Генерирует изображение на основе запроса + /// + Task GenerateImageAsync(GenerationRequest request); + + /// + /// Проверяет доступность API + /// + Task CheckApiAvailability(string apiEndpoint); + + /// + /// Получает список доступных моделей с API + /// + Task> GetAvailableModels(string apiEndpoint); + + /// + /// Получает список доступных сэмплеров + /// + Task> GetAvailableSamplers(string apiEndpoint); + } +} diff --git a/Source/AIImages/Services/PawnDescriptionService.cs b/Source/AIImages/Services/PawnDescriptionService.cs new file mode 100644 index 0000000..2dace17 --- /dev/null +++ b/Source/AIImages/Services/PawnDescriptionService.cs @@ -0,0 +1,183 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using AIImages.Models; +using RimWorld; +using Verse; + +namespace AIImages.Services +{ + /// + /// Сервис для извлечения и описания данных о внешности персонажа + /// + public class PawnDescriptionService : IPawnDescriptionService + { + public PawnAppearanceData ExtractAppearanceData(Pawn pawn) + { + if (pawn?.story == null) + return null; + + var data = new PawnAppearanceData + { + Name = pawn.Name?.ToStringShort ?? "Unknown", + Gender = pawn.gender, + Age = pawn.ageTracker.AgeBiologicalYears, + BodyType = pawn.story.bodyType?.defName, + SkinColor = pawn.story.SkinColor, + HairStyle = pawn.story.hairDef?.label, + HairColor = pawn.story.HairColor, + }; + + // Извлекаем черты характера + if (pawn.story.traits?.allTraits != null) + { + data.Traits.AddRange(pawn.story.traits.allTraits); + } + + // Извлекаем одежду + if (pawn.apparel?.WornApparel != null) + { + foreach (var apparel in pawn.apparel.WornApparel) + { + var apparelData = new ApparelData + { + Label = apparel.def.label, + Material = apparel.Stuff?.label, + Color = apparel.DrawColor, + LayerType = apparel.def.apparel?.LastLayer.ToString(), + Durability = apparel.HitPoints, + MaxDurability = apparel.MaxHitPoints, + }; + + if (apparel.TryGetQuality(out QualityCategory quality)) + { + apparelData.Quality = quality; + } + + data.Apparel.Add(apparelData); + } + } + + return data; + } + + public string GetAppearanceDescription(Pawn pawn) + { + if (pawn?.story == null) + return "AIImages.Appearance.NoInfo".Translate(); + + StringBuilder sb = new StringBuilder(); + + // Пол + sb.AppendLine("AIImages.Appearance.Gender".Translate(pawn.gender.GetLabel())); + + // Возраст + sb.AppendLine("AIImages.Appearance.Age".Translate(pawn.ageTracker.AgeBiologicalYears)); + + // Тип тела + if (pawn.story.bodyType != null) + { + sb.AppendLine( + "AIImages.Appearance.BodyType".Translate(pawn.story.bodyType.defName) + ); + } + + // Цвет кожи (с умным описанием) + if (pawn.story.SkinColor != null) + { + string skinDescription = ColorDescriptionService.GetSkinToneDescription( + pawn.story.SkinColor + ); + sb.AppendLine("AIImages.Appearance.SkinTone".Translate(skinDescription)); + } + + // Волосы + if (pawn.story.hairDef != null) + { + sb.AppendLine("AIImages.Appearance.Hairstyle".Translate(pawn.story.hairDef.label)); + if (pawn.story.HairColor != null) + { + string hairColorDescription = ColorDescriptionService.GetHairColorDescription( + pawn.story.HairColor + ); + sb.AppendLine( + "AIImages.Appearance.HairColorDesc".Translate(hairColorDescription) + ); + } + } + + // Черты характера + if (pawn.story.traits?.allTraits != null && pawn.story.traits.allTraits.Any()) + { + sb.AppendLine("\n" + "AIImages.Appearance.Traits".Translate()); + foreach (var trait in pawn.story.traits.allTraits) + { + sb.AppendLine($" • {trait.LabelCap}"); + } + } + + return sb.ToString(); + } + + public string GetApparelDescription(Pawn pawn) + { + if (pawn?.apparel == null) + return "AIImages.Apparel.NoInfo".Translate(); + + StringBuilder sb = new StringBuilder(); + List wornApparel = pawn.apparel.WornApparel; + + if (wornApparel == null || !wornApparel.Any()) + { + sb.AppendLine("AIImages.Apparel.NoClothes".Translate()); + } + else + { + sb.AppendLine("AIImages.Apparel.ListHeader".Translate(wornApparel.Count) + "\n"); + foreach (Apparel apparel in wornApparel) + { + FormatApparelItem(sb, apparel); + } + } + + return sb.ToString(); + } + + private void FormatApparelItem(StringBuilder sb, Apparel apparel) + { + sb.AppendLine($"• {apparel.LabelCap}"); + + if (apparel.TryGetQuality(out QualityCategory quality)) + { + sb.AppendLine("AIImages.Apparel.Quality".Translate(quality.GetLabel())); + } + + if (apparel.Stuff != null) + { + sb.AppendLine("AIImages.Apparel.Material".Translate(apparel.Stuff.LabelCap)); + } + + if (apparel.HitPoints < apparel.MaxHitPoints) + { + int percentage = (int)((float)apparel.HitPoints / apparel.MaxHitPoints * 100); + sb.AppendLine( + "AIImages.Apparel.Durability".Translate( + apparel.HitPoints, + apparel.MaxHitPoints, + percentage + ) + ); + } + + if (apparel.DrawColor != UnityEngine.Color.white) + { + string colorDesc = ColorDescriptionService.GetApparelColorDescription( + apparel.DrawColor + ); + sb.AppendLine("AIImages.Apparel.ColorDesc".Translate(colorDesc)); + } + + sb.AppendLine(); + } + } +} diff --git a/Source/AIImages/Services/StableDiffusionApiService.cs b/Source/AIImages/Services/StableDiffusionApiService.cs new file mode 100644 index 0000000..34a409a --- /dev/null +++ b/Source/AIImages/Services/StableDiffusionApiService.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using AIImages.Models; +using Newtonsoft.Json; +using Verse; + +namespace AIImages.Services +{ + /// + /// Сервис для работы с Stable Diffusion API (AUTOMATIC1111 WebUI) + /// + public class StableDiffusionApiService : IStableDiffusionApiService + { + private readonly HttpClient httpClient; + private readonly string saveFolderPath; + + public StableDiffusionApiService(string savePath = "AIImages/Generated") + { + httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + + // Определяем путь для сохранения + saveFolderPath = Path.Combine(GenFilePaths.SaveDataFolderPath, savePath); + + // Создаем папку, если не существует + if (!Directory.Exists(saveFolderPath)) + { + Directory.CreateDirectory(saveFolderPath); + } + } + + public async Task GenerateImageAsync(GenerationRequest request) + { + try + { + Log.Message( + $"[AI Images] Starting image generation with prompt: {request.Prompt.Substring(0, Math.Min(50, request.Prompt.Length))}..." + ); + + // Формируем JSON запрос для AUTOMATIC1111 API + var apiRequest = new + { + prompt = request.Prompt, + negative_prompt = request.NegativePrompt, + steps = request.Steps, + cfg_scale = request.CfgScale, + width = request.Width, + height = request.Height, + sampler_name = request.Sampler, + seed = request.Seed, + save_images = false, + send_images = true, + }; + + string jsonRequest = JsonConvert.SerializeObject(apiRequest); + var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); + + // Отправляем запрос + string endpoint = $"{request.Model}/sdapi/v1/txt2img"; + HttpResponseMessage response = await httpClient.PostAsync(endpoint, content); + + if (!response.IsSuccessStatusCode) + { + string errorContent = await response.Content.ReadAsStringAsync(); + Log.Error( + $"[AI Images] API request failed: {response.StatusCode} - {errorContent}" + ); + return GenerationResult.Failure($"API Error: {response.StatusCode}"); + } + + string jsonResponse = await response.Content.ReadAsStringAsync(); + var apiResponse = JsonConvert.DeserializeObject(jsonResponse); + + if (apiResponse?.images == null || apiResponse.images.Length == 0) + { + return GenerationResult.Failure("No images returned from API"); + } + + // Декодируем изображение из base64 + byte[] imageData = Convert.FromBase64String(apiResponse.images[0]); + + // Сохраняем изображение + string fileName = $"pawn_{DateTime.Now:yyyyMMdd_HHmmss}.png"; + string fullPath = Path.Combine(saveFolderPath, fileName); + await File.WriteAllBytesAsync(fullPath, imageData); + + Log.Message($"[AI Images] Image generated successfully and saved to: {fullPath}"); + + return GenerationResult.SuccessResult(imageData, fullPath, request); + } + catch (TaskCanceledException) + { + return GenerationResult.Failure("Request timeout. Generation took too long."); + } + catch (HttpRequestException ex) + { + Log.Error($"[AI Images] HTTP error: {ex.Message}"); + return GenerationResult.Failure($"Connection error: {ex.Message}"); + } + catch (Exception ex) + { + Log.Error($"[AI Images] Unexpected error: {ex.Message}\n{ex.StackTrace}"); + return GenerationResult.Failure($"Error: {ex.Message}"); + } + } + + public async Task CheckApiAvailability(string apiEndpoint) + { + try + { + string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models"; + HttpResponseMessage response = await httpClient.GetAsync(endpoint); + return response.IsSuccessStatusCode; + } + catch (Exception ex) + { + Log.Warning($"[AI Images] API check failed: {ex.Message}"); + return false; + } + } + + public async Task> GetAvailableModels(string apiEndpoint) + { + try + { + string endpoint = $"{apiEndpoint}/sdapi/v1/sd-models"; + HttpResponseMessage response = await httpClient.GetAsync(endpoint); + + if (!response.IsSuccessStatusCode) + return new List(); + + string jsonResponse = await response.Content.ReadAsStringAsync(); + var models = JsonConvert.DeserializeObject>(jsonResponse); + + var modelNames = new List(); + if (models != null) + { + foreach (var model in models) + { + modelNames.Add(model.title ?? model.model_name); + } + } + + Log.Message($"[AI Images] Found {modelNames.Count} models"); + return modelNames; + } + catch (Exception ex) + { + Log.Error($"[AI Images] Failed to load models: {ex.Message}"); + return new List(); + } + } + + public async Task> GetAvailableSamplers(string apiEndpoint) + { + try + { + string endpoint = $"{apiEndpoint}/sdapi/v1/samplers"; + HttpResponseMessage response = await httpClient.GetAsync(endpoint); + + if (!response.IsSuccessStatusCode) + return GetDefaultSamplers(); + + string jsonResponse = await response.Content.ReadAsStringAsync(); + var samplers = JsonConvert.DeserializeObject>(jsonResponse); + + var samplerNames = new List(); + if (samplers != null) + { + foreach (var sampler in samplers) + { + samplerNames.Add(sampler.name); + } + } + + Log.Message($"[AI Images] Found {samplerNames.Count} samplers"); + return samplerNames; + } + catch (Exception ex) + { + Log.Warning($"[AI Images] Failed to load samplers: {ex.Message}"); + return GetDefaultSamplers(); + } + } + + private List GetDefaultSamplers() + { + return new List + { + "Euler a", + "Euler", + "LMS", + "Heun", + "DPM2", + "DPM2 a", + "DPM++ 2S a", + "DPM++ 2M", + "DPM++ SDE", + "DPM fast", + "DPM adaptive", + "LMS Karras", + "DPM2 Karras", + "DPM2 a Karras", + "DPM++ 2S a Karras", + "DPM++ 2M Karras", + "DPM++ SDE Karras", + "DDIM", + "PLMS", + }; + } + + // Вспомогательные классы для десериализации JSON ответов +#pragma warning disable S3459, S1144 // Properties set by JSON deserializer + private sealed class Txt2ImgResponse + { + public string[] images { get; set; } + } + + private sealed class SdModel + { + public string title { get; set; } + public string model_name { get; set; } + } + + private sealed class SdSampler + { + public string name { get; set; } + } +#pragma warning restore S3459, S1144 + } +} diff --git a/Source/AIImages/Settings/AIImagesModSettings.cs b/Source/AIImages/Settings/AIImagesModSettings.cs new file mode 100644 index 0000000..621726e --- /dev/null +++ b/Source/AIImages/Settings/AIImagesModSettings.cs @@ -0,0 +1,94 @@ +using AIImages.Models; +using Verse; + +namespace AIImages.Settings +{ + /// + /// Настройки мода AI Images + /// +#pragma warning disable S1104 // Fields required for RimWorld's Scribe serialization system + public class AIImagesModSettings : ModSettings + { + // API настройки + public string apiEndpoint = "http://127.0.0.1:7860"; + public string selectedModel = ""; + public string selectedSampler = "Euler a"; + + // Настройки генерации + public int steps = 30; + public float cfgScale = 7.5f; + public int width = 512; + public int height = 768; + public int seed = -1; + + // Промпты + public string basePositivePrompt = ""; + public string baseNegativePrompt = + "ugly, deformed, low quality, blurry, bad anatomy, worst quality"; + + // Художественный стиль + public ArtStyle artStyle = ArtStyle.Realistic; + public ShotType shotType = ShotType.Portrait; + + // Путь для сохранения + public string savePath = "AIImages/Generated"; + + // Флаги + public bool autoLoadModels = true; + public bool showTechnicalInfo = true; + public bool saveGenerationHistory = true; + + public override void ExposeData() + { + Scribe_Values.Look(ref apiEndpoint, "apiEndpoint", "http://127.0.0.1:7860"); + Scribe_Values.Look(ref selectedModel, "selectedModel", ""); + Scribe_Values.Look(ref selectedSampler, "selectedSampler", "Euler a"); + + Scribe_Values.Look(ref steps, "steps", 30); + Scribe_Values.Look(ref cfgScale, "cfgScale", 7.5f); + Scribe_Values.Look(ref width, "width", 512); + Scribe_Values.Look(ref height, "height", 768); + Scribe_Values.Look(ref seed, "seed", -1); + + Scribe_Values.Look(ref basePositivePrompt, "basePositivePrompt", ""); + Scribe_Values.Look( + ref baseNegativePrompt, + "baseNegativePrompt", + "ugly, deformed, low quality, blurry, bad anatomy, worst quality" + ); + + Scribe_Values.Look(ref artStyle, "artStyle", ArtStyle.Realistic); + Scribe_Values.Look(ref shotType, "shotType", ShotType.Portrait); + + Scribe_Values.Look(ref savePath, "savePath", "AIImages/Generated"); + + Scribe_Values.Look(ref autoLoadModels, "autoLoadModels", true); + Scribe_Values.Look(ref showTechnicalInfo, "showTechnicalInfo", true); + Scribe_Values.Look(ref saveGenerationHistory, "saveGenerationHistory", true); + + base.ExposeData(); + } + + /// + /// Создает объект StableDiffusionSettings из настроек мода + /// + public StableDiffusionSettings ToStableDiffusionSettings() + { + return new StableDiffusionSettings + { + Steps = steps, + CfgScale = cfgScale, + Width = width, + Height = height, + Sampler = selectedSampler, + Seed = seed, + Model = selectedModel, + ArtStyle = artStyle, + ShotType = shotType, + PositivePrompt = basePositivePrompt, + NegativePrompt = baseNegativePrompt, + }; + } + } +#pragma warning restore S1104 +} diff --git a/Source/AIImages/UI/AIImagesSettingsUI.cs b/Source/AIImages/UI/AIImagesSettingsUI.cs new file mode 100644 index 0000000..f09e81a --- /dev/null +++ b/Source/AIImages/UI/AIImagesSettingsUI.cs @@ -0,0 +1,284 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AIImages.Models; +using AIImages.Settings; +using RimWorld; +using UnityEngine; +using Verse; + +namespace AIImages +{ + /// + /// UI для настроек мода в меню настроек RimWorld + /// + public static class AIImagesSettingsUI + { + private static Vector2 scrollPosition = Vector2.zero; + private static string stepsBuffer; + private static string widthBuffer; + private static string heightBuffer; + + public static void DoSettingsWindowContents(Rect inRect, AIImagesModSettings settings) + { + // Инициализируем буферы при первом вызове + if (string.IsNullOrEmpty(stepsBuffer)) + { + stepsBuffer = settings.steps.ToString(); + widthBuffer = settings.width.ToString(); + heightBuffer = settings.height.ToString(); + } + + Listing_Standard listingStandard = new Listing_Standard(); + Rect viewRect = new Rect(0f, 0f, inRect.width - 20f, 1200f); + + Widgets.BeginScrollView(inRect, ref scrollPosition, viewRect); + listingStandard.Begin(viewRect); + + // === API Settings === + listingStandard.Label( + "AIImages.Settings.ApiSection".Translate(), + -1f, + "AIImages.Settings.ApiSectionTooltip".Translate() + ); + listingStandard.GapLine(); + + listingStandard.Label("AIImages.Settings.ApiEndpoint".Translate() + ":"); + settings.apiEndpoint = listingStandard.TextEntry(settings.apiEndpoint); + listingStandard.Gap(8f); + + // Кнопка проверки подключения + if (listingStandard.ButtonText("AIImages.Settings.TestConnection".Translate())) + { + _ = TestApiConnection(settings.apiEndpoint); + } + + // Кнопка загрузки моделей + if (listingStandard.ButtonText("AIImages.Settings.LoadModels".Translate())) + { + _ = LoadModelsFromApi(settings); + } + + listingStandard.Gap(12f); + + // === Generation Settings === + listingStandard.Label( + "AIImages.Settings.GenerationSection".Translate(), + -1f, + "AIImages.Settings.GenerationSectionTooltip".Translate() + ); + listingStandard.GapLine(); + + // Art Style + if ( + listingStandard.ButtonTextLabeled( + "AIImages.Settings.ArtStyle".Translate(), + settings.artStyle.ToString() + ) + ) + { + List styleOptions = new List(); + foreach (ArtStyle style in Enum.GetValues(typeof(ArtStyle))) + { + ArtStyle localStyle = style; + styleOptions.Add( + new FloatMenuOption(style.ToString(), () => settings.artStyle = localStyle) + ); + } + Find.WindowStack.Add(new FloatMenu(styleOptions)); + } + + // Shot Type + if ( + listingStandard.ButtonTextLabeled( + "AIImages.Settings.ShotType".Translate(), + settings.shotType.ToString() + ) + ) + { + List shotOptions = new List(); + foreach (ShotType shot in Enum.GetValues(typeof(ShotType))) + { + ShotType localShot = shot; + shotOptions.Add( + new FloatMenuOption(shot.ToString(), () => settings.shotType = localShot) + ); + } + Find.WindowStack.Add(new FloatMenu(shotOptions)); + } + + listingStandard.Gap(8f); + + // Steps + listingStandard.Label("AIImages.Settings.Steps".Translate() + $": {settings.steps}"); + settings.steps = (int)listingStandard.Slider(settings.steps, 1, 150); + listingStandard.Gap(8f); + + // CFG Scale + listingStandard.Label( + "AIImages.Settings.CfgScale".Translate() + $": {settings.cfgScale:F1}" + ); + settings.cfgScale = listingStandard.Slider(settings.cfgScale, 1f, 30f); + listingStandard.Gap(8f); + + // Width + listingStandard.Label("AIImages.Settings.Width".Translate() + ":"); + widthBuffer = listingStandard.TextEntry(widthBuffer); + if (int.TryParse(widthBuffer, out int width)) + { + settings.width = Mathf.Clamp(width, 64, 2048); + } + + // Height + listingStandard.Label("AIImages.Settings.Height".Translate() + ":"); + heightBuffer = listingStandard.TextEntry(heightBuffer); + if (int.TryParse(heightBuffer, out int height)) + { + settings.height = Mathf.Clamp(height, 64, 2048); + } + + // Common size presets + listingStandard.Gap(4f); + Rect presetRect = listingStandard.GetRect(30f); + if (Widgets.ButtonText(new Rect(presetRect.x, presetRect.y, 80f, 30f), "512x512")) + { + settings.width = 512; + settings.height = 512; + widthBuffer = "512"; + heightBuffer = "512"; + } + if (Widgets.ButtonText(new Rect(presetRect.x + 85f, presetRect.y, 80f, 30f), "512x768")) + { + settings.width = 512; + settings.height = 768; + widthBuffer = "512"; + heightBuffer = "768"; + } + if ( + Widgets.ButtonText(new Rect(presetRect.x + 170f, presetRect.y, 80f, 30f), "768x768") + ) + { + settings.width = 768; + settings.height = 768; + widthBuffer = "768"; + heightBuffer = "768"; + } + + listingStandard.Gap(12f); + + // Sampler + listingStandard.Label("AIImages.Settings.Sampler".Translate() + ":"); + settings.selectedSampler = listingStandard.TextEntry(settings.selectedSampler); + listingStandard.Gap(12f); + + // === Prompts === + listingStandard.Label( + "AIImages.Settings.PromptsSection".Translate(), + -1f, + "AIImages.Settings.PromptsSectionTooltip".Translate() + ); + listingStandard.GapLine(); + + listingStandard.Label("AIImages.Settings.BasePositivePrompt".Translate() + ":"); + settings.basePositivePrompt = listingStandard.TextEntry(settings.basePositivePrompt, 3); + listingStandard.Gap(8f); + + listingStandard.Label("AIImages.Settings.BaseNegativePrompt".Translate() + ":"); + settings.baseNegativePrompt = listingStandard.TextEntry(settings.baseNegativePrompt, 3); + listingStandard.Gap(12f); + + // === Options === + listingStandard.Label("AIImages.Settings.OptionsSection".Translate()); + listingStandard.GapLine(); + + listingStandard.CheckboxLabeled( + "AIImages.Settings.AutoLoadModels".Translate(), + ref settings.autoLoadModels + ); + listingStandard.CheckboxLabeled( + "AIImages.Settings.ShowTechnicalInfo".Translate(), + ref settings.showTechnicalInfo + ); + listingStandard.CheckboxLabeled( + "AIImages.Settings.SaveHistory".Translate(), + ref settings.saveGenerationHistory + ); + + listingStandard.Gap(12f); + + // Save path + listingStandard.Label("AIImages.Settings.SavePath".Translate() + ":"); + settings.savePath = listingStandard.TextEntry(settings.savePath); + + listingStandard.End(); + Widgets.EndScrollView(); + } + + private static async System.Threading.Tasks.Task TestApiConnection(string endpoint) + { + try + { + Log.Message($"[AI Images] Testing connection to {endpoint}..."); + bool available = await AIImagesMod.ApiService.CheckApiAvailability(endpoint); + + if (available) + { + Messages.Message( + "AIImages.Settings.ConnectionSuccess".Translate(), + MessageTypeDefOf.PositiveEvent + ); + } + else + { + Messages.Message( + "AIImages.Settings.ConnectionFailed".Translate(), + MessageTypeDefOf.RejectInput + ); + } + } + catch (Exception ex) + { + Messages.Message($"Error: {ex.Message}", MessageTypeDefOf.RejectInput); + } + } + + private static async System.Threading.Tasks.Task LoadModelsFromApi( + AIImagesModSettings settings + ) + { + try + { + Log.Message("[AI Images] Loading models from API..."); + var models = await AIImagesMod.ApiService.GetAvailableModels(settings.apiEndpoint); + + if (models.Count > 0) + { + Messages.Message( + "AIImages.Settings.ModelsLoaded".Translate(models.Count), + MessageTypeDefOf.PositiveEvent + ); + + // Если модель не выбрана, выбираем первую + if (string.IsNullOrEmpty(settings.selectedModel) && models.Count > 0) + { + settings.selectedModel = models[0]; + } + } + else + { + Messages.Message( + "AIImages.Settings.NoModelsFound".Translate(), + MessageTypeDefOf.RejectInput + ); + } + } + catch (Exception ex) + { + Messages.Message( + $"Error loading models: {ex.Message}", + MessageTypeDefOf.RejectInput + ); + } + } + } +} diff --git a/Source/AIImages/Window_AIImage.cs b/Source/AIImages/Window_AIImage.cs index 20a9576..901177e 100644 --- a/Source/AIImages/Window_AIImage.cs +++ b/Source/AIImages/Window_AIImage.cs @@ -1,6 +1,6 @@ -using System.Collections.Generic; +using System; using System.Linq; -using System.Text; +using AIImages.Models; using RimWorld; using UnityEngine; using Verse; @@ -10,7 +10,7 @@ using Verse; namespace AIImages { /// - /// Empty window that opens when clicking the pawn button + /// Окно для просмотра персонажа и генерации AI изображений /// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Style", @@ -25,29 +25,48 @@ namespace AIImages public class Window_AIImage : Window { private Pawn pawn; + private PawnAppearanceData appearanceData; + private StableDiffusionSettings generationSettings; + + private Texture2D generatedImage; + private bool isGenerating = false; + private string generationStatus = ""; public Window_AIImage(Pawn pawn) { this.pawn = pawn; this.doCloseX = true; this.doCloseButton = true; - this.forcePause = false; // Не ставим игру на паузу - this.absorbInputAroundWindow = false; // Не блокируем клики вне окна - this.draggable = true; // Делаем окно перемещаемым - this.preventCameraMotion = false; // Не блокируем управление камерой + this.forcePause = false; + this.absorbInputAroundWindow = false; + this.draggable = true; + this.preventCameraMotion = false; + + // Извлекаем данные персонажа + RefreshPawnData(); } - public override Vector2 InitialSize => new Vector2(700f, 700f); + public override Vector2 InitialSize => new Vector2(900f, 800f); private Vector2 scrollPosition = Vector2.zero; private float copiedMessageTime = 0f; + /// + /// Обновляет данные персонажа + /// + private void RefreshPawnData() + { + appearanceData = AIImagesMod.PawnDescriptionService.ExtractAppearanceData(pawn); + generationSettings = AIImagesMod.Settings.ToStableDiffusionSettings(); + } + /// /// Обновляет текущую пешку в окне /// public void UpdatePawn(Pawn newPawn) { this.pawn = newPawn; + RefreshPawnData(); } /// @@ -70,7 +89,7 @@ namespace AIImages // Если выбрана новая колонистская пешка, обновляем окно if (selectedPawn != null && selectedPawn != pawn) { - pawn = selectedPawn; + UpdatePawn(selectedPawn); } // Уменьшаем таймер сообщения о копировании @@ -81,274 +100,71 @@ namespace AIImages } /// - /// Получает описание внешности персонажа + /// Асинхронная генерация изображения /// - private string GetAppearanceDescription() + private async System.Threading.Tasks.Task GenerateImage() { - if (pawn?.story == null) - return "AIImages.Appearance.NoInfo".Translate(); + if (isGenerating) + return; - StringBuilder sb = new StringBuilder(); + isGenerating = true; + generationStatus = "AIImages.Generation.InProgress".Translate(); - // Пол - sb.AppendLine("AIImages.Appearance.Gender".Translate(pawn.gender.GetLabel())); - - // Возраст - sb.AppendLine("AIImages.Appearance.Age".Translate(pawn.ageTracker.AgeBiologicalYears)); - - // Тип тела - if (pawn.story.bodyType != null) + try { - sb.AppendLine( - "AIImages.Appearance.BodyType".Translate(pawn.story.bodyType.defName) + // Генерируем промпты + string positivePrompt = AIImagesMod.PromptGeneratorService.GeneratePositivePrompt( + appearanceData, + generationSettings ); - } - - // Цвет кожи - if (pawn.story.SkinColor != null) - { - Color skinColor = pawn.story.SkinColor; - sb.AppendLine( - "AIImages.Appearance.SkinColor".Translate( - skinColor.r.ToString("F2"), - skinColor.g.ToString("F2"), - skinColor.b.ToString("F2") - ) + string negativePrompt = AIImagesMod.PromptGeneratorService.GenerateNegativePrompt( + generationSettings ); - } - // Волосы - if (pawn.story.hairDef != null) - { - sb.AppendLine("AIImages.Appearance.Hairstyle".Translate(pawn.story.hairDef.label)); - if (pawn.story.HairColor != null) + // Создаем запрос + var request = new GenerationRequest { - sb.AppendLine( - "AIImages.Appearance.HairColor".Translate( - pawn.story.HairColor.r.ToString("F2"), - pawn.story.HairColor.g.ToString("F2"), - pawn.story.HairColor.b.ToString("F2") - ) + Prompt = positivePrompt, + NegativePrompt = negativePrompt, + Steps = generationSettings.Steps, + CfgScale = generationSettings.CfgScale, + Width = generationSettings.Width, + Height = generationSettings.Height, + Sampler = generationSettings.Sampler, + Seed = generationSettings.Seed, + Model = AIImagesMod.Settings.apiEndpoint, + }; + + // Генерируем изображение + var result = await AIImagesMod.ApiService.GenerateImageAsync(request); + + if (result.Success) + { + // Загружаем текстуру + generatedImage = new Texture2D(2, 2); + generatedImage.LoadImage(result.ImageData); + generationStatus = "AIImages.Generation.Success".Translate(); + + Messages.Message( + "AIImages.Generation.SavedTo".Translate(result.SavedPath), + MessageTypeDefOf.PositiveEvent ); } - } - - // Черты характера - if (pawn.story.traits?.allTraits != null && pawn.story.traits.allTraits.Any()) - { - sb.AppendLine("\n" + "AIImages.Appearance.Traits".Translate()); - foreach (var trait in pawn.story.traits.allTraits) + else { - sb.AppendLine($" • {trait.LabelCap}"); + generationStatus = + $"AIImages.Generation.Failed".Translate() + ": {result.ErrorMessage}"; + Messages.Message(generationStatus, MessageTypeDefOf.RejectInput); } } - - return sb.ToString(); - } - - /// - /// Получает описание одежды персонажа - /// - private string GetApparelDescription() - { - if (pawn?.apparel == null) - return "AIImages.Apparel.NoInfo".Translate(); - - StringBuilder sb = new StringBuilder(); - List wornApparel = pawn.apparel.WornApparel; - - if (wornApparel == null || !wornApparel.Any()) + catch (Exception ex) { - sb.AppendLine("AIImages.Apparel.NoClothes".Translate()); + generationStatus = $"Error: {ex.Message}"; + Log.Error($"[AI Images] Generation error: {ex}"); } - else + finally { - sb.AppendLine("AIImages.Apparel.ListHeader".Translate(wornApparel.Count) + "\n"); - foreach (Apparel apparel in wornApparel) - { - FormatApparelItem(sb, apparel); - } - } - - return sb.ToString(); - } - - /// - /// Форматирует информацию об одном предмете одежды - /// - private void FormatApparelItem(StringBuilder sb, Apparel apparel) - { - sb.AppendLine($"• {apparel.LabelCap}"); - - if (apparel.TryGetQuality(out QualityCategory quality)) - { - sb.AppendLine("AIImages.Apparel.Quality".Translate(quality.GetLabel())); - } - - if (apparel.Stuff != null) - { - sb.AppendLine("AIImages.Apparel.Material".Translate(apparel.Stuff.LabelCap)); - } - - if (apparel.HitPoints < apparel.MaxHitPoints) - { - int percentage = (int)((float)apparel.HitPoints / apparel.MaxHitPoints * 100); - sb.AppendLine( - "AIImages.Apparel.Durability".Translate( - apparel.HitPoints, - apparel.MaxHitPoints, - percentage - ) - ); - } - - if (apparel.DrawColor != Color.white) - { - sb.AppendLine( - "AIImages.Apparel.Color".Translate( - apparel.DrawColor.r.ToString("F2"), - apparel.DrawColor.g.ToString("F2"), - apparel.DrawColor.b.ToString("F2") - ) - ); - } - - sb.AppendLine(); - } - - /// - /// Генерирует промпт для Stable Diffusion на основе внешности персонажа - /// - private string GenerateStableDiffusionPrompt() - { - if (pawn?.story == null) - return "portrait of a person"; - - StringBuilder prompt = new StringBuilder("portrait of a "); - - prompt.Append(GetAgeAndGenderDescription()); - prompt.Append(GetBodyTypeDescription()); - prompt.Append(GetSkinToneDescription()); - prompt.Append(GetHairDescription()); - prompt.Append(GetApparelPromptDescription()); - prompt.Append( - "realistic, detailed, high quality, professional lighting, 8k, photorealistic" - ); - - return prompt.ToString(); - } - - private string GetAgeAndGenderDescription() - { - string ageGroup = pawn.ageTracker.AgeBiologicalYears switch - { - < 18 => "young", - < 30 => "young adult", - < 50 => "middle-aged", - _ => "mature", - }; - return $"{ageGroup} {pawn.gender.GetLabel()}, "; - } - - private string GetBodyTypeDescription() - { - if (pawn.story.bodyType == null) - return ""; - - string bodyDesc = pawn.story.bodyType.defName.ToLower() switch - { - "thin" => "slender build", - "hulk" => "muscular build", - "fat" => "heavyset build", - _ => "average build", - }; - return $"{bodyDesc}, "; - } - - private string GetSkinToneDescription() - { - if (pawn.story.SkinColor == null) - return ""; - - float brightness = - (pawn.story.SkinColor.r + pawn.story.SkinColor.g + pawn.story.SkinColor.b) / 3f; - string skinTone = brightness switch - { - >= 0.8f => "fair skin", - >= 0.6f => "light skin", - >= 0.4f => "olive skin", - >= 0.2f => "brown skin", - _ => "dark skin", - }; - return $"{skinTone}, "; - } - - private string GetHairDescription() - { - if (pawn.story.hairDef == null) - return ""; - - string result = $"{pawn.story.hairDef.label.ToLower()} hair"; - if (pawn.story.HairColor != null) - { - result += $", {GetColorDescription(pawn.story.HairColor)} hair color"; - } - return result + ", "; - } - - private string GetApparelPromptDescription() - { - if (pawn.apparel?.WornApparel == null || !pawn.apparel.WornApparel.Any()) - return ""; - - List items = pawn - .apparel.WornApparel.Take(3) - .Select(a => - a.Stuff != null - ? $"{a.Stuff.label.ToLower()} {a.def.label.ToLower()}" - : a.def.label.ToLower() - ) - .ToList(); - - return $"wearing {string.Join(", ", items)}, "; - } - - /// - /// Получает текстовое описание цвета - /// - private string GetColorDescription(Color color) - { - // Определяем доминирующий цвет - float max = Mathf.Max(color.r, color.g, color.b); - float min = Mathf.Min(color.r, color.g, color.b); - float diff = max - min; - - if (diff < 0.1f) - { - // Оттенки серого - return max switch - { - > 0.8f => "white", - > 0.6f => "light gray", - > 0.4f => "gray", - > 0.2f => "dark gray", - _ => "black", - }; - } - - // Цветные - const float epsilon = 0.001f; - if (Mathf.Abs(color.r - max) < epsilon) - { - return color.g > color.b ? "orange" : "red"; - } - else if (Mathf.Abs(color.g - max) < epsilon) - { - return color.r > color.b ? "yellow" : "green"; - } - else - { - return color.r > color.g ? "purple" : "blue"; + isGenerating = false; } } @@ -376,16 +192,29 @@ namespace AIImages Widgets.DrawLineHorizontal(0f, curY, inRect.width); curY += 10f; - // Область для прокрутки контента - Rect scrollRect = new Rect(0f, curY, inRect.width, inRect.height - curY); - Rect scrollViewRect = new Rect( - 0f, - 0f, - scrollRect.width - 20f, - CalculateContentHeight() - ); + // Разделяем на две колонки: левая - информация, правая - изображение + float leftColumnWidth = inRect.width * 0.55f; + float rightColumnWidth = inRect.width * 0.42f; + float columnGap = inRect.width * 0.03f; - Widgets.BeginScrollView(scrollRect, ref scrollPosition, scrollViewRect); + // Левая колонка - прокручиваемая информация + Rect leftColumnRect = new Rect(0f, curY, leftColumnWidth, inRect.height - curY); + DrawLeftColumn(leftColumnRect); + + // Правая колонка - превью и управление + Rect rightColumnRect = new Rect( + leftColumnWidth + columnGap, + curY, + rightColumnWidth, + inRect.height - curY + ); + DrawRightColumn(rightColumnRect); + } + + private void DrawLeftColumn(Rect rect) + { + Rect scrollViewRect = new Rect(0f, 0f, rect.width - 20f, CalculateContentHeight()); + Widgets.BeginScrollView(rect, ref scrollPosition, scrollViewRect); float contentY = 0f; @@ -398,7 +227,9 @@ namespace AIImages contentY += 35f; Text.Font = GameFont.Small; - string appearanceText = GetAppearanceDescription(); + string appearanceText = AIImagesMod.PawnDescriptionService.GetAppearanceDescription( + pawn + ); float appearanceHeight = Text.CalcHeight(appearanceText, scrollViewRect.width - 30f); Widgets.Label( new Rect(20f, contentY, scrollViewRect.width - 30f, appearanceHeight), @@ -419,60 +250,133 @@ namespace AIImages contentY += 35f; Text.Font = GameFont.Small; - string apparelText = GetApparelDescription(); + string apparelText = AIImagesMod.PawnDescriptionService.GetApparelDescription(pawn); float apparelHeight = Text.CalcHeight(apparelText, scrollViewRect.width - 30f); Widgets.Label( new Rect(20f, contentY, scrollViewRect.width - 30f, apparelHeight), apparelText ); - contentY += apparelHeight + 20f; - // Разделитель - Widgets.DrawLineHorizontal(10f, contentY, scrollViewRect.width - 20f); - contentY += 15f; + Widgets.EndScrollView(); + } - // Секция "Stable Diffusion Промпт" + private void DrawRightColumn(Rect rect) + { + float curY = 0f; + + // Превью изображения + if (generatedImage != null) + { + float imageSize = Mathf.Min(rect.width, 400f); + Rect imageRect = new Rect( + (rect.width - imageSize) / 2f, + curY, + imageSize, + imageSize + ); + GUI.DrawTexture(imageRect, generatedImage); + curY += imageSize + 10f; + } + else + { + // Placeholder для изображения + float placeholderSize = Mathf.Min(rect.width, 300f); + Rect placeholderRect = new Rect( + (rect.width - placeholderSize) / 2f, + curY, + placeholderSize, + placeholderSize + ); + Widgets.DrawBoxSolid(placeholderRect, new Color(0.2f, 0.2f, 0.2f)); + Text.Anchor = TextAnchor.MiddleCenter; + Widgets.Label(placeholderRect, "AIImages.Generation.NoImage".Translate()); + Text.Anchor = TextAnchor.UpperLeft; + curY += placeholderSize + 10f; + } + + // Статус генерации + if (!string.IsNullOrEmpty(generationStatus)) + { + Text.Font = GameFont.Small; + float statusHeight = Text.CalcHeight(generationStatus, rect.width); + Widgets.Label(new Rect(0f, curY, rect.width, statusHeight), generationStatus); + curY += statusHeight + 10f; + } + + // Кнопка генерации + Text.Font = GameFont.Small; + if ( + Widgets.ButtonText( + new Rect(0f, curY, rect.width, 35f), + isGenerating + ? "AIImages.Generation.Generating".Translate() + : "AIImages.Generation.Generate".Translate() + ) && !isGenerating + ) + { + _ = GenerateImage(); + } + curY += 40f; + + // Промпт секция Text.Font = GameFont.Medium; Widgets.Label( - new Rect(10f, contentY, scrollViewRect.width - 20f, 30f), + new Rect(0f, curY, rect.width, 30f), "AIImages.Prompt.SectionTitle".Translate() ); - contentY += 35f; + curY += 35f; - // Промпт текст - Text.Font = GameFont.Small; - string promptText = GenerateStableDiffusionPrompt(); - float promptHeight = Text.CalcHeight(promptText, scrollViewRect.width - 30f); - Widgets.Label( - new Rect(20f, contentY, scrollViewRect.width - 30f, promptHeight), - promptText + // Получаем промпт + Text.Font = GameFont.Tiny; + string promptText = AIImagesMod.PromptGeneratorService.GeneratePositivePrompt( + appearanceData, + generationSettings ); - contentY += promptHeight + 10f; - // Кнопка копирования - Rect copyButtonRect = new Rect(20f, contentY, 150f, 30f); + float promptHeight = Mathf.Min(Text.CalcHeight(promptText, rect.width), 150f); + Rect promptRect = new Rect(0f, curY, rect.width, promptHeight); - if (Widgets.ButtonText(copyButtonRect, "AIImages.Prompt.CopyButton".Translate())) + // Рисуем промпт в скроллируемой области если он длинный + Widgets.DrawBoxSolid(promptRect, new Color(0.1f, 0.1f, 0.1f, 0.5f)); + Widgets.Label(promptRect.ContractedBy(5f), promptText); + curY += promptHeight + 10f; + + // Кнопка копирования промпта + if ( + Widgets.ButtonText( + new Rect(0f, curY, rect.width / 2f - 5f, 30f), + "AIImages.Prompt.CopyButton".Translate() + ) + ) { GUIUtility.systemCopyBuffer = promptText; - copiedMessageTime = 2f; // Показываем сообщение на 2 секунды + copiedMessageTime = 2f; + } + + // Кнопка обновления данных + if ( + Widgets.ButtonText( + new Rect(rect.width / 2f + 5f, curY, rect.width / 2f - 5f, 30f), + "AIImages.Window.Refresh".Translate() + ) + ) + { + RefreshPawnData(); } // Сообщение о копировании if (copiedMessageTime > 0f) { - Rect copiedRect = new Rect(copyButtonRect.xMax + 10f, contentY, 100f, 30f); - GUI.color = new Color(0f, 1f, 0f, copiedMessageTime / 2f); // Затухающий зеленый - Widgets.Label(copiedRect, "AIImages.Prompt.Copied".Translate()); + curY += 35f; + GUI.color = new Color(0f, 1f, 0f, copiedMessageTime / 2f); + Widgets.Label( + new Rect(0f, curY, rect.width, 25f), + "AIImages.Prompt.Copied".Translate() + ); GUI.color = Color.white; } - - Widgets.EndScrollView(); } - /// - /// Вычисляет высоту всего контента для прокрутки - /// private float CalculateContentHeight() { float height = 0f; @@ -481,8 +385,10 @@ namespace AIImages height += 35f; // Текст внешности - string appearanceText = GetAppearanceDescription(); - height += Text.CalcHeight(appearanceText, 640f) + 20f; + string appearanceText = AIImagesMod.PawnDescriptionService.GetAppearanceDescription( + pawn + ); + height += Text.CalcHeight(appearanceText, 400f) + 20f; // Разделитель height += 15f; @@ -491,21 +397,11 @@ namespace AIImages height += 35f; // Текст одежды - string apparelText = GetApparelDescription(); - height += Text.CalcHeight(apparelText, 640f) + 20f; + string apparelText = AIImagesMod.PawnDescriptionService.GetApparelDescription(pawn); + height += Text.CalcHeight(apparelText, 400f) + 20f; - // Разделитель - height += 15f; - - // Заголовок "Промпт" - height += 35f; - - // Текст промпта - string promptText = GenerateStableDiffusionPrompt(); - height += Text.CalcHeight(promptText, 640f) + 10f; - - // Кнопка и отступ - height += 30f + 20f; + // Дополнительный отступ + height += 50f; return height; }