From 387ac36325fbe1b97e3c92e10a59da11a7c334ab Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 22 Nov 2025 22:31:01 -0500 Subject: [PATCH 01/15] Release v1.2 - fixed menus --- faststack/faststack/.app.py.swp | Bin 0 -> 36864 bytes faststack/faststack/app.py | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 faststack/faststack/.app.py.swp diff --git a/faststack/faststack/.app.py.swp b/faststack/faststack/.app.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..2bec65bcd76a7e540cc0ed74cbe7e4c258a06900 GIT binary patch literal 36864 zcmeI536x}2dB>mPf*QdP@Z^Z-OSR)vLw8LN2x1@GFw?WpVU}KI03FBY>#A4X#Z=WR z-g`AY4Wg*v7Cq5yMnYml!6inCCeg$riBWTs5H%WETw(}H5{-(6C@ul>`|k3Vs_NOm zb3C4^GykbqclqwU-~H})?{~lZZZA$;JhRh1zjC(2=ShxpZ&Yer_Vp(^C;hSG_)Whp z!AJUR37XCE=RA92;s^;hwA#sr>V|4q3pUjKIEfR#dSKv_-|193t4AO=j2?1e$bqBf zKsTw6pMTQcbI#tVhUrM@SKMDZW$)4E9;O>|V90?X2ZkINa$v}TAqR#W7;<39fu92o zB%P-?FQXlgG;O)ed_SV+`zPkJXu=Ql#6M`_cbV`{^~675;^$2G>81c%&i|SC=bQLu zPyCNf{EJNdCwt-_W5Q?5_lRkbrTot&4h%Uk z9W<2-}f{g)UrPjVb<|9|>q$N3bv1$+?P2!0Q|9=rrp!3J;| zcmnwDNseydA3xD?z6rhx?gY1kHvu191jfM_c<>h- z=ZoN7paz}|9t*zm1j2w1ei?lJ@s9Hja1fja&IL~cPX?cQoa1~L+zdJ(1Y5zmpbSn2 zCxiR2>23yZ0&fIu&;l2LF>n?TyYHW{4Q~ZEftP_QSOiZ2Kfx~jGI%4H22Te+z~pWV{l#t60i%L2>zKphbGtto(iPT{_`jeP&fCf z`qkyY>vWT;IB3=@jh4R@cq*{sd2x_5lAz^zV@@uMs}t25%^=R_QUO^?MXwVD^&p{$ z$enU$(;wxOUT>)++q{|tJLdOJOU=qH|B$yxKs=>u8aJ*8bikOap2PaoQjmDHK#r2s zJY6@58BNRR%}E)Jc2dp-FD<%fx*I3Xe%6_1o-;5X38vg?*seF0Dil|5hJI3BUk#&R z{g}JHK#72jm2)#HBkuUPJ56I6?Irh_DHG(^_;^~!^dZNPpw>u2Vcd-L%2xHOJnD|P zGwnvw@S8N!O_l>!rz=v38*s#z%nSNyr{9K1RL;?_H6oZO-M-?hJ{ogt!D4r53U2b{ zwcu#hj(kGMUZS%MrbkCdLA-2wquGd)jE>^RW={JA?V39swmM-uXeY5Squ7KF3E+}v zWyou^Yr&yOcXuaggl)gMk51fAO?E-Rg)3VSwI~W|uFXI%lc7ko)`&Yzf7OcuzqU%| zZGJOWx%Wg=$!*kKlR`?Vob>Lou@DH`?9`YzS9!e4E?7cv(Cm zE-`vyuiL5lh+LKiF6+8fn|>UlN_4_`Et^rT`5*aJN_7X16ykYpzZG~MNrW3wkaVNAyUciH5;@CNvy9jAqeMlU z@F_P=qHZ;*bQs0u(sO4TjhUI!nA;Iyi}_BxL+bz4F}J>Jn*T0AEox?}N2;<>#F1eZ z2oIuIktZ!NddNaUG)DU4Q1(NW!q2mHB)VvR_w2Tr9a|S{#mi?;(0Jyt(Mea=PB$-W z4%y447fzHamxVB>gFMBfw3i?PM=L>FCb)7*&TefyuNjnd_>tGB zR`oh4UW?;sO8Ban_TjWz*+mrEqwJb=t{ zfU043F*%!lw_RQKOsH26tKI%qgm%`qxy+0{fV^Ey81lsx#eno`-pzUhWoH&L)X?p9RL>dqkXMMd=%XjF)-R=XYB81=(5QN20zLe3`DztCQH zzxUPme)ub9l(ndSY%Ct)f-tTr%dWQ9ZY_(F(S}QN3tiXoiV-QgefTp4)nmcAICta4L=gZU!>fx{he$3rF zvyd=tS~_uekqlM%VMCH-S^P;SnxYT@vz)2ayUaF0nlv;5i`ZcbwbJIuZ(XxwwF4#B zP_j$YF}2)5iW^m!y{XNtv!~l>nmjUyn~g=AX;#LxA>c<#Ol@&mdnwe$#mh}rvnHXQ z?$TeYX_c+WJLv44Otr3`twUI!Fpk`gh=Nl2+c^PGkRD|K?}0 z>Hi%38Mqqk0aIWdaKQ=S8`$w*2CoB4pbj>GFJZTT5xf`tA=n9afb+ojvEAec&x3QogYf(Ta5oVC-vJ)3kISdpY_RCUys^tG zcWTNld7iQmypma(s)?~Z6=XFE_%%Uwb||N#n5Delx5d8nVnrV z3Z+pCL6b_rl5BxDy<;Xna@AOs)tKs2({u4^%wnRElo{@7RZ$)t)hkR1@E5~wvL~GD zHj)b3u1yJLzRkM65-)cXOvJYCPnPl6!T}bUYa=uzkt5Vgr2*f|C2AZo;ZC}K9h*xw zM=Uv6QI=WfuB)6=kKwK;u?jX--K&P(Hi_B97m`XDs;r%A*)oG z{gnX3QZ@!fbg+SD*R@lUtZiLtNQ!1tO=<$J<%L+%-!IYNK=yUov^GTKQzOn%Ys8mg|!`wmPeNP z$W^jhE@j<`z-2$OzFsS1w}BuhqI9xc6*fT-pu`x65<&K2?@#MMqvS^1t%o`x%f^g| z64bpj7^V=C9)!)h9foUYM71omwo18VS2k2eD=izKn^6p-I>qc+(M3@jI9xa74f}^W zRH;gVURl?aIRisI+ZQnkDc^jt?vxmWG5wz`Yvb4cfPm};l+9X@9T_7lV_HV|noG`= zJw!h#8TlGO_&}*G#_GFq{>vdG1((d#M%vM&WWYA{+u~)ITYNj>= z36(C7s*@WoPsZt9*@&xz(b5K8huDnnf%z<3)CPh5aU>oUDndsMv@~QtMh2(M6^t%A z({5eu68>j1nW>&hw>w($+l?#K47?>21y?R*OZ8fCaP8!S=}H|^l{i=umiAjG zW*~|p*h)kOa9vOtM-t>egP39CbJN7GP}d{J7fZH+X2M&*o`>lSo83$xHk}lowMr7@ zV?=O7rE3FITp&=5h3ppF6J;@c8IcRBJz6(m0GWR3DO~El;zw-=luH9@ZxkP!S1kA1 zp6NQ6@A7^as6Nc z%Zb#QGjQH~KRyM_D3pH5jn=Zr3v1JZvdgqE(y|0AUJDZJ* z+1>MdyxC2AE-cB0yj3X!^-G_Eb3{f8H1~LOduOIE@}_t1+B3g<$AY)8=lMIfD$-2l zEW+p!GnMynMfxyZE$2j~Ct0@IENt%_ANL9*Sh%wU)t#A4z>5KER@GqjUvcwWWmVxz zdiy&Ut%9+w)5gg}X?3gK)^gR(Ot!;{5=}*MQnt0Jjo2u9k?bypVUrNGiQv__Tv|2pj2mty;h|KFa!{wVhSHDDHq zkN;HgZ`k&~4(7o3u;sr1J^?-s-T^KHr-6URuD=U>61)LC2RK0d^)CT4;6m_t@D=R& zw}TtNHDD6lk1c;YcmsGLcq|b6{zusJw}I=xPH-x?2V4GY;AZd|Fb8JA7BB)Hpk03t zZU)lEn}FE*;*Pxnw)`J}H-i1(S>R;wAK3CY zfos7vzymYjncyyL`j3GRg7<+Nz$-!S3*i?*xKu2F-{a$E28ZOFhQgIbCp77MawWzv{QEK~wyeg9_5aQf>QJl#^gkq5(eNv+ylBw#xD?9(=pI z?`k)K%yY)?%FLyF)P*dCA3{Sui)>`}cdNvxh7}sl_-{B8mH#j!Hd;$+RjroavMCgr zURP3(O^=dIwksO4#L`*tuVa6*ql0f7V=p^DBCB*9T~5BJ5qBH5Z+czi=>BN^?AXWIGinShV-9wV0r)vt05>+T7uUc3jHsa1}-?yrQZ2YiM z(GJw;ur)IERvuI51G9TjZ!ffwZY7vyLaOG0KiuS~DryeKOa4qzrQ-MRA^1(h=5?qSU0J9ov^tIlqS-5W25D`qH(JgJ5bkjb=OaMcKB80qbg zTwUNXXJPz=9UWUOwXwnJr~Z8Fb7Q}s?N*?KeX?=fpF$nHV6mu1N`8FMIcjjk>FAJ^ zWB&j#9W09~+hbWR^FvR{?wMB)-)qzCIN?>J+Et#sMIJ}oz3QNbZ1AvI9Cw*9%SyB2 z@saKBpw`o;sw-6E!bt3G&(bQ}hb)5)nbaO6IrM}E&i^M%_i{6+6P|>fF&9}_=6Nwp zl8{xMFhD+fBeLMG(mp92?Xt+kc9TsJ1ivjavK%n0sdhr_kXxP@-#9Tbk!t7xOfIxV zcsu4w#U^!yshxRJRW#4Kp6W-TCkky;O&&d*^d9x7lIALrL-3C@vLx-G(S?6NxTN~A zFw}%6#aim(Sk3j19CnGvT-E{I+$lRrzV=unM{a0I+EnjMs}ZEnmh- zt5y_J^O&;_=r28bikh}iu1uIp0gcHXyE2aq3n#_RS`Z*iC)psp*vIZ{vp<;ii?NmZ z8!1MSog$TClno!+w%v{nlSKO?j4T-WYqIgLrUfG$EC>9;VTMBE`pT0k;T(BhcE~Y* z5MsY(wYC80X>niW~8|$s-lY z#+tNB4ke~@V@1wqGec%$FfAyb3T;j0Q_fHxJ`OoBDO|{I7wJgC%f2_+RY(4}fdIW^gjN_i>K% zesC?g065@1`21fBt_0_UbAX%!xD8+b+rY)(H1Iuq{C9%4g6qI4*aYsu$Nv`~0u$ho z;G_8WuL29;B=8M<`u_lK1TO^g{{0hx-1+x+;6@<)x)8`&{xbL)Jp66&a&QQ|7}S8( zG*Qq}M|13ZlQuj}AOU)hE)8PeNF1d(X;MyBytO*a3Kc$9w$Pr(iOf0eC|tj#2W4G!5a&H`j-@Q$if86zfb42^V-CKk*Q3IfRrytJa^PB5(EcLhkx2*W(3_;v|3Stax1T2ogxZgC<* z9htvM_l`XsDn#U?;@I1hH|cr5RyE5-wBBJF)RW&8h=Y-CVeQhyP)5ce!vQE1D*44(`b+!E*iBe?b zt?QhFO-jp33gSx45o2|0!bL&QNySe>MH*KP+tj#mY*qV+aMX@c%Dh^On5Yu>XRZNB zEnOh*4jj^huCJNqqJ_dREmF|k2MrE@WepHVY-C=uKRgZVwdpI`ZNy4<2%EEg;*7^s z-Lv&lnK_ce>)y4BW>&ri=8Wy&>SfJgJa08;J*;-ogk{i+>s-5~<<0cMnC`2|z%i{# zUt>IPR-;F=vTrb}jB5=A6?vFppyD6O5XczbUrCqOOSR@+R};Aw@0;RSp7|Mn2gln@ ze%qHtk)}^}qTLkdHGgni`5bOB150@tim!f?#Sz&S`)M)YD40iTt!@W)jHK;VmK4S8 zk?)XHS?)V}Ww}b<&B%rJ|1tJ3`HV6xDulfXJ>e zY1CJhmk16@5_4yQWAns~N2!7hiDT8kDqEgyhu5B}#%co1Y2ag|VKs1H zwqw!2Ds!rV`$*+Aa9^a>5Heiznp7pT1J;p-%13w48e0KtOTRhl!h22jgc0QJ$|-w) z;USBErY#rxG{_)rxdtz?mI{@;IIOKLi|TGL_wpwQdPcb;Wyz4G<1djn6Kv10(&jrb9pOfZA>N>ud zx+e*5w)1kLyup&ATL>IT*`~0x?e8Aysxdvp^I-#Nn*^4$`PoBREGRiMKq}Q&db2~> zRr^#xVJ6+LUr>__8J$6WjAUJz@-ThK`qN6DC#OHMV7=%o6q%dxmH0{@7jmn1v5w|_ z-2Mt@xsT!e|DRyPe;tVZk14G_cVX{;3H&K&fiuA4!6Sf5YtjwBh8!4jV90?X2ZkIN za$v}TAqR#W7;<39fguNm9Qc{yfNMTK?~Yim!j)|S^L9YG6`{8Z)GeptKUJFu@|K%b!L!8{lN-)5uZPH+d1djsDBeDGp02TI^uoFljc+z$Q* zyamWT|2~)p&j8=yOu-ky7r@7X+$k7<8rTcggZnsR@UP(W;QinbSOGi0lY#s#zWX_M z@Hy~7a0tlX0oVwh4xR>10rztT;X2R;a{s`&;0*9oa60%QefT->W+3+vNT0p}TmaUUyU1 zXXkzJc4t0y6E)(jT4 zr6E^g$$NI@vLU>EO@_KKR|Ofy9?Qi;QiNvS`>H0nln)yNax~XsKrYF{%>a(Oaip)|%Y}Mt zjQTXm!_nlF*nqaCaSwMRc|V+M7L(<*+h|j)*+lhsfDWhB^dAbcTE)^E&`KM%W+Qux zvP$cSs!Xofr-|~FsZp7hM2$+QX`M=t*Vk0(a7{~fr79pJEz?nf^dPldq^;II>Dr82 zM!Nd1 zWQEJsu*rd2)rF&SbJO~abL+G9<%qkx&8-CHM87P*SR`jDm0+Z7OCOFXUi|SZN@u;3 zE7hy34kM8ME$biV{!!_V+?kFbk-H=F)zGOqRU)_fon1#_P5!h>CWSs_IZ@&M0XZtU zQ66kjav#QY({FX;bPCs%q Date: Sat, 22 Nov 2025 22:35:47 -0500 Subject: [PATCH 02/15] Release v1.2 - fixed menus --- faststack/faststack/.app.py.swp | Bin 36864 -> 0 bytes faststack/faststack/a2 | 2151 +++++++++++++++++++++++++++++++ faststack/faststack/app.py | 7 +- 3 files changed, 2155 insertions(+), 3 deletions(-) delete mode 100644 faststack/faststack/.app.py.swp create mode 100644 faststack/faststack/a2 diff --git a/faststack/faststack/.app.py.swp b/faststack/faststack/.app.py.swp deleted file mode 100644 index 2bec65bcd76a7e540cc0ed74cbe7e4c258a06900..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36864 zcmeI536x}2dB>mPf*QdP@Z^Z-OSR)vLw8LN2x1@GFw?WpVU}KI03FBY>#A4X#Z=WR z-g`AY4Wg*v7Cq5yMnYml!6inCCeg$riBWTs5H%WETw(}H5{-(6C@ul>`|k3Vs_NOm zb3C4^GykbqclqwU-~H})?{~lZZZA$;JhRh1zjC(2=ShxpZ&Yer_Vp(^C;hSG_)Whp z!AJUR37XCE=RA92;s^;hwA#sr>V|4q3pUjKIEfR#dSKv_-|193t4AO=j2?1e$bqBf zKsTw6pMTQcbI#tVhUrM@SKMDZW$)4E9;O>|V90?X2ZkINa$v}TAqR#W7;<39fu92o zB%P-?FQXlgG;O)ed_SV+`zPkJXu=Ql#6M`_cbV`{^~675;^$2G>81c%&i|SC=bQLu zPyCNf{EJNdCwt-_W5Q?5_lRkbrTot&4h%Uk z9W<2-}f{g)UrPjVb<|9|>q$N3bv1$+?P2!0Q|9=rrp!3J;| zcmnwDNseydA3xD?z6rhx?gY1kHvu191jfM_c<>h- z=ZoN7paz}|9t*zm1j2w1ei?lJ@s9Hja1fja&IL~cPX?cQoa1~L+zdJ(1Y5zmpbSn2 zCxiR2>23yZ0&fIu&;l2LF>n?TyYHW{4Q~ZEftP_QSOiZ2Kfx~jGI%4H22Te+z~pWV{l#t60i%L2>zKphbGtto(iPT{_`jeP&fCf z`qkyY>vWT;IB3=@jh4R@cq*{sd2x_5lAz^zV@@uMs}t25%^=R_QUO^?MXwVD^&p{$ z$enU$(;wxOUT>)++q{|tJLdOJOU=qH|B$yxKs=>u8aJ*8bikOap2PaoQjmDHK#r2s zJY6@58BNRR%}E)Jc2dp-FD<%fx*I3Xe%6_1o-;5X38vg?*seF0Dil|5hJI3BUk#&R z{g}JHK#72jm2)#HBkuUPJ56I6?Irh_DHG(^_;^~!^dZNPpw>u2Vcd-L%2xHOJnD|P zGwnvw@S8N!O_l>!rz=v38*s#z%nSNyr{9K1RL;?_H6oZO-M-?hJ{ogt!D4r53U2b{ zwcu#hj(kGMUZS%MrbkCdLA-2wquGd)jE>^RW={JA?V39swmM-uXeY5Squ7KF3E+}v zWyou^Yr&yOcXuaggl)gMk51fAO?E-Rg)3VSwI~W|uFXI%lc7ko)`&Yzf7OcuzqU%| zZGJOWx%Wg=$!*kKlR`?Vob>Lou@DH`?9`YzS9!e4E?7cv(Cm zE-`vyuiL5lh+LKiF6+8fn|>UlN_4_`Et^rT`5*aJN_7X16ykYpzZG~MNrW3wkaVNAyUciH5;@CNvy9jAqeMlU z@F_P=qHZ;*bQs0u(sO4TjhUI!nA;Iyi}_BxL+bz4F}J>Jn*T0AEox?}N2;<>#F1eZ z2oIuIktZ!NddNaUG)DU4Q1(NW!q2mHB)VvR_w2Tr9a|S{#mi?;(0Jyt(Mea=PB$-W z4%y447fzHamxVB>gFMBfw3i?PM=L>FCb)7*&TefyuNjnd_>tGB zR`oh4UW?;sO8Ban_TjWz*+mrEqwJb=t{ zfU043F*%!lw_RQKOsH26tKI%qgm%`qxy+0{fV^Ey81lsx#eno`-pzUhWoH&L)X?p9RL>dqkXMMd=%XjF)-R=XYB81=(5QN20zLe3`DztCQH zzxUPme)ub9l(ndSY%Ct)f-tTr%dWQ9ZY_(F(S}QN3tiXoiV-QgefTp4)nmcAICta4L=gZU!>fx{he$3rF zvyd=tS~_uekqlM%VMCH-S^P;SnxYT@vz)2ayUaF0nlv;5i`ZcbwbJIuZ(XxwwF4#B zP_j$YF}2)5iW^m!y{XNtv!~l>nmjUyn~g=AX;#LxA>c<#Ol@&mdnwe$#mh}rvnHXQ z?$TeYX_c+WJLv44Otr3`twUI!Fpk`gh=Nl2+c^PGkRD|K?}0 z>Hi%38Mqqk0aIWdaKQ=S8`$w*2CoB4pbj>GFJZTT5xf`tA=n9afb+ojvEAec&x3QogYf(Ta5oVC-vJ)3kISdpY_RCUys^tG zcWTNld7iQmypma(s)?~Z6=XFE_%%Uwb||N#n5Delx5d8nVnrV z3Z+pCL6b_rl5BxDy<;Xna@AOs)tKs2({u4^%wnRElo{@7RZ$)t)hkR1@E5~wvL~GD zHj)b3u1yJLzRkM65-)cXOvJYCPnPl6!T}bUYa=uzkt5Vgr2*f|C2AZo;ZC}K9h*xw zM=Uv6QI=WfuB)6=kKwK;u?jX--K&P(Hi_B97m`XDs;r%A*)oG z{gnX3QZ@!fbg+SD*R@lUtZiLtNQ!1tO=<$J<%L+%-!IYNK=yUov^GTKQzOn%Ys8mg|!`wmPeNP z$W^jhE@j<`z-2$OzFsS1w}BuhqI9xc6*fT-pu`x65<&K2?@#MMqvS^1t%o`x%f^g| z64bpj7^V=C9)!)h9foUYM71omwo18VS2k2eD=izKn^6p-I>qc+(M3@jI9xa74f}^W zRH;gVURl?aIRisI+ZQnkDc^jt?vxmWG5wz`Yvb4cfPm};l+9X@9T_7lV_HV|noG`= zJw!h#8TlGO_&}*G#_GFq{>vdG1((d#M%vM&WWYA{+u~)ITYNj>= z36(C7s*@WoPsZt9*@&xz(b5K8huDnnf%z<3)CPh5aU>oUDndsMv@~QtMh2(M6^t%A z({5eu68>j1nW>&hw>w($+l?#K47?>21y?R*OZ8fCaP8!S=}H|^l{i=umiAjG zW*~|p*h)kOa9vOtM-t>egP39CbJN7GP}d{J7fZH+X2M&*o`>lSo83$xHk}lowMr7@ zV?=O7rE3FITp&=5h3ppF6J;@c8IcRBJz6(m0GWR3DO~El;zw-=luH9@ZxkP!S1kA1 zp6NQ6@A7^as6Nc z%Zb#QGjQH~KRyM_D3pH5jn=Zr3v1JZvdgqE(y|0AUJDZJ* z+1>MdyxC2AE-cB0yj3X!^-G_Eb3{f8H1~LOduOIE@}_t1+B3g<$AY)8=lMIfD$-2l zEW+p!GnMynMfxyZE$2j~Ct0@IENt%_ANL9*Sh%wU)t#A4z>5KER@GqjUvcwWWmVxz zdiy&Ut%9+w)5gg}X?3gK)^gR(Ot!;{5=}*MQnt0Jjo2u9k?bypVUrNGiQv__Tv|2pj2mty;h|KFa!{wVhSHDDHq zkN;HgZ`k&~4(7o3u;sr1J^?-s-T^KHr-6URuD=U>61)LC2RK0d^)CT4;6m_t@D=R& zw}TtNHDD6lk1c;YcmsGLcq|b6{zusJw}I=xPH-x?2V4GY;AZd|Fb8JA7BB)Hpk03t zZU)lEn}FE*;*Pxnw)`J}H-i1(S>R;wAK3CY zfos7vzymYjncyyL`j3GRg7<+Nz$-!S3*i?*xKu2F-{a$E28ZOFhQgIbCp77MawWzv{QEK~wyeg9_5aQf>QJl#^gkq5(eNv+ylBw#xD?9(=pI z?`k)K%yY)?%FLyF)P*dCA3{Sui)>`}cdNvxh7}sl_-{B8mH#j!Hd;$+RjroavMCgr zURP3(O^=dIwksO4#L`*tuVa6*ql0f7V=p^DBCB*9T~5BJ5qBH5Z+czi=>BN^?AXWIGinShV-9wV0r)vt05>+T7uUc3jHsa1}-?yrQZ2YiM z(GJw;ur)IERvuI51G9TjZ!ffwZY7vyLaOG0KiuS~DryeKOa4qzrQ-MRA^1(h=5?qSU0J9ov^tIlqS-5W25D`qH(JgJ5bkjb=OaMcKB80qbg zTwUNXXJPz=9UWUOwXwnJr~Z8Fb7Q}s?N*?KeX?=fpF$nHV6mu1N`8FMIcjjk>FAJ^ zWB&j#9W09~+hbWR^FvR{?wMB)-)qzCIN?>J+Et#sMIJ}oz3QNbZ1AvI9Cw*9%SyB2 z@saKBpw`o;sw-6E!bt3G&(bQ}hb)5)nbaO6IrM}E&i^M%_i{6+6P|>fF&9}_=6Nwp zl8{xMFhD+fBeLMG(mp92?Xt+kc9TsJ1ivjavK%n0sdhr_kXxP@-#9Tbk!t7xOfIxV zcsu4w#U^!yshxRJRW#4Kp6W-TCkky;O&&d*^d9x7lIALrL-3C@vLx-G(S?6NxTN~A zFw}%6#aim(Sk3j19CnGvT-E{I+$lRrzV=unM{a0I+EnjMs}ZEnmh- zt5y_J^O&;_=r28bikh}iu1uIp0gcHXyE2aq3n#_RS`Z*iC)psp*vIZ{vp<;ii?NmZ z8!1MSog$TClno!+w%v{nlSKO?j4T-WYqIgLrUfG$EC>9;VTMBE`pT0k;T(BhcE~Y* z5MsY(wYC80X>niW~8|$s-lY z#+tNB4ke~@V@1wqGec%$FfAyb3T;j0Q_fHxJ`OoBDO|{I7wJgC%f2_+RY(4}fdIW^gjN_i>K% zesC?g065@1`21fBt_0_UbAX%!xD8+b+rY)(H1Iuq{C9%4g6qI4*aYsu$Nv`~0u$ho z;G_8WuL29;B=8M<`u_lK1TO^g{{0hx-1+x+;6@<)x)8`&{xbL)Jp66&a&QQ|7}S8( zG*Qq}M|13ZlQuj}AOU)hE)8PeNF1d(X;MyBytO*a3Kc$9w$Pr(iOf0eC|tj#2W4G!5a&H`j-@Q$if86zfb42^V-CKk*Q3IfRrytJa^PB5(EcLhkx2*W(3_;v|3Stax1T2ogxZgC<* z9htvM_l`XsDn#U?;@I1hH|cr5RyE5-wBBJF)RW&8h=Y-CVeQhyP)5ce!vQE1D*44(`b+!E*iBe?b zt?QhFO-jp33gSx45o2|0!bL&QNySe>MH*KP+tj#mY*qV+aMX@c%Dh^On5Yu>XRZNB zEnOh*4jj^huCJNqqJ_dREmF|k2MrE@WepHVY-C=uKRgZVwdpI`ZNy4<2%EEg;*7^s z-Lv&lnK_ce>)y4BW>&ri=8Wy&>SfJgJa08;J*;-ogk{i+>s-5~<<0cMnC`2|z%i{# zUt>IPR-;F=vTrb}jB5=A6?vFppyD6O5XczbUrCqOOSR@+R};Aw@0;RSp7|Mn2gln@ ze%qHtk)}^}qTLkdHGgni`5bOB150@tim!f?#Sz&S`)M)YD40iTt!@W)jHK;VmK4S8 zk?)XHS?)V}Ww}b<&B%rJ|1tJ3`HV6xDulfXJ>e zY1CJhmk16@5_4yQWAns~N2!7hiDT8kDqEgyhu5B}#%co1Y2ag|VKs1H zwqw!2Ds!rV`$*+Aa9^a>5Heiznp7pT1J;p-%13w48e0KtOTRhl!h22jgc0QJ$|-w) z;USBErY#rxG{_)rxdtz?mI{@;IIOKLi|TGL_wpwQdPcb;Wyz4G<1djn6Kv10(&jrb9pOfZA>N>ud zx+e*5w)1kLyup&ATL>IT*`~0x?e8Aysxdvp^I-#Nn*^4$`PoBREGRiMKq}Q&db2~> zRr^#xVJ6+LUr>__8J$6WjAUJz@-ThK`qN6DC#OHMV7=%o6q%dxmH0{@7jmn1v5w|_ z-2Mt@xsT!e|DRyPe;tVZk14G_cVX{;3H&K&fiuA4!6Sf5YtjwBh8!4jV90?X2ZkIN za$v}TAqR#W7;<39fguNm9Qc{yfNMTK?~Yim!j)|S^L9YG6`{8Z)GeptKUJFu@|K%b!L!8{lN-)5uZPH+d1djsDBeDGp02TI^uoFljc+z$Q* zyamWT|2~)p&j8=yOu-ky7r@7X+$k7<8rTcggZnsR@UP(W;QinbSOGi0lY#s#zWX_M z@Hy~7a0tlX0oVwh4xR>10rztT;X2R;a{s`&;0*9oa60%QefT->W+3+vNT0p}TmaUUyU1 zXXkzJc4t0y6E)(jT4 zr6E^g$$NI@vLU>EO@_KKR|Ofy9?Qi;QiNvS`>H0nln)yNax~XsKrYF{%>a(Oaip)|%Y}Mt zjQTXm!_nlF*nqaCaSwMRc|V+M7L(<*+h|j)*+lhsfDWhB^dAbcTE)^E&`KM%W+Qux zvP$cSs!Xofr-|~FsZp7hM2$+QX`M=t*Vk0(a7{~fr79pJEz?nf^dPldq^;II>Dr82 zM!Nd1 zWQEJsu*rd2)rF&SbJO~abL+G9<%qkx&8-CHM87P*SR`jDm0+Z7OCOFXUi|SZN@u;3 zE7hy34kM8ME$biV{!!_V+?kFbk-H=F)zGOqRU)_fon1#_P5!h>CWSs_IZ@&M0XZtU zQ66kjav#QY({FX;bPCs%q bool: + # Don't handle key events when a dialog is open + if self._dialog_open: + return False + + if watched == self.main_window and event.type() == QEvent.Type.KeyPress: + handled = self.keybinder.handle_key_press(event) + if handled: + return True + return super().eventFilter(watched, event) + + def _do_prefetch(self, index: int, is_navigation: bool = False, direction: Optional[int] = None): + """Helper to defer prefetch until display size is stable. + + Args: + index: The index to prefetch around + is_navigation: True if called from user navigation (arrow keys, etc.) + direction: 1 for forward, -1 for backward, None to use last direction + """ + # If navigation occurs during resize debounce, cancel timer and apply resize immediately + # to ensure prefetch uses correct dimensions + if is_navigation and self.resize_timer.isActive(): + self.resize_timer.stop() + self._handle_resize() + + if not self.display_ready: + log.debug("Display not ready, deferring prefetch for index %d", index) + self.pending_prefetch_index = index + return + self.prefetcher.update_prefetch(index, is_navigation=is_navigation, direction=direction) + + def load(self): + """Loads images, sidecar data, and starts services.""" + self.refresh_image_list() # Initial scan from disk + if not self.image_files: + self.current_index = 0 + else: + self.current_index = max(0, min(self.sidecar.data.last_index, len(self.image_files) - 1)) + self.stacks = self.sidecar.data.stacks # Load stacks from sidecar + self.dataChanged.emit() # Emit after stacks are loaded + self.watcher.start() + self._do_prefetch(self.current_index) + + # Defer initial UI sync until after images are loaded + self.sync_ui_state() + + + def refresh_image_list(self): + """Rescans the directory for images from disk and updates cache. + + This does a full disk scan and should only be called when: + - Application starts (load()) + - Directory watcher detects file changes + - User explicitly refreshes + + For filtering, use _apply_filter_to_cached_list() instead. + """ + self._all_images = find_images(self.image_dir) + self._apply_filter_to_cached_list() + + def _apply_filter_to_cached_list(self): + """Applies current filter to cached image list without disk I/O.""" + if self._filter_enabled and self._filter_string: + needle = self._filter_string.lower() + self.image_files = [ + img for img in self._all_images + if needle in img.path.stem.lower() + ] + else: + self.image_files = self._all_images + + self.prefetcher.set_image_files(self.image_files) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.ui_state.imageCountChanged.emit() + + def get_decoded_image(self, index: int) -> Optional[DecodedImage]: + """Retrieves a decoded image, blocking until ready to ensure correct display. + + This blocks the UI thread on cache miss, but that's acceptable for an image viewer + where users expect to see the correct image immediately. The prefetcher minimizes + cache misses by decoding adjacent images in advance. + """ + if not self.image_files or index < 0 or index >= len(self.image_files): + log.warning("get_decoded_image called with empty image_files or out of bounds index.") + return None + + # If editor is open for this image, return the live preview + if self.ui_state.isEditorOpen and self.image_editor.original_image and str(self.image_editor.current_filepath) == str(self.image_files[index].path): + preview_data = self.image_editor.get_preview_data() + if preview_data: + return preview_data + + _, _, display_gen = self.get_display_info() + cache_key = f"{index}_{display_gen}" + + # Check cache first + if cache_key in self.image_cache: + decoded = self.image_cache[cache_key] + with self._last_image_lock: + self.last_displayed_image = decoded + return decoded + + # Cache miss: need to decode synchronously to ensure correct image displays + if _debug_mode: + decode_start = time.perf_counter() + log.info("Cache miss for index %d (gen: %d). Blocking decode.", index, display_gen) + + # Submit with priority=True to cancel pending prefetch tasks and free up workers + future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) + if future: + try: + # Wait for decode to complete (blocking but fast for JPEGs) + result = future.result(timeout=5.0) # 5 second timeout as safety + if result: + decoded_index, decoded_display_gen = result + cache_key = f"{decoded_index}_{decoded_display_gen}" + if cache_key in self.image_cache: + decoded = self.image_cache[cache_key] + with self._last_image_lock: + self.last_displayed_image = decoded + if _debug_mode: + elapsed = time.perf_counter() - decode_start + log.info("Decoded image %d in %.3fs", index, elapsed) + return decoded + except concurrent.futures.TimeoutError: + log.exception("Timeout decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except concurrent.futures.CancelledError: + log.warning("Decode cancelled for index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except Exception as e: + log.exception("Error decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + + with self._last_image_lock: + return self.last_displayed_image + + def sync_ui_state(self): + """Forces the UI to update by emitting all state change signals.""" + self.ui_refresh_generation += 1 + self._metadata_cache_index = (-1, -1) # Invalidate cache + + # tell QML that index and image changed + self.ui_state.currentIndexChanged.emit() + self.ui_state.currentImageSourceChanged.emit() + + # this is the one your footer needs + self.ui_state.metadataChanged.emit() + + log.debug( + "UI State Synced: Index=%d, Count=%d", + self.ui_state.currentIndex, + self.ui_state.imageCount + ) + log.debug( + "Metadata Synced: Filename=%s, Uploaded=%s, StackInfo='%s', BatchInfo='%s'", + self.ui_state.currentFilename, + self.ui_state.isUploaded, + self.ui_state.stackInfoText, + self.ui_state.batchInfoText + ) + + + # --- Actions --- + + def next_image(self): + if self.current_index < len(self.image_files) - 1: + self.current_index += 1 + self._do_prefetch(self.current_index, is_navigation=True, direction=1) + self.sync_ui_state() + + def prev_image(self): + if self.current_index > 0: + self.current_index -= 1 + self._do_prefetch(self.current_index, is_navigation=True, direction=-1) + self.sync_ui_state() + + @Slot(int) + def jump_to_image(self, index: int): + """Jump to a specific image by index (0-based).""" + if 0 <= index < len(self.image_files): + direction = 1 if index > self.current_index else -1 + self.current_index = index + self._do_prefetch(self.current_index, is_navigation=True, direction=direction) + self.sync_ui_state() + self.update_status_message(f"Jumped to image {index + 1}") + else: + log.warning("Invalid image index: %d", index) + self.update_status_message("Invalid image number") + + def show_jump_to_image_dialog(self): + """Shows the jump to image dialog (called from keybinder).""" + if self.main_window and hasattr(self.main_window, 'show_jump_to_image_dialog'): + self.main_window.show_jump_to_image_dialog() + else: + log.warning("Cannot open jump to image dialog: main_window or function not available") + + @Slot() + def dialog_opened(self): + """Called when any dialog opens to disable global keybindings.""" + self._dialog_open = True + log.debug("Dialog opened, disabling global keybindings") + + @Slot() + def dialog_closed(self): + """Called when any dialog closes to re-enable global keybindings.""" + self._dialog_open = False + log.debug("Dialog closed, re-enabling global keybindings") + + def toggle_grid_view(self): + log.warning("Grid view not implemented yet.") + + def toggle_uploaded(self): + """Toggle uploaded flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.uploaded = not meta.uploaded + if meta.uploaded: + meta.uploaded_date = today + else: + meta.uploaded_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "uploaded" if meta.uploaded else "not uploaded" + self.update_status_message(f"Marked as {status}") + log.info("Toggled uploaded flag to %s for %s", meta.uploaded, stem) + + def toggle_edited(self): + """Toggle edited flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.edited = not meta.edited + if meta.edited: + meta.edited_date = today + else: + meta.edited_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "edited" if meta.edited else "not edited" + self.update_status_message(f"Marked as {status}") + log.info("Toggled edited flag to %s for %s", meta.edited, stem) + + def toggle_stacked(self): + """Toggle stacked flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.stacked = not meta.stacked + if meta.stacked: + meta.stacked_date = today + else: + meta.stacked_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "stacked" if meta.stacked else "not stacked" + self.update_status_message(f"Marked as {status}") + log.info("Toggled stacked flag to %s for %s", meta.stacked, stem) + + def get_current_metadata(self) -> Dict: + if not self.image_files or self.current_index >= len(self.image_files): + if not self._logged_empty_metadata: + log.debug("get_current_metadata: image_files is empty or index out of bounds, returning {}.") + self._logged_empty_metadata = True + return {} + self._logged_empty_metadata = False + + # Cache hit check + cache_key = (self.current_index, self.ui_refresh_generation) + if cache_key == self._metadata_cache_index: + return self._metadata_cache + + # Compute and cache + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + stack_info = self._get_stack_info(self.current_index) + batch_info = self._get_batch_info(self.current_index) + + self._metadata_cache = { + "filename": self.image_files[self.current_index].path.name, + "stacked": meta.stacked, + "stacked_date": meta.stacked_date or "", + "uploaded": meta.uploaded, + "uploaded_date": meta.uploaded_date or "", + "edited": meta.edited, + "edited_date": meta.edited_date or "", + "stack_info_text": stack_info, + "batch_info_text": batch_info + } + self._metadata_cache_index = cache_key + return self._metadata_cache + + def begin_new_stack(self): + self.stack_start_index = self.current_index + log.info("Stack start marked at index %d", self.stack_start_index) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Update UI to show start marker + self.sync_ui_state() + + def end_current_stack(self): + log.info("end_current_stack called. stack_start_index: %s", self.stack_start_index) + if self.stack_start_index is not None: + start = min(self.stack_start_index, self.current_index) + end = max(self.stack_start_index, self.current_index) + self.stacks.append([start, end]) + self.stacks.sort() # Keep stacks sorted by start index + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + log.info("Defined new stack: [%d, %d]", start, end) + self.stack_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() # Notify QML of data change + self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog + self.sync_ui_state() + else: + log.warning("No stack start marked. Press '[' first.") + + def begin_new_batch(self): + """Mark the start of a new batch for drag-and-drop.""" + self.batch_start_index = self.current_index + log.info("Batch start marked at index %d", self.batch_start_index) + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("Batch start marked") + + def end_current_batch(self): + """End the current batch and save the range.""" + log.info("end_current_batch called. batch_start_index: %s", self.batch_start_index) + if self.batch_start_index is not None: + start = min(self.batch_start_index, self.current_index) + end = max(self.batch_start_index, self.current_index) + self.batches.append([start, end]) + self.batches.sort() # Keep batches sorted by start index + log.info("Defined new batch: [%d, %d]", start, end) + self.batch_start_index = None + self._metadata_cache_index = (-1, -1) # Invalidate cache + self.dataChanged.emit() + self.sync_ui_state() + count = end - start + 1 + self.update_status_message(f"Batch defined: {count} images") + else: + log.warning("No batch start marked. Press '{' first.") + self.update_status_message("No batch start marked") + + def clear_all_batches(self): + """Clear all defined batches and stacks.""" + self.clear_all_stacks() + + def remove_from_batch_or_stack(self): + """Remove current image from any batch or stack it's in.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + removed = False + + # Check and remove from batches + new_batches = [] + batch_modified = False + for start, end in self.batches: + if not batch_modified and start <= self.current_index <= end: + # This is the batch to modify. + + # Single image batch - remove entirely by not adding anything. + if start == end: + pass + # Remove from beginning - shift start forward + elif self.current_index == start: + new_batches.append([start + 1, end]) + # Remove from end - shift end backward + elif self.current_index == end: + new_batches.append([start, end - 1]) + # Remove from middle - split into two ranges + else: + new_batches.append([start, self.current_index - 1]) + new_batches.append([self.current_index + 1, end]) + + log.info("Removed index %d from batch [%d, %d]", self.current_index, start, end) + self.update_status_message(f"Removed from batch") + removed = True + batch_modified = True + else: + new_batches.append([start, end]) + + if batch_modified: + self.batches = new_batches + + # Check and remove from stacks + if not removed: + new_stacks = [] + stack_modified = False + for start, end in self.stacks: + if not stack_modified and start <= self.current_index <= end: + # This is the stack to modify. + + # Single image stack - remove entirely. + if start == end: + pass + # Remove from beginning + elif self.current_index == start: + new_stacks.append([start + 1, end]) + # Remove from end + elif self.current_index == end: + new_stacks.append([start, end - 1]) + # Remove from middle + else: + new_stacks.append([start, self.current_index - 1]) + new_stacks.append([self.current_index + 1, end]) + + self.sidecar.data.stacks = self.stacks # Update sidecar BEFORE self.stacks is replaced + self.sidecar.save() + log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) + self.update_status_message(f"Removed from stack") + removed = True + stack_modified = True + else: + new_stacks.append([start, end]) + + if stack_modified: + self.stacks = new_stacks + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + + if removed: + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + else: + self.update_status_message("Not in any batch or stack") + + def toggle_batch_membership(self): + """Toggles the current image's inclusion in a batch.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + index_to_toggle = self.current_index + + # Check if the image is already in a batch + in_batch = False + for start, end in self.batches: + if start <= index_to_toggle <= end: + in_batch = True + break + + new_batches = [] + if in_batch: + # Remove from batch + item_removed = False + for start, end in self.batches: + if not item_removed and start <= index_to_toggle <= end: + if start < index_to_toggle: + new_batches.append([start, index_to_toggle - 1]) + if index_to_toggle < end: + new_batches.append([index_to_toggle + 1, end]) + item_removed = True + else: + new_batches.append([start, end]) + self.batches = new_batches + self.update_status_message("Removed image from batch") + log.info("Removed index %d from a batch.", index_to_toggle) + else: + # Add to batch - merge with adjacent batches if possible + if not self.batches: + self.batches.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new batch with current image.") + log.info("No existing batches. Created new batch for index %d.", index_to_toggle) + else: + # Check if adjacent to any existing batch + merged = False + for i, (start, end) in enumerate(self.batches): + # Adjacent to start of batch + if index_to_toggle == start - 1: + self.batches[i] = [index_to_toggle, end] + merged = True + break + # Adjacent to end of batch + elif index_to_toggle == end + 1: + self.batches[i] = [start, index_to_toggle] + merged = True + break + + if not merged: + # Not adjacent to any batch, create new one + self.batches.append([index_to_toggle, index_to_toggle]) + + # Sort and merge any overlapping batches + self.batches.sort() + merged_batches = [self.batches[0]] if self.batches else [] + for i in range(1, len(self.batches)): + last_start, last_end = merged_batches[-1] + current_start, current_end = self.batches[i] + if current_start <= last_end + 1: + merged_batches[-1] = [last_start, max(last_end, current_end)] + else: + merged_batches.append([current_start, current_end]) + self.batches = merged_batches + + self.update_status_message("Added image to batch") + log.info("Added index %d to batch.", index_to_toggle) + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + def toggle_stack_membership(self): + """Toggles the current image's inclusion in a stack.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + index_to_toggle = self.current_index + + # Check if the image is already in a stack + stack_to_modify_idx = -1 + for i, (start, end) in enumerate(self.stacks): + if start <= index_to_toggle <= end: + stack_to_modify_idx = i + break + + if stack_to_modify_idx != -1: + # --- Remove from existing stack --- + new_stacks = [] + item_removed = False + for i, (start, end) in enumerate(self.stacks): + if not item_removed and i == stack_to_modify_idx: + if start < index_to_toggle: + new_stacks.append([start, index_to_toggle - 1]) + if index_to_toggle < end: + new_stacks.append([index_to_toggle + 1, end]) + item_removed = True + else: + new_stacks.append([start, end]) + self.stacks = new_stacks + self.update_status_message("Removed image from stack") + log.info("Removed index %d from stack #%d.", index_to_toggle, stack_to_modify_idx + 1) + + else: + # --- Add to nearest stack --- + if not self.stacks: + self.stacks.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new stack with current image.") + log.info("No existing stacks. Created new stack for index %d.", index_to_toggle) + else: + # Find closest stack + dist_backward = float('inf') + stack_idx_backward = -1 + for i in range(index_to_toggle - 1, -1, -1): + for j, (start, end) in enumerate(self.stacks): + if start <= i <= end: + dist_backward = index_to_toggle - i + stack_idx_backward = j + break + if stack_idx_backward != -1: + break + + dist_forward = float('inf') + stack_idx_forward = -1 + for i in range(index_to_toggle + 1, len(self.image_files)): + for j, (start, end) in enumerate(self.stacks): + if start <= i <= end: + dist_forward = i - index_to_toggle + stack_idx_forward = j + break + if stack_idx_forward != -1: + break + + if stack_idx_backward == -1 and stack_idx_forward == -1: + # This case should be covered by `if not self.stacks`, but as a fallback. + self.stacks.append([index_to_toggle, index_to_toggle]) + self.update_status_message("Created new stack with current image.") + log.info("No stacks found nearby. Created new stack for index %d.", index_to_toggle) + else: + if dist_backward <= dist_forward: + stack_to_join_idx = stack_idx_backward + else: + stack_to_join_idx = stack_idx_forward + + start, end = self.stacks[stack_to_join_idx] + self.stacks[stack_to_join_idx] = [min(start, index_to_toggle), max(end, index_to_toggle)] + + # Merge overlapping stacks + self.stacks.sort() + merged_stacks = [self.stacks[0]] if self.stacks else [] + for i in range(1, len(self.stacks)): + last_start, last_end = merged_stacks[-1] + current_start, current_end = self.stacks[i] + if current_start <= last_end + 1: + merged_stacks[-1] = [last_start, max(last_end, current_end)] + else: + merged_stacks.append([current_start, current_end]) + self.stacks = merged_stacks + + # Find the new stack index for the status message + new_stack_idx = -1 + for i, (start, end) in enumerate(self.stacks): + if start <= index_to_toggle <= end: + new_stack_idx = i + break + + self.update_status_message(f"Added image to Stack #{new_stack_idx + 1}") + log.info("Added index %d to stack #%d.", index_to_toggle, new_stack_idx + 1) + + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + + + + + def launch_helicon(self): + """Launches Helicon Focus with selected files (RAW preferred, JPG fallback) or stacks.""" + if self.stacks: + log.info("Launching Helicon for %d defined stacks.", len(self.stacks)) + any_success = False + for start, end in self.stacks: + files_to_process = [] + for idx in range(start, end + 1): + if idx < len(self.image_files): + img_file = self.image_files[idx] + # Use RAW if available, otherwise use JPG + file_to_use = img_file.raw_pair if img_file.raw_pair else img_file.path + files_to_process.append(file_to_use) + + if files_to_process: + success = self._launch_helicon_with_files(files_to_process) + if success: + any_success = True + else: + log.warning("No valid files found for stack [%d, %d].", start, end) + + # Only clear stacks if at least one launch succeeded + if any_success: + self.clear_all_stacks() + + else: + log.warning("No selection or stacks defined to launch Helicon Focus.") + return + + self.sync_ui_state() + + def _launch_helicon_with_files(self, files: List[Path]) -> bool: + """Helper to launch Helicon with a specific list of files (RAW or JPG). + + Returns: + True if Helicon was successfully launched, False otherwise. + """ + log.info("Launching Helicon Focus with %d files.", len(files)) + unique_files = sorted(list(set(files))) + success, tmp_path = launch_helicon_focus(unique_files) + if success and tmp_path: + # Schedule delayed deletion of the temporary file + QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) + + # Record stacking metadata + today = date.today().isoformat() + for file_path in unique_files: + # Find the corresponding image file to get the stem + for img_file in self.image_files: + # Match by either RAW pair or JPG path + if img_file.raw_pair == file_path or img_file.path == file_path: + stem = img_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.stacked = True + meta.stacked_date = today + break + self.sidecar.save() + self._metadata_cache_index = (-1, -1) # Invalidate cache + + return success + + def _delete_temp_file(self, tmp_path: Path): + """Deletes the temporary file list passed to Helicon Focus.""" + if tmp_path.exists(): + try: + os.remove(tmp_path) + log.info("Deleted temporary file: %s", tmp_path) + except OSError as e: + log.error("Error deleting temporary file %s: %s", tmp_path, e) + + def clear_all_stacks(self): + log.info("Clearing all defined stacks, batches, and markers.") + self.stacks = [] + self.stack_start_index = None + self.batches = [] + self.batch_start_index = None + + self.sidecar.data.stacks = self.stacks + self.sidecar.save() + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.ui_state.stackSummaryChanged.emit() + self.sync_ui_state() + self.update_status_message("All stacks and batches cleared") + + def get_helicon_path(self): + return config.get('helicon', 'exe') + + def set_helicon_path(self, path): + config.set('helicon', 'exe', path) + config.save() + + def get_photoshop_path(self): + return config.get('photoshop', 'exe') + + def set_photoshop_path(self, path): + config.set('photoshop', 'exe', path) + config.save() + + def open_file_dialog(self): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.FileMode.ExistingFile) + dialog.setNameFilter("Executables (*.exe)") + if dialog.exec(): + return dialog.selectedFiles()[0] + return "" + + def check_path_exists(self, path): + return os.path.exists(path) + + def get_cache_size(self): + return config.getfloat('core', 'cache_size_gb') + + def get_cache_usage_gb(self): + """Returns current cache usage in GB.""" + return self.image_cache.currsize / (1024**3) + + def set_cache_size(self, size): + config.set('core', 'cache_size_gb', size) + config.save() + + def get_prefetch_radius(self): + return config.getint('core', 'prefetch_radius') + + def set_prefetch_radius(self, radius): + config.set('core', 'prefetch_radius', radius) + config.save() + self.prefetcher.prefetch_radius = radius + self.prefetcher.update_prefetch(self.current_index) + + def get_theme(self): + return 0 if config.get('core', 'theme') == 'dark' else 1 + + def set_theme(self, theme_index): + # update Python-side state + self.ui_state.theme = theme_index + + # persist it + theme = 'dark' if theme_index == 0 else 'light' + config.set('core', 'theme', theme) + config.save() + + # tell QML it changed (once is enough) + self.ui_state.themeChanged.emit() + + @Slot(result=str) + def get_color_mode(self): + """Returns current color management mode: 'none', 'saturation', or 'icc'.""" + return config.get('color', 'mode', fallback='none') + + @Slot(str) + def set_color_mode(self, mode: str): + """Sets color management mode and clears cache to force re-decode.""" + mode = mode.lower() + if mode not in ['none', 'saturation', 'icc']: + log.error("Invalid color mode: %s", mode) + return + + log.info("Setting color mode to: %s", mode) + config.set('color', 'mode', mode) + config.save() + + # Clear ICC caches when color mode changes + clear_icc_caches() + + # Clear cache and restart prefetcher to apply new color mode + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Notify QML that color mode changed + self.ui_state.colorModeChanged.emit() + + # Update status message + mode_names = { + 'none': 'Original Colors', + 'saturation': 'Saturation Compensation', + 'icc': 'Full ICC Profile' + } + self.update_status_message(f"Color mode: {mode_names.get(mode, mode)}") + + @Slot(result=float) + def get_saturation_factor(self): + """Returns current saturation factor (0.0-1.0).""" + return config.getfloat('color', 'saturation_factor', fallback=0.85) + + @Slot(float) + def set_saturation_factor(self, factor: float): + """Sets saturation factor and refreshes images.""" + factor = max(0.0, min(1.0, factor)) # Clamp to 0-1 + log.info("Setting saturation factor to: %.2f", factor) + config.set('color', 'saturation_factor', str(factor)) + config.save() + + # Only refresh if in saturation mode + if self.get_color_mode() == 'saturation': + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Notify QML + self.ui_state.saturationFactorChanged.emit() + + @Slot(result=str) + def get_awb_mode(self): + return config.get("awb", "mode") + + @Slot(str) + def set_awb_mode(self, mode): + config.set("awb", "mode", mode) + config.save() + + @Slot(result=float) + def get_awb_strength(self): + return config.getfloat("awb", "strength") + + @Slot(float) + def set_awb_strength(self, value): + config.set("awb", "strength", value) + config.save() + + @Slot(result=int) + def get_awb_warm_bias(self): + return config.getint("awb", "warm_bias") + + @Slot(int) + def set_awb_warm_bias(self, value): + config.set("awb", "warm_bias", value) + config.save() + + @Slot(result=int) + def get_awb_luma_lower_bound(self): + return config.getint("awb", "luma_lower_bound") + + @Slot(int) + def set_awb_luma_lower_bound(self, value): + config.set("awb", "luma_lower_bound", value) + config.save() + + @Slot(result=int) + def get_awb_luma_upper_bound(self): + return config.getint("awb", "luma_upper_bound") + + @Slot(int) + def set_awb_luma_upper_bound(self, value): + config.set("awb", "luma_upper_bound", value) + config.save() + + @Slot(result=int) + def get_awb_rgb_lower_bound(self): + return config.getint("awb", "rgb_lower_bound") + + @Slot(int) + def set_awb_rgb_lower_bound(self, value): + config.set("awb", "rgb_lower_bound", value) + config.save() + + @Slot(result=int) + def get_awb_rgb_upper_bound(self): + return config.getint("awb", "rgb_upper_bound") + + @Slot(int) + def set_awb_rgb_upper_bound(self, value): + config.set("awb", "rgb_upper_bound", value) + config.save() + + def get_default_directory(self): + return config.get('core', 'default_directory') + + def set_default_directory(self, path): + config.set('core', 'default_directory', path) + config.save() + + def open_directory_dialog(self): + dialog = QFileDialog() + dialog.setFileMode(QFileDialog.FileMode.Directory) + if dialog.exec(): + return dialog.selectedFiles()[0] + return "" + + @Slot() + def open_folder(self): + """Opens a directory dialog and reloads the application with the selected folder.""" + path = self.open_directory_dialog() + if path: + self.image_dir = Path(path) + self.load() + + + def preload_all_images(self): + if self.ui_state.isPreloading: + log.info("Preloading is already in progress.") + return + + log.info("Starting to preload all images.") + self.ui_state.isPreloading = True + self.ui_state.preloadProgress = 0 + + self.reporter = self.ProgressReporter() + self.reporter.progress_updated.connect(self._update_preload_progress) + self.reporter.finished.connect(self._finish_preloading) + + # Use existing prefetch executor (better resource utilization) + total = len(self.image_files) + + if total == 0: + log.info("No images to preload.") + self.reporter.progress_updated.emit(100) # Or 0, depending on desired UX + self.reporter.finished.emit() + return + + completed = 0 + + def _on_done(_future): + nonlocal completed + completed += 1 + progress = int((completed / total) * 100) + self.reporter.progress_updated.emit(progress) + if completed == total: + self.reporter.finished.emit() + + for i in range(total): + future = self.prefetcher.submit_task(i, self.prefetcher.generation) + if future: + future.add_done_callback(_on_done) + + def _update_preload_progress(self, progress: int): + log.debug("Updating preload progress in UI: %d%%", progress) + self.ui_state.preloadProgress = progress + + def _finish_preloading(self): + self.ui_state.isPreloading = False + self.ui_state.preloadProgress = 0 + log.info("Finished preloading all images.") + + @Slot() + def delete_current_image(self): + """Moves current JPG and RAW to recycle bin.""" + if not self.image_files: + self.update_status_message("No image to delete.") + return + + image_file = self.image_files[self.current_index] + jpg_path = image_file.path + raw_path = image_file.raw_pair + + # Create recycle bin if it doesn't exist + try: + self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + self.update_status_message(f"Failed to create recycle bin: {e}") + log.error("Failed to create recycle bin directory: %s", e) + return + + # Move files to recycle bin + deleted_files = [] + try: + if jpg_path.exists(): + dest = self.recycle_bin_dir / jpg_path.name + jpg_path.rename(dest) + deleted_files.append(jpg_path.name) + log.info("Moved %s to recycle bin", jpg_path.name) + + if raw_path and raw_path.exists(): + dest = self.recycle_bin_dir / raw_path.name + raw_path.rename(dest) + deleted_files.append(raw_path.name) + log.info("Moved %s to recycle bin", raw_path.name) + + # Add to delete history only if at least one file was moved + if deleted_files: + import time + timestamp = time.time() + self.delete_history.append((jpg_path, raw_path)) + self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) + + # Update status + if deleted_files: + files_str = ", ".join(deleted_files) + self.update_status_message(f"Deleted: {files_str}") + else: + self.update_status_message("No files to delete") + + # Refresh image list and move to next image + self.refresh_image_list() + if self.image_files: + # Stay at same index (which now shows the next image) + self.current_index = min(self.current_index, len(self.image_files) - 1) + # Clear cache and invalidate display generation to force image reload + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() # Cancel stale tasks since image list changed + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + except OSError as e: + self.update_status_message(f"Delete failed: {e}") + log.exception("Failed to delete image") + + @Slot() + def undo_delete(self): + """Unified undo that handles both delete and auto white balance operations.""" + if not self.undo_history: + self.update_status_message("Nothing to undo.") + return + + # Get the most recent action + action_type, action_data, timestamp = self.undo_history.pop() + + if action_type == "delete": + jpg_path, raw_path = action_data + # Also remove from delete_history + if self.delete_history and self.delete_history[-1] == (jpg_path, raw_path): + self.delete_history.pop() + + restored_files = [] + try: + # Restore JPG + jpg_in_bin = self.recycle_bin_dir / jpg_path.name + if jpg_in_bin.exists(): + jpg_in_bin.rename(jpg_path) + restored_files.append(jpg_path.name) + log.info("Restored %s from recycle bin", jpg_path.name) + + # Restore RAW + if raw_path: + raw_in_bin = self.recycle_bin_dir / raw_path.name + if raw_in_bin.exists(): + raw_in_bin.rename(raw_path) + restored_files.append(raw_path.name) + log.info("Restored %s from recycle bin", raw_path.name) + + # Update status + if restored_files: + files_str = ", ".join(restored_files) + self.update_status_message(f"Restored: {files_str}") + else: + self.update_status_message("No files to restore") + + # Refresh image list + self.refresh_image_list() + + # Find and navigate to the restored image + for i, img_file in enumerate(self.image_files): + if img_file.path == jpg_path: + self.current_index = i + break + + # Clear cache and invalidate display generation to force image reload + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() # Cancel stale tasks since image list changed + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to restore image") + # Put it back in history if it failed + self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) + self.delete_history.append((jpg_path, raw_path)) + + elif action_type == "auto_white_balance": + saved_path, backup_path = action_data + filepath_obj = Path(saved_path) + + try: + if backup_path.exists(): + # Restore the backup + filepath_obj.unlink() # Remove the edited version + backup_path.rename(filepath_obj) # Restore backup + log.info("Restored backup %s for %s", backup_path.name, saved_path) + + # Refresh the view + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + self.update_status_message("Undid auto white balance") + else: + # This case should not be reached if glob finds files + self.update_status_message("Backup not found") + log.warning("Backup %s disappeared before it could be restored.", backup_path) + self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to undo auto white balance") + # Put it back in history if it failed + self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) + + def shutdown(self): + log.info("Application shutting down.") + + # Check if recycle bin has files and prompt to empty + if self.recycle_bin_dir.exists(): + files_in_bin = list(self.recycle_bin_dir.glob("*")) + if files_in_bin: + file_count = len(files_in_bin) + msg_box = QMessageBox() + msg_box.setWindowTitle("Recycle Bin") + msg_box.setText(f"There are {file_count} files in the recycle bin.") + msg_box.setInformativeText("What would you like to do?") + + # Add custom buttons + delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.YesRole) + restore_btn = msg_box.addButton(f"Restore {file_count} deleted files", QMessageBox.ActionRole) + keep_btn = msg_box.addButton("Keep in Recycle Bin", QMessageBox.NoRole) + + msg_box.setDefaultButton(keep_btn) + msg_box.exec() + + clicked_button = msg_box.clickedButton() + if clicked_button == delete_btn: + self.empty_recycle_bin() + elif clicked_button == restore_btn: + self.restore_all_from_recycle_bin() + + # Clear QML context property to prevent TypeErrors during shutdown + if self.engine: + log.info("Clearing uiState context property in QML.") + del self.engine # Explicitly delete the engine + + self.watcher.stop() + self.prefetcher.shutdown() + self.sidecar.set_last_index(self.current_index) + self.sidecar.save() + + def empty_recycle_bin(self): + """Permanently deletes all files in the recycle bin.""" + if not self.recycle_bin_dir.exists(): + return + + try: + import shutil + shutil.rmtree(self.recycle_bin_dir) + self.delete_history.clear() + log.info("Emptied recycle bin and cleared delete history") + except OSError: + log.exception("Failed to empty recycle bin") + + def restore_all_from_recycle_bin(self): + """Restores all files from recycle bin to working directory.""" + if not self.recycle_bin_dir.exists(): + return + + try: + files_in_bin = list(self.recycle_bin_dir.glob("*")) + restored_count = 0 + + for file_in_bin in files_in_bin: + # Restore to original location (working directory) + dest_path = self.image_dir / file_in_bin.name + + # If file already exists, skip (don't overwrite) + if dest_path.exists(): + log.warning("File already exists, skipping: %s", dest_path) + continue + + try: + file_in_bin.rename(dest_path) + restored_count += 1 + log.info("Restored %s from recycle bin", file_in_bin.name) + except OSError as e: + log.error("Failed to restore %s: %s", file_in_bin.name, e) + + # Clear delete history since we restored everything + self.delete_history.clear() + + log.info("Restored %d files from recycle bin", restored_count) + + except OSError: + log.exception("Failed to restore files from recycle bin") + + @Slot() + def edit_in_photoshop(self): + if not self.image_files: + self.update_status_message("No image to edit.") + return + + # Prefer RAW file if it exists, otherwise use JPG + image_file = self.image_files[self.current_index] + jpg_path = image_file.path + + # Handle backup images: strip -backup, -backup2, -backup-1, etc. to find original RAW + import re + original_stem = jpg_path.stem + # Remove -backup with optional digits or -backup-digits (handles both formats) + original_stem = re.sub(r'-backup(-?\d+)?$', '', original_stem) + + # Look for RAW file with the original stem + raw_path = None + if image_file.raw_pair and image_file.raw_pair.exists(): + # Use the paired RAW if it exists + raw_path = image_file.raw_pair + else: + # Search for RAW file manually by original stem + from faststack.io.indexer import RAW_EXTENSIONS + for ext in RAW_EXTENSIONS: + potential_raw = jpg_path.parent / f"{original_stem}{ext}" + if potential_raw.exists(): + raw_path = potential_raw + break + + if raw_path and raw_path.exists(): + current_image_path = raw_path + log.info("Using RAW file for Photoshop: %s", raw_path) + else: + current_image_path = jpg_path + log.info("Using JPG file for Photoshop (no RAW found): %s", current_image_path) + + photoshop_exe = config.get('photoshop', 'exe') + photoshop_args = config.get('photoshop', 'args') + + # Validate executable path securely + is_valid, error_msg = validate_executable_path( + photoshop_exe, + app_type="photoshop", + allow_custom_paths=True + ) + + if not is_valid: + self.update_status_message(f"Photoshop validation failed: {error_msg}") + log.error("Photoshop executable validation failed: %s", error_msg) + return + + # Validate that the file path exists and is a file + if not current_image_path.exists() or not current_image_path.is_file(): + self.update_status_message(f"Image file not found: {current_image_path.name}") + log.error("Image file not found or not a file: %s", current_image_path) + return + + try: + # Build command list safely + command = [photoshop_exe] + + # Parse additional args safely using shlex (handles quotes and escapes properly) + if photoshop_args: + try: + # Use shlex to properly parse arguments with quotes/escapes + # On Windows, use posix=False to handle Windows-style paths + parsed_args = shlex.split(photoshop_args, posix=(os.name != 'nt')) + command.extend(parsed_args) + except ValueError as e: + log.error("Invalid photoshop_args format: %s", e) + self.update_status_message("Invalid Photoshop arguments configured") + return + + # Add the file path as the last argument + # Convert to string but keep it as a list element (not shell-interpolated) + command.append(str(current_image_path.resolve())) + + # SECURITY: Explicitly disable shell execution + subprocess.Popen( + command, + shell=False, # CRITICAL: Never use shell=True with user input + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True # Close unused file descriptors + ) + + # Mark as edited on successful launch + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + stem = image_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.edited = True + meta.edited_date = today + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + + self.update_status_message(f"Opened {current_image_path.name} in Photoshop.") + log.info("Launched Photoshop with: %s", command) + except FileNotFoundError as e: + self.update_status_message(f"Photoshop executable not found: {e}") + log.exception("Photoshop executable not found") + # Don't mark as edited if launch failed + return + except (OSError, subprocess.SubprocessError) as e: + self.update_status_message(f"Failed to open in Photoshop: {e}") + log.exception("Error launching Photoshop") + # Don't mark as edited if launch failed + return + + @Slot() + def copy_path_to_clipboard(self): + if not self.image_files: + self.update_status_message("No image path to copy.") + return + + current_image_path = str(self.image_files[self.current_index].path) + QApplication.clipboard().setText(current_image_path) + self.update_status_message(f"Copied: {current_image_path}") + log.info("Copied path to clipboard: %s", current_image_path) + + @Slot() + def reset_zoom_pan(self): + """Resets zoom and pan to fit the image in the window (like Ctrl+0 in Photoshop).""" + log.info("Resetting zoom and pan to fit window") + self.ui_state.resetZoomPan() + self.update_status_message("Reset zoom and pan") + + def update_status_message(self, message: str, timeout: int = 3000): + """ + Updates the UI status message and clears it after a timeout. + """ + def clear_message(): + if self.ui_state.statusMessage == message: + self.ui_state.statusMessage = "" + + self.ui_state.statusMessage = message + QTimer.singleShot(timeout, clear_message) + + + + @Slot() + def start_drag_current_image(self): + if not self.image_files or self.current_index >= len(self.image_files): + return + + # Collect all files: current + any in defined batches + files_to_drag = set() + files_to_drag.add(self.current_index) + + # Add all files from defined batches + for start, end in self.batches: + for idx in range(start, end + 1): + if 0 <= idx < len(self.image_files): + files_to_drag.add(idx) + + # Convert to sorted list and get only existing paths + file_indices = sorted(files_to_drag) + existing_indices = [idx for idx in file_indices if self.image_files[idx].path.exists()] + file_paths = [self.image_files[idx].path for idx in existing_indices] + + if not file_paths: + log.error("No valid files to drag") + return + + if self.main_window is None: + return + + drag = QDrag(self.main_window) + mime_data = QMimeData() + + # Use Qt's standard setUrls - it handles both browser and native app compatibility + urls = [QUrl.fromLocalFile(str(p)) for p in file_paths] + mime_data.setUrls(urls) + + drag.setMimeData(mime_data) + + # --- thumbnail / drag preview --- + pix = QPixmap(str(file_paths[0])) + if not pix.isNull(): + # scale it down so it's not huge + scaled = pix.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation) + drag.setPixmap(scaled) + # hotspot = center of image + drag.setHotSpot(QPoint(scaled.width() // 2, scaled.height() // 2)) + + log.info("Starting drag for %d file(s): %s", len(file_paths), [str(p) for p in file_paths]) + # Support both Copy and Move actions for browser compatibility + result = drag.exec(Qt.CopyAction | Qt.MoveAction) + log.info("Drag completed with result: %s", result) + + # Reset zoom/pan after drag completes (drag can cause unwanted panning) + self.ui_state.resetZoomPan() + + # Mark all dragged files as uploaded if drag was successful + if result in (Qt.CopyAction, Qt.MoveAction): + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + + for idx in existing_indices: + stem = self.image_files[idx].path.stem + meta = self.sidecar.get_metadata(stem) + meta.uploaded = True + meta.uploaded_date = today + + self.sidecar.save() + + # Clear all batches after successful drag (like pressing \) + self.batches = [] + self.batch_start_index = None + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(existing_indices), today) + + # --- Image Editor Logic --- + + @Slot(result=bool) + def load_image_for_editing(self): + """Loads the currently viewed image into the editor.""" + if self.image_files and self.current_index < len(self.image_files): + filepath = str(self.image_files[self.current_index].path) + # Only load if the editor is not already open for this file + if str(self.image_editor.current_filepath) == filepath and self.image_editor.original_image is not None: + # Already loaded, just reset UI state for a fresh start + self.reset_edit_parameters() + return True + + # Get the cached, display-sized image to use for fast previews + cached_preview = self.get_decoded_image(self.current_index) + + if self.image_editor.load_image(filepath, cached_preview=cached_preview): + # Pass initial edits to uiState + initial_edits = self.image_editor._initial_edits() + for key, value in initial_edits.items(): + if hasattr(self.ui_state, key): + setattr(self.ui_state, key, value) + + # Set aspect ratios for QML dropdown + self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] + self.ui_state.currentAspectRatioIndex = 0 + self.ui_state.currentCropBox = (0, 0, 1000, 1000) # Reset crop box visually + return True + return False + + @Slot(result=DecodedImage) + def get_preview_data(self) -> Optional[DecodedImage]: + """Gets the preview data of the currently edited image as a DecodedImage.""" + return self.image_editor.get_preview_data() + + @Slot(str, "QVariant") + def set_edit_parameter(self, key: str, value: Any): + """Sets an edit parameter and updates the UIState for the slider visual.""" + if self.image_editor.set_edit_param(key, value): + # Update the corresponding UIState property to reflect the new value in QML + if hasattr(self.ui_state, key): + setattr(self.ui_state, key, value) + + # Trigger a refresh of the image to show the edit + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + + @Slot(int, int, int, int) + def set_crop_box(self, left: int, top: int, right: int, bottom: int): + """Sets the normalized crop box (0-1000) in the editor.""" + from typing import Tuple + crop_box: Tuple[int, int, int, int] = (left, top, right, bottom) + self.image_editor.set_crop_box(crop_box) + self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) + + @Slot() + def reset_edit_parameters(self): + """Resets all editing parameters in the editor.""" + self.image_editor.current_edits = self.image_editor._initial_edits() + if hasattr(self.ui_state, 'reset_editor_state'): + self.ui_state.reset_editor_state() + + # Trigger a refresh to show the reset image + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + + @Slot() + def save_edited_image(self): + """Saves the edited image.""" + save_result = self.image_editor.save_image() + if save_result: + saved_path, backup_path = save_result + # Clear the image editor state so it will reload fresh next time + self.image_editor.original_image = None + self.image_editor.current_filepath = None + self.image_editor._preview_image = None + + # Reset all edit parameters in the controller/UI + self.reset_edit_parameters() + + # Refresh the view - need to refresh image list since backup file was created + original_path = saved_path + self.refresh_image_list() + + # Find the edited image (not the backup) in the refreshed list + for i, img_file in enumerate(self.image_files): + if img_file.path == original_path: + self.current_index = i + break + + # Invalidate cache and refresh display + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + QMessageBox.information( + None, + "Save Successful", + f"Image saved to: {saved_path}. Original backed up.", + QMessageBox.Ok + ) + + @Slot() + def rotate_image_cw(self): + """Rotate the edited image 90 degrees clockwise.""" + current = self.image_editor.current_edits.get('rotation', 0) + new_rotation = (current + 90) % 360 + self.set_edit_parameter('rotation', new_rotation) + + @Slot() + def rotate_image_ccw(self): + """Rotate the edited image 90 degrees counter-clockwise.""" + current = self.image_editor.current_edits.get('rotation', 0) + new_rotation = (current - 90) % 360 + if new_rotation < 0: + new_rotation += 360 + self.set_edit_parameter('rotation', new_rotation) + + @Slot() + def quick_auto_white_balance(self): + """Quickly apply auto white balance, save the image, and track for undo.""" + if not self.image_files: + self.update_status_message("No image to adjust") + return + + import time + image_file = self.image_files[self.current_index] + filepath = str(image_file.path) + + # Load the image into the editor if not already loaded + cached_preview = self.get_decoded_image(self.current_index) + if not self.image_editor.load_image(filepath, cached_preview=cached_preview): + self.update_status_message("Failed to load image") + return + + # Calculate and apply auto white balance + self.auto_white_balance() + + # Save the edited image (this creates a backup automatically) + save_result = self.image_editor.save_image() + if save_result: + saved_path, backup_path = save_result + # Track this action for undo + timestamp = time.time() + self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) + + # Force the image editor to clear its current state so it reloads fresh + self.image_editor.original_image = None + self.image_editor.current_filepath = None + self.image_editor._preview_image = None + + # Refresh the view - need to refresh image list since backup file was created + original_path = Path(filepath) + self.refresh_image_list() + + # Find the edited image (not the backup) in the refreshed list + for i, img_file in enumerate(self.image_files): + if img_file.path == original_path: + self.current_index = i + break + + # Invalidate cache for the edited image so it's reloaded from disk + # This ensures the Image Editor will see the updated version + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + self.update_status_message("Auto white balance applied and saved") + log.info("Quick auto white balance applied to %s", filepath) + else: + self.update_status_message("Failed to save image") + + @Slot() + def auto_white_balance(self): + """ + Dispatcher for auto white balance. Calls the appropriate method based on + the mode set in the config ('lab' or 'rgb'). + """ + mode = config.get('awb', 'mode', fallback='lab') + if mode == 'lab': + self.auto_white_balance_lab() + elif mode == 'rgb': + self.auto_white_balance_legacy() + else: + log.error(f"Unknown AWB mode: {mode}") + self.update_status_message(f"Error: Unknown AWB mode '{mode}'") + + def auto_white_balance_legacy(self): + """ + Calculates and applies auto white balance using the legacy grey world + assumption on the entire RGB image. + """ + if not self.image_editor.original_image: + log.warning("No image loaded in editor for auto white balance") + return + + try: + import numpy as np + except ImportError: + log.error("NumPy not found. Please install with: pip install numpy") + self.update_status_message("Error: NumPy not installed") + return + + log.info("Applying legacy (RGB Grey World) Auto White Balance") + + img = self.image_editor.original_image + arr = np.array(img, dtype=np.float32) + + r_mean = arr[:, :, 0].mean() + g_mean = arr[:, :, 1].mean() + b_mean = arr[:, :, 2].mean() + + grey_target = (r_mean + g_mean + b_mean) / 3.0 + + r_diff = r_mean - grey_target + g_diff = g_mean - grey_target + + by_shift = -(r_diff + g_diff) / 2.0 + mg_shift = -(r_diff - g_diff) / 2.0 + + by_value = by_shift / 63.75 + mg_value = mg_shift / 63.75 + + by_value = float(np.clip(by_value, -1.0, 1.0)) + mg_value = float(np.clip(mg_value, -1.0, 1.0)) + + self.image_editor.set_edit_param('white_balance_by', by_value) + self.image_editor.set_edit_param('white_balance_mg', mg_value) + + self.ui_state.white_balance_by = by_value + self.ui_state.white_balance_mg = mg_value + + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.update_status_message("Auto white balance applied (Legacy)") + + + def auto_white_balance_lab(self): + """ + Calculates and applies auto white balance using the Lab color space, + filtering out clipped and saturated pixels for a more robust result. + """ + if not self.image_editor.original_image: + log.warning("No image loaded in editor for auto white balance") + return + + try: + import cv2 + import numpy as np + except ImportError: + log.error("OpenCV or NumPy not found. Please install with: pip install opencv-python numpy") + self.update_status_message("Error: OpenCV or NumPy not installed") + return + + img = self.image_editor.original_image + # Ensure image is RGB before processing + if img.mode != 'RGB': + img = img.convert('RGB') + + arr = np.array(img, dtype=np.uint8) + + # --- Tunable Constants for Auto White Balance (from config) --- + _LOWER_BOUND_RGB = config.getint('awb', 'rgb_lower_bound', 5) + _UPPER_BOUND_RGB = config.getint('awb', 'rgb_upper_bound', 250) + _LUMA_LOWER_BOUND = config.getint('awb', 'luma_lower_bound', 30) + _LUMA_UPPER_BOUND = config.getint('awb', 'luma_upper_bound', 220) + warm_bias = config.getint('awb', 'warm_bias', 6) + _TARGET_A_LAB = 128.0 + _TARGET_B_LAB = 128.0 + warm_bias + _SCALING_FACTOR_LAB_TO_SLIDER = 128.0 + _CORRECTION_STRENGTH = config.getfloat('awb', 'strength', 0.7) + + # --- 1. Reject clipped channels and use a luma midtone mask --- + mask = ( + (arr[:, :, 0] > _LOWER_BOUND_RGB) & (arr[:, :, 0] < _UPPER_BOUND_RGB) & + (arr[:, :, 1] > _LOWER_BOUND_RGB) & (arr[:, :, 1] < _UPPER_BOUND_RGB) & + (arr[:, :, 2] > _LOWER_BOUND_RGB) & (arr[:, :, 2] < _UPPER_BOUND_RGB) + ) + + luma = (0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2]) + mask &= (luma > _LUMA_LOWER_BOUND) & (luma < _LUMA_UPPER_BOUND) + + if not np.any(mask): + log.warning("Auto white balance: No pixels found after clipping and luma filter. Aborting.") + self.update_status_message("AWB failed: no valid pixels found") + return + + # --- 2. Work in Lab color space --- + lab_image = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB) + + a_channel = lab_image[:, :, 1] + b_channel = lab_image[:, :, 2] + + masked_a = a_channel[mask] + masked_b = b_channel[mask] + + a_mean = masked_a.mean() + b_mean = masked_b.mean() + + a_shift = _TARGET_A_LAB - a_mean + b_shift = _TARGET_B_LAB - b_mean + + log.info( + "Auto WB (Lab) - means: a*=%.1f, b*=%.1f; targets: a*=%.1f, b*=%.1f; shifts: a*=%.1f, b*=%.1f", + a_mean, b_mean, _TARGET_A_LAB, _TARGET_B_LAB, a_shift, b_shift + ) + + # --- 3. Convert Lab shift to our slider values with strength factor --- + by_value = (b_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH + mg_value = (a_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH + + by_value = float(np.clip(by_value, -1.0, 1.0)) + mg_value = float(np.clip(mg_value, -1.0, 1.0)) + + log.info(f"Auto white balance values: B/Y={by_value:.3f}, M/G={mg_value:.3f}") + + self.image_editor.set_edit_param('white_balance_by', by_value) + self.image_editor.set_edit_param('white_balance_mg', mg_value) + + self.ui_state.white_balance_by = by_value + self.ui_state.white_balance_mg = mg_value + + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.update_status_message("Auto white balance applied") + + def _get_stack_info(self, index: int) -> str: + info = "" + for i, (start, end) in enumerate(self.stacks): + if start <= index <= end: + count_in_stack = end - start + 1 + pos_in_stack = index - start + 1 + info = f"Stack {i+1} ({pos_in_stack}/{count_in_stack})" + break + if not info and self.stack_start_index is not None and self.stack_start_index == index: + info = "Stack Start Marked" + log.debug("_get_stack_info for index %d: %s", index, info) + return info + + def _get_batch_info(self, index: int) -> str: + """Get batch info for the given index.""" + info = "" + # Check if current image is in any batch + in_batch = False + for start, end in self.batches: + if start <= index <= end: + in_batch = True + break + + if in_batch: + # Calculate total count across all batches + total_count = sum(end - start + 1 for start, end in self.batches) + info = f"{total_count} in Batch" + elif self.batch_start_index is not None and self.batch_start_index == index: + info = "Batch Start Marked" + + log.debug("_get_batch_info for index %d: %s", index, info) + return info + + def get_stack_summary(self) -> str: + if not self.stacks: + return "No stacks defined." + summary = [] + for i, (start, end) in enumerate(self.stacks): + summary.append(f"Stack {i+1}: {start}-{end}") + return "; ".join(summary) + + def is_stacked(self) -> bool: + if not self.image_files or self.current_index >= len(self.image_files): + return False + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + return meta.stacked + +def main(image_dir: str = "", debug: bool = False): + """FastStack Application Entry Point""" + global _debug_mode + _debug_mode = debug + + t0 = time.perf_counter() + setup_logging(debug) + if debug: + log.info("Startup: after setup_logging: %.3fs", time.perf_counter() - t0) + log.info("Starting FastStack") + + os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" + os.environ["QML2_IMPORT_PATH"] = os.path.join(os.path.dirname(__file__), "qml") + + app = QApplication(sys.argv) # Moved here + if debug: + log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) + + if not image_dir: + image_dir_str = config.get('core', 'default_directory') + if not image_dir_str: + log.warning("No image directory provided and no default directory set. Opening directory selection dialog.") + selected_dir = QFileDialog.getExistingDirectory(None, "Select Image Directory") + if not selected_dir: + log.error("No image directory selected. Exiting.") + sys.exit(1) + image_dir_str = selected_dir + image_dir_path = Path(image_dir_str) + else: + image_dir_path = Path(image_dir) + + if not image_dir_path.is_dir(): + log.error("Image directory not found: %s", image_dir_path) + sys.exit(1) + app.setOrganizationName("FastStack") + app.setOrganizationDomain("faststack.dev") + app.setApplicationName("FastStack") + + engine = QQmlApplicationEngine() + engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml")) + engine.addImportPath("qrc:/qt-project.org/imports") + engine.addImportPath(os.path.join(os.path.dirname(__file__), "qml")) + # Add the path to Qt5Compat.GraphicalEffects to QML import paths + engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml", "Qt5Compat")) + + controller = AppController(image_dir_path, engine) + if debug: + log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) + image_provider = ImageProvider(controller) + engine.addImageProvider("provider", image_provider) + + # Expose controller and UI state to QML + context = engine.rootContext() + context.setContextProperty("uiState", controller.ui_state) + context.setContextProperty("controller", controller) + + qml_file = Path(__file__).parent / "qml" / "Main.qml" + engine.load(QUrl.fromLocalFile(str(qml_file))) + if debug: + log.info("Startup: after engine.load(QML): %.3fs", time.perf_counter() - t0) + + if not engine.rootObjects(): + log.error("Failed to load QML.") + sys.exit(-1) + + # Connect key events from the main window + main_window = engine.rootObjects()[0] + controller.main_window = main_window + main_window.installEventFilter(controller) + + # Load data and start services + controller.load() + if debug: + log.info("Startup: after controller.load(): %.3fs", time.perf_counter() - t0) + + # Graceful shutdown + app.aboutToQuit.connect(controller.shutdown) + + sys.exit(app.exec()) + +def cli(): + """CLI entry point.""" + parser = argparse.ArgumentParser(description="FastStack - Ultra-fast JPG Viewer for Focus Stacking Selection") + parser.add_argument("image_dir", nargs="?", default="", help="Directory of images to view") + parser.add_argument("--debug", action="store_true", help="Enable debug logging and timing information") + args = parser.parse_args() + main(image_dir=args.image_dir, debug=args.debug) + +if __name__ == "__main__": + cli() diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 1adba92..8ce1103 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -616,7 +616,7 @@ def end_current_batch(self): self.update_status_message("No batch start marked") def clear_all_batches(self): - """Clear all defined batches.""" + """Clear all defined batches and stacks.""" self.clear_all_stacks() def remove_from_batch_or_stack(self): @@ -1779,8 +1779,9 @@ def reset_edit_parameters(self): @Slot() def save_edited_image(self): """Saves the edited image.""" - saved_path = self.image_editor.save_image() - if saved_path: + save_result = self.image_editor.save_image() + if save_result: + saved_path, backup_path = save_result # Clear the image editor state so it will reload fresh next time self.image_editor.original_image = None self.image_editor.current_filepath = None From 253786422773724049cc854fe360ac0335de77e8 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 22 Nov 2025 22:38:03 -0500 Subject: [PATCH 03/15] Release v1.2 - fixed menus --- faststack/faststack/a2 | 2151 ---------------------------------------- 1 file changed, 2151 deletions(-) delete mode 100644 faststack/faststack/a2 diff --git a/faststack/faststack/a2 b/faststack/faststack/a2 deleted file mode 100644 index ffdc0a6..0000000 --- a/faststack/faststack/a2 +++ /dev/null @@ -1,2151 +0,0 @@ -"""Main application entry point for FastStack.""" - -import logging -import sys -import struct -import shlex -import time -import argparse -from pathlib import Path -from typing import Optional, List, Dict, Any, Tuple -from datetime import date -import os -import concurrent.futures -import threading -import subprocess -from faststack.ui.provider import ImageProvider, UIState -import PySide6 -from PySide6.QtGui import QDrag, QPixmap -from PySide6.QtCore import ( - QUrl, - QTimer, - QObject, - QEvent, - Signal, - Slot, - QMimeData, - Qt, - QPoint -) -from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox -from PySide6.QtQml import QQmlApplicationEngine -from PIL import Image -Image.MAX_IMAGE_PIXELS = None - -# ⬇️ these are the ones that went missing -from faststack.config import config -from faststack.logging_setup import setup_logging -from faststack.models import ImageFile, DecodedImage, EntryMetadata -from faststack.io.indexer import find_images -from faststack.io.sidecar import SidecarManager -from faststack.io.watcher import Watcher -from faststack.io.helicon import launch_helicon_focus -from faststack.io.executable_validator import validate_executable_path -from faststack.imaging.cache import ByteLRUCache, get_decoded_image_size -from faststack.imaging.prefetch import Prefetcher, clear_icc_caches -from faststack.ui.provider import ImageProvider -from faststack.ui.keystrokes import Keybinder -from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS - -def make_hdrop(paths): - """ - Build a real CF_HDROP (DROPFILES) payload for Windows drag-and-drop. - paths: list[str] - """ - files_part = ("\0".join(paths) + "\0\0").encode("utf-16le") - - # DROPFILES header (20 bytes): bool: - # Don't handle key events when a dialog is open - if self._dialog_open: - return False - - if watched == self.main_window and event.type() == QEvent.Type.KeyPress: - handled = self.keybinder.handle_key_press(event) - if handled: - return True - return super().eventFilter(watched, event) - - def _do_prefetch(self, index: int, is_navigation: bool = False, direction: Optional[int] = None): - """Helper to defer prefetch until display size is stable. - - Args: - index: The index to prefetch around - is_navigation: True if called from user navigation (arrow keys, etc.) - direction: 1 for forward, -1 for backward, None to use last direction - """ - # If navigation occurs during resize debounce, cancel timer and apply resize immediately - # to ensure prefetch uses correct dimensions - if is_navigation and self.resize_timer.isActive(): - self.resize_timer.stop() - self._handle_resize() - - if not self.display_ready: - log.debug("Display not ready, deferring prefetch for index %d", index) - self.pending_prefetch_index = index - return - self.prefetcher.update_prefetch(index, is_navigation=is_navigation, direction=direction) - - def load(self): - """Loads images, sidecar data, and starts services.""" - self.refresh_image_list() # Initial scan from disk - if not self.image_files: - self.current_index = 0 - else: - self.current_index = max(0, min(self.sidecar.data.last_index, len(self.image_files) - 1)) - self.stacks = self.sidecar.data.stacks # Load stacks from sidecar - self.dataChanged.emit() # Emit after stacks are loaded - self.watcher.start() - self._do_prefetch(self.current_index) - - # Defer initial UI sync until after images are loaded - self.sync_ui_state() - - - def refresh_image_list(self): - """Rescans the directory for images from disk and updates cache. - - This does a full disk scan and should only be called when: - - Application starts (load()) - - Directory watcher detects file changes - - User explicitly refreshes - - For filtering, use _apply_filter_to_cached_list() instead. - """ - self._all_images = find_images(self.image_dir) - self._apply_filter_to_cached_list() - - def _apply_filter_to_cached_list(self): - """Applies current filter to cached image list without disk I/O.""" - if self._filter_enabled and self._filter_string: - needle = self._filter_string.lower() - self.image_files = [ - img for img in self._all_images - if needle in img.path.stem.lower() - ] - else: - self.image_files = self._all_images - - self.prefetcher.set_image_files(self.image_files) - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.ui_state.imageCountChanged.emit() - - def get_decoded_image(self, index: int) -> Optional[DecodedImage]: - """Retrieves a decoded image, blocking until ready to ensure correct display. - - This blocks the UI thread on cache miss, but that's acceptable for an image viewer - where users expect to see the correct image immediately. The prefetcher minimizes - cache misses by decoding adjacent images in advance. - """ - if not self.image_files or index < 0 or index >= len(self.image_files): - log.warning("get_decoded_image called with empty image_files or out of bounds index.") - return None - - # If editor is open for this image, return the live preview - if self.ui_state.isEditorOpen and self.image_editor.original_image and str(self.image_editor.current_filepath) == str(self.image_files[index].path): - preview_data = self.image_editor.get_preview_data() - if preview_data: - return preview_data - - _, _, display_gen = self.get_display_info() - cache_key = f"{index}_{display_gen}" - - # Check cache first - if cache_key in self.image_cache: - decoded = self.image_cache[cache_key] - with self._last_image_lock: - self.last_displayed_image = decoded - return decoded - - # Cache miss: need to decode synchronously to ensure correct image displays - if _debug_mode: - decode_start = time.perf_counter() - log.info("Cache miss for index %d (gen: %d). Blocking decode.", index, display_gen) - - # Submit with priority=True to cancel pending prefetch tasks and free up workers - future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) - if future: - try: - # Wait for decode to complete (blocking but fast for JPEGs) - result = future.result(timeout=5.0) # 5 second timeout as safety - if result: - decoded_index, decoded_display_gen = result - cache_key = f"{decoded_index}_{decoded_display_gen}" - if cache_key in self.image_cache: - decoded = self.image_cache[cache_key] - with self._last_image_lock: - self.last_displayed_image = decoded - if _debug_mode: - elapsed = time.perf_counter() - decode_start - log.info("Decoded image %d in %.3fs", index, elapsed) - return decoded - except concurrent.futures.TimeoutError: - log.exception("Timeout decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except concurrent.futures.CancelledError: - log.warning("Decode cancelled for index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except Exception as e: - log.exception("Error decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image - - with self._last_image_lock: - return self.last_displayed_image - - def sync_ui_state(self): - """Forces the UI to update by emitting all state change signals.""" - self.ui_refresh_generation += 1 - self._metadata_cache_index = (-1, -1) # Invalidate cache - - # tell QML that index and image changed - self.ui_state.currentIndexChanged.emit() - self.ui_state.currentImageSourceChanged.emit() - - # this is the one your footer needs - self.ui_state.metadataChanged.emit() - - log.debug( - "UI State Synced: Index=%d, Count=%d", - self.ui_state.currentIndex, - self.ui_state.imageCount - ) - log.debug( - "Metadata Synced: Filename=%s, Uploaded=%s, StackInfo='%s', BatchInfo='%s'", - self.ui_state.currentFilename, - self.ui_state.isUploaded, - self.ui_state.stackInfoText, - self.ui_state.batchInfoText - ) - - - # --- Actions --- - - def next_image(self): - if self.current_index < len(self.image_files) - 1: - self.current_index += 1 - self._do_prefetch(self.current_index, is_navigation=True, direction=1) - self.sync_ui_state() - - def prev_image(self): - if self.current_index > 0: - self.current_index -= 1 - self._do_prefetch(self.current_index, is_navigation=True, direction=-1) - self.sync_ui_state() - - @Slot(int) - def jump_to_image(self, index: int): - """Jump to a specific image by index (0-based).""" - if 0 <= index < len(self.image_files): - direction = 1 if index > self.current_index else -1 - self.current_index = index - self._do_prefetch(self.current_index, is_navigation=True, direction=direction) - self.sync_ui_state() - self.update_status_message(f"Jumped to image {index + 1}") - else: - log.warning("Invalid image index: %d", index) - self.update_status_message("Invalid image number") - - def show_jump_to_image_dialog(self): - """Shows the jump to image dialog (called from keybinder).""" - if self.main_window and hasattr(self.main_window, 'show_jump_to_image_dialog'): - self.main_window.show_jump_to_image_dialog() - else: - log.warning("Cannot open jump to image dialog: main_window or function not available") - - @Slot() - def dialog_opened(self): - """Called when any dialog opens to disable global keybindings.""" - self._dialog_open = True - log.debug("Dialog opened, disabling global keybindings") - - @Slot() - def dialog_closed(self): - """Called when any dialog closes to re-enable global keybindings.""" - self._dialog_open = False - log.debug("Dialog closed, re-enabling global keybindings") - - def toggle_grid_view(self): - log.warning("Grid view not implemented yet.") - - def toggle_uploaded(self): - """Toggle uploaded flag for current image.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - - meta.uploaded = not meta.uploaded - if meta.uploaded: - meta.uploaded_date = today - else: - meta.uploaded_date = None - - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - status = "uploaded" if meta.uploaded else "not uploaded" - self.update_status_message(f"Marked as {status}") - log.info("Toggled uploaded flag to %s for %s", meta.uploaded, stem) - - def toggle_edited(self): - """Toggle edited flag for current image.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - - meta.edited = not meta.edited - if meta.edited: - meta.edited_date = today - else: - meta.edited_date = None - - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - status = "edited" if meta.edited else "not edited" - self.update_status_message(f"Marked as {status}") - log.info("Toggled edited flag to %s for %s", meta.edited, stem) - - def toggle_stacked(self): - """Toggle stacked flag for current image.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - - meta.stacked = not meta.stacked - if meta.stacked: - meta.stacked_date = today - else: - meta.stacked_date = None - - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - status = "stacked" if meta.stacked else "not stacked" - self.update_status_message(f"Marked as {status}") - log.info("Toggled stacked flag to %s for %s", meta.stacked, stem) - - def get_current_metadata(self) -> Dict: - if not self.image_files or self.current_index >= len(self.image_files): - if not self._logged_empty_metadata: - log.debug("get_current_metadata: image_files is empty or index out of bounds, returning {}.") - self._logged_empty_metadata = True - return {} - self._logged_empty_metadata = False - - # Cache hit check - cache_key = (self.current_index, self.ui_refresh_generation) - if cache_key == self._metadata_cache_index: - return self._metadata_cache - - # Compute and cache - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - stack_info = self._get_stack_info(self.current_index) - batch_info = self._get_batch_info(self.current_index) - - self._metadata_cache = { - "filename": self.image_files[self.current_index].path.name, - "stacked": meta.stacked, - "stacked_date": meta.stacked_date or "", - "uploaded": meta.uploaded, - "uploaded_date": meta.uploaded_date or "", - "edited": meta.edited, - "edited_date": meta.edited_date or "", - "stack_info_text": stack_info, - "batch_info_text": batch_info - } - self._metadata_cache_index = cache_key - return self._metadata_cache - - def begin_new_stack(self): - self.stack_start_index = self.current_index - log.info("Stack start marked at index %d", self.stack_start_index) - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() # Update UI to show start marker - self.sync_ui_state() - - def end_current_stack(self): - log.info("end_current_stack called. stack_start_index: %s", self.stack_start_index) - if self.stack_start_index is not None: - start = min(self.stack_start_index, self.current_index) - end = max(self.stack_start_index, self.current_index) - self.stacks.append([start, end]) - self.stacks.sort() # Keep stacks sorted by start index - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - log.info("Defined new stack: [%d, %d]", start, end) - self.stack_start_index = None - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() # Notify QML of data change - self.ui_state.stackSummaryChanged.emit() # Update stack summary in dialog - self.sync_ui_state() - else: - log.warning("No stack start marked. Press '[' first.") - - def begin_new_batch(self): - """Mark the start of a new batch for drag-and-drop.""" - self.batch_start_index = self.current_index - log.info("Batch start marked at index %d", self.batch_start_index) - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() - self.sync_ui_state() - self.update_status_message("Batch start marked") - - def end_current_batch(self): - """End the current batch and save the range.""" - log.info("end_current_batch called. batch_start_index: %s", self.batch_start_index) - if self.batch_start_index is not None: - start = min(self.batch_start_index, self.current_index) - end = max(self.batch_start_index, self.current_index) - self.batches.append([start, end]) - self.batches.sort() # Keep batches sorted by start index - log.info("Defined new batch: [%d, %d]", start, end) - self.batch_start_index = None - self._metadata_cache_index = (-1, -1) # Invalidate cache - self.dataChanged.emit() - self.sync_ui_state() - count = end - start + 1 - self.update_status_message(f"Batch defined: {count} images") - else: - log.warning("No batch start marked. Press '{' first.") - self.update_status_message("No batch start marked") - - def clear_all_batches(self): - """Clear all defined batches and stacks.""" - self.clear_all_stacks() - - def remove_from_batch_or_stack(self): - """Remove current image from any batch or stack it's in.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - removed = False - - # Check and remove from batches - new_batches = [] - batch_modified = False - for start, end in self.batches: - if not batch_modified and start <= self.current_index <= end: - # This is the batch to modify. - - # Single image batch - remove entirely by not adding anything. - if start == end: - pass - # Remove from beginning - shift start forward - elif self.current_index == start: - new_batches.append([start + 1, end]) - # Remove from end - shift end backward - elif self.current_index == end: - new_batches.append([start, end - 1]) - # Remove from middle - split into two ranges - else: - new_batches.append([start, self.current_index - 1]) - new_batches.append([self.current_index + 1, end]) - - log.info("Removed index %d from batch [%d, %d]", self.current_index, start, end) - self.update_status_message(f"Removed from batch") - removed = True - batch_modified = True - else: - new_batches.append([start, end]) - - if batch_modified: - self.batches = new_batches - - # Check and remove from stacks - if not removed: - new_stacks = [] - stack_modified = False - for start, end in self.stacks: - if not stack_modified and start <= self.current_index <= end: - # This is the stack to modify. - - # Single image stack - remove entirely. - if start == end: - pass - # Remove from beginning - elif self.current_index == start: - new_stacks.append([start + 1, end]) - # Remove from end - elif self.current_index == end: - new_stacks.append([start, end - 1]) - # Remove from middle - else: - new_stacks.append([start, self.current_index - 1]) - new_stacks.append([self.current_index + 1, end]) - - self.sidecar.data.stacks = self.stacks # Update sidecar BEFORE self.stacks is replaced - self.sidecar.save() - log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) - self.update_status_message(f"Removed from stack") - removed = True - stack_modified = True - else: - new_stacks.append([start, end]) - - if stack_modified: - self.stacks = new_stacks - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - - if removed: - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.ui_state.stackSummaryChanged.emit() - self.sync_ui_state() - else: - self.update_status_message("Not in any batch or stack") - - def toggle_batch_membership(self): - """Toggles the current image's inclusion in a batch.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - index_to_toggle = self.current_index - - # Check if the image is already in a batch - in_batch = False - for start, end in self.batches: - if start <= index_to_toggle <= end: - in_batch = True - break - - new_batches = [] - if in_batch: - # Remove from batch - item_removed = False - for start, end in self.batches: - if not item_removed and start <= index_to_toggle <= end: - if start < index_to_toggle: - new_batches.append([start, index_to_toggle - 1]) - if index_to_toggle < end: - new_batches.append([index_to_toggle + 1, end]) - item_removed = True - else: - new_batches.append([start, end]) - self.batches = new_batches - self.update_status_message("Removed image from batch") - log.info("Removed index %d from a batch.", index_to_toggle) - else: - # Add to batch - merge with adjacent batches if possible - if not self.batches: - self.batches.append([index_to_toggle, index_to_toggle]) - self.update_status_message("Created new batch with current image.") - log.info("No existing batches. Created new batch for index %d.", index_to_toggle) - else: - # Check if adjacent to any existing batch - merged = False - for i, (start, end) in enumerate(self.batches): - # Adjacent to start of batch - if index_to_toggle == start - 1: - self.batches[i] = [index_to_toggle, end] - merged = True - break - # Adjacent to end of batch - elif index_to_toggle == end + 1: - self.batches[i] = [start, index_to_toggle] - merged = True - break - - if not merged: - # Not adjacent to any batch, create new one - self.batches.append([index_to_toggle, index_to_toggle]) - - # Sort and merge any overlapping batches - self.batches.sort() - merged_batches = [self.batches[0]] if self.batches else [] - for i in range(1, len(self.batches)): - last_start, last_end = merged_batches[-1] - current_start, current_end = self.batches[i] - if current_start <= last_end + 1: - merged_batches[-1] = [last_start, max(last_end, current_end)] - else: - merged_batches.append([current_start, current_end]) - self.batches = merged_batches - - self.update_status_message("Added image to batch") - log.info("Added index %d to batch.", index_to_toggle) - - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - - def toggle_stack_membership(self): - """Toggles the current image's inclusion in a stack.""" - if not self.image_files or self.current_index >= len(self.image_files): - return - - index_to_toggle = self.current_index - - # Check if the image is already in a stack - stack_to_modify_idx = -1 - for i, (start, end) in enumerate(self.stacks): - if start <= index_to_toggle <= end: - stack_to_modify_idx = i - break - - if stack_to_modify_idx != -1: - # --- Remove from existing stack --- - new_stacks = [] - item_removed = False - for i, (start, end) in enumerate(self.stacks): - if not item_removed and i == stack_to_modify_idx: - if start < index_to_toggle: - new_stacks.append([start, index_to_toggle - 1]) - if index_to_toggle < end: - new_stacks.append([index_to_toggle + 1, end]) - item_removed = True - else: - new_stacks.append([start, end]) - self.stacks = new_stacks - self.update_status_message("Removed image from stack") - log.info("Removed index %d from stack #%d.", index_to_toggle, stack_to_modify_idx + 1) - - else: - # --- Add to nearest stack --- - if not self.stacks: - self.stacks.append([index_to_toggle, index_to_toggle]) - self.update_status_message("Created new stack with current image.") - log.info("No existing stacks. Created new stack for index %d.", index_to_toggle) - else: - # Find closest stack - dist_backward = float('inf') - stack_idx_backward = -1 - for i in range(index_to_toggle - 1, -1, -1): - for j, (start, end) in enumerate(self.stacks): - if start <= i <= end: - dist_backward = index_to_toggle - i - stack_idx_backward = j - break - if stack_idx_backward != -1: - break - - dist_forward = float('inf') - stack_idx_forward = -1 - for i in range(index_to_toggle + 1, len(self.image_files)): - for j, (start, end) in enumerate(self.stacks): - if start <= i <= end: - dist_forward = i - index_to_toggle - stack_idx_forward = j - break - if stack_idx_forward != -1: - break - - if stack_idx_backward == -1 and stack_idx_forward == -1: - # This case should be covered by `if not self.stacks`, but as a fallback. - self.stacks.append([index_to_toggle, index_to_toggle]) - self.update_status_message("Created new stack with current image.") - log.info("No stacks found nearby. Created new stack for index %d.", index_to_toggle) - else: - if dist_backward <= dist_forward: - stack_to_join_idx = stack_idx_backward - else: - stack_to_join_idx = stack_idx_forward - - start, end = self.stacks[stack_to_join_idx] - self.stacks[stack_to_join_idx] = [min(start, index_to_toggle), max(end, index_to_toggle)] - - # Merge overlapping stacks - self.stacks.sort() - merged_stacks = [self.stacks[0]] if self.stacks else [] - for i in range(1, len(self.stacks)): - last_start, last_end = merged_stacks[-1] - current_start, current_end = self.stacks[i] - if current_start <= last_end + 1: - merged_stacks[-1] = [last_start, max(last_end, current_end)] - else: - merged_stacks.append([current_start, current_end]) - self.stacks = merged_stacks - - # Find the new stack index for the status message - new_stack_idx = -1 - for i, (start, end) in enumerate(self.stacks): - if start <= index_to_toggle <= end: - new_stack_idx = i - break - - self.update_status_message(f"Added image to Stack #{new_stack_idx + 1}") - log.info("Added index %d to stack #%d.", index_to_toggle, new_stack_idx + 1) - - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.ui_state.stackSummaryChanged.emit() - self.sync_ui_state() - - - - - def launch_helicon(self): - """Launches Helicon Focus with selected files (RAW preferred, JPG fallback) or stacks.""" - if self.stacks: - log.info("Launching Helicon for %d defined stacks.", len(self.stacks)) - any_success = False - for start, end in self.stacks: - files_to_process = [] - for idx in range(start, end + 1): - if idx < len(self.image_files): - img_file = self.image_files[idx] - # Use RAW if available, otherwise use JPG - file_to_use = img_file.raw_pair if img_file.raw_pair else img_file.path - files_to_process.append(file_to_use) - - if files_to_process: - success = self._launch_helicon_with_files(files_to_process) - if success: - any_success = True - else: - log.warning("No valid files found for stack [%d, %d].", start, end) - - # Only clear stacks if at least one launch succeeded - if any_success: - self.clear_all_stacks() - - else: - log.warning("No selection or stacks defined to launch Helicon Focus.") - return - - self.sync_ui_state() - - def _launch_helicon_with_files(self, files: List[Path]) -> bool: - """Helper to launch Helicon with a specific list of files (RAW or JPG). - - Returns: - True if Helicon was successfully launched, False otherwise. - """ - log.info("Launching Helicon Focus with %d files.", len(files)) - unique_files = sorted(list(set(files))) - success, tmp_path = launch_helicon_focus(unique_files) - if success and tmp_path: - # Schedule delayed deletion of the temporary file - QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) - - # Record stacking metadata - today = date.today().isoformat() - for file_path in unique_files: - # Find the corresponding image file to get the stem - for img_file in self.image_files: - # Match by either RAW pair or JPG path - if img_file.raw_pair == file_path or img_file.path == file_path: - stem = img_file.path.stem - meta = self.sidecar.get_metadata(stem) - meta.stacked = True - meta.stacked_date = today - break - self.sidecar.save() - self._metadata_cache_index = (-1, -1) # Invalidate cache - - return success - - def _delete_temp_file(self, tmp_path: Path): - """Deletes the temporary file list passed to Helicon Focus.""" - if tmp_path.exists(): - try: - os.remove(tmp_path) - log.info("Deleted temporary file: %s", tmp_path) - except OSError as e: - log.error("Error deleting temporary file %s: %s", tmp_path, e) - - def clear_all_stacks(self): - log.info("Clearing all defined stacks, batches, and markers.") - self.stacks = [] - self.stack_start_index = None - self.batches = [] - self.batch_start_index = None - - self.sidecar.data.stacks = self.stacks - self.sidecar.save() - - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.ui_state.stackSummaryChanged.emit() - self.sync_ui_state() - self.update_status_message("All stacks and batches cleared") - - def get_helicon_path(self): - return config.get('helicon', 'exe') - - def set_helicon_path(self, path): - config.set('helicon', 'exe', path) - config.save() - - def get_photoshop_path(self): - return config.get('photoshop', 'exe') - - def set_photoshop_path(self, path): - config.set('photoshop', 'exe', path) - config.save() - - def open_file_dialog(self): - dialog = QFileDialog() - dialog.setFileMode(QFileDialog.FileMode.ExistingFile) - dialog.setNameFilter("Executables (*.exe)") - if dialog.exec(): - return dialog.selectedFiles()[0] - return "" - - def check_path_exists(self, path): - return os.path.exists(path) - - def get_cache_size(self): - return config.getfloat('core', 'cache_size_gb') - - def get_cache_usage_gb(self): - """Returns current cache usage in GB.""" - return self.image_cache.currsize / (1024**3) - - def set_cache_size(self, size): - config.set('core', 'cache_size_gb', size) - config.save() - - def get_prefetch_radius(self): - return config.getint('core', 'prefetch_radius') - - def set_prefetch_radius(self, radius): - config.set('core', 'prefetch_radius', radius) - config.save() - self.prefetcher.prefetch_radius = radius - self.prefetcher.update_prefetch(self.current_index) - - def get_theme(self): - return 0 if config.get('core', 'theme') == 'dark' else 1 - - def set_theme(self, theme_index): - # update Python-side state - self.ui_state.theme = theme_index - - # persist it - theme = 'dark' if theme_index == 0 else 'light' - config.set('core', 'theme', theme) - config.save() - - # tell QML it changed (once is enough) - self.ui_state.themeChanged.emit() - - @Slot(result=str) - def get_color_mode(self): - """Returns current color management mode: 'none', 'saturation', or 'icc'.""" - return config.get('color', 'mode', fallback='none') - - @Slot(str) - def set_color_mode(self, mode: str): - """Sets color management mode and clears cache to force re-decode.""" - mode = mode.lower() - if mode not in ['none', 'saturation', 'icc']: - log.error("Invalid color mode: %s", mode) - return - - log.info("Setting color mode to: %s", mode) - config.set('color', 'mode', mode) - config.save() - - # Clear ICC caches when color mode changes - clear_icc_caches() - - # Clear cache and restart prefetcher to apply new color mode - self.image_cache.clear() - self.prefetcher.cancel_all() - self.display_generation += 1 - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - # Notify QML that color mode changed - self.ui_state.colorModeChanged.emit() - - # Update status message - mode_names = { - 'none': 'Original Colors', - 'saturation': 'Saturation Compensation', - 'icc': 'Full ICC Profile' - } - self.update_status_message(f"Color mode: {mode_names.get(mode, mode)}") - - @Slot(result=float) - def get_saturation_factor(self): - """Returns current saturation factor (0.0-1.0).""" - return config.getfloat('color', 'saturation_factor', fallback=0.85) - - @Slot(float) - def set_saturation_factor(self, factor: float): - """Sets saturation factor and refreshes images.""" - factor = max(0.0, min(1.0, factor)) # Clamp to 0-1 - log.info("Setting saturation factor to: %.2f", factor) - config.set('color', 'saturation_factor', str(factor)) - config.save() - - # Only refresh if in saturation mode - if self.get_color_mode() == 'saturation': - self.image_cache.clear() - self.prefetcher.cancel_all() - self.display_generation += 1 - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - # Notify QML - self.ui_state.saturationFactorChanged.emit() - - @Slot(result=str) - def get_awb_mode(self): - return config.get("awb", "mode") - - @Slot(str) - def set_awb_mode(self, mode): - config.set("awb", "mode", mode) - config.save() - - @Slot(result=float) - def get_awb_strength(self): - return config.getfloat("awb", "strength") - - @Slot(float) - def set_awb_strength(self, value): - config.set("awb", "strength", value) - config.save() - - @Slot(result=int) - def get_awb_warm_bias(self): - return config.getint("awb", "warm_bias") - - @Slot(int) - def set_awb_warm_bias(self, value): - config.set("awb", "warm_bias", value) - config.save() - - @Slot(result=int) - def get_awb_luma_lower_bound(self): - return config.getint("awb", "luma_lower_bound") - - @Slot(int) - def set_awb_luma_lower_bound(self, value): - config.set("awb", "luma_lower_bound", value) - config.save() - - @Slot(result=int) - def get_awb_luma_upper_bound(self): - return config.getint("awb", "luma_upper_bound") - - @Slot(int) - def set_awb_luma_upper_bound(self, value): - config.set("awb", "luma_upper_bound", value) - config.save() - - @Slot(result=int) - def get_awb_rgb_lower_bound(self): - return config.getint("awb", "rgb_lower_bound") - - @Slot(int) - def set_awb_rgb_lower_bound(self, value): - config.set("awb", "rgb_lower_bound", value) - config.save() - - @Slot(result=int) - def get_awb_rgb_upper_bound(self): - return config.getint("awb", "rgb_upper_bound") - - @Slot(int) - def set_awb_rgb_upper_bound(self, value): - config.set("awb", "rgb_upper_bound", value) - config.save() - - def get_default_directory(self): - return config.get('core', 'default_directory') - - def set_default_directory(self, path): - config.set('core', 'default_directory', path) - config.save() - - def open_directory_dialog(self): - dialog = QFileDialog() - dialog.setFileMode(QFileDialog.FileMode.Directory) - if dialog.exec(): - return dialog.selectedFiles()[0] - return "" - - @Slot() - def open_folder(self): - """Opens a directory dialog and reloads the application with the selected folder.""" - path = self.open_directory_dialog() - if path: - self.image_dir = Path(path) - self.load() - - - def preload_all_images(self): - if self.ui_state.isPreloading: - log.info("Preloading is already in progress.") - return - - log.info("Starting to preload all images.") - self.ui_state.isPreloading = True - self.ui_state.preloadProgress = 0 - - self.reporter = self.ProgressReporter() - self.reporter.progress_updated.connect(self._update_preload_progress) - self.reporter.finished.connect(self._finish_preloading) - - # Use existing prefetch executor (better resource utilization) - total = len(self.image_files) - - if total == 0: - log.info("No images to preload.") - self.reporter.progress_updated.emit(100) # Or 0, depending on desired UX - self.reporter.finished.emit() - return - - completed = 0 - - def _on_done(_future): - nonlocal completed - completed += 1 - progress = int((completed / total) * 100) - self.reporter.progress_updated.emit(progress) - if completed == total: - self.reporter.finished.emit() - - for i in range(total): - future = self.prefetcher.submit_task(i, self.prefetcher.generation) - if future: - future.add_done_callback(_on_done) - - def _update_preload_progress(self, progress: int): - log.debug("Updating preload progress in UI: %d%%", progress) - self.ui_state.preloadProgress = progress - - def _finish_preloading(self): - self.ui_state.isPreloading = False - self.ui_state.preloadProgress = 0 - log.info("Finished preloading all images.") - - @Slot() - def delete_current_image(self): - """Moves current JPG and RAW to recycle bin.""" - if not self.image_files: - self.update_status_message("No image to delete.") - return - - image_file = self.image_files[self.current_index] - jpg_path = image_file.path - raw_path = image_file.raw_pair - - # Create recycle bin if it doesn't exist - try: - self.recycle_bin_dir.mkdir(parents=True, exist_ok=True) - except OSError as e: - self.update_status_message(f"Failed to create recycle bin: {e}") - log.error("Failed to create recycle bin directory: %s", e) - return - - # Move files to recycle bin - deleted_files = [] - try: - if jpg_path.exists(): - dest = self.recycle_bin_dir / jpg_path.name - jpg_path.rename(dest) - deleted_files.append(jpg_path.name) - log.info("Moved %s to recycle bin", jpg_path.name) - - if raw_path and raw_path.exists(): - dest = self.recycle_bin_dir / raw_path.name - raw_path.rename(dest) - deleted_files.append(raw_path.name) - log.info("Moved %s to recycle bin", raw_path.name) - - # Add to delete history only if at least one file was moved - if deleted_files: - import time - timestamp = time.time() - self.delete_history.append((jpg_path, raw_path)) - self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) - - # Update status - if deleted_files: - files_str = ", ".join(deleted_files) - self.update_status_message(f"Deleted: {files_str}") - else: - self.update_status_message("No files to delete") - - # Refresh image list and move to next image - self.refresh_image_list() - if self.image_files: - # Stay at same index (which now shows the next image) - self.current_index = min(self.current_index, len(self.image_files) - 1) - # Clear cache and invalidate display generation to force image reload - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() # Cancel stale tasks since image list changed - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - except OSError as e: - self.update_status_message(f"Delete failed: {e}") - log.exception("Failed to delete image") - - @Slot() - def undo_delete(self): - """Unified undo that handles both delete and auto white balance operations.""" - if not self.undo_history: - self.update_status_message("Nothing to undo.") - return - - # Get the most recent action - action_type, action_data, timestamp = self.undo_history.pop() - - if action_type == "delete": - jpg_path, raw_path = action_data - # Also remove from delete_history - if self.delete_history and self.delete_history[-1] == (jpg_path, raw_path): - self.delete_history.pop() - - restored_files = [] - try: - # Restore JPG - jpg_in_bin = self.recycle_bin_dir / jpg_path.name - if jpg_in_bin.exists(): - jpg_in_bin.rename(jpg_path) - restored_files.append(jpg_path.name) - log.info("Restored %s from recycle bin", jpg_path.name) - - # Restore RAW - if raw_path: - raw_in_bin = self.recycle_bin_dir / raw_path.name - if raw_in_bin.exists(): - raw_in_bin.rename(raw_path) - restored_files.append(raw_path.name) - log.info("Restored %s from recycle bin", raw_path.name) - - # Update status - if restored_files: - files_str = ", ".join(restored_files) - self.update_status_message(f"Restored: {files_str}") - else: - self.update_status_message("No files to restore") - - # Refresh image list - self.refresh_image_list() - - # Find and navigate to the restored image - for i, img_file in enumerate(self.image_files): - if img_file.path == jpg_path: - self.current_index = i - break - - # Clear cache and invalidate display generation to force image reload - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() # Cancel stale tasks since image list changed - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - except OSError as e: - self.update_status_message(f"Undo failed: {e}") - log.exception("Failed to restore image") - # Put it back in history if it failed - self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) - self.delete_history.append((jpg_path, raw_path)) - - elif action_type == "auto_white_balance": - saved_path, backup_path = action_data - filepath_obj = Path(saved_path) - - try: - if backup_path.exists(): - # Restore the backup - filepath_obj.unlink() # Remove the edited version - backup_path.rename(filepath_obj) # Restore backup - log.info("Restored backup %s for %s", backup_path.name, saved_path) - - # Refresh the view - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - self.update_status_message("Undid auto white balance") - else: - # This case should not be reached if glob finds files - self.update_status_message("Backup not found") - log.warning("Backup %s disappeared before it could be restored.", backup_path) - self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) - except OSError as e: - self.update_status_message(f"Undo failed: {e}") - log.exception("Failed to undo auto white balance") - # Put it back in history if it failed - self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) - - def shutdown(self): - log.info("Application shutting down.") - - # Check if recycle bin has files and prompt to empty - if self.recycle_bin_dir.exists(): - files_in_bin = list(self.recycle_bin_dir.glob("*")) - if files_in_bin: - file_count = len(files_in_bin) - msg_box = QMessageBox() - msg_box.setWindowTitle("Recycle Bin") - msg_box.setText(f"There are {file_count} files in the recycle bin.") - msg_box.setInformativeText("What would you like to do?") - - # Add custom buttons - delete_btn = msg_box.addButton("Delete Permanently", QMessageBox.YesRole) - restore_btn = msg_box.addButton(f"Restore {file_count} deleted files", QMessageBox.ActionRole) - keep_btn = msg_box.addButton("Keep in Recycle Bin", QMessageBox.NoRole) - - msg_box.setDefaultButton(keep_btn) - msg_box.exec() - - clicked_button = msg_box.clickedButton() - if clicked_button == delete_btn: - self.empty_recycle_bin() - elif clicked_button == restore_btn: - self.restore_all_from_recycle_bin() - - # Clear QML context property to prevent TypeErrors during shutdown - if self.engine: - log.info("Clearing uiState context property in QML.") - del self.engine # Explicitly delete the engine - - self.watcher.stop() - self.prefetcher.shutdown() - self.sidecar.set_last_index(self.current_index) - self.sidecar.save() - - def empty_recycle_bin(self): - """Permanently deletes all files in the recycle bin.""" - if not self.recycle_bin_dir.exists(): - return - - try: - import shutil - shutil.rmtree(self.recycle_bin_dir) - self.delete_history.clear() - log.info("Emptied recycle bin and cleared delete history") - except OSError: - log.exception("Failed to empty recycle bin") - - def restore_all_from_recycle_bin(self): - """Restores all files from recycle bin to working directory.""" - if not self.recycle_bin_dir.exists(): - return - - try: - files_in_bin = list(self.recycle_bin_dir.glob("*")) - restored_count = 0 - - for file_in_bin in files_in_bin: - # Restore to original location (working directory) - dest_path = self.image_dir / file_in_bin.name - - # If file already exists, skip (don't overwrite) - if dest_path.exists(): - log.warning("File already exists, skipping: %s", dest_path) - continue - - try: - file_in_bin.rename(dest_path) - restored_count += 1 - log.info("Restored %s from recycle bin", file_in_bin.name) - except OSError as e: - log.error("Failed to restore %s: %s", file_in_bin.name, e) - - # Clear delete history since we restored everything - self.delete_history.clear() - - log.info("Restored %d files from recycle bin", restored_count) - - except OSError: - log.exception("Failed to restore files from recycle bin") - - @Slot() - def edit_in_photoshop(self): - if not self.image_files: - self.update_status_message("No image to edit.") - return - - # Prefer RAW file if it exists, otherwise use JPG - image_file = self.image_files[self.current_index] - jpg_path = image_file.path - - # Handle backup images: strip -backup, -backup2, -backup-1, etc. to find original RAW - import re - original_stem = jpg_path.stem - # Remove -backup with optional digits or -backup-digits (handles both formats) - original_stem = re.sub(r'-backup(-?\d+)?$', '', original_stem) - - # Look for RAW file with the original stem - raw_path = None - if image_file.raw_pair and image_file.raw_pair.exists(): - # Use the paired RAW if it exists - raw_path = image_file.raw_pair - else: - # Search for RAW file manually by original stem - from faststack.io.indexer import RAW_EXTENSIONS - for ext in RAW_EXTENSIONS: - potential_raw = jpg_path.parent / f"{original_stem}{ext}" - if potential_raw.exists(): - raw_path = potential_raw - break - - if raw_path and raw_path.exists(): - current_image_path = raw_path - log.info("Using RAW file for Photoshop: %s", raw_path) - else: - current_image_path = jpg_path - log.info("Using JPG file for Photoshop (no RAW found): %s", current_image_path) - - photoshop_exe = config.get('photoshop', 'exe') - photoshop_args = config.get('photoshop', 'args') - - # Validate executable path securely - is_valid, error_msg = validate_executable_path( - photoshop_exe, - app_type="photoshop", - allow_custom_paths=True - ) - - if not is_valid: - self.update_status_message(f"Photoshop validation failed: {error_msg}") - log.error("Photoshop executable validation failed: %s", error_msg) - return - - # Validate that the file path exists and is a file - if not current_image_path.exists() or not current_image_path.is_file(): - self.update_status_message(f"Image file not found: {current_image_path.name}") - log.error("Image file not found or not a file: %s", current_image_path) - return - - try: - # Build command list safely - command = [photoshop_exe] - - # Parse additional args safely using shlex (handles quotes and escapes properly) - if photoshop_args: - try: - # Use shlex to properly parse arguments with quotes/escapes - # On Windows, use posix=False to handle Windows-style paths - parsed_args = shlex.split(photoshop_args, posix=(os.name != 'nt')) - command.extend(parsed_args) - except ValueError as e: - log.error("Invalid photoshop_args format: %s", e) - self.update_status_message("Invalid Photoshop arguments configured") - return - - # Add the file path as the last argument - # Convert to string but keep it as a list element (not shell-interpolated) - command.append(str(current_image_path.resolve())) - - # SECURITY: Explicitly disable shell execution - subprocess.Popen( - command, - shell=False, # CRITICAL: Never use shell=True with user input - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - close_fds=True # Close unused file descriptors - ) - - # Mark as edited on successful launch - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - stem = image_file.path.stem - meta = self.sidecar.get_metadata(stem) - meta.edited = True - meta.edited_date = today - self.sidecar.save() - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - - self.update_status_message(f"Opened {current_image_path.name} in Photoshop.") - log.info("Launched Photoshop with: %s", command) - except FileNotFoundError as e: - self.update_status_message(f"Photoshop executable not found: {e}") - log.exception("Photoshop executable not found") - # Don't mark as edited if launch failed - return - except (OSError, subprocess.SubprocessError) as e: - self.update_status_message(f"Failed to open in Photoshop: {e}") - log.exception("Error launching Photoshop") - # Don't mark as edited if launch failed - return - - @Slot() - def copy_path_to_clipboard(self): - if not self.image_files: - self.update_status_message("No image path to copy.") - return - - current_image_path = str(self.image_files[self.current_index].path) - QApplication.clipboard().setText(current_image_path) - self.update_status_message(f"Copied: {current_image_path}") - log.info("Copied path to clipboard: %s", current_image_path) - - @Slot() - def reset_zoom_pan(self): - """Resets zoom and pan to fit the image in the window (like Ctrl+0 in Photoshop).""" - log.info("Resetting zoom and pan to fit window") - self.ui_state.resetZoomPan() - self.update_status_message("Reset zoom and pan") - - def update_status_message(self, message: str, timeout: int = 3000): - """ - Updates the UI status message and clears it after a timeout. - """ - def clear_message(): - if self.ui_state.statusMessage == message: - self.ui_state.statusMessage = "" - - self.ui_state.statusMessage = message - QTimer.singleShot(timeout, clear_message) - - - - @Slot() - def start_drag_current_image(self): - if not self.image_files or self.current_index >= len(self.image_files): - return - - # Collect all files: current + any in defined batches - files_to_drag = set() - files_to_drag.add(self.current_index) - - # Add all files from defined batches - for start, end in self.batches: - for idx in range(start, end + 1): - if 0 <= idx < len(self.image_files): - files_to_drag.add(idx) - - # Convert to sorted list and get only existing paths - file_indices = sorted(files_to_drag) - existing_indices = [idx for idx in file_indices if self.image_files[idx].path.exists()] - file_paths = [self.image_files[idx].path for idx in existing_indices] - - if not file_paths: - log.error("No valid files to drag") - return - - if self.main_window is None: - return - - drag = QDrag(self.main_window) - mime_data = QMimeData() - - # Use Qt's standard setUrls - it handles both browser and native app compatibility - urls = [QUrl.fromLocalFile(str(p)) for p in file_paths] - mime_data.setUrls(urls) - - drag.setMimeData(mime_data) - - # --- thumbnail / drag preview --- - pix = QPixmap(str(file_paths[0])) - if not pix.isNull(): - # scale it down so it's not huge - scaled = pix.scaled(128, 128, Qt.KeepAspectRatio, Qt.SmoothTransformation) - drag.setPixmap(scaled) - # hotspot = center of image - drag.setHotSpot(QPoint(scaled.width() // 2, scaled.height() // 2)) - - log.info("Starting drag for %d file(s): %s", len(file_paths), [str(p) for p in file_paths]) - # Support both Copy and Move actions for browser compatibility - result = drag.exec(Qt.CopyAction | Qt.MoveAction) - log.info("Drag completed with result: %s", result) - - # Reset zoom/pan after drag completes (drag can cause unwanted panning) - self.ui_state.resetZoomPan() - - # Mark all dragged files as uploaded if drag was successful - if result in (Qt.CopyAction, Qt.MoveAction): - from datetime import datetime - today = datetime.now().strftime("%Y-%m-%d") - - for idx in existing_indices: - stem = self.image_files[idx].path.stem - meta = self.sidecar.get_metadata(stem) - meta.uploaded = True - meta.uploaded_date = today - - self.sidecar.save() - - # Clear all batches after successful drag (like pressing \) - self.batches = [] - self.batch_start_index = None - - self._metadata_cache_index = (-1, -1) - self.dataChanged.emit() - self.sync_ui_state() - log.info("Marked %d file(s) as uploaded on %s. Cleared all batches.", len(existing_indices), today) - - # --- Image Editor Logic --- - - @Slot(result=bool) - def load_image_for_editing(self): - """Loads the currently viewed image into the editor.""" - if self.image_files and self.current_index < len(self.image_files): - filepath = str(self.image_files[self.current_index].path) - # Only load if the editor is not already open for this file - if str(self.image_editor.current_filepath) == filepath and self.image_editor.original_image is not None: - # Already loaded, just reset UI state for a fresh start - self.reset_edit_parameters() - return True - - # Get the cached, display-sized image to use for fast previews - cached_preview = self.get_decoded_image(self.current_index) - - if self.image_editor.load_image(filepath, cached_preview=cached_preview): - # Pass initial edits to uiState - initial_edits = self.image_editor._initial_edits() - for key, value in initial_edits.items(): - if hasattr(self.ui_state, key): - setattr(self.ui_state, key, value) - - # Set aspect ratios for QML dropdown - self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] - self.ui_state.currentAspectRatioIndex = 0 - self.ui_state.currentCropBox = (0, 0, 1000, 1000) # Reset crop box visually - return True - return False - - @Slot(result=DecodedImage) - def get_preview_data(self) -> Optional[DecodedImage]: - """Gets the preview data of the currently edited image as a DecodedImage.""" - return self.image_editor.get_preview_data() - - @Slot(str, "QVariant") - def set_edit_parameter(self, key: str, value: Any): - """Sets an edit parameter and updates the UIState for the slider visual.""" - if self.image_editor.set_edit_param(key, value): - # Update the corresponding UIState property to reflect the new value in QML - if hasattr(self.ui_state, key): - setattr(self.ui_state, key, value) - - # Trigger a refresh of the image to show the edit - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - - @Slot(int, int, int, int) - def set_crop_box(self, left: int, top: int, right: int, bottom: int): - """Sets the normalized crop box (0-1000) in the editor.""" - from typing import Tuple - crop_box: Tuple[int, int, int, int] = (left, top, right, bottom) - self.image_editor.set_crop_box(crop_box) - self.ui_state.currentCropBox = crop_box # Update QML visual (if implemented) - - @Slot() - def reset_edit_parameters(self): - """Resets all editing parameters in the editor.""" - self.image_editor.current_edits = self.image_editor._initial_edits() - if hasattr(self.ui_state, 'reset_editor_state'): - self.ui_state.reset_editor_state() - - # Trigger a refresh to show the reset image - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - - @Slot() - def save_edited_image(self): - """Saves the edited image.""" - save_result = self.image_editor.save_image() - if save_result: - saved_path, backup_path = save_result - # Clear the image editor state so it will reload fresh next time - self.image_editor.original_image = None - self.image_editor.current_filepath = None - self.image_editor._preview_image = None - - # Reset all edit parameters in the controller/UI - self.reset_edit_parameters() - - # Refresh the view - need to refresh image list since backup file was created - original_path = saved_path - self.refresh_image_list() - - # Find the edited image (not the backup) in the refreshed list - for i, img_file in enumerate(self.image_files): - if img_file.path == original_path: - self.current_index = i - break - - # Invalidate cache and refresh display - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - QMessageBox.information( - None, - "Save Successful", - f"Image saved to: {saved_path}. Original backed up.", - QMessageBox.Ok - ) - - @Slot() - def rotate_image_cw(self): - """Rotate the edited image 90 degrees clockwise.""" - current = self.image_editor.current_edits.get('rotation', 0) - new_rotation = (current + 90) % 360 - self.set_edit_parameter('rotation', new_rotation) - - @Slot() - def rotate_image_ccw(self): - """Rotate the edited image 90 degrees counter-clockwise.""" - current = self.image_editor.current_edits.get('rotation', 0) - new_rotation = (current - 90) % 360 - if new_rotation < 0: - new_rotation += 360 - self.set_edit_parameter('rotation', new_rotation) - - @Slot() - def quick_auto_white_balance(self): - """Quickly apply auto white balance, save the image, and track for undo.""" - if not self.image_files: - self.update_status_message("No image to adjust") - return - - import time - image_file = self.image_files[self.current_index] - filepath = str(image_file.path) - - # Load the image into the editor if not already loaded - cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image(filepath, cached_preview=cached_preview): - self.update_status_message("Failed to load image") - return - - # Calculate and apply auto white balance - self.auto_white_balance() - - # Save the edited image (this creates a backup automatically) - save_result = self.image_editor.save_image() - if save_result: - saved_path, backup_path = save_result - # Track this action for undo - timestamp = time.time() - self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) - - # Force the image editor to clear its current state so it reloads fresh - self.image_editor.original_image = None - self.image_editor.current_filepath = None - self.image_editor._preview_image = None - - # Refresh the view - need to refresh image list since backup file was created - original_path = Path(filepath) - self.refresh_image_list() - - # Find the edited image (not the backup) in the refreshed list - for i, img_file in enumerate(self.image_files): - if img_file.path == original_path: - self.current_index = i - break - - # Invalidate cache for the edited image so it's reloaded from disk - # This ensures the Image Editor will see the updated version - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - self.update_status_message("Auto white balance applied and saved") - log.info("Quick auto white balance applied to %s", filepath) - else: - self.update_status_message("Failed to save image") - - @Slot() - def auto_white_balance(self): - """ - Dispatcher for auto white balance. Calls the appropriate method based on - the mode set in the config ('lab' or 'rgb'). - """ - mode = config.get('awb', 'mode', fallback='lab') - if mode == 'lab': - self.auto_white_balance_lab() - elif mode == 'rgb': - self.auto_white_balance_legacy() - else: - log.error(f"Unknown AWB mode: {mode}") - self.update_status_message(f"Error: Unknown AWB mode '{mode}'") - - def auto_white_balance_legacy(self): - """ - Calculates and applies auto white balance using the legacy grey world - assumption on the entire RGB image. - """ - if not self.image_editor.original_image: - log.warning("No image loaded in editor for auto white balance") - return - - try: - import numpy as np - except ImportError: - log.error("NumPy not found. Please install with: pip install numpy") - self.update_status_message("Error: NumPy not installed") - return - - log.info("Applying legacy (RGB Grey World) Auto White Balance") - - img = self.image_editor.original_image - arr = np.array(img, dtype=np.float32) - - r_mean = arr[:, :, 0].mean() - g_mean = arr[:, :, 1].mean() - b_mean = arr[:, :, 2].mean() - - grey_target = (r_mean + g_mean + b_mean) / 3.0 - - r_diff = r_mean - grey_target - g_diff = g_mean - grey_target - - by_shift = -(r_diff + g_diff) / 2.0 - mg_shift = -(r_diff - g_diff) / 2.0 - - by_value = by_shift / 63.75 - mg_value = mg_shift / 63.75 - - by_value = float(np.clip(by_value, -1.0, 1.0)) - mg_value = float(np.clip(mg_value, -1.0, 1.0)) - - self.image_editor.set_edit_param('white_balance_by', by_value) - self.image_editor.set_edit_param('white_balance_mg', mg_value) - - self.ui_state.white_balance_by = by_value - self.ui_state.white_balance_mg = mg_value - - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - self.update_status_message("Auto white balance applied (Legacy)") - - - def auto_white_balance_lab(self): - """ - Calculates and applies auto white balance using the Lab color space, - filtering out clipped and saturated pixels for a more robust result. - """ - if not self.image_editor.original_image: - log.warning("No image loaded in editor for auto white balance") - return - - try: - import cv2 - import numpy as np - except ImportError: - log.error("OpenCV or NumPy not found. Please install with: pip install opencv-python numpy") - self.update_status_message("Error: OpenCV or NumPy not installed") - return - - img = self.image_editor.original_image - # Ensure image is RGB before processing - if img.mode != 'RGB': - img = img.convert('RGB') - - arr = np.array(img, dtype=np.uint8) - - # --- Tunable Constants for Auto White Balance (from config) --- - _LOWER_BOUND_RGB = config.getint('awb', 'rgb_lower_bound', 5) - _UPPER_BOUND_RGB = config.getint('awb', 'rgb_upper_bound', 250) - _LUMA_LOWER_BOUND = config.getint('awb', 'luma_lower_bound', 30) - _LUMA_UPPER_BOUND = config.getint('awb', 'luma_upper_bound', 220) - warm_bias = config.getint('awb', 'warm_bias', 6) - _TARGET_A_LAB = 128.0 - _TARGET_B_LAB = 128.0 + warm_bias - _SCALING_FACTOR_LAB_TO_SLIDER = 128.0 - _CORRECTION_STRENGTH = config.getfloat('awb', 'strength', 0.7) - - # --- 1. Reject clipped channels and use a luma midtone mask --- - mask = ( - (arr[:, :, 0] > _LOWER_BOUND_RGB) & (arr[:, :, 0] < _UPPER_BOUND_RGB) & - (arr[:, :, 1] > _LOWER_BOUND_RGB) & (arr[:, :, 1] < _UPPER_BOUND_RGB) & - (arr[:, :, 2] > _LOWER_BOUND_RGB) & (arr[:, :, 2] < _UPPER_BOUND_RGB) - ) - - luma = (0.2126 * arr[:, :, 0] + 0.7152 * arr[:, :, 1] + 0.0722 * arr[:, :, 2]) - mask &= (luma > _LUMA_LOWER_BOUND) & (luma < _LUMA_UPPER_BOUND) - - if not np.any(mask): - log.warning("Auto white balance: No pixels found after clipping and luma filter. Aborting.") - self.update_status_message("AWB failed: no valid pixels found") - return - - # --- 2. Work in Lab color space --- - lab_image = cv2.cvtColor(arr, cv2.COLOR_RGB2LAB) - - a_channel = lab_image[:, :, 1] - b_channel = lab_image[:, :, 2] - - masked_a = a_channel[mask] - masked_b = b_channel[mask] - - a_mean = masked_a.mean() - b_mean = masked_b.mean() - - a_shift = _TARGET_A_LAB - a_mean - b_shift = _TARGET_B_LAB - b_mean - - log.info( - "Auto WB (Lab) - means: a*=%.1f, b*=%.1f; targets: a*=%.1f, b*=%.1f; shifts: a*=%.1f, b*=%.1f", - a_mean, b_mean, _TARGET_A_LAB, _TARGET_B_LAB, a_shift, b_shift - ) - - # --- 3. Convert Lab shift to our slider values with strength factor --- - by_value = (b_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH - mg_value = (a_shift / _SCALING_FACTOR_LAB_TO_SLIDER) * _CORRECTION_STRENGTH - - by_value = float(np.clip(by_value, -1.0, 1.0)) - mg_value = float(np.clip(mg_value, -1.0, 1.0)) - - log.info(f"Auto white balance values: B/Y={by_value:.3f}, M/G={mg_value:.3f}") - - self.image_editor.set_edit_param('white_balance_by', by_value) - self.image_editor.set_edit_param('white_balance_mg', mg_value) - - self.ui_state.white_balance_by = by_value - self.ui_state.white_balance_mg = mg_value - - self.ui_refresh_generation += 1 - self.ui_state.currentImageSourceChanged.emit() - self.update_status_message("Auto white balance applied") - - def _get_stack_info(self, index: int) -> str: - info = "" - for i, (start, end) in enumerate(self.stacks): - if start <= index <= end: - count_in_stack = end - start + 1 - pos_in_stack = index - start + 1 - info = f"Stack {i+1} ({pos_in_stack}/{count_in_stack})" - break - if not info and self.stack_start_index is not None and self.stack_start_index == index: - info = "Stack Start Marked" - log.debug("_get_stack_info for index %d: %s", index, info) - return info - - def _get_batch_info(self, index: int) -> str: - """Get batch info for the given index.""" - info = "" - # Check if current image is in any batch - in_batch = False - for start, end in self.batches: - if start <= index <= end: - in_batch = True - break - - if in_batch: - # Calculate total count across all batches - total_count = sum(end - start + 1 for start, end in self.batches) - info = f"{total_count} in Batch" - elif self.batch_start_index is not None and self.batch_start_index == index: - info = "Batch Start Marked" - - log.debug("_get_batch_info for index %d: %s", index, info) - return info - - def get_stack_summary(self) -> str: - if not self.stacks: - return "No stacks defined." - summary = [] - for i, (start, end) in enumerate(self.stacks): - summary.append(f"Stack {i+1}: {start}-{end}") - return "; ".join(summary) - - def is_stacked(self) -> bool: - if not self.image_files or self.current_index >= len(self.image_files): - return False - stem = self.image_files[self.current_index].path.stem - meta = self.sidecar.get_metadata(stem) - return meta.stacked - -def main(image_dir: str = "", debug: bool = False): - """FastStack Application Entry Point""" - global _debug_mode - _debug_mode = debug - - t0 = time.perf_counter() - setup_logging(debug) - if debug: - log.info("Startup: after setup_logging: %.3fs", time.perf_counter() - t0) - log.info("Starting FastStack") - - os.environ["QT_QUICK_CONTROLS_STYLE"] = "Material" - os.environ["QML2_IMPORT_PATH"] = os.path.join(os.path.dirname(__file__), "qml") - - app = QApplication(sys.argv) # Moved here - if debug: - log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) - - if not image_dir: - image_dir_str = config.get('core', 'default_directory') - if not image_dir_str: - log.warning("No image directory provided and no default directory set. Opening directory selection dialog.") - selected_dir = QFileDialog.getExistingDirectory(None, "Select Image Directory") - if not selected_dir: - log.error("No image directory selected. Exiting.") - sys.exit(1) - image_dir_str = selected_dir - image_dir_path = Path(image_dir_str) - else: - image_dir_path = Path(image_dir) - - if not image_dir_path.is_dir(): - log.error("Image directory not found: %s", image_dir_path) - sys.exit(1) - app.setOrganizationName("FastStack") - app.setOrganizationDomain("faststack.dev") - app.setApplicationName("FastStack") - - engine = QQmlApplicationEngine() - engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml")) - engine.addImportPath("qrc:/qt-project.org/imports") - engine.addImportPath(os.path.join(os.path.dirname(__file__), "qml")) - # Add the path to Qt5Compat.GraphicalEffects to QML import paths - engine.addImportPath(os.path.join(os.path.dirname(PySide6.__file__), "qml", "Qt5Compat")) - - controller = AppController(image_dir_path, engine) - if debug: - log.info("Startup: after AppController: %.3fs", time.perf_counter() - t0) - image_provider = ImageProvider(controller) - engine.addImageProvider("provider", image_provider) - - # Expose controller and UI state to QML - context = engine.rootContext() - context.setContextProperty("uiState", controller.ui_state) - context.setContextProperty("controller", controller) - - qml_file = Path(__file__).parent / "qml" / "Main.qml" - engine.load(QUrl.fromLocalFile(str(qml_file))) - if debug: - log.info("Startup: after engine.load(QML): %.3fs", time.perf_counter() - t0) - - if not engine.rootObjects(): - log.error("Failed to load QML.") - sys.exit(-1) - - # Connect key events from the main window - main_window = engine.rootObjects()[0] - controller.main_window = main_window - main_window.installEventFilter(controller) - - # Load data and start services - controller.load() - if debug: - log.info("Startup: after controller.load(): %.3fs", time.perf_counter() - t0) - - # Graceful shutdown - app.aboutToQuit.connect(controller.shutdown) - - sys.exit(app.exec()) - -def cli(): - """CLI entry point.""" - parser = argparse.ArgumentParser(description="FastStack - Ultra-fast JPG Viewer for Focus Stacking Selection") - parser.add_argument("image_dir", nargs="?", default="", help="Directory of images to view") - parser.add_argument("--debug", action="store_true", help="Enable debug logging and timing information") - args = parser.parse_args() - main(image_dir=args.image_dir, debug=args.debug) - -if __name__ == "__main__": - cli() From 14213f488f73670f5901721e8c7e7c0bbda989d8 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 22 Nov 2025 22:53:11 -0500 Subject: [PATCH 04/15] Release v1.2 - fixed menus --- faststack/faststack/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 8ce1103..8c19ff8 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -130,6 +130,9 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self._metadata_cache = {} self._metadata_cache_index = (-1, -1) + # Clear last displayed image since it references old directory + with self._last_image_lock: + self.last_displayed_image = None self._logged_empty_metadata = False # -- Delete/Undo State -- @@ -1781,7 +1784,7 @@ def save_edited_image(self): """Saves the edited image.""" save_result = self.image_editor.save_image() if save_result: - saved_path, backup_path = save_result + saved_path, _ = save_result # Clear the image editor state so it will reload fresh next time self.image_editor.original_image = None self.image_editor.current_filepath = None From fe6fc42ad6648469592091d4893383ebce28fb02 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 23 Nov 2025 11:35:58 -0500 Subject: [PATCH 05/15] Release v1.3 - added crop feature --- faststack/ChangeLog.md | 7 +- faststack/README.md | 5 +- faststack/faststack/app.py | 216 +++++++++++- faststack/faststack/imaging/editor.py | 54 ++- faststack/faststack/qml/Components.qml | 444 ++++++++++++++++++++++++- faststack/faststack/qml/Main.qml | 3 +- faststack/faststack/ui/keystrokes.py | 1 + faststack/faststack/ui/provider.py | 21 +- faststack/pyproject.toml | 2 +- 9 files changed, 719 insertions(+), 34 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index e2aa0e3..d00048a 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -1,6 +1,11 @@ # ChangeLog -Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Add the ability to pull in images from a stack if they are taken with a camera with in-camera stacking. +Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Add the ability to pull in images from a stack if they are taken with a camera with in-camera stacking + +# [1.3.0] - 2025-11-23 + +- Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. + # [1.2.0] - 2025-11-22 diff --git a/faststack/README.md b/faststack/README.md index d2bceaf..256d1d7 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 1.2 - November 22, 2025 +# Version 1.3 - November 23, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. @@ -9,10 +9,11 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive ## Features -- **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. +- **Crop:** Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. - **Zoom & Pan:** Smooth zooming and panning. - **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). - **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). +- **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. - **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) - **Quick Auto White Balance:** Press A to apply auto white balance and save automatically with undo support (Ctrl+Z). For better white balance load the raw into Photoshop with the P key. - **Photoshop Integration:** Edit current image in Photoshop (P key) - always uses RAW files when available, even for backup files diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 8c19ff8..b45a0f2 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -45,7 +45,7 @@ from faststack.imaging.prefetch import Prefetcher, clear_icc_caches from faststack.ui.provider import ImageProvider from faststack.ui.keystrokes import Keybinder -from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS +from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS, create_backup_file def make_hdrop(paths): """ @@ -251,6 +251,16 @@ def eventFilter(self, watched: QObject, event: QEvent) -> bool: return False if watched == self.main_window and event.type() == QEvent.Type.KeyPress: + # Handle Enter key in crop mode + if self.ui_state.isCropping and (event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return): + self.execute_crop() + return True + + # Handle ESC key to exit crop mode + if self.ui_state.isCropping and event.key() == Qt.Key_Escape: + self.cancel_crop_mode() + return True + handled = self.keybinder.handle_key_press(event) if handled: return True @@ -1386,13 +1396,22 @@ def undo_delete(self): filepath_obj = Path(saved_path) try: - if backup_path.exists(): + backup_path_obj = Path(backup_path) + if backup_path_obj.exists(): # Restore the backup filepath_obj.unlink() # Remove the edited version - backup_path.rename(filepath_obj) # Restore backup - log.info("Restored backup %s for %s", backup_path.name, saved_path) + backup_path_obj.rename(filepath_obj) # Restore backup + log.info("Restored backup %s for %s", backup_path_obj.name, saved_path) # Refresh the view + self.refresh_image_list() + + # Find the restored image + for i, img_file in enumerate(self.image_files): + if img_file.path == filepath_obj: + self.current_index = i + break + self.display_generation += 1 self.image_cache.clear() self.prefetcher.cancel_all() @@ -1410,6 +1429,44 @@ def undo_delete(self): log.exception("Failed to undo auto white balance") # Put it back in history if it failed self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) + + elif action_type == "crop": + saved_path, backup_path = action_data + filepath_obj = Path(saved_path) + + try: + backup_path_obj = Path(backup_path) + if backup_path_obj.exists(): + # Restore the backup + filepath_obj.unlink() # Remove the cropped version + backup_path_obj.rename(filepath_obj) # Restore backup + log.info("Restored backup %s for %s", backup_path_obj.name, saved_path) + + # Refresh the view + self.refresh_image_list() + + # Find the restored image + for i, img_file in enumerate(self.image_files): + if img_file.path == filepath_obj: + self.current_index = i + break + + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + self.update_status_message("Undid crop") + else: + self.update_status_message("Backup not found") + log.warning("Backup %s disappeared before it could be restored.", backup_path) + self.undo_history.append(("crop", (saved_path, backup_path), timestamp)) + except OSError as e: + self.update_status_message(f"Undo failed: {e}") + log.exception("Failed to undo crop") + # Put it back in history if it failed + self.undo_history.append(("crop", (saved_path, backup_path), timestamp)) def shutdown(self): log.info("Application shutting down.") @@ -1833,6 +1890,157 @@ def rotate_image_ccw(self): new_rotation += 360 self.set_edit_parameter('rotation', new_rotation) + @Slot() + def toggle_crop_mode(self): + """Toggle crop mode on/off.""" + self.ui_state.isCropping = not self.ui_state.isCropping + if self.ui_state.isCropping: + # Reset crop box when entering crop mode + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + # Set aspect ratios for QML dropdown + self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] + self.ui_state.currentAspectRatioIndex = 0 + self.update_status_message("Crop mode: Drag to select area, Enter to crop") + log.info("Crop mode enabled") + else: + self.update_status_message("Crop mode disabled") + log.info("Crop mode disabled") + + @Slot() + def cancel_crop_mode(self): + """Cancel crop mode without applying changes.""" + if self.ui_state.isCropping: + self.ui_state.isCropping = False + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + self.update_status_message("Crop cancelled") + log.info("Crop mode cancelled") + + @Slot() + def execute_crop(self): + """Execute the crop operation: crop image, save, backup, and refresh.""" + if not self.image_files or self.current_index >= len(self.image_files): + self.update_status_message("No image to crop") + return + + if not self.ui_state.isCropping: + return + + # Convert QJSValue to Python list if needed + crop_box_raw = self.ui_state.currentCropBox + try: + # Try to convert QJSValue to list + if hasattr(crop_box_raw, 'toVariant'): + # It's a QJSValue, convert to list + variant = crop_box_raw.toVariant() + if isinstance(variant, (list, tuple)): + crop_box = list(variant) + else: + # Try to iterate if it's iterable + crop_box = [variant[0], variant[1], variant[2], variant[3]] + elif isinstance(crop_box_raw, (list, tuple)): + crop_box = list(crop_box_raw) + else: + # Try direct access (might work for some QJSValue types) + crop_box = [crop_box_raw[0], crop_box_raw[1], crop_box_raw[2], crop_box_raw[3]] + except (TypeError, IndexError, AttributeError) as e: + self.update_status_message("Invalid crop box") + log.error("Failed to parse crop box (type: %s): %s", type(crop_box_raw), e) + return + + if len(crop_box) != 4: + self.update_status_message("Invalid crop box") + return + + if crop_box == [0, 0, 1000, 1000] or crop_box == (0, 0, 1000, 1000): + self.update_status_message("No crop area selected") + return + + image_file = self.image_files[self.current_index] + filepath = str(image_file.path) + + try: + # Load the image + img = Image.open(filepath).convert("RGB") + width, height = img.size + + # Convert normalized crop box (0-1000) to pixel coordinates + left = int(crop_box[0] * width / 1000) + top = int(crop_box[1] * height / 1000) + right = int(crop_box[2] * width / 1000) + bottom = int(crop_box[3] * height / 1000) + + # Ensure valid crop box + left = max(0, min(left, width - 1)) + top = max(0, min(top, height - 1)) + right = max(left + 1, min(right, width)) + bottom = max(top + 1, min(bottom, height)) + + # Crop the image + cropped_img = img.crop((left, top, right, bottom)) + + # Create backup + original_path = Path(filepath) + backup_path = create_backup_file(original_path) + if backup_path is None: + self.update_status_message("Failed to create backup") + log.error("Failed to create backup for crop operation") + return + + # Save the cropped image + with Image.open(original_path) as original_img: + original_format = original_img.format or original_path.suffix.lstrip('.').upper() + exif_data = original_img.info.get('exif') + + save_kwargs = {} + if original_format == 'JPEG': + save_kwargs['format'] = 'JPEG' + save_kwargs['quality'] = 95 + if exif_data: + save_kwargs['exif'] = exif_data + else: + save_kwargs['format'] = original_format + + try: + cropped_img.save(original_path, **save_kwargs) + except Exception as e: + log.warning(f"Could not save with original format settings: {e}") + cropped_img.save(original_path) + + # Track for undo + import time + timestamp = time.time() + self.undo_history.append(("crop", (str(original_path), str(backup_path)), timestamp)) + + # Exit crop mode + self.ui_state.isCropping = False + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + + # Refresh the view + self.refresh_image_list() + + # Find the edited image in the refreshed list + for i, img_file in enumerate(self.image_files): + if img_file.path == original_path: + self.current_index = i + break + + # Invalidate cache and refresh display + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + # Reset zoom/pan to fit the new cropped image + self.ui_state.resetZoomPan() + + self.update_status_message("Image cropped and saved") + log.info("Crop operation completed for %s", filepath) + + except Exception as e: + self.update_status_message(f"Crop failed: {e}") + log.exception("Failed to crop image") + @Slot() def quick_auto_white_balance(self): """Quickly apply auto white balance, save the image, and track for undo.""" diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 7cc19ee..c57fdbf 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -1,6 +1,7 @@ import os import shutil import glob +import re from pathlib import Path from typing import Optional, Dict, Any, Tuple import numpy as np @@ -19,6 +20,39 @@ "9:16 (Story)": (9, 16), } +def create_backup_file(original_path: Path) -> Optional[Path]: + """ + Creates a backup of the original file with naming pattern: + filename-backup.jpg, filename-backup2.jpg, etc. + + Returns: + Path to the backup file on success, None on failure. + """ + if not original_path.exists(): + return None + + # Extract base name without any existing -backup suffix + stem = original_path.stem + # Remove any existing -backup, -backup2, -backup-1, etc. (handles both old and new formats) + base_stem = re.sub(r'-backup(-?\d+)?$', '', stem) + + # Try filename-backup.jpg first + backup_path = original_path.parent / f"{base_stem}-backup{original_path.suffix}" + + # If that exists, try filename-backup2.jpg, filename-backup3.jpg, etc. + i = 2 + while backup_path.exists(): + backup_path = original_path.parent / f"{base_stem}-backup{i}{original_path.suffix}" + i += 1 + + try: + # Perform the backup + shutil.copy2(original_path, backup_path) + return backup_path + except Exception as e: + print(f"Failed to create backup: {e}") + return None + class ImageEditor: """Handles core image manipulation using PIL.""" def __init__(self): @@ -256,24 +290,12 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: original_path = self.current_filepath - # Extract base name without any existing -backup suffix - stem = original_path.stem - # Remove any existing -backup, -backup2, -backup-1, etc. (handles both old and new formats) - import re - base_stem = re.sub(r'-backup(-?\d+)?$', '', stem) - - # Try filename-backup.jpg first - backup_path = original_path.parent / f"{base_stem}-backup{original_path.suffix}" - - # If that exists, try filename-backup2.jpg, filename-backup3.jpg, etc. - i = 2 - while backup_path.exists(): - backup_path = original_path.parent / f"{base_stem}-backup{i}{original_path.suffix}" - i += 1 + # Use the reusable backup function + backup_path = create_backup_file(original_path) + if backup_path is None: + return None try: - # Perform the backup and overwrite - shutil.copy2(original_path, backup_path) # Re-open original to correctly detect format and get EXIF with Image.open(original_path) as original_img: diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index f8e510b..5f2bb2a 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -86,9 +86,17 @@ Item { } } - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton + MouseArea { + id: mainMouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + hoverEnabled: true + cursorShape: { + if (!uiState || !uiState.isCropping) return Qt.ArrowCursor + // Use a simple cross cursor for crop mode - edge detection would require tracking mouse position + // which is complex in QML. The edge dragging will still work based on click position. + return Qt.CrossCursor + } // Drag-to-pan with drag-and-drop when dragging outside window property real lastX: 0 @@ -97,16 +105,165 @@ Item { property real startY: 0 property bool isDraggingOutside: false property int dragThreshold: 10 // Minimum distance before checking for outside drag + property bool isCropDragging: false + property real cropStartX: 0 + property real cropStartY: 0 + property string cropDragMode: "none" // "none", "new", "move", "left", "right", "top", "bottom", "topleft", "topright", "bottomleft", "bottomright" + property real cropBoxStartLeft: 0 + property real cropBoxStartTop: 0 + property real cropBoxStartRight: 0 + property real cropBoxStartBottom: 0 + onPressed: function(mouse) { lastX = mouse.x lastY = mouse.y startX = mouse.x startY = mouse.y isDraggingOutside = false + + if (uiState && uiState.isCropping) { + // Check if clicking on existing crop box + var cropRect = getCropRect() + var edgeThreshold = 10 + var inside = mouse.x >= cropRect.x && mouse.x <= cropRect.x + cropRect.width && + mouse.y >= cropRect.y && mouse.y <= cropRect.y + cropRect.height + + if (inside && cropRect.width > 0 && cropRect.height > 0) { + // Determine which edge/corner is being dragged + var nearLeft = Math.abs(mouse.x - cropRect.x) < edgeThreshold + var nearRight = Math.abs(mouse.x - (cropRect.x + cropRect.width)) < edgeThreshold + var nearTop = Math.abs(mouse.y - cropRect.y) < edgeThreshold + var nearBottom = Math.abs(mouse.y - (cropRect.y + cropRect.height)) < edgeThreshold + + if (nearLeft && nearTop) cropDragMode = "topleft" + else if (nearRight && nearTop) cropDragMode = "topright" + else if (nearLeft && nearBottom) cropDragMode = "bottomleft" + else if (nearRight && nearBottom) cropDragMode = "bottomright" + else if (nearLeft) cropDragMode = "left" + else if (nearRight) cropDragMode = "right" + else if (nearTop) cropDragMode = "top" + else if (nearBottom) cropDragMode = "bottom" + else cropDragMode = "move" + + // Store initial crop box + var box = uiState.currentCropBox + cropBoxStartLeft = box[0] + cropBoxStartTop = box[1] + cropBoxStartRight = box[2] + cropBoxStartBottom = box[3] + } else { + // Start new crop rectangle + cropDragMode = "new" + cropStartX = mouse.x + cropStartY = mouse.y + } + isCropDragging = true + } + } + + function getCropRect() { + if (!mainImage.source || !uiState || !uiState.currentCropBox) { + return {x: 0, y: 0, width: 0, height: 0} + } + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + var box = uiState.currentCropBox + return { + x: imgX + (box[0] / 1000) * imgWidth, + y: imgY + (box[1] / 1000) * imgHeight, + width: (box[2] - box[0]) / 1000 * imgWidth, + height: (box[3] - box[1]) / 1000 * imgHeight + } } onPositionChanged: function(mouse) { + if (uiState && uiState.isCropping && isCropDragging) { + if (cropDragMode === "new") { + // Update crop rectangle while dragging + updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y) + } else if (cropDragMode !== "none") { + // Refine existing crop box + var cropRect = getCropRect() + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + + // Convert mouse position to normalized coordinates + var mouseX = (mouse.x - imgX) / imgWidth + var mouseY = (mouse.y - imgY) / imgHeight + mouseX = Math.max(0, Math.min(1, mouseX)) * 1000 + mouseY = Math.max(0, Math.min(1, mouseY)) * 1000 + + var left = cropBoxStartLeft + var top = cropBoxStartTop + var right = cropBoxStartRight + var bottom = cropBoxStartBottom + + // Adjust based on drag mode + if (cropDragMode === "move") { + var dx = mouseX - (cropBoxStartLeft + cropBoxStartRight) / 2 + var dy = mouseY - (cropBoxStartTop + cropBoxStartBottom) / 2 + var width = cropBoxStartRight - cropBoxStartLeft + var height = cropBoxStartBottom - cropBoxStartTop + left = Math.max(0, Math.min(1000 - width, cropBoxStartLeft + dx)) + top = Math.max(0, Math.min(1000 - height, cropBoxStartTop + dy)) + right = left + width + bottom = top + height + } else if (cropDragMode === "left") { + left = Math.max(0, Math.min(right - 10, mouseX)) + } else if (cropDragMode === "right") { + right = Math.max(left + 10, Math.min(1000, mouseX)) + } else if (cropDragMode === "top") { + top = Math.max(0, Math.min(bottom - 10, mouseY)) + } else if (cropDragMode === "bottom") { + bottom = Math.max(top + 10, Math.min(1000, mouseY)) + } else if (cropDragMode === "topleft") { + left = Math.max(0, Math.min(right - 10, mouseX)) + top = Math.max(0, Math.min(bottom - 10, mouseY)) + } else if (cropDragMode === "topright") { + right = Math.max(left + 10, Math.min(1000, mouseX)) + top = Math.max(0, Math.min(bottom - 10, mouseY)) + } else if (cropDragMode === "bottomleft") { + left = Math.max(0, Math.min(right - 10, mouseX)) + bottom = Math.max(top + 10, Math.min(1000, mouseY)) + } else if (cropDragMode === "bottomright") { + right = Math.max(left + 10, Math.min(1000, mouseX)) + bottom = Math.max(top + 10, Math.min(1000, mouseY)) + } + + // Apply aspect ratio if needed + if (uiState.currentAspectRatioIndex > 0 && uiState.aspectRatioNames && uiState.aspectRatioNames.length > uiState.currentAspectRatioIndex) { + var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex] + var ratio = getAspectRatio(ratioName) + if (ratio) { + var targetAspect = ratio[0] / ratio[1] + var currentWidth = right - left + var currentHeight = bottom - top + var currentAspect = currentWidth / currentHeight + + if (currentAspect > targetAspect) { + var newHeight = currentWidth / targetAspect + var centerY = (top + bottom) / 2 + top = Math.max(0, centerY - newHeight / 2) + bottom = Math.min(1000, top + newHeight) + } else { + var newWidth = currentHeight * targetAspect + var centerX = (left + right) / 2 + left = Math.max(0, centerX - newWidth / 2) + right = Math.min(1000, left + newWidth) + } + } + } + + uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + } + return + } + if (pressed && !isDraggingOutside) { // Check if we've moved beyond the threshold var dx = mouse.x - startX @@ -126,16 +283,22 @@ Item { } } - // Normal pan behavior - panTransform.x += (mouse.x - lastX) - panTransform.y += (mouse.y - lastY) - lastX = mouse.x - lastY = mouse.y + // Normal pan behavior (only when not cropping) + if (!uiState || !uiState.isCropping) { + panTransform.x += (mouse.x - lastX) + panTransform.y += (mouse.y - lastY) + lastX = mouse.x + lastY = mouse.y + } } } onReleased: function(mouse) { isDraggingOutside = false + if (uiState && uiState.isCropping && isCropDragging) { + isCropDragging = false + cropDragMode = "none" + } } // Wheel for zoom @@ -146,6 +309,271 @@ Item { scaleTransform.xScale *= scaleFactor; scaleTransform.yScale *= scaleFactor; } + + function updateCropBox(x1, y1, x2, y2) { + if (!uiState || !mainImage.source) return + + // Get image display bounds (accounting for PreserveAspectFit) + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + + // Convert mouse coordinates to image coordinates + var imgCoordX1 = (x1 - imgX) / imgWidth + var imgCoordY1 = (y1 - imgY) / imgHeight + var imgCoordX2 = (x2 - imgX) / imgWidth + var imgCoordY2 = (y2 - imgY) / imgHeight + + // Clamp to image bounds + imgCoordX1 = Math.max(0, Math.min(1, imgCoordX1)) + imgCoordY1 = Math.max(0, Math.min(1, imgCoordY1)) + imgCoordX2 = Math.max(0, Math.min(1, imgCoordX2)) + imgCoordY2 = Math.max(0, Math.min(1, imgCoordY2)) + + // Ensure left < right and top < bottom + var left = Math.min(imgCoordX1, imgCoordX2) * 1000 + var right = Math.max(imgCoordX1, imgCoordX2) * 1000 + var top = Math.min(imgCoordY1, imgCoordY2) * 1000 + var bottom = Math.max(imgCoordY1, imgCoordY2) * 1000 + + // Apply aspect ratio constraint if selected (index 0 is Freeform, so skip it) + if (uiState.currentAspectRatioIndex > 0 && uiState.aspectRatioNames && uiState.aspectRatioNames.length > uiState.currentAspectRatioIndex) { + var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex] + var ratio = getAspectRatio(ratioName) + if (ratio) { + var currentWidth = right - left + var currentHeight = bottom - top + var targetAspect = ratio[0] / ratio[1] + var currentAspect = currentWidth / currentHeight + + if (currentAspect > targetAspect) { + // Too wide, adjust height + var newHeight = currentWidth / targetAspect + var centerY = (top + bottom) / 2 + top = centerY - newHeight / 2 + bottom = centerY + newHeight / 2 + // Clamp to image bounds + if (top < 0) { + bottom += -top + top = 0 + } + if (bottom > 1000) { + top -= (bottom - 1000) + bottom = 1000 + } + } else { + // Too tall, adjust width + var newWidth = currentHeight * targetAspect + var centerX = (left + right) / 2 + left = centerX - newWidth / 2 + right = centerX + newWidth / 2 + // Clamp to image bounds + if (left < 0) { + right += -left + left = 0 + } + if (right > 1000) { + left -= (right - 1000) + right = 1000 + } + } + } + } + + uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + } + + function getAspectRatio(name) { + // Map aspect ratio names to ratios + if (name === "1:1 (Square)") return [1, 1] + if (name === "4:5 (Portrait)") return [4, 5] + if (name === "1.91:1 (Landscape)") return [191, 100] + if (name === "9:16 (Story)") return [9, 16] + return null + } + + function updateCropBoxFromAspectRatio() { + if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return + var box = uiState.currentCropBox + updateCropBox( + box[0] / 1000 * mainImage.paintedWidth + (mainImage.width - mainImage.paintedWidth) / 2, + box[1] / 1000 * mainImage.paintedHeight + (mainImage.height - mainImage.paintedHeight) / 2, + box[2] / 1000 * mainImage.paintedWidth + (mainImage.width - mainImage.paintedWidth) / 2, + box[3] / 1000 * mainImage.paintedHeight + (mainImage.height - mainImage.paintedHeight) / 2 + ) + } + } + + // Crop rectangle overlay + Item { + id: cropOverlay + visible: uiState && uiState.isCropping && uiState.currentCropBox + anchors.fill: parent + z: 100 + + property var cropBox: uiState ? uiState.currentCropBox : [0, 0, 1000, 1000] + + onCropBoxChanged: { + if (!mainImage.source) return + updateCropRect() + } + + Component.onCompleted: { + if (mainImage.source) updateCropRect() + } + + Connections { + target: mainImage + function onPaintedWidthChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } + function onPaintedHeightChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } + } + + function updateCropRect() { + if (!mainImage.source) return + + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + + var left = imgX + (cropBox[0] / 1000) * imgWidth + var top = imgY + (cropBox[1] / 1000) * imgHeight + var right = imgX + (cropBox[2] / 1000) * imgWidth + var bottom = imgY + (cropBox[3] / 1000) * imgHeight + + cropRect.x = left + cropRect.y = top + cropRect.width = right - left + cropRect.height = bottom - top + } + + // Semi-transparent overlay - draw 4 rectangles around the crop area + Rectangle { + // Top + x: 0 + y: 0 + width: parent.width + height: cropRect.y + color: "black" + opacity: 0.3 + } + Rectangle { + // Bottom + x: 0 + y: cropRect.y + cropRect.height + width: parent.width + height: parent.height - (cropRect.y + cropRect.height) + color: "black" + opacity: 0.3 + } + Rectangle { + // Left + x: 0 + y: cropRect.y + width: cropRect.x + height: cropRect.height + color: "black" + opacity: 0.3 + } + Rectangle { + // Right + x: cropRect.x + cropRect.width + y: cropRect.y + width: parent.width - (cropRect.x + cropRect.width) + height: cropRect.height + color: "black" + opacity: 0.3 + } + + // Crop rectangle with thick white border + Rectangle { + id: cropRect + color: "transparent" + border.color: "white" + border.width: 3 + } + } + + // Aspect ratio selector window (upper left corner) + Rectangle { + id: aspectRatioWindow + visible: uiState && uiState.isCropping + anchors.top: parent.top + anchors.left: parent.left + anchors.margins: 10 + width: 200 + height: Math.max(150, aspectRatioColumn.implicitHeight + 20) + color: "#333333" + border.color: "#666666" + border.width: 1 + radius: 4 + z: 1000 + + // Try to get root from parent hierarchy + property bool isDark: { + var p = parent + while (p) { + if (p.hasOwnProperty("isDarkTheme")) { + return p.isDarkTheme + } + p = p.parent + } + return true + } + + Component.onCompleted: { + // Update colors based on theme + color = isDark ? "#333333" : "#f0f0f0" + border.color = isDark ? "#666666" : "#cccccc" + } + + Column { + id: aspectRatioColumn + anchors.fill: parent + anchors.margins: 10 + spacing: 5 + + Text { + text: "Aspect Ratio" + font.bold: true + color: "white" + font.pixelSize: 12 + } + + Repeater { + model: uiState && uiState.aspectRatioNames ? uiState.aspectRatioNames.length : 0 + + Rectangle { + width: parent.width + height: 30 + color: uiState && uiState.currentAspectRatioIndex === index ? "#555555" : "transparent" + radius: 3 + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: uiState && uiState.aspectRatioNames ? uiState.aspectRatioNames[index] : "" + color: "white" + font.pixelSize: 11 + } + + MouseArea { + anchors.fill: parent + onClicked: { + if (uiState) { + uiState.currentAspectRatioIndex = index + // Re-apply aspect ratio to current crop box + if (uiState.currentCropBox && uiState.currentCropBox.length === 4) { + mainMouseArea.updateCropBoxFromAspectRatio() + } + } + } + } + } + } + } } diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 76464e1..078012c 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -807,11 +807,12 @@ ApplicationWindow { "  Ctrl+S: Toggle stacked flag

" + "File Management:
" + "  Delete: Move current image to recycle bin
" + - "  Ctrl+Z: Undo last action (delete or auto white balance)

" + + "  Ctrl+Z: Undo last action (delete, auto white balance, or crop)

" + "Actions:
" + "  Enter: Launch Helicon Focus
" + "  P: Edit in Photoshop
" + "  A: Quick auto white balance (saves automatically)
" + + "  O: Toggle crop mode (Enter to execute crop, ESC to cancel)
" + "  E: Toggle Image Editor
" + "  Ctrl+C: Copy image path to clipboard" padding: 10 diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 1f8b9bf..75eab3a 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -46,6 +46,7 @@ def __init__(self, controller): Qt.Key_P: "edit_in_photoshop", Qt.Key_C: "clear_all_stacks", Qt.Key_A: "quick_auto_white_balance", + Qt.Key_O: "toggle_crop_mode", Qt.Key_Delete: "delete_current_image", Qt.Key_Backspace: "delete_current_image", } diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 5a5e841..094a48f 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -590,7 +590,26 @@ def currentCropBox(self) -> tuple: return self._current_crop_box @currentCropBox.setter - def currentCropBox(self, new_value: tuple): + def currentCropBox(self, new_value): + # Convert QJSValue or list to tuple if needed + try: + if hasattr(new_value, 'toVariant'): + # It's a QJSValue, convert to tuple + variant = new_value.toVariant() + if isinstance(variant, (list, tuple)): + new_value = tuple(variant) + else: + # Try to access elements directly + new_value = (variant[0], variant[1], variant[2], variant[3]) + elif isinstance(new_value, list): + new_value = tuple(new_value) + elif not isinstance(new_value, tuple): + # Try to convert to tuple + new_value = tuple(new_value) + except (TypeError, IndexError, AttributeError): + # If conversion fails, try to use as-is or log error + pass + if self._current_crop_box != new_value: self._current_crop_box = new_value self.current_crop_box_changed.emit(new_value) diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index 2cbce1e..323992e 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.2" +version = "1.3" authors = [ { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, ] From 2068c2cabe15c31c367c7374b85dd58d7eb9b3f9 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 23 Nov 2025 12:51:24 -0500 Subject: [PATCH 06/15] Changes to be committed: modified: faststack/README.md modified: faststack/faststack/app.py modified: faststack/faststack/imaging/editor.py modified: faststack/faststack/qml/Components.qml modified: faststack/faststack/ui/provider.py new file: faststack/z.patch --- faststack/README.md | 3 ++- faststack/faststack/app.py | 14 +++++++++++++- faststack/faststack/imaging/editor.py | 2 +- faststack/faststack/qml/Components.qml | 13 ++----------- faststack/faststack/ui/provider.py | 19 ++++++++++++++----- faststack/z.patch | 11 +++++++++++ 6 files changed, 43 insertions(+), 19 deletions(-) create mode 100644 faststack/z.patch diff --git a/faststack/README.md b/faststack/README.md index 256d1d7..25fe589 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -9,7 +9,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive ## Features -- **Crop:** Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. +- **Crop:** Added the ability to crop images via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. - **Zoom & Pan:** Smooth zooming and panning. - **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). - **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). @@ -60,6 +60,7 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `Ctrl+Z`: Undo last action (delete or auto white balance) - `A`: Quick auto white balance (saves automatically) - `E`: Toggle Image Editor +- 'O': Crop image - `Ctrl+C`: Copy image path to clipboard - `Ctrl+0`: Reset zoom and pan - `C`: Clear all stacks diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index b45a0f2..5916fa7 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -130,7 +130,6 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self._metadata_cache = {} self._metadata_cache_index = (-1, -1) - # Clear last displayed image since it references old directory with self._last_image_lock: self.last_displayed_image = None self._logged_empty_metadata = False @@ -1209,10 +1208,20 @@ def open_folder(self): self.stack_start_index = None # Clear caches since they reference old directory's images + with self._last_image_lock: + self.last_displayed_image = None self.image_cache.clear() self.prefetcher.cancel_all() + self.display_generation += 1 self._metadata_cache = {} self._metadata_cache_index = (-1, -1) + # Clear last displayed image since it references the old directory + with self._last_image_lock: + self.last_displayed_image = None + # Clear editor state if open + self.image_editor.original_image = None + self.image_editor.current_filepath = None + self.image_editor._preview_image = None # Load images from new directory self.load() @@ -1873,6 +1882,9 @@ def save_edited_image(self): f"Image saved to: {saved_path}. Original backed up.", QMessageBox.Ok ) + else: + self.update_status_message("Failed to save image") + log.error("Failed to save edited image") @Slot() def rotate_image_cw(self): diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index c57fdbf..08654d3 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -49,7 +49,7 @@ def create_backup_file(original_path: Path) -> Optional[Path]: # Perform the backup shutil.copy2(original_path, backup_path) return backup_path - except Exception as e: + except OSError as e: print(f"Failed to create backup: {e}") return None diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 5f2bb2a..a55a552 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -125,7 +125,7 @@ Item { if (uiState && uiState.isCropping) { // Check if clicking on existing crop box var cropRect = getCropRect() - var edgeThreshold = 10 + var edgeThreshold = 10 * Screen.devicePixelRatio var inside = mouse.x >= cropRect.x && mouse.x <= cropRect.x + cropRect.width && mouse.y >= cropRect.y && mouse.y <= cropRect.y + cropRect.height @@ -511,16 +511,7 @@ Item { z: 1000 // Try to get root from parent hierarchy - property bool isDark: { - var p = parent - while (p) { - if (p.hasOwnProperty("isDarkTheme")) { - return p.isDarkTheme - } - p = p.parent - } - return true - } + property bool isDark: root.isDarkTheme Component.onCompleted: { // Update colors based on theme diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 094a48f..0211797 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -592,8 +592,9 @@ def currentCropBox(self) -> tuple: @currentCropBox.setter def currentCropBox(self, new_value): # Convert QJSValue or list to tuple if needed + original_value = new_value try: - if hasattr(new_value, 'toVariant'): + if hasattr(new_value, 'toVariant') # It's a QJSValue, convert to tuple variant = new_value.toVariant() if isinstance(variant, (list, tuple)): @@ -606,10 +607,18 @@ def currentCropBox(self, new_value): elif not isinstance(new_value, tuple): # Try to convert to tuple new_value = tuple(new_value) - except (TypeError, IndexError, AttributeError): - # If conversion fails, try to use as-is or log error - pass - + except (TypeError, IndexError, AttributeError) as e: + log.warning( + "UIState.currentCropBox: failed to normalize value %r (type %s): %s", + original_value, + type(original_value), + e, + ) + + # only accept 4‑element tuples + if not isinstance(new_value, tuple) or len(new_value) != 4: + log.warning("UIState.currentCropBox: ignoring invalid crop box %r", new_value) + return if self._current_crop_box != new_value: self._current_crop_box = new_value self.current_crop_box_changed.emit(new_value) diff --git a/faststack/z.patch b/faststack/z.patch new file mode 100644 index 0000000..0ce91d0 --- /dev/null +++ b/faststack/z.patch @@ -0,0 +1,11 @@ +--- a/faststack/faststack/imaging/editor.py ++++ b/faststack/faststack/imaging/editor.py +@@ -47,7 +47,7 @@ def create_backup_file(original_path: Path) -> Optional[Path]: + try: + # Perform the backup + shutil.copy2(original_path, backup_path) + return backup_path +- except Exception as e: ++ except OSError as e: + print(f"Failed to create backup: {e}") + return None From 21cec2ad04056d3370c4f862d5fb3d8d3e8dd76e Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 23 Nov 2025 12:56:34 -0500 Subject: [PATCH 07/15] modified: faststack/faststack/ui/provider.py --- faststack/faststack/ui/provider.py | 2 +- faststack/z.patch | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 faststack/z.patch diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 0211797..dfafb41 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -594,7 +594,7 @@ def currentCropBox(self, new_value): # Convert QJSValue or list to tuple if needed original_value = new_value try: - if hasattr(new_value, 'toVariant') + if hasattr(new_value, 'toVariant'): # It's a QJSValue, convert to tuple variant = new_value.toVariant() if isinstance(variant, (list, tuple)): diff --git a/faststack/z.patch b/faststack/z.patch deleted file mode 100644 index 0ce91d0..0000000 --- a/faststack/z.patch +++ /dev/null @@ -1,11 +0,0 @@ ---- a/faststack/faststack/imaging/editor.py -+++ b/faststack/faststack/imaging/editor.py -@@ -47,7 +47,7 @@ def create_backup_file(original_path: Path) -> Optional[Path]: - try: - # Perform the backup - shutil.copy2(original_path, backup_path) - return backup_path -- except Exception as e: -+ except OSError as e: - print(f"Failed to create backup: {e}") - return None From 4d2b3f887a9ccc663b649f673c70194fbdf51f13 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 23 Nov 2025 13:35:00 -0500 Subject: [PATCH 08/15] Added RGB histogram modified: faststack/faststack/app.py new file: faststack/faststack/qml/HistogramWindow.qml modified: faststack/faststack/qml/Main.qml modified: faststack/faststack/ui/keystrokes.py modified: faststack/faststack/ui/provider.py --- faststack/faststack/app.py | 78 ++++++++ faststack/faststack/qml/HistogramWindow.qml | 206 ++++++++++++++++++++ faststack/faststack/qml/Main.qml | 39 +++- faststack/faststack/ui/keystrokes.py | 2 + faststack/faststack/ui/provider.py | 28 +++ 5 files changed, 350 insertions(+), 3 deletions(-) create mode 100644 faststack/faststack/qml/HistogramWindow.qml diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 5916fa7..2c68d94 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -427,12 +427,18 @@ def next_image(self): self.current_index += 1 self._do_prefetch(self.current_index, is_navigation=True, direction=1) self.sync_ui_state() + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() def prev_image(self): if self.current_index > 0: self.current_index -= 1 self._do_prefetch(self.current_index, is_navigation=True, direction=-1) self.sync_ui_state() + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() @Slot(int) def jump_to_image(self, index: int): @@ -442,6 +448,9 @@ def jump_to_image(self, index: int): self.current_index = index self._do_prefetch(self.current_index, is_navigation=True, direction=direction) self.sync_ui_state() + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() self.update_status_message(f"Jumped to image {index + 1}") else: log.warning("Invalid image index: %d", index) @@ -1825,6 +1834,10 @@ def set_edit_parameter(self, key: str, value: Any): # Trigger a refresh of the image to show the edit self.ui_refresh_generation += 1 self.ui_state.currentImageSourceChanged.emit() + + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() @Slot(int, int, int, int) def set_crop_box(self, left: int, top: int, right: int, bottom: int): @@ -1902,6 +1915,63 @@ def rotate_image_ccw(self): new_rotation += 360 self.set_edit_parameter('rotation', new_rotation) + @Slot() + def toggle_histogram(self): + """Toggle histogram window visibility.""" + self.ui_state.isHistogramVisible = not self.ui_state.isHistogramVisible + if self.ui_state.isHistogramVisible: + self.update_histogram() + log.info("Histogram window opened") + else: + log.info("Histogram window closed") + + @Slot() + def update_histogram(self): + """Update histogram data from current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + try: + import numpy as np + + # Get the current image data + decoded = self.get_decoded_image(self.current_index) + if not decoded: + return + + # If editor is open and has a preview, use that instead + if self.ui_state.isEditorOpen and self.image_editor.original_image: + # Use the preview from editor (includes edits) + preview_data = self.image_editor.get_preview_data() + if preview_data: + decoded = preview_data + + # Convert buffer to numpy array + arr = np.frombuffer(decoded.buffer, dtype=np.uint8) + arr = arr.reshape((decoded.height, decoded.width, 3)) + + # Compute histograms for each channel + r_hist = np.histogram(arr[:, :, 0], bins=256, range=(0, 256))[0] + g_hist = np.histogram(arr[:, :, 1], bins=256, range=(0, 256))[0] + b_hist = np.histogram(arr[:, :, 2], bins=256, range=(0, 256))[0] + + # Convert to Python lists + histogram_data = { + 'r': r_hist.tolist(), + 'g': g_hist.tolist(), + 'b': b_hist.tolist() + } + + self.ui_state.histogramData = histogram_data + log.debug("Histogram updated") + + except ImportError: + log.error("NumPy not available for histogram computation") + self.update_status_message("Histogram requires NumPy") + except Exception as e: + log.exception("Failed to compute histogram: %s", e) + self.update_status_message(f"Histogram error: {e}") + @Slot() def toggle_crop_mode(self): """Toggle crop mode on/off.""" @@ -2046,6 +2116,10 @@ def execute_crop(self): # Reset zoom/pan to fit the new cropped image self.ui_state.resetZoomPan() + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + self.update_status_message("Image cropped and saved") log.info("Crop operation completed for %s", filepath) @@ -2104,6 +2178,10 @@ def quick_auto_white_balance(self): self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() + # Update histogram if visible + if self.ui_state.isHistogramVisible: + self.update_histogram() + self.update_status_message("Auto white balance applied and saved") log.info("Quick auto white balance applied to %s", filepath) else: diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml new file mode 100644 index 0000000..cb0d99b --- /dev/null +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -0,0 +1,206 @@ +import QtQuick +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +Window { + id: histogramWindow + title: "RGB Histogram" + width: 600 + height: 400 + minimumWidth: 400 + minimumHeight: 300 + visible: uiState && uiState.isHistogramVisible + + property bool isDarkTheme: uiState ? uiState.theme === 0 : true + property color backgroundColor: isDarkTheme ? "#2b2b2b" : "#ffffff" + property color textColor: isDarkTheme ? "white" : "black" + + color: backgroundColor + + onVisibleChanged: { + if (visible && controller) { + controller.update_histogram() + } + } + + Connections { + target: uiState + function onHistogramDataChanged() { + if (histogramWindow.visible) { + histogramCanvas.requestPaint() + } + } + function onCurrentImageSourceChanged() { + if (histogramWindow.visible && controller) { + controller.update_histogram() + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + Text { + text: "RGB Histogram" + font.bold: true + font.pixelSize: 14 + color: histogramWindow.textColor + Layout.alignment: Qt.AlignHCenter + } + + Canvas { + id: histogramCanvas + Layout.fillWidth: true + Layout.fillHeight: true + + onPaint: { + var ctx = getContext("2d") + var width = histogramCanvas.width + var height = histogramCanvas.height + + // Clear canvas + ctx.fillStyle = histogramWindow.backgroundColor + ctx.fillRect(0, 0, width, height) + + if (!uiState || !uiState.histogramData) { + return + } + + var data = uiState.histogramData + var rData = data.r || [] + var gData = data.g || [] + var bData = data.b || [] + + if (rData.length === 0) { + return + } + + // Find max value for normalization + var maxValue = 0 + for (var i = 0; i < 256; i++) { + maxValue = Math.max(maxValue, rData[i] || 0) + maxValue = Math.max(maxValue, gData[i] || 0) + maxValue = Math.max(maxValue, bData[i] || 0) + } + + if (maxValue === 0) { + return + } + + // Draw grid lines + ctx.strokeStyle = histogramWindow.isDarkTheme ? "#555555" : "#cccccc" + ctx.lineWidth = 1 + for (var gridY = 0; gridY <= 4; gridY++) { + var y = (height - 40) * (gridY / 4) + 20 + ctx.beginPath() + ctx.moveTo(20, y) + ctx.lineTo(width - 20, y) + ctx.stroke() + } + + // Draw histogram bars + var barWidth = (width - 40) / 256 + + // Draw Red channel + ctx.fillStyle = "rgba(255, 0, 0, 0.6)" + for (var i = 0; i < 256; i++) { + var value = (rData[i] || 0) / maxValue + var barHeight = (height - 40) * value + var x = 20 + i * barWidth + var y = height - 20 - barHeight + ctx.fillRect(x, y, barWidth - 1, barHeight) + } + + // Draw Green channel + ctx.fillStyle = "rgba(0, 255, 0, 0.6)" + for (var i = 0; i < 256; i++) { + var value = (gData[i] || 0) / maxValue + var barHeight = (height - 40) * value + var x = 20 + i * barWidth + var y = height - 20 - barHeight + ctx.fillRect(x, y, barWidth - 1, barHeight) + } + + // Draw Blue channel + ctx.fillStyle = "rgba(0, 0, 255, 0.6)" + for (var i = 0; i < 256; i++) { + var value = (bData[i] || 0) / maxValue + var barHeight = (height - 40) * value + var x = 20 + i * barWidth + var y = height - 20 - barHeight + ctx.fillRect(x, y, barWidth - 1, barHeight) + } + + // Draw axis labels + ctx.fillStyle = histogramWindow.textColor + ctx.font = "10px sans-serif" + ctx.textAlign = "center" + + // X-axis labels (0, 64, 128, 192, 255) + for (var labelX = 0; labelX <= 4; labelX++) { + var value = labelX * 64 + if (labelX === 4) value = 255 + var x = 20 + (value / 255) * (width - 40) + ctx.fillText(value.toString(), x, height - 5) + } + + // Y-axis label + ctx.save() + ctx.translate(10, height / 2) + ctx.rotate(-Math.PI / 2) + ctx.textAlign = "center" + ctx.fillText("Pixel Count", 0, 0) + ctx.restore() + } + } + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 20 + + Rectangle { + width: 20 + height: 20 + color: "red" + opacity: 0.6 + border.color: histogramWindow.textColor + border.width: 1 + } + Text { + text: "Red" + color: histogramWindow.textColor + font.pixelSize: 12 + } + + Rectangle { + width: 20 + height: 20 + color: "green" + opacity: 0.6 + border.color: histogramWindow.textColor + border.width: 1 + } + Text { + text: "Green" + color: histogramWindow.textColor + font.pixelSize: 12 + } + + Rectangle { + width: 20 + height: 20 + color: "blue" + opacity: 0.6 + border.color: histogramWindow.textColor + border.width: 1 + } + Text { + text: "Blue" + color: histogramWindow.textColor + font.pixelSize: 12 + } + } + } +} diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 078012c..b75dee6 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -456,6 +456,26 @@ ApplicationWindow { leftPadding: 10 } } + ItemDelegate { + width: 220 + height: 36 + text: "Crop Image" + onClicked: { + if (controller) { + controller.toggle_crop_mode() + } + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } ItemDelegate { width: 220 @@ -607,10 +627,17 @@ ApplicationWindow { } // Toggle Image Editor with 'E' key + // If editor is open, close it without saving. Otherwise open it. if (event.key === Qt.Key_E && !event.isAutoRepeat) { - uiState.isEditorOpen = !uiState.isEditorOpen if (uiState.isEditorOpen) { - controller.load_image_for_editing() + // Close editor without saving + uiState.isEditorOpen = false + } else { + // Open editor + uiState.isEditorOpen = true + if (controller) { + controller.load_image_for_editing() + } } event.accepted = true } @@ -812,8 +839,10 @@ ApplicationWindow { "  Enter: Launch Helicon Focus
" + "  P: Edit in Photoshop
" + "  A: Quick auto white balance (saves automatically)
" + + "  Ctrl+Shift+B: Quick auto white balance (saves automatically)
" + "  O: Toggle crop mode (Enter to execute crop, ESC to cancel)
" + - "  E: Toggle Image Editor
" + + "  H: Toggle histogram window
" + + "  E: Toggle Image Editor (closes without saving if open)
" + "  Ctrl+C: Copy image path to clipboard" padding: 10 wrapMode: Text.WordWrap @@ -863,6 +892,10 @@ ApplicationWindow { textColor: root.currentTextColor maxImageCount: uiState ? uiState.imageCount : 0 } + + HistogramWindow { + id: histogramWindow + } ImageEditorDialog { id: imageEditorDialog diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 75eab3a..3018a58 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -47,6 +47,7 @@ def __init__(self, controller): Qt.Key_C: "clear_all_stacks", Qt.Key_A: "quick_auto_white_balance", Qt.Key_O: "toggle_crop_mode", + Qt.Key_H: "toggle_histogram", Qt.Key_Delete: "delete_current_image", Qt.Key_Backspace: "delete_current_image", } @@ -57,6 +58,7 @@ def __init__(self, controller): (Qt.Key_Z, Qt.ControlModifier): "undo_delete", (Qt.Key_E, Qt.ControlModifier): "toggle_edited", (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", + (Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier): "quick_auto_white_balance", } def _call(self, method_name: str): diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index dfafb41..813013e 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -95,6 +95,8 @@ class UIState(QObject): # Image Editor Signals is_editor_open_changed = Signal(bool) is_cropping_changed = Signal(bool) + is_histogram_visible_changed = Signal(bool) + histogram_data_changed = Signal() brightness_changed = Signal(float) contrast_changed = Signal(float) saturation_changed = Signal(float) @@ -126,6 +128,8 @@ def __init__(self, app_controller): # Image Editor State self._is_editor_open = False self._is_cropping = False + self._is_histogram_visible = False + self._histogram_data = None # Will be a dict with 'r', 'g', 'b' arrays self._brightness = 0.0 self._contrast = 0.0 self._saturation = 0.0 @@ -496,6 +500,30 @@ def isCropping(self, new_value: bool): if self._is_cropping != new_value: self._is_cropping = new_value self.is_cropping_changed.emit(new_value) + + @Property(bool, notify=is_histogram_visible_changed) + def isHistogramVisible(self) -> bool: + return self._is_histogram_visible + + @isHistogramVisible.setter + def isHistogramVisible(self, new_value: bool): + if self._is_histogram_visible != new_value: + self._is_histogram_visible = new_value + self.is_histogram_visible_changed.emit(new_value) + if new_value: + # Update histogram when opened + self.app_controller.update_histogram() + + @Property('QVariant', notify=histogram_data_changed) + def histogramData(self): + """Returns histogram data as a dict with 'r', 'g', 'b' keys, each containing a list of 256 values.""" + return self._histogram_data + + @histogramData.setter + def histogramData(self, new_value): + if self._histogram_data != new_value: + self._histogram_data = new_value + self.histogram_data_changed.emit() @Property(float, notify=brightness_changed) def brightness(self) -> float: From c67b23790e073b766f736bb79f4dc3c109126628 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 23 Nov 2025 15:27:25 -0500 Subject: [PATCH 09/15] Release v1.3 - fixed some bugs --- faststack/ChangeLog.md | 8 +- faststack/faststack.egg-info/PKG-INFO | 6 +- faststack/faststack/app.py | 98 +- faststack/faststack/benchmark_decode.py | 20 - faststack/faststack/config.py | 7 +- faststack/faststack/imaging/cache.py | 8 +- faststack/faststack/imaging/editor.py | 49 +- faststack/faststack/imaging/prefetch.py | 71 +- faststack/faststack/io/sidecar.py | 3 +- faststack/faststack/qml/Components.qml | 77 +- faststack/faststack/qml/FilterDialog.qml | 2 +- .../faststack/qml/ImageEditorDialog.qml.new | 219 -- faststack/faststack/qml/SettingsDialog.qml | 4 +- faststack/faststack/ui/keystrokes.py.bak | 45 - faststack/faststack/ui/provider.py.bak | 247 -- z | 2977 ----------------- 16 files changed, 182 insertions(+), 3659 deletions(-) delete mode 100644 faststack/faststack/benchmark_decode.py delete mode 100644 faststack/faststack/qml/ImageEditorDialog.qml.new delete mode 100644 faststack/faststack/ui/keystrokes.py.bak delete mode 100644 faststack/faststack/ui/provider.py.bak delete mode 100644 z diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index d00048a..7565a3f 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -2,12 +2,12 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Add the ability to pull in images from a stack if they are taken with a camera with in-camera stacking -# [1.3.0] - 2025-11-23 +## [1.3.0] - 2025-11-23 - Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. -# [1.2.0] - 2025-11-22 +## [1.2.0] - 2025-11-22 - Fixed menus, they now work well and look cool. - Updated auto white balance to make it better, and put some controls for it in the settings @@ -82,9 +82,7 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better docum ### Features - **JPG Fallback for Helicon:** Helicon Focus stacking now works with JPG-only workflows when RAW files absent. -- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis. -- **Added a Jump to Photo feature that can be activated by pressing the G key - +- **Comprehensive Timing Instrumentation:** Added detailed decode timing logs in debug mode for performance analysis.- **Jump to Photo:** Press `G` to jump directly to any image (feature documented more fully in [1.0.0]). ## [0.8.0] - 2025-11-20 ### Added diff --git a/faststack/faststack.egg-info/PKG-INFO b/faststack/faststack.egg-info/PKG-INFO index 4976776..bb5c9e1 100644 --- a/faststack/faststack.egg-info/PKG-INFO +++ b/faststack/faststack.egg-info/PKG-INFO @@ -1,7 +1,7 @@ Metadata-Version: 2.4 Name: faststack -Version: 1.1 -Summary: Ultra-fast JPG Viewer for Focus Stacking Selection and website upload +Version: 1.3 +Summary: Ultra-fast JPG Viewer for Focus Stacking Selection and website upload via drag and drop Author-email: Alan Rockefeller Classifier: Programming Language :: Python :: 3 Classifier: License :: OSI Approved :: MIT License @@ -20,7 +20,7 @@ Dynamic: license-file # FastStack -# Version 1.1 - November 22, 2025 +# Version 1.3 - November 23, 2025 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking. diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 2c68d94..d98826b 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -30,8 +30,7 @@ from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox from PySide6.QtQml import QQmlApplicationEngine from PIL import Image -Image.MAX_IMAGE_PIXELS = None - +Image.MAX_IMAGE_PIXELS = 200_000_000 # 200 megapixels, enough for most photos # ⬇️ these are the ones that went missing from faststack.config import config from faststack.logging_setup import setup_logging @@ -136,7 +135,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): # -- Delete/Undo State -- self.recycle_bin_dir = self.image_dir / "image recycle bin" - self.delete_history: List[tuple[Path, Optional[Path]]] = [] # [(jpg_path, raw_path), ...] + self.delete_history: List[Tuple[Path, Optional[Path]]] = [] # [(jpg_path, raw_path), ...] # Track all undoable actions with timestamps self.undo_history: List[Tuple[str, Any, float]] = [] # (action_type, action_data, timestamp) @@ -145,7 +144,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self.resize_timer.timeout.connect(self._handle_resize) self.pending_width = None self.pending_height = None - + # Track if any dialog is open to disable keybindings self._dialog_open = False @@ -678,6 +677,7 @@ def remove_from_batch_or_stack(self): if batch_modified: self.batches = new_batches + # Check and remove from stacks # Check and remove from stacks if not removed: new_stacks = [] @@ -700,8 +700,6 @@ def remove_from_batch_or_stack(self): new_stacks.append([start, self.current_index - 1]) new_stacks.append([self.current_index + 1, end]) - self.sidecar.data.stacks = self.stacks # Update sidecar BEFORE self.stacks is replaced - self.sidecar.save() log.info("Removed index %d from stack [%d, %d]", self.current_index, start, end) self.update_status_message(f"Removed from stack") removed = True @@ -712,8 +710,7 @@ def remove_from_batch_or_stack(self): if stack_modified: self.stacks = new_stacks self.sidecar.data.stacks = self.stacks - self.sidecar.save() - + self.sidecar.save() if removed: self._metadata_cache_index = (-1, -1) self.dataChanged.emit() @@ -1111,9 +1108,6 @@ def set_saturation_factor(self, factor: float): # Notify QML self.ui_state.saturationFactorChanged.emit() - def get_default_directory(self): - return config.get('core', 'default_directory') - @Slot(result=str) def get_awb_mode(self): return config.get("awb", "mode") @@ -1323,13 +1317,11 @@ def delete_current_image(self): self.delete_history.append((jpg_path, raw_path)) self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) - # Update status + # Add to delete history only if at least one file was moved if deleted_files: - files_str = ", ".join(deleted_files) - self.update_status_message(f"Deleted: {files_str}") - else: - self.update_status_message("No files to delete") - + timestamp = time.time() + self.delete_history.append((jpg_path, raw_path)) + self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) # Refresh image list and move to next image self.refresh_image_list() if self.image_files: @@ -1862,42 +1854,50 @@ def reset_edit_parameters(self): def save_edited_image(self): """Saves the edited image.""" save_result = self.image_editor.save_image() - if save_result: - saved_path, _ = save_result - # Clear the image editor state so it will reload fresh next time - self.image_editor.original_image = None - self.image_editor.current_filepath = None - self.image_editor._preview_image = None - - # Reset all edit parameters in the controller/UI - self.reset_edit_parameters() - - # Refresh the view - need to refresh image list since backup file was created - original_path = saved_path - self.refresh_image_list() - - # Find the edited image (not the backup) in the refreshed list - for i, img_file in enumerate(self.image_files): - if img_file.path == original_path: - self.current_index = i - break - - # Invalidate cache and refresh display - self.display_generation += 1 - self.image_cache.clear() - self.prefetcher.cancel_all() - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() - - QMessageBox.information( + if not save_result: + QMessageBox.warning( None, - "Save Successful", - f"Image saved to: {saved_path}. Original backed up.", - QMessageBox.Ok + "Save Failed", + "Failed to save edited image. Please check the log for details.", + QMessageBox.Ok, ) - else: self.update_status_message("Failed to save image") log.error("Failed to save edited image") + return + + saved_path, _ = save_result + # Clear the image editor state so it will reload fresh next time + self.image_editor.original_image = None + self.image_editor.current_filepath = None + self.image_editor._preview_image = None + + # Reset all edit parameters in the controller/UI + self.reset_edit_parameters() + + # Refresh the view - need to refresh image list since backup file was created + original_path = saved_path + self.refresh_image_list() + + # Find the edited image (not the backup) in the refreshed list + for i, img_file in enumerate(self.image_files): + if img_file.path == original_path: + self.current_index = i + break + + # Invalidate cache and refresh display + self.display_generation += 1 + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + + QMessageBox.information( + None, + "Save Successful", + f"Image saved to: {saved_path}. Original backed up.", + QMessageBox.Ok + ) + @Slot() def rotate_image_cw(self): diff --git a/faststack/faststack/benchmark_decode.py b/faststack/faststack/benchmark_decode.py deleted file mode 100644 index 6a5b858..0000000 --- a/faststack/faststack/benchmark_decode.py +++ /dev/null @@ -1,20 +0,0 @@ -import mmap -import time -from pathlib import Path -from faststack.imaging.jpeg import decode_jpeg_resized, TURBO_AVAILABLE - -print(f"TurboJPEG available: {TURBO_AVAILABLE}") - -test_image = Path(r"C:\Users\alanr\Pictures\Lightroom\2025\2025-11-14\20251114-PB140001-2.JPG") - -# Match the real code path with mmap -iterations = 20 -start = time.perf_counter() -for _ in range(iterations): - with open(test_image, "rb") as f: - with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: - jpeg_bytes = mmapped[:] - decode_jpeg_resized(jpeg_bytes, 1920, 1080) -elapsed = time.perf_counter() - start - -print(f"Average time (with mmap): {elapsed/iterations*1000:.1f}ms") diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index 92019c4..c27c938 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -25,11 +25,10 @@ }, "color": { "mode": "none", # Options: "none", "saturation", "icc" - "saturation_factor": "0.85", # Option A: 0.0-1.0, lower = less saturated - "monitor_icc_path": "", # Option C: path to monitor ICC profile + "saturation_factor": "0.85", # For 'saturation' mode: 0.0-1.0, lower = less saturated + "monitor_icc_path": "", # For 'icc' mode: path to monitor ICC profile }, - "awb": { - "mode": "lab", # "lab" or "rgb" + "awb": { "mode": "lab", # "lab" or "rgb" "strength": "0.7", "warm_bias": "6", "luma_lower_bound": "30", diff --git a/faststack/faststack/imaging/cache.py b/faststack/faststack/imaging/cache.py index d0d4262..53b0ed1 100644 --- a/faststack/faststack/imaging/cache.py +++ b/faststack/faststack/imaging/cache.py @@ -37,9 +37,11 @@ def get_decoded_image_size(item) -> int: # Handle both numpy arrays and memoryview buffers if hasattr(item.buffer, 'nbytes'): return item.buffer.nbytes - elif hasattr(item.buffer, '__len__'): + elif isinstance(item.buffer, (bytes, bytearray)): return len(item.buffer) else: - # Fallback: compute from dimensions - return item.width * item.height * 3 + # Fallback: estimate using sys.getsizeof or compute from dimensions + # Assuming 4 bytes/pixel for RGBA or 3 for RGB - check item.channels if available + import sys + return sys.getsizeof(item.buffer) if hasattr(item.buffer, '__sizeof__') else item.width * item.height * 4 return 1 # Should not happen diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 08654d3..e48e71b 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -149,13 +149,15 @@ def _apply_edits(self, img: Image.Image) -> Image.Image: arr = (arr * 255).clip(0, 255).astype(np.uint8) img = Image.fromarray(arr) - # 4. Blacks/Whites (Levels) blacks = self.current_edits['blacks'] whites = self.current_edits['whites'] if abs(blacks) > 0.001 or abs(whites) > 0.001: arr = np.array(img, dtype=np.float32) black_point = -blacks * 40 white_point = 255 + whites * 40 + # Prevent division by zero + if abs(white_point - black_point) < 0.001: + white_point = black_point + 0.001 arr = (arr - black_point) * (255.0 / (white_point - black_point)) img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) @@ -218,9 +220,12 @@ def _apply_edits(self, img: Image.Image) -> Image.Image: arr = np.array(img, dtype=np.float32) by_shift = by_val * 127.5 mg_shift = mg_val * 127.5 - arr[:, :, 0] += by_shift + mg_shift - arr[:, :, 1] += by_shift - mg_shift - arr[:, :, 2] -= by_shift - mg_shift + # Apply temperature (by_shift) primarily between R and B, and + # tint (mg_shift) primarily to G relative to R/B. We apply half + # of the tint opposite to R/B so that tint shifts G against R/B. + arr[:, :, 0] += (by_shift - 0.5 * mg_shift) # R + arr[:, :, 1] += (1.0 * mg_shift) # G + arr[:, :, 2] -= (by_shift - 0.5 * mg_shift) # B np.clip(arr, 0, 255, out=arr) img = Image.fromarray(arr.astype(np.uint8)) @@ -312,16 +317,46 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: save_kwargs['format'] = original_format try: + # First attempt: preserve EXIF (if any) and original format settings final_img.save(original_path, **save_kwargs) except Exception as e: - print(f"Warning: Could not save with original format settings: {e}") - # Fallback to saving based on suffix - final_img.save(original_path) + exif_was_requested = 'exif' in save_kwargs + print( + f"Warning: Could not save with original format settings" + f"{' (with EXIF)' if exif_was_requested else ''}: {e}" + ) + + # If EXIF was requested, try again without EXIF but keep format/quality + if exif_was_requested: + retry_kwargs = dict(save_kwargs) + retry_kwargs.pop('exif', None) + try: + final_img.save(original_path, **retry_kwargs) + print( + "Note: Image saved without EXIF metadata; " + "EXIF may be corrupted or incompatible with the edited image." + ) + except Exception as e2: + print(f"Warning: Could not save even without EXIF metadata: {e2}") + # Fall through to the final fallback below + + # Final fallback: let Pillow infer format from suffix / image mode + try: + final_img.save(original_path) + print( + "Warning: Used final fallback save; image may not use the original " + "format settings and EXIF metadata is likely lost." + ) + except Exception as e3: + print(f"Failed to save edited image even with fallback: {e3}") + # Reraise so the outer except logs and returns None + raise return original_path, backup_path except Exception as e: print(f"Failed to save edited image or backup: {e}") return None + # Dictionary of ratios for QML dropdown ASPECT_RATIOS = [{"name": name, "ratio": ratio} for name, ratio in INSTAGRAM_RATIOS.items()] diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index ab2f394..5c3927e 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -17,6 +17,8 @@ log = logging.getLogger(__name__) +import threading + # ---- Option C: ICC Color Management Setup ---- SRGB_PROFILE = ImageCms.createProfile("sRGB") @@ -27,6 +29,9 @@ # Cache for ICC transforms to avoid rebuilding on every image _icc_transform_cache: Dict[tuple, ImageCms.ImageCmsTransform] = {} +# Thread lock for all ICC caches +_icc_cache_lock = threading.Lock() + def get_icc_transform(src_profile: ImageCms.ImageCmsProfile, monitor_profile: ImageCms.ImageCmsProfile, src_profile_key: str, monitor_profile_path: str): """Get or create a cached ICC transform. @@ -36,20 +41,22 @@ def get_icc_transform(src_profile: ImageCms.ImageCmsProfile, monitor_profile: Im - monitor_profile_path: file path to the monitor ICC profile """ key = (src_profile_key, monitor_profile_path) - if key not in _icc_transform_cache: - _icc_transform_cache[key] = ImageCms.buildTransform( - src_profile, monitor_profile, "RGB", "RGB" - ) - log.debug("Built new ICC transform for profile pair (src=%s, monitor=%s)", src_profile_key[:16], monitor_profile_path) - return _icc_transform_cache[key] + with _icc_cache_lock: + if key not in _icc_transform_cache: + _icc_transform_cache[key] = ImageCms.buildTransform( + src_profile, monitor_profile, "RGB", "RGB" + ) + log.debug("Built new ICC transform for profile pair (src=%s, monitor=%s)", src_profile_key[:16], monitor_profile_path) + return _icc_transform_cache[key] def clear_icc_caches(): """Clear all ICC-related caches (profiles and transforms).""" global _monitor_profile_cache, _icc_transform_cache, _monitor_profile_warning_logged - _monitor_profile_cache.clear() - _icc_transform_cache.clear() - _monitor_profile_warning_logged = False - log.info("Cleared ICC profile and transform caches") + with _icc_cache_lock: + _monitor_profile_cache.clear() + _icc_transform_cache.clear() + _monitor_profile_warning_logged = False + log.info("Cleared ICC profile and transform caches") def get_monitor_profile(): """Dynamically load monitor ICC profile based on current config. @@ -60,29 +67,29 @@ def get_monitor_profile(): monitor_icc_path = config.get('color', 'monitor_icc_path', fallback="").strip() - # Check cache first - if monitor_icc_path in _monitor_profile_cache: + with _icc_cache_lock: + # Check cache first + if monitor_icc_path in _monitor_profile_cache: + return _monitor_profile_cache[monitor_icc_path] + + # Handle empty path case + if not monitor_icc_path: + if not _monitor_profile_warning_logged: + log.warning("ICC mode enabled but no monitor_icc_path configured") + _monitor_profile_warning_logged = True + _monitor_profile_cache[monitor_icc_path] = None + return None + + # Load and cache the profile + try: + profile = ImageCms.ImageCmsProfile(monitor_icc_path) + log.debug("Loaded monitor ICC profile: %s", monitor_icc_path) + _monitor_profile_cache[monitor_icc_path] = profile + except (OSError, ImageCms.PyCMSError) as e: + log.warning("Failed to load monitor ICC profile from %s: %s", monitor_icc_path, e) + _monitor_profile_cache[monitor_icc_path] = None + return _monitor_profile_cache[monitor_icc_path] - - # Handle empty path case - if not monitor_icc_path: - if not _monitor_profile_warning_logged: - log.warning("ICC mode enabled but no monitor_icc_path configured") - _monitor_profile_warning_logged = True - _monitor_profile_cache[monitor_icc_path] = None - return None - - # Load and cache the profile - try: - profile = ImageCms.ImageCmsProfile(monitor_icc_path) - log.debug("Loaded monitor ICC profile: %s", monitor_icc_path) - _monitor_profile_cache[monitor_icc_path] = profile - except (OSError, ImageCms.PyCMSError) as e: - log.warning("Failed to load monitor ICC profile from %s: %s", monitor_icc_path, e) - _monitor_profile_cache[monitor_icc_path] = None - - return _monitor_profile_cache[monitor_icc_path] - def apply_saturation_compensation( arr: np.ndarray, diff --git a/faststack/faststack/io/sidecar.py b/faststack/faststack/io/sidecar.py index 46128cc..fcff8e7 100644 --- a/faststack/faststack/io/sidecar.py +++ b/faststack/faststack/io/sidecar.py @@ -37,8 +37,7 @@ def load(self) -> Sidecar: json_load_time = time.perf_counter() - t_start if self.debug: - log.info(f"SidecarManager.load: json.load() took {json_load_time:.3f}s") - + log.info(f"SidecarManager.load: loading sidecar took {json_load_time:.3f}s") if data.get("version") != 2: log.warning("Old sidecar format detected. Starting fresh.") return Sidecar() diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index a55a552..b0f02ef 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -148,6 +148,7 @@ Item { // Store initial crop box var box = uiState.currentCropBox + if (!box || box.length !== 4) return cropBoxStartLeft = box[0] cropBoxStartTop = box[1] cropBoxStartRight = box[2] @@ -160,10 +161,9 @@ Item { } isCropDragging = true } - } - + } function getCropRect() { - if (!mainImage.source || !uiState || !uiState.currentCropBox) { + if (!mainImage.source || !uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) { return {x: 0, y: 0, width: 0, height: 0} } var imgWidth = mainImage.paintedWidth @@ -178,7 +178,6 @@ Item { height: (box[3] - box[1]) / 1000 * imgHeight } } - onPositionChanged: function(mouse) { if (uiState && uiState.isCropping && isCropDragging) { if (cropDragMode === "new") { @@ -235,29 +234,13 @@ Item { bottom = Math.max(top + 10, Math.min(1000, mouseY)) } - // Apply aspect ratio if needed - if (uiState.currentAspectRatioIndex > 0 && uiState.aspectRatioNames && uiState.aspectRatioNames.length > uiState.currentAspectRatioIndex) { - var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex] - var ratio = getAspectRatio(ratioName) - if (ratio) { - var targetAspect = ratio[0] / ratio[1] - var currentWidth = right - left - var currentHeight = bottom - top - var currentAspect = currentWidth / currentHeight - - if (currentAspect > targetAspect) { - var newHeight = currentWidth / targetAspect - var centerY = (top + bottom) / 2 - top = Math.max(0, centerY - newHeight / 2) - bottom = Math.min(1000, top + newHeight) - } else { - var newWidth = currentHeight * targetAspect - var centerX = (left + right) / 2 - left = Math.max(0, centerX - newWidth / 2) - right = Math.min(1000, left + newWidth) - } - } - } + + var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom) + left = constrainedBox[0] + top = constrainedBox[1] + right = constrainedBox[2] + bottom = constrainedBox[3] + uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] } @@ -337,7 +320,27 @@ Item { var top = Math.min(imgCoordY1, imgCoordY2) * 1000 var bottom = Math.max(imgCoordY1, imgCoordY2) * 1000 - // Apply aspect ratio constraint if selected (index 0 is Freeform, so skip it) + + var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom) + left = constrainedBox[0] + top = constrainedBox[1] + right = constrainedBox[2] + bottom = constrainedBox[3] + + + uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] + } + + function getAspectRatio(name) { + // Map aspect ratio names to ratios + if (name === "1:1 (Square)") return [1, 1] + if (name === "4:5 (Portrait)") return [4, 5] + if (name === "1.91:1 (Landscape)") return [191, 100] + if (name === "9:16 (Story)") return [9, 16] + return null + } + + function applyAspectRatioConstraint(left, top, right, bottom) { if (uiState.currentAspectRatioIndex > 0 && uiState.aspectRatioNames && uiState.aspectRatioNames.length > uiState.currentAspectRatioIndex) { var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex] var ratio = getAspectRatio(ratioName) @@ -380,17 +383,7 @@ Item { } } } - - uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] - } - - function getAspectRatio(name) { - // Map aspect ratio names to ratios - if (name === "1:1 (Square)") return [1, 1] - if (name === "4:5 (Portrait)") return [4, 5] - if (name === "1.91:1 (Landscape)") return [191, 100] - if (name === "9:16 (Story)") return [9, 16] - return null + return [left, top, right, bottom] } function updateCropBoxFromAspectRatio() { @@ -511,15 +504,13 @@ Item { z: 1000 // Try to get root from parent hierarchy - property bool isDark: root.isDarkTheme + property bool isDark: typeof root !== "undefined" && root ? root.isDarkTheme : true Component.onCompleted: { // Update colors based on theme color = isDark ? "#333333" : "#f0f0f0" border.color = isDark ? "#666666" : "#cccccc" - } - - Column { + } Column { id: aspectRatioColumn anchors.fill: parent anchors.margins: 10 diff --git a/faststack/faststack/qml/FilterDialog.qml b/faststack/faststack/qml/FilterDialog.qml index 16b6e72..7fccc44 100644 --- a/faststack/faststack/qml/FilterDialog.qml +++ b/faststack/faststack/qml/FilterDialog.qml @@ -21,7 +21,7 @@ Dialog { background: Rectangle { color: filterDialog.backgroundColor - border.color: Material.theme === Material.Dark ? "#404040" : "#c0c0c0" + border.color: "#404040" border.width: 1 radius: 4 } diff --git a/faststack/faststack/qml/ImageEditorDialog.qml.new b/faststack/faststack/qml/ImageEditorDialog.qml.new deleted file mode 100644 index 550be81..0000000 --- a/faststack/faststack/qml/ImageEditorDialog.qml.new +++ /dev/null @@ -1,219 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Controls 2.15 -import QtQuick.Controls.Material 2.15 -import QtQuick.Layouts 1.15 -import QtQuick.Window 2.15 -import Qt5Compat.GraphicalEffects - -Window { - id: editDialog - width: 720 - height: 600 - title: "Image Editor" - visible: uiState.isEditorOpen - flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint - - Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light - Material.accent: "#4fb360" - - // When the dialog is closed by the user (e.g. clicking X), update the state - onVisibleChanged: { - if (!visible) { - uiState.isEditorOpen = false - } - } - - property int slidersPressedCount: 0 - onSlidersPressedCountChanged: { - uiState.setAnySliderPressed(slidersPressedCount > 0) - } - - function getBackendValue(key) { - if (key in uiState) return uiState[key]; - return 0.0; - } - - // Background - color: "#2b2b2b" - - ScrollView { - anchors.fill: parent - anchors.margins: 10 - clip: true - contentWidth: availableWidth - - RowLayout { - width: parent.width - spacing: 20 - - ColumnLayout { // Left Column - Layout.fillWidth: true - Layout.preferredWidth: (parent.width - 20) / 2 - Layout.alignment: Qt.AlignTop - spacing: 2 - - // --- Light Group --- - Label { text: "Light"; font.bold: true; color: uiState.theme === 0 ? "white" : "black" } - ListModel { - id: lightModel - ListElement { name: "Exposure"; key: "exposure" } - ListElement { name: "Highlights"; key: "highlights" } - ListElement { name: "Shadows"; key: "shadows" } - ListElement { name: "Whites"; key: "whites"; reverse: true } - ListElement { name: "Blacks"; key: "blacks" } - ListElement { name: "Brightness"; key: "brightness" } - ListElement { name: "Contrast"; key: "contrast" } - } - Repeater { model: lightModel; delegate: editSlider } - - // --- Detail Group --- - Label { text: "Detail"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } - ListModel { - id: detailModel - ListElement { name: "Clarity"; key: "clarity" } - ListElement { name: "Sharpness"; key: "sharpness" } - } - Repeater { model: detailModel; delegate: editSlider } - } - - ColumnLayout { // Right Column - Layout.fillWidth: true - Layout.preferredWidth: (parent.width - 20) / 2 - Layout.alignment: Qt.AlignTop - spacing: 2 - - // --- Color Group --- - Label { text: "Color"; font.bold: true; color: uiState.theme === 0 ? "white" : "black" } - ListModel { - id: colorModel - ListElement { name: "Saturation"; key: "saturation"; reverse: false } - ListElement { name: "Vibrance"; key: "vibrance"; reverse: false } - ListElement { name: "White Balance (B/Y)"; key: "white_balance_by"; reverse: false } - ListElement { name: "White Balance (G/M)"; key: "white_balance_mg"; reverse: false } - } - Repeater { model: colorModel; delegate: editSlider } - - // --- Effects Group --- - Label { text: "Effects"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } - ListModel { - id: effectsModel - ListElement { name: "Vignette"; key: "vignette"; min: 0; max: 100 } - } - Repeater { model: effectsModel; delegate: editSlider } - - // --- Transform Group --- - Label { text: "Transform"; font.bold: true; color: uiState.theme === 0 ? "white" : "black"; Layout.topMargin: 10 } - RowLayout { - Layout.fillWidth: true - Label { text: "Rotation"; color: uiState.theme === 0 ? "white" : "black" } - Button { text: "↶"; onClicked: controller.rotate_image_ccw() } - Button { text: "↷"; onClicked: controller.rotate_image_cw() } - } - - // --- Action Buttons --- - Item { Layout.fillHeight: true; Layout.minimumHeight: 20 } - Button { - text: "Reset All Edits" - Layout.fillWidth: true - onClicked: controller.reset_edit_parameters() - } - Button { - text: "Save Edited Image (Ctrl+S)" - Layout.fillWidth: true - onClicked: controller.save_edited_image() - } - Button { - text: "Close Editor (E)" - Layout.fillWidth: true - onClicked: { - uiState.isEditorOpen = false - } - } - } - } - } - - Component { - id: editSlider - ColumnLayout { - Layout.fillWidth: true - spacing: 0 - - property bool isReversed: model.reverse !== undefined ? model.reverse : false - property real displayValue: isReversed ? -slider.value : slider.value - - Text { - text: model.name + ": " + displayValue.toFixed(0) - color: uiState.theme === 0 ? "white" : "black" - font.pixelSize: 14 - wrapMode: Text.WordWrap - Layout.fillWidth: true - } - Slider { - id: slider - Layout.fillWidth: true - Layout.minimumHeight: 30 - from: model.min === undefined ? -100 : model.min - to: model.max === undefined ? 100 : model.max - stepSize: 1 - - property real backendValue: { - var val = editDialog.getBackendValue(model.key) * (model.max === undefined ? 100 : model.max) - return isReversed ? -val : val - } - - value: backendValue - - onMoved: { - var sendValue = isReversed ? -value : value - controller.set_edit_parameter(model.key, sendValue / (model.max === undefined ? 100.0 : model.max)) - } - - onPressedChanged: { - if (pressed) editDialog.slidersPressedCount++; else editDialog.slidersPressedCount--; - } - - background: Rectangle { - implicitHeight: 8 - radius: 4 - color: "#333333" - - Rectangle { - width: slider.visualPosition * parent.width - height: parent.height - radius: 4 - gradient: Gradient { - GradientStop { position: 0.0; color: "#6fcf7c" } - GradientStop { position: 1.0; color: "#4fb360" } - } - } - } - - handle: Rectangle { - x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width) - y: slider.topPadding + slider.availableHeight / 2 - height / 2 - implicitWidth: 24 - implicitHeight: 24 - radius: 12 - color: slider.pressed ? "#4fb360" : "white" - border.color: "#4fb360" - border.width: 3 - - Behavior on width { NumberAnimation { duration: 120 } } - Behavior on height { NumberAnimation { duration: 120 } } - - // Optional: subtle shadow - layer.enabled: true - layer.effect: Shadow { - radius: 8 - samples: 17 - color: "#80000000" - verticalOffset: 2 - horizontalOffset: 0 - } - } - - } - } - } -} diff --git a/faststack/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml index 4c86ae5..2fa074a 100644 --- a/faststack/faststack/qml/SettingsDialog.qml +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -213,8 +213,8 @@ Dialog { ComboBox { id: awbModeComboBox model: ["lab", "rgb"] - currentIndex: model.indexOf(settingsDialog.awbMode) - onCurrentIndexChanged: settingsDialog.awbMode = model[currentIndex] + currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode)) + onCurrentIndexChanged: settingsDialog.awbMode = model[currentIndex] Layout.topMargin: 10 } Label { diff --git a/faststack/faststack/ui/keystrokes.py.bak b/faststack/faststack/ui/keystrokes.py.bak deleted file mode 100644 index 812a764..0000000 --- a/faststack/faststack/ui/keystrokes.py.bak +++ /dev/null @@ -1,45 +0,0 @@ -"""Maps Qt Key events to application actions.""" - -import logging -from PySide6.QtCore import Qt - -log = logging.getLogger(__name__) - -class Keybinder: - def __init__(self, main_window): - self.main_window = main_window - self.key_map = { - # Navigation - Qt.Key.Key_J: self.main_window.next_image, - Qt.Key.Key_Right: self.main_window.next_image, - Qt.Key.Key_K: self.main_window.prev_image, - Qt.Key.Key_Left: self.main_window.prev_image, - - # View Mode - Qt.Key.Key_G: self.main_window.toggle_grid_view, - - # Metadata - Qt.Key.Key_Space: self.main_window.toggle_current_flag, - Qt.Key.Key_X: self.main_window.toggle_current_reject, - - # Stacking - Qt.Key.Key_BracketLeft: self.main_window.begin_new_stack, - Qt.Key.Key_BracketRight: self.main_window.end_current_stack, - - # Actions - Qt.Key.Key_S: self.main_window.toggle_selection, - Qt.Key.Key_Enter: self.main_window.launch_helicon, - Qt.Key.Key_Return: self.main_window.launch_helicon, - - # Stack Management - Qt.Key.Key_C: self.main_window.clear_all_stacks, - } - - def handle_key_press(self, event): - """Handles a key press event from the main window.""" - log.info(f"Key pressed: {event.key()}") - action = self.key_map.get(event.key()) - if action: - action() - return True - return False diff --git a/faststack/faststack/ui/provider.py.bak b/faststack/faststack/ui/provider.py.bak deleted file mode 100644 index fad115f..0000000 --- a/faststack/faststack/ui/provider.py.bak +++ /dev/null @@ -1,247 +0,0 @@ -"""QML Image Provider and application state bridge.""" - -import logging -from typing import Optional - -import numpy as np -from PySide6.QtCore import QObject, Signal, Property, QUrl, Slot, Qt -from PySide6.QtGui import QImage -from PySide6.QtQuick import QQuickImageProvider - -from faststack.models import DecodedImage - -log = logging.getLogger(__name__) - -class ImageProvider(QQuickImageProvider): - def __init__(self, app_controller): - super().__init__(QQuickImageProvider.ImageType.Image) - self.app_controller = app_controller - self.placeholder = QImage(256, 256, QImage.Format.Format_RGB888) - self.placeholder.fill(Qt.GlobalColor.darkGray) - - def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: - """Handles image requests from QML.""" - if not id: - return self.placeholder - - # The ID is expected to be the image index - try: - image_index_str = id.split('/')[0] - index = int(image_index_str) - image_data = self.app_controller.get_decoded_image(index) - - if image_data: - # Zero-copy QImage from numpy buffer - qimg = QImage( - image_data.buffer, - image_data.width, - image_data.height, - image_data.bytes_per_line, - QImage.Format.Format_RGB888 - ) - # Keep a reference to the original buffer to prevent garbage collection - qimg.original_buffer = image_data.buffer - return qimg - - except (ValueError, IndexError) as e: - log.error(f"Invalid image ID requested from QML: {id}. Error: {e}") - - return self.placeholder - -class UIState(QObject): - """Manages the state exposed to the QML user interface.""" - - # Signals - currentIndexChanged = Signal() - imageCountChanged = Signal() - currentImageSourceChanged = Signal() - metadataChanged = Signal() - themeChanged = Signal() - preloadingStateChanged = Signal() - preloadProgressChanged = Signal() - isZoomedChanged = Signal() - - def __init__(self, app_controller): - super().__init__() - self.app_controller = app_controller - self._is_preloading = False - self._preload_progress = 0 - self._theme = 1 - @Property(int, notify=themeChanged) - - def theme(self): - return self._theme - - @theme.setter - def theme(self, value: int): - value = int(value) - if value == self._theme: - return - self._theme = value - self.themeChanged.emit() - - - @Property(bool, notify=isZoomedChanged) - def isZoomed(self): - return self.app_controller.is_zoomed - - @Slot(bool) - def setZoomed(self, zoomed: bool): - self.app_controller.set_zoomed(zoomed) - - @Property(bool, notify=preloadingStateChanged) - def isPreloading(self): - return self._is_preloading - - @isPreloading.setter - def isPreloading(self, value): - if self._is_preloading != value: - self._is_preloading = value - self.preloadingStateChanged.emit() - - @Property(int, notify=preloadProgressChanged) - def preloadProgress(self): - return self._preload_progress - - @preloadProgress.setter - def preloadProgress(self, value): - if self._preload_progress != value: - self._preload_progress = value - self.preloadProgressChanged.emit() - - @Property(int, notify=currentIndexChanged) - def currentIndex(self): - return self.app_controller.current_index - - @Property(int, notify=imageCountChanged) - def imageCount(self): - return len(self.app_controller.image_files) - - @Property(str, notify=currentImageSourceChanged) - def currentImageSource(self): - # The source is the provider ID, which we tie to the index and a generation counter - # to force QML to request a new image even if the index is the same. - return f"image://provider/{self.app_controller.current_index}/{self.app_controller.ui_refresh_generation}" - - # --- Metadata Properties --- - @Property(str, notify=metadataChanged) - def currentFilename(self): - return self.app_controller.get_current_metadata().get("filename", "") - - @Property(bool, notify=metadataChanged) - def isFlagged(self): - return self.app_controller.get_current_metadata().get("flag", False) - - @Property(bool, notify=metadataChanged) - def isRejected(self): - return self.app_controller.get_current_metadata().get("reject", False) - - @Property(bool, notify=metadataChanged) - def isStacked(self): - return self.app_controller.get_current_metadata().get("stacked", False) - - @Property(str, notify=metadataChanged) - def stackedDate(self): - return self.app_controller.get_current_metadata().get("stacked_date", "") - - @Property(str, notify=metadataChanged) - def stackInfoText(self): - return self.app_controller.get_current_metadata().get("stack_info_text", "") - - @Property(str, notify=metadataChanged) - def get_stack_summary(self): - if not self.app_controller.stacks: - return "No stacks defined." - - summary = f"Found {len(self.app_controller.stacks)} stacks:\n\n" - for i, (start, end) in enumerate(self.app_controller.stacks): - count = end - start + 1 - summary += f"Stack {i+1}: {count} photos (indices {start}-{end})\n" - return summary - - # --- Slots for QML to call --- - @Slot() - def nextImage(self): - self.app_controller.next_image() - - @Slot() - def prevImage(self): - self.app_controller.prev_image() - - @Slot() - def toggleFlag(self): - self.app_controller.toggle_current_flag() - - @Slot() - def launch_helicon(self): - self.app_controller.launch_helicon() - - @Slot() - def clear_all_stacks(self): - self.app_controller.clear_all_stacks() - - @Slot(result=str) - def get_helicon_path(self): - return self.app_controller.get_helicon_path() - - @Slot(str) - def set_helicon_path(self, path): - self.app_controller.set_helicon_path(path) - - @Slot(result=str) - def open_file_dialog(self): - return self.app_controller.open_file_dialog() - - @Slot(str, result=bool) - def check_path_exists(self, path): - return self.app_controller.check_path_exists(path) - - @Slot(result=float) - def get_cache_size(self): - return self.app_controller.get_cache_size() - - @Slot(float) - def set_cache_size(self, size): - self.app_controller.set_cache_size(size) - - @Slot(result=int) - def get_prefetch_radius(self): - return self.app_controller.get_prefetch_radius() - - @Slot(int) - def set_prefetch_radius(self, radius): - self.app_controller.set_prefetch_radius(radius) - - @Slot(result=int) - def get_theme(self): - return self.app_controller.get_theme() - - @Slot(int) - def set_theme(self, theme_index): - # update UI property - self.ui_state.theme = theme_index - - # persist - theme = 'dark' if theme_index == 0 else 'light' - config.set('core', 'theme', theme) - config.save() - - @Slot(result=str) - def get_default_directory(self): - return self.app_controller.get_default_directory() - - @Slot(str) - def set_default_directory(self, path): - self.app_controller.set_default_directory(path) - - @Slot(result=str) - def open_directory_dialog(self): - return self.app_controller.open_directory_dialog() - - @Slot() - def preloadAllImages(self): - self.app_controller.preload_all_images() - - @Slot(int, int) - def onDisplaySizeChanged(self, width: int, height: int): - self.app_controller.on_display_size_changed(width, height) diff --git a/z b/z deleted file mode 100644 index 0a47495..0000000 --- a/z +++ /dev/null @@ -1,2977 +0,0 @@ -tar: faststack/README.md: Cannot open: No such file or directory -tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory -tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory -tar: faststack/faststack.egg-info/dependency_links.txt: Cannot open: No such file or directory -tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory -tar: faststack/faststack.egg-info/requires.txt: Cannot open: No such file or directory -tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory -tar: faststack/faststack.egg-info/entry_points.txt: Cannot open: No such file or directory -tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory -tar: faststack/faststack.egg-info/PKG-INFO: Cannot open: No such file or directory -tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory -tar: faststack/faststack.egg-info/top_level.txt: Cannot open: No such file or directory -tar: faststack/faststack.egg-info: Cannot mkdir: No such file or directory -tar: faststack/faststack.egg-info/SOURCES.txt: Cannot open: No such file or directory -tar: faststack/LICENSE: Cannot open: No such file or directory -tar: faststack/faststack.spec: Cannot open: No such file or directory -tar: faststack/ChangeLog.md: Cannot open: No such file or directory -tar: faststack/pyproject.toml: Cannot open: No such file or directory -tar: faststack/patch: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/config.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/indexer.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/helicon.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/__pycache__: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/__pycache__/helicon.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/__pycache__/executable_validator.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/__pycache__/sidecar.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/__pycache__/indexer.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/__pycache__/watcher.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/watcher.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/executable_validator.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/io/sidecar.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui/keystrokes.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui/provider.py.bak: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui/__pycache__: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui/__pycache__/controller.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui/__pycache__/provider.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui/__pycache__/keystrokes.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui/keystrokes.py.bak: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/ui/provider.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/benchmark_decode.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/app.py.bak: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/__pycache__: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/__pycache__/models.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/__pycache__/app.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/__pycache__/logging_setup.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/__pycache__/entry.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/__pycache__/__init__.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/__pycache__/config.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/qml: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/qml/JumpToImageDialog.qml: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/qml/Main.qml.bak: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/qml/SettingsDialog.qml: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/qml/Main.qml: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/qml/Components.qml: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/qml/ImageEditorDialog.qml: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/qml/FilterDialog.qml: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/app.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/logging_setup.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging/cache.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging/editor.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging/prefetch.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging/__pycache__: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging/__pycache__/cache.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging/__pycache__/jpeg.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging/__pycache__/prefetch.cpython-313.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/imaging/jpeg.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/models.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/__init__.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/tests: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/tests/test_executable_validator.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/tests/__pycache__: Cannot mkdir: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/tests/__pycache__/test_executable_validator.cpython-313-pytest-8.4.2.pyc: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/tests/test_sidecar.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/tests/test_cache.py: Cannot open: No such file or directory -tar: faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/faststack/tests/test_pairing.py: Cannot open: No such file or directory -tar: faststack/requirements.txt: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_operator.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/weakref.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/copy.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_weakrefset.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/contextvars.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/genericpath.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/contextlib.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/parsers: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/parsers/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/parsers/expat: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/parsers/expat/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/parsers/expat/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/parsers/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/minidom.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/minicompat.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/xmlbuilder.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/xmlbuilder.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/minicompat.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/domreg.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/expatbuilder.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/expatbuilder.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/domreg.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/dom/minidom.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax/_exceptions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax/handler.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax/xmlreader.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax/_exceptions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax/handler.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/sax/xmlreader.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/etree: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/etree/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/etree/ElementTree.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/etree/ElementTree.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/xml/etree/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_decimal.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/socket.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/cProfile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/fractions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/builtins.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/copyreg.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/os: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/os/path.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/os/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/os/path.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/os/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/math.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/selectors.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numbers.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/dis.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/warnings.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/itertools.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_bisect.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/bisect.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_locale.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/codeop.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sre_compile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/signal.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/contextvars.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/http: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/http/client.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/http/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/http/client.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/http/cookiejar.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/http/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/http/cookiejar.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/locale.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pickle.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/selectors.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_weakref.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageFile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/TiffImagePlugin.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImagePalette.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImagePalette.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_typing.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageFilter.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/Image.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/TiffTags.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_imaging.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageColor.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageColor.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/PaletteFile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/GimpPaletteFile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_imagingcms.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_version.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_version.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageFilter.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageCms.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_imaging.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ExifTags.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/Image.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ExifTags.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_imagingcms.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageMode.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/TiffTags.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageQt.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_util.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageOps.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_util.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageFile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_typing.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageOps.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/GimpGradientFile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_binary.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/GimpPaletteFile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageCms.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageMode.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/GimpGradientFile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/TiffImagePlugin.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_deprecate.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_deprecate.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/ImageQt.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/PaletteFile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PIL/_binary.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_warnings.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_weakref.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/keyword.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sre_constants.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/keyword.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/typing.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/string.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/codeop.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/struct.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zipimport.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/bisect.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/io.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/time.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/shlex.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/wave.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ssl.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/tokenize.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/reprlib.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/@plugins_snapshot.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/token.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/types.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zipimport.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zlib.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/base64.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/getopt.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/configparser.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_ast.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pathlib.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_typeshed: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_typeshed/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_typeshed/xml.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_typeshed/xml.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_typeshed/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zlib.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/bdb.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pathlib.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/builtins.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_codecs.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/parse.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/parse.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/error.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/request.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/response.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/request.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/error.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/urllib/response.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/dataclasses.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sqlite3: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sqlite3/dbapi2.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sqlite3/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sqlite3/dbapi2.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sqlite3/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pdb.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/shutil.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_thread.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pydoc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/copyreg.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sre_constants.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numbers.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/contextlib.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/linecache.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_random.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/dataclasses.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/random.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/subprocess.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/reprlib.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/log.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/traitlets.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/_version.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/_version.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/loader.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/configurable.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/application.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/argcomplete_config.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/configurable.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/loader.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/argcomplete_config.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/config/application.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/log.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/traitlets.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/text.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/warnings.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/decorators.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/decorators.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/bunch.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/sentinel.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/bunch.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/getargspec.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/descriptions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/importstring.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/warnings.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/importstring.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/sentinel.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/nested_update.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/getargspec.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/text.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/descriptions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traitlets/utils/nested_update.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/bdb.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/uuid.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/subprocess.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/operator.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/threading.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_compression.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/notebooknode.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/json_compat.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/nbjson.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/nbbase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/nbjson.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/nbbase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/rwbase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/rwbase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/convert.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v1/convert.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/_imports.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/warnings.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/sentinel.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/validator.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/_imports.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/nbjson.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/nbbase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/nbjson.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/nbpy.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/nbpy.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/nbbase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/rwbase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/rwbase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/convert.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v3/convert.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/_version.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/_version.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/nbjson.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/nbbase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/nbjson.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/nbbase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/rwbase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/rwbase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/convert.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v4/convert.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/warnings.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/json_compat.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/_struct.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/reader.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/sentinel.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/nbjson.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/nbbase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/nbjson.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/nbpy.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/nbpy.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/nbxml.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/nbbase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/rwbase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/nbxml.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/rwbase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/convert.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/v2/convert.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/validator.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/_struct.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/converter.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/notebooknode.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/converter.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/corpus: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/corpus/words.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/corpus/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/corpus/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/corpus/words.data.json.bc126a5597e10293: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/corpus/words.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/nbformat/reader.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/opcode.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/random.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/posixpath.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/display.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/paths.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/display.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib/display.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib/display.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib/clipboard.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib/pretty.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib/pretty.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/lib/clipboard.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/auto_match.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/auto_match.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/filters.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/auto_suggest.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/auto_suggest.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/shortcuts/filters.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/ptutils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/interactiveshell.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/embed.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/magics.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/pt_inputhooks: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/pt_inputhooks/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/pt_inputhooks/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/magics.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/prompts.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/ptutils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/embed.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/debugger.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/ipapp.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/prompts.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/debugger.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/ipapp.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/terminal/interactiveshell.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/paths.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/extensions: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/extensions/storemagic.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/extensions/storemagic.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/extensions/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/extensions/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/testing: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/testing/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/testing/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/testing/skipdoctest.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/testing/skipdoctest.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/capture.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/contexts.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/tokenutil.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/_process_common.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/text.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/path.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/decorators.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/encoding.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/decorators.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/data.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/sentinel.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/coloransi.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/io.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/openpy.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/py3compat.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/module_paths.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/_process_posix.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/process.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/encoding.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/strdispatch.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/_process_win32.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/dir2.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/importstring.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/_sysinfo.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/wildcard.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/capture.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/module_paths.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/docs.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/syspathcontext.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/process.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/docs.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/timing.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/importstring.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/sentinel.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/frame.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/sysinfo.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/_sysinfo.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/colorable.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/generics.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/_process_win32.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/path.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/_process_common.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/strdispatch.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/syspathcontext.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/ipstruct.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/terminal.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/openpy.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/dir2.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/contexts.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/text.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/py3compat.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/PyColorize.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/sysinfo.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/PyColorize.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/io.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/frame.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/tokenutil.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/data.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/_process_posix.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/coloransi.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/wildcard.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/terminal.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/colorable.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/timing.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/generics.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/utils/ipstruct.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/payload.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/builtin_trap.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/ultratb.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/pylabtools.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/profiledir.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/payload.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/shellapp.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/excolors.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/display.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/displayhook.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/display_trap.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/page.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/code.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/pylab.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/display.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/execution.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/namespace.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/display.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/history.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/code.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/extension.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/pylab.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/config.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/logging.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/namespace.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/script.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/packaging.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/osm.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/execution.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/basic.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/basic.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/auto.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/extension.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/history.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/packaging.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/auto.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/script.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/config.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/ast_mod.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/osm.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/logging.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magics/ast_mod.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/alias.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/display.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/hooks.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/ultratb.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/completer.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/excolors.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/interactiveshell.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/pylabtools.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/history.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/error.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/crashhandler.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/getipython.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/oinspect.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/prefilter.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/displaypub.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/release.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/completerlib.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/oinspect.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/prefilter.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magic.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/page.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/crashhandler.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/logger.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/guarded_eval.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/extensions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/compilerop.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/latex_symbols.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/guarded_eval.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/completerlib.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/displayhook.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/application.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/usage.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magic.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/inputtransformer2.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/splitinput.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/async_helpers.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/macro.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/autocall.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/logger.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/events.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/builtin_trap.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/inputtransformer2.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/usage.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/extensions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/compilerop.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/history.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/hooks.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/macro.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/shellapp.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/completer.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/formatters.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/error.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/autocall.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/debugger.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/alias.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/release.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/displaypub.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/async_helpers.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magic_arguments.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/latex_symbols.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/magic_arguments.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/getipython.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/debugger.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/events.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/display_functions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/profiledir.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/display_trap.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/display_functions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/application.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/splitinput.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/formatters.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/IPython/core/interactiveshell.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/timeit.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/typing_extensions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/socket.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/glob.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/collections: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/collections/abc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/collections/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/collections/abc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/collections/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pickle.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_codecs.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/inspect.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/platform.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/glob.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/api.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify_c.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/polling.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/polling.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/api.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify_buffer.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify_buffer.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/observers/inotify_c.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/events.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/events.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/patterns.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/patterns.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/bricks.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/dirsnapshot.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/bricks.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/dirsnapshot.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/delayed_queue.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/watchdog/utils/delayed_queue.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pdb.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_stat.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/bz2.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/time.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/abc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/cmd.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_lsprof.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sys: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sys/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sys/_monitoring.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sys/_monitoring.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sys/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/math.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/datetime.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/errno.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/functools.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/async_case.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/result.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/async_case.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/_log.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/runner.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/loader.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/_log.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/signals.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/signals.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/suite.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/main.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/loader.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/result.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/case.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/case.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/suite.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/main.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unittest/runner.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/codecs.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/resource.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_ctypes.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/enum.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/site.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/__future__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/mimetypes.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/struct.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes/_endian.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes/util.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes/util.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes/wintypes.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes/_endian.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ctypes/wintypes.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ast.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/string.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/difflib.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/timeit.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/weakref.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unicodedata.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_collections_abc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sre_compile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pydoc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/queue.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/atexit.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zipfile: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zipfile/_path.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zipfile/_path.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zipfile/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/zipfile/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/linecache.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/mimetypes.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/dis.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/warnings.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/__future__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/html: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/html/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/html/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/fnmatch.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/tarfile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/gzip.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/gzip.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_ctypes.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/shutil.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/unicodedata.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/__main__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/argparse.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/datetime.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/msvcrt.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/getopt.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/resource.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_compression.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_thread.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/hashlib.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/bz2.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/functools.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/runpy.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/tokenize.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/textwrap.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/configparser.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/colorsys.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/gc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/abc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/opcode.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/shlex.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_decimal.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/decimal.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/tarfile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/stat.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/base64.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/profile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/enum.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/typing_extensions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ast.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/operator.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_weakrefset.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/mmap.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/cmd.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/posixpath.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/ssl.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/json: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/json/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/json/decoder.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/json/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/json/decoder.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/json/encoder.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/json/encoder.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_socket.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/atexit.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/argparse.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/threading.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_locale.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/runpy.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/types.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/context.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/reduction.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/shared_memory.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/synchronize.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/popen_fork.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/synchronize.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/sharedctypes.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/util.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/popen_spawn_posix.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/spawn.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/process.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/popen_spawn_posix.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/util.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/popen_spawn_win32.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/queues.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/popen_spawn_win32.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/reduction.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/context.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/shared_memory.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/pool.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/process.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/managers.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/connection.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/sharedctypes.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/spawn.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/popen_fork.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/queues.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/managers.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/popen_forkserver.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/popen_forkserver.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/pool.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/multiprocessing/connection.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/colorsys.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sre_parse.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/itertools.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_warnings.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/sre_parse.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/re.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/difflib.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/fractions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_bisect.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pstats.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/token.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/codecs.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_collections_abc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/locale.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/tempfile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/textwrap.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/_abc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/machinery.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/metadata: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/metadata/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/metadata/_meta.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/metadata/_meta.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/metadata/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/abc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/readers.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/abc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/_abc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/readers.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/resources: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/resources/abc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/resources/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/resources/abc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/resources/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/importlib/machinery.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/futures.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/exceptions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/selector_events.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/unix_events.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/futures.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/timeouts.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/subprocess.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/subprocess.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/unix_events.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/exceptions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/locks.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/protocols.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/queues.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/locks.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/base_events.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/coroutines.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/events.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/transports.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/taskgroups.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/selector_events.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/transports.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/coroutines.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/mixins.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/queues.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/mixins.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/base_events.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/threads.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/streams.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/tasks.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/taskgroups.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/tasks.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/runners.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/streams.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/events.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/runners.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/threads.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/protocols.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/asyncio/timeouts.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traceback.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/ui.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io/sidecar.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io/sidecar.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io/executable_validator.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io/indexer.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io/helicon.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io/indexer.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io/helicon.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io/executable_validator.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/models.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/ui.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/logging_setup.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/logging_setup.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/imaging.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/models.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/config.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/faststack.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/imaging.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/faststack.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/config.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/faststack: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/faststack/io.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/profile.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/select.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/binascii.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/uuid.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_random.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/io.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_lsprof.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/typing.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/traceback.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_operator.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/charset.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/_policybase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/message.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/header.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/policy.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/errors.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/charset.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/contentmanager.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/errors.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/message.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/_policybase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/contentmanager.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/header.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/email/policy.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/array.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/platform.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_ast.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/msvcrt.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_stat.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/copy.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/binascii.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/logging: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/logging/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/logging/handlers.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/logging/config.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/logging/handlers.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/logging/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/logging/config.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/stat.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/fnmatch.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/exceptions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/typing: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/typing/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/typing/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/polynomial.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/legendre.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/legendre.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/polyutils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/_polybase.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/chebyshev.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/hermite.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/laguerre.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/_polybase.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/hermite_e.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/hermite_e.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/polynomial.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/polyutils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/chebyshev.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/hermite.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/polynomial/laguerre.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/version.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/dtypes.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_pytesttester.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_pytesttester.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/dtypes.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/exceptions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ctypeslib.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/utils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/scimath.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/stride_tricks.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/arraysetops.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/npyio.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/npyio.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/arraysetops.data.json.fb9c4864355d404e: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/twodim_base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/utils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/histograms.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/format.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/function_base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/index_tricks.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/shape_base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/polynomial.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/nanfunctions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/arraysetops.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/arrayterator.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/nanfunctions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/_version.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/_version.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/arrayterator.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/stride_tricks.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/arraypad.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/arraypad.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/shape_base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/type_check.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/ufunclike.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/type_check.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/scimath.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/function_base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/mixins.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/polynomial.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/mixins.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/histograms.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/twodim_base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/ufunclike.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/format.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/lib/index_tricks.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_callable.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_char_codes.data.json.feedcc2d4b38d34a: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_scalars.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_nbit.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_scalars.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_array_like.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_ufunc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_array_like.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_shape.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_extended_precision.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_shape.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_ufunc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_char_codes.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_add_docstring.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_extended_precision.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_nbit.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_nested_sequence.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_char_codes.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_nested_sequence.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_add_docstring.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_dtype_like.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_callable.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_typing/_dtype_like.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/bit_generator.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_philox.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_mt19937.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_pcg64.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_generator.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/bit_generator.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_generator.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_philox.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/mtrand.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_mt19937.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_pcg64.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_sfc64.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/mtrand.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/random/_sfc64.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/fft: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/fft/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/fft/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/fft/_pocketfft.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/fft/helper.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/fft/helper.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/fft/_pocketfft.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma/extras.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma/extras.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma/core.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma/core.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma/mrecords.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ma/mrecords.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/matrixlib: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/matrixlib/defmatrix.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/matrixlib/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/matrixlib/defmatrix.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/matrixlib/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/ctypeslib.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_utils: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_utils/_convertions.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_utils/_convertions.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_utils/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/_utils/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/linalg: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/linalg/linalg.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/linalg/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/linalg/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/linalg/linalg.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/testing: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/testing/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/testing/_private: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/testing/_private/utils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/testing/_private/utils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/testing/_private/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/testing/_private/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/testing/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/version.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/numerictypes.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/fromnumeric.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/fromnumeric.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/numerictypes.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/_ufunc_config.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/function_base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/shape_base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/numeric.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/numeric.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/_asarray.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/_asarray.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/records.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/_internal.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/multiarray.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/_ufunc_config.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/defchararray.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/umath.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/_type_aliases.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/einsumfunc.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/multiarray.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/arrayprint.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/arrayprint.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/shape_base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/records.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/defchararray.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/umath.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/function_base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/_type_aliases.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/einsumfunc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/numpy/core/_internal.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/hashlib.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/__main__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/mmap.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/signal.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/decimal.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/cProfile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/queue.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/inspect.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6/sip.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6/QtCore.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6/QtGui.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6/sip.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6/QtCore.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/PyQt6/QtGui.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/array.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures/process.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures/thread.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures/thread.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures/process.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures/_base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/futures/_base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/concurrent/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/tempfile.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/site.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/gc.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/_socket.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pprint.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pprint.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pstats.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/utils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/buffer.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/utils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/dummy.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/dimension.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/controls.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/scrollable_pane.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/margins.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/utils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/mouse_handlers.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/menus.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/screen.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/layout.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/dummy.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/mouse_handlers.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/processors.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/containers.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/scrollable_pane.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/dimension.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/controls.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/containers.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/processors.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/menus.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/margins.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/layout.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/layout/screen.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/data_structures.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/filesystem.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/fuzzy_completer.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/word_completer.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/deduplicate.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/nested.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/word_completer.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/filesystem.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/nested.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/deduplicate.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/completion/fuzzy_completer.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/utils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/utils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/formatters.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/progress_bar/formatters.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/prompt.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/prompt.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/dialogs.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/shortcuts/dialogs.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/search.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/utils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/utils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/utils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/app.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/cli.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/app.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/filters/cli.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/keys.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/mouse_events.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/history.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/enums.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/buffer.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/cache.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/auto_suggest.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/keys.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/patch_stdout.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/utils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/utils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/inputhook.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/inputhook.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/async_generator.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/eventloop/async_generator.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/auto_suggest.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/validation.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/flush_stdout.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/plain_text.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/vt100.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/plain_text.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/defaults.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/color_depth.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/vt100.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/defaults.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/flush_stdout.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/color_depth.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/output/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/mouse_events.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/document.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/renderer.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/toolbars.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/menus.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/dialogs.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/toolbars.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/dialogs.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/menus.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/widgets/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/key_processor.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/cpr.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/named_commands.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/cpr.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/completion.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/focus.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/auto_suggest.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/focus.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/auto_suggest.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/mouse.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/vi.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/page_navigation.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/open_in_editor.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/basic.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/basic.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/open_in_editor.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/scroll.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/completion.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/mouse.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/emacs.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/vi.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/emacs.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/page_navigation.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/scroll.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/bindings/named_commands.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/key_bindings.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/key_processor.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/vi_state.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/emacs_state.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/defaults.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/defaults.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/digraphs.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/vi_state.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/emacs_state.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/key_bindings.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/key_binding/digraphs.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/vt100_parser.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/typeahead.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/ansi_escape_sequences.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/defaults.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/typeahead.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/defaults.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/vt100_parser.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/input/ansi_escape_sequences.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/enums.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/search.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/history.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/validation.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/utils.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/ansi.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/pygments.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/utils.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/pygments.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/ansi.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/html.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/formatted_text/html.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/patch_stdout.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/cursor_shapes.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/pygments.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/style.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/style.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/style_transformation.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/named_colors.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/defaults.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/pygments.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/style_transformation.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/defaults.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/named_colors.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/styles/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/dummy.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/dummy.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/current.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/run_in_terminal.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/application.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/run_in_terminal.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/current.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/application/application.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/in_memory.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/clipboard/in_memory.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/document.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/pygments.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/pygments.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/base.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/lexers/base.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/data_structures.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/selection.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/selection.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/renderer.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/cache.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/prompt_toolkit/cursor_shapes.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/re.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/genericpath.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pyexpat: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pyexpat/errors.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pyexpat/__init__.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pyexpat/errors.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pyexpat/__init__.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pyexpat/model.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/pyexpat/model.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/select.meta.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/errno.data.json: Cannot open: No such file or directory -tar: faststack/.mypy_cache: Cannot mkdir: No such file or directory -tar: faststack/.mypy_cache/3.12/wave.data.json: Cannot open: No such file or directory -tar: faststack: Cannot utime: No such file or directory -tar: faststack: Cannot stat: No such file or directory -tar: Exiting with failure status due to previous errors From 1096d736ae1d35165d6c00645f9376d6cb60c011 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 23 Nov 2025 21:34:29 -0500 Subject: [PATCH 10/15] Release v1.3 - fixed editor clearing --- faststack.json | 6 ++++++ faststack/faststack/app.py | 12 +++--------- faststack/faststack/config.py | 4 ++-- faststack/faststack/imaging/editor.py | 8 ++++++++ 4 files changed, 19 insertions(+), 11 deletions(-) create mode 100644 faststack.json diff --git a/faststack.json b/faststack.json new file mode 100644 index 0000000..82bff3b --- /dev/null +++ b/faststack.json @@ -0,0 +1,6 @@ +{ + "version": 2, + "last_index": 0, + "entries": {}, + "stacks": [] +} \ No newline at end of file diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index d98826b..47a7baf 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -1222,9 +1222,7 @@ def open_folder(self): with self._last_image_lock: self.last_displayed_image = None # Clear editor state if open - self.image_editor.original_image = None - self.image_editor.current_filepath = None - self.image_editor._preview_image = None + self.image_editor.clear() # Load images from new directory self.load() @@ -1867,9 +1865,7 @@ def save_edited_image(self): saved_path, _ = save_result # Clear the image editor state so it will reload fresh next time - self.image_editor.original_image = None - self.image_editor.current_filepath = None - self.image_editor._preview_image = None + self.image_editor.clear() # Reset all edit parameters in the controller/UI self.reset_edit_parameters() @@ -2156,9 +2152,7 @@ def quick_auto_white_balance(self): self.undo_history.append(("auto_white_balance", (saved_path, backup_path), timestamp)) # Force the image editor to clear its current state so it reloads fresh - self.image_editor.original_image = None - self.image_editor.current_filepath = None - self.image_editor._preview_image = None + self.image_editor.clear() # Refresh the view - need to refresh image list since backup file was created original_path = Path(filepath) diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index c27c938..f46a813 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -28,8 +28,8 @@ "saturation_factor": "0.85", # For 'saturation' mode: 0.0-1.0, lower = less saturated "monitor_icc_path": "", # For 'icc' mode: path to monitor ICC profile }, - "awb": { "mode": "lab", # "lab" or "rgb" - "strength": "0.7", + "awb": { + "mode": "lab", # "lab" or "rgb" "strength": "0.7", "warm_bias": "6", "luma_lower_bound": "30", "luma_upper_bound": "220", diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index e48e71b..2575a9e 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -63,6 +63,14 @@ def __init__(self): # Stores the currently applied edits (used for preview) self.current_edits: Dict[str, Any] = self._initial_edits() self.current_filepath: Optional[Path] = None + + def clear(self): + """Clear all editor state so the next edit starts from a clean slate.""" + self.original_image = None + self.current_filepath = None + self._preview_image = None + # Optionally also reset edits if that matches your mental model: + # self.current_edits = self._initial_edits() def _initial_edits(self) -> Dict[str, Any]: return { From 5de3c3abb252d0f1e8154e98ea12bd6ed258788f Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 23 Nov 2025 21:55:24 -0500 Subject: [PATCH 11/15] Fix some minor bugs modified: faststack/faststack/app.py modified: faststack/faststack/imaging/cache.py modified: faststack/faststack/logging_setup.py --- faststack/faststack/app.py | 17 ++++++++++++----- faststack/faststack/imaging/cache.py | 8 ++++---- faststack/faststack/logging_setup.py | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 47a7baf..3886ea7 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -443,6 +443,9 @@ def prev_image(self): def jump_to_image(self, index: int): """Jump to a specific image by index (0-based).""" if 0 <= index < len(self.image_files): + if index == self.current_index: + self.update_status_message(f"Already at image {index + 1}") + return direction = 1 if index > self.current_index else -1 self.current_index = index self._do_prefetch(self.current_index, is_navigation=True, direction=direction) @@ -1125,6 +1128,14 @@ def get_awb_strength(self): def set_awb_strength(self, value): config.set("awb", "strength", value) config.save() + + # Refresh if AWB was recently applied + if self.get_color_mode() in ['saturation', 'icc']: + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() @Slot(result=int) def get_awb_warm_bias(self): @@ -1315,11 +1326,7 @@ def delete_current_image(self): self.delete_history.append((jpg_path, raw_path)) self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) - # Add to delete history only if at least one file was moved - if deleted_files: - timestamp = time.time() - self.delete_history.append((jpg_path, raw_path)) - self.undo_history.append(("delete", (jpg_path, raw_path), timestamp)) + # Refresh image list and move to next image self.refresh_image_list() if self.image_files: diff --git a/faststack/faststack/imaging/cache.py b/faststack/faststack/imaging/cache.py index 53b0ed1..08bb96c 100644 --- a/faststack/faststack/imaging/cache.py +++ b/faststack/faststack/imaging/cache.py @@ -40,8 +40,8 @@ def get_decoded_image_size(item) -> int: elif isinstance(item.buffer, (bytes, bytearray)): return len(item.buffer) else: - # Fallback: estimate using sys.getsizeof or compute from dimensions - # Assuming 4 bytes/pixel for RGBA or 3 for RGB - check item.channels if available - import sys - return sys.getsizeof(item.buffer) if hasattr(item.buffer, '__sizeof__') else item.width * item.height * 4 + # Fallback: estimate from dimensions (more accurate for image buffers than sys.getsizeof) + bytes_per_pixel = getattr(item, 'channels', 4) # Default to RGBA + return item.width * item.height * bytes_per_pixel + return 1 # Should not happen diff --git a/faststack/faststack/logging_setup.py b/faststack/faststack/logging_setup.py index 73737b6..6c413ce 100644 --- a/faststack/faststack/logging_setup.py +++ b/faststack/faststack/logging_setup.py @@ -33,8 +33,8 @@ def setup_logging(debug: bool = False): root_logger = logging.getLogger() # Set log level based on debug flag root_logger.setLevel(logging.DEBUG if debug else logging.INFO) + root_logger.handlers.clear() root_logger.addHandler(handler) - # Configure logging for key modules if debug: logging.getLogger("faststack.imaging.cache").setLevel(logging.DEBUG) From aa90bbd0fc2c547eabf6415ae987c92114068ec0 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 23 Nov 2025 22:01:21 -0500 Subject: [PATCH 12/15] modified: faststack/faststack/app.py --- faststack/faststack/app.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 3886ea7..5c6d819 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -1129,13 +1129,13 @@ def set_awb_strength(self, value): config.set("awb", "strength", value) config.save() - # Refresh if AWB was recently applied - if self.get_color_mode() in ['saturation', 'icc']: - self.image_cache.clear() - self.prefetcher.cancel_all() - self.display_generation += 1 - self.prefetcher.update_prefetch(self.current_index) - self.sync_ui_state() + # Refresh if AWB was recently applied + if self.get_color_mode() in ['saturation', 'icc']: + self.image_cache.clear() + self.prefetcher.cancel_all() + self.display_generation += 1 + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() @Slot(result=int) def get_awb_warm_bias(self): From e90e64deff8c2c93bd93d3fc08e7f550768bd012 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Mon, 24 Nov 2025 09:30:05 -0500 Subject: [PATCH 13/15] Release v1.3 - pull in source images of a stack --- faststack/faststack/app.py | 132 ++++++++++++++++++++++++++++- faststack/faststack/config.py | 4 + faststack/faststack/qml/Main.qml | 19 +++++ faststack/faststack/ui/provider.py | 10 +++ 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 5c6d819..28834c5 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -45,6 +45,8 @@ from faststack.ui.provider import ImageProvider from faststack.ui.keystrokes import Keybinder from faststack.imaging.editor import ImageEditor, ASPECT_RATIOS, create_backup_file +import re +from faststack.io.indexer import RAW_EXTENSIONS def make_hdrop(paths): """ @@ -1987,9 +1989,135 @@ def toggle_crop_mode(self): self.ui_state.currentAspectRatioIndex = 0 self.update_status_message("Crop mode: Drag to select area, Enter to crop") log.info("Crop mode enabled") - else: - self.update_status_message("Crop mode disabled") + else: # Exiting crop mode + self.ui_state.isCropping = False + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + self.update_status_message("Crop cancelled") log.info("Crop mode disabled") + + @Slot() + def stack_source_raws(self): + """ + Finds the source RAW files for the current stacked JPG and launches Helicon Focus. + """ + if not self.image_files or self.current_index >= len(self.image_files): + self.update_status_message("No image selected.") + return + + current_image_path = self.image_files[self.current_index].path + filename = current_image_path.name + + # Ensure it's a stacked JPG + if not filename.lower().endswith(" stacked.jpg"): + self.update_status_message("Current image is not a stacked JPG.") + return + + # Extract base name and number, e.g., "PB210633" from "20251121-PB210633 stacked.JPG" + match = re.search(r'([A-Z]+)(\d+)\s+stacked\.JPG', filename, re.IGNORECASE) + if not match: + self.update_status_message("Could not parse stacked JPG filename format.") + log.error("Could not parse stacked JPG filename: %s", filename) + return + + base_prefix = match.group(1) # e.g., "PB" + base_number_str = match.group(2) # e.g., "210633" + base_number = int(base_number_str) + + # Determine the RAW source directory + raw_source_dir_str = config.get('raw', 'source_dir') + if not raw_source_dir_str: + self.update_status_message("RAW source directory not configured in settings.") + log.warning("RAW source directory (raw.source_dir) is not set in config.") + return + + raw_base_dir = Path(raw_source_dir_str) + if not raw_base_dir.is_dir(): + self.update_status_message(f"RAW source directory not found: {raw_base_dir}") + log.warning("Configured RAW source directory does not exist: %s", raw_base_dir) + return + + # Get the mirror base from config + mirror_base_str = config.get('raw', 'mirror_base') + if not mirror_base_str: + self.update_status_message("RAW mirror base directory not configured in settings.") + log.warning("RAW mirror base (raw.mirror_base) is not set in config.") + return + + mirror_base_dir = Path(mirror_base_str) + if not mirror_base_dir.is_dir(): + self.update_status_message(f"RAW mirror base directory not found: {mirror_base_dir}") + log.warning("Configured RAW mirror base directory does not exist: %s", mirror_base_dir) + return + + # The date structure in the RAW directory mirrors the structure relative to the mirror_base + try: + relative_part = current_image_path.parent.relative_to(mirror_base_dir) + except ValueError: + self.update_status_message("Current image is not in the configured mirror base directory.") + log.error( + "Could not find relative path for '%s' from base '%s'. Check 'mirror_base' config.", + current_image_path.parent, + mirror_base_dir + ) + return + + raw_search_dir = raw_base_dir / relative_part + + if not raw_search_dir.is_dir(): + self.update_status_message(f"RAW directory for this date not found: {raw_search_dir}") + log.warning("RAW search directory does not exist: %s", raw_search_dir) + return + + # Find RAW files by decrementing the number + found_raw_files: List[Path] = [] + # Start one number less than the stacked image number + current_raw_number = base_number - 1 + + # Limit to reasonable number of RAWs to avoid infinite loop or too many files + max_raw_search = 15 # As per user request, typically between 3 and 15 + search_count = 0 + + while current_raw_number >= 0 and search_count < max_raw_search: + raw_filename_stem = f"{base_prefix}{current_raw_number:06d}" # e.g., PB210632 + + # Look for any of the common RAW extensions + potential_raw_paths = [] + for ext in RAW_EXTENSIONS: + potential_raw_paths.append(raw_search_dir / f"{raw_filename_stem}{ext}") + + found_this_number = False + for p in potential_raw_paths: + if p.is_file(): + found_raw_files.append(p) + found_this_number = True + break + + if not found_this_number: + # User specified "continue until there is a gap in the numbers" + # If we don't find any RAW for a number, assume it's a gap and stop + if found_raw_files: # Only break if we've found at least one file before this gap + break + + current_raw_number -= 1 + search_count += 1 + + if not found_raw_files: + self.update_status_message(f"No source RAW files found in {raw_search_dir} for {filename}.") + log.info("No source RAWs found for %s in %s", filename, raw_search_dir) + return + + # Sort the files by name to ensure Helicon Focus receives them in sequence + found_raw_files.sort() + + self.update_status_message(f"Launching Helicon Focus with {len(found_raw_files)} RAWs...") + log.info("Launching Helicon Focus for %s with RAWs: %s", filename, [str(p) for p in found_raw_files]) + success = self._launch_helicon_with_files(found_raw_files) + + if success: + self.update_status_message("Helicon Focus launched successfully.") + else: + self.update_status_message("Failed to launch Helicon Focus.") + @Slot() def cancel_crop_mode(self): diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index f46a813..bd3b31b 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -36,6 +36,10 @@ "rgb_lower_bound": "5", "rgb_upper_bound": "250", }, + "raw": { + "source_dir": "C:\\Users\\alanr\\pictures\\olympus.stack.input.photos", + "mirror_base": "C:\\Users\\alanr\\Pictures\\Lightroom", + } } class AppConfig: diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index b75dee6..7aa6303 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -572,6 +572,25 @@ ApplicationWindow { leftPadding: 10 } } + ItemDelegate { + width: 220 + height: 36 + text: "Stack Source RAWs" + enabled: uiState ? uiState.isStackedJpg : false + onClicked: { + if (uiState) uiState.stack_source_raws(); + actionsMenu.close() + } + background: Rectangle { + color: parent.hovered ? (root.isDarkTheme ? "#555555" : "#e0e0e0") : "transparent" + } + contentItem: Text { + text: parent.text + color: root.currentTextColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + } } } diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 813013e..330ef3f 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -92,6 +92,7 @@ class UIState(QObject): awbRgbLowerBoundChanged = Signal() awbRgbUpperBoundChanged = Signal() default_directory_changed = Signal(str) + isStackedJpgChanged = Signal() # New signal for isStackedJpg # Image Editor Signals is_editor_open_changed = Signal(bool) is_cropping_changed = Signal(bool) @@ -363,6 +364,11 @@ def currentDirectory(self): """Returns the path of the current working directory.""" return str(self.app_controller.image_dir) + @Property(bool, notify=metadataChanged) + def isStackedJpg(self): + """Returns True if the current image is a stacked JPG.""" + return self.currentFilename.endswith(" stacked.JPG") + # --- Slots for QML to call --- @Slot() def nextImage(self): @@ -456,6 +462,10 @@ def open_folder(self): def preloadAllImages(self): self.app_controller.preload_all_images() + @Slot() + def stack_source_raws(self): + self.app_controller.stack_source_raws() + @Slot(int, int) def onDisplaySizeChanged(self, width: int, height: int): self.app_controller.on_display_size_changed(width, height) From 7eb25f539ee87f9584a666ad0393fa3eed2b7efd Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sat, 29 Nov 2025 21:23:36 -0800 Subject: [PATCH 14/15] Release v1.3 - some fixes --- faststack/faststack/app.py | 89 +++-- faststack/faststack/qml/HistogramWindow.qml | 365 +++++++++++--------- faststack/faststack/qml/Main.qml | 5 +- faststack/faststack/ui/provider.py | 5 + 4 files changed, 272 insertions(+), 192 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 28834c5..5d23473 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -1246,7 +1246,7 @@ def preload_all_images(self): log.info("Preloading is already in progress.") return - log.info("Starting to preload all images.") + log.info("Starting to preload all images, skipping cached.") self.ui_state.isPreloading = True self.ui_state.preloadProgress = 0 @@ -1254,26 +1254,52 @@ def preload_all_images(self): self.reporter.progress_updated.connect(self._update_preload_progress) self.reporter.finished.connect(self._finish_preloading) - # Use existing prefetch executor (better resource utilization) - total = len(self.image_files) - - if total == 0: + total_images = len(self.image_files) + if total_images == 0: log.info("No images to preload.") - self.reporter.progress_updated.emit(100) # Or 0, depending on desired UX - self.reporter.finished.emit() + self.ui_state.isPreloading = False + self.ui_state.preloadProgress = 0 return - completed = 0 + # --- Check for cached images --- + images_to_preload = [] + already_cached_count = 0 + _, _, display_gen = self.get_display_info() + + for i in range(total_images): + cache_key = f"{i}_{display_gen}" + if cache_key in self.image_cache: + already_cached_count += 1 + else: + images_to_preload.append(i) + log.info(f"Found {already_cached_count} cached images. Preloading {len(images_to_preload)} images.") + + if not images_to_preload: + log.info("All images are already cached.") + self._update_preload_progress(100) + self._finish_preloading() + return + + # --- Setup progress tracking --- + # `completed` starts at the number of images already cached. + completed = already_cached_count + + # Update initial progress + initial_progress = int((completed / total_images) * 100) + self._update_preload_progress(initial_progress) + def _on_done(_future): nonlocal completed completed += 1 - progress = int((completed / total) * 100) + progress = int((completed / total_images) * 100) self.reporter.progress_updated.emit(progress) - if completed == total: + # Check if all images (including cached ones) are accounted for + if completed == total_images: self.reporter.finished.emit() - for i in range(total): + # --- Submit tasks --- + for i in images_to_preload: future = self.prefetcher.submit_task(i, self.prefetcher.generation) if future: future.add_done_callback(_on_done) @@ -1946,7 +1972,6 @@ def update_histogram(self): # If editor is open and has a preview, use that instead if self.ui_state.isEditorOpen and self.image_editor.original_image: - # Use the preview from editor (includes edits) preview_data = self.image_editor.get_preview_data() if preview_data: decoded = preview_data @@ -1955,20 +1980,44 @@ def update_histogram(self): arr = np.frombuffer(decoded.buffer, dtype=np.uint8) arr = arr.reshape((decoded.height, decoded.width, 3)) + # --- New Histogram Logic --- + bins = 256 + value_range = (0, 256) + # Compute histograms for each channel - r_hist = np.histogram(arr[:, :, 0], bins=256, range=(0, 256))[0] - g_hist = np.histogram(arr[:, :, 1], bins=256, range=(0, 256))[0] - b_hist = np.histogram(arr[:, :, 2], bins=256, range=(0, 256))[0] + r_hist = np.histogram(arr[:, :, 0], bins=bins, range=value_range)[0] + g_hist = np.histogram(arr[:, :, 1], bins=bins, range=value_range)[0] + b_hist = np.histogram(arr[:, :, 2], bins=bins, range=value_range)[0] - # Convert to Python lists + # Calculate clip and pre-clip counts *before* log scaling + r_clip_count = int(r_hist[255]) + g_clip_count = int(g_hist[255]) + b_clip_count = int(b_hist[255]) + + r_preclip_count = int(np.sum(r_hist[250:255])) + g_preclip_count = int(np.sum(g_hist[250:255])) + b_preclip_count = int(np.sum(b_hist[250:255])) + + # Apply log scaling for better visualization + log_r_hist = np.log1p(r_hist).tolist() + log_g_hist = np.log1p(g_hist).tolist() + log_b_hist = np.log1p(b_hist).tolist() + + # Create the structured data for QML histogram_data = { - 'r': r_hist.tolist(), - 'g': g_hist.tolist(), - 'b': b_hist.tolist() + 'r_hist': log_r_hist, + 'g_hist': log_g_hist, + 'b_hist': log_b_hist, + 'r_clip': r_clip_count, + 'g_clip': g_clip_count, + 'b_clip': b_clip_count, + 'r_preclip': r_preclip_count, + 'g_preclip': g_preclip_count, + 'b_preclip': b_preclip_count, } self.ui_state.histogramData = histogram_data - log.debug("Histogram updated") + log.debug("Histogram updated with log scale and clip counts") except ImportError: log.error("NumPy not available for histogram computation") diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index cb0d99b..d81618d 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -1,33 +1,26 @@ import QtQuick +import QtQuick.Window import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Window { id: histogramWindow title: "RGB Histogram" - width: 600 - height: 400 - minimumWidth: 400 - minimumHeight: 300 - visible: uiState && uiState.isHistogramVisible - - property bool isDarkTheme: uiState ? uiState.theme === 0 : true - property color backgroundColor: isDarkTheme ? "#2b2b2b" : "#ffffff" - property color textColor: isDarkTheme ? "white" : "black" - - color: backgroundColor - - onVisibleChanged: { - if (visible && controller) { - controller.update_histogram() - } - } + width: 750 + height: 450 + minimumWidth: 500 + minimumHeight: 350 + visible: uiState ? uiState.isHistogramVisible : false + // Connections need to be outside the visibility check Connections { target: uiState + function onIsHistogramVisibleChanged() { + histogramWindow.visible = uiState.isHistogramVisible + } function onHistogramDataChanged() { if (histogramWindow.visible) { - histogramCanvas.requestPaint() + // Since data is bound, the components will update automatically } } function onCurrentImageSourceChanged() { @@ -36,171 +29,201 @@ Window { } } } - - ColumnLayout { - anchors.fill: parent - anchors.margins: 10 - spacing: 10 - - Text { - text: "RGB Histogram" - font.bold: true - font.pixelSize: 14 - color: histogramWindow.textColor - Layout.alignment: Qt.AlignHCenter + + onVisibleChanged: { + if (visible && controller) { + controller.update_histogram() } - - Canvas { - id: histogramCanvas - Layout.fillWidth: true - Layout.fillHeight: true - - onPaint: { - var ctx = getContext("2d") - var width = histogramCanvas.width - var height = histogramCanvas.height - - // Clear canvas - ctx.fillStyle = histogramWindow.backgroundColor - ctx.fillRect(0, 0, width, height) - - if (!uiState || !uiState.histogramData) { - return - } - - var data = uiState.histogramData - var rData = data.r || [] - var gData = data.g || [] - var bData = data.b || [] - - if (rData.length === 0) { - return - } - - // Find max value for normalization - var maxValue = 0 - for (var i = 0; i < 256; i++) { - maxValue = Math.max(maxValue, rData[i] || 0) - maxValue = Math.max(maxValue, gData[i] || 0) - maxValue = Math.max(maxValue, bData[i] || 0) - } - - if (maxValue === 0) { - return - } - - // Draw grid lines - ctx.strokeStyle = histogramWindow.isDarkTheme ? "#555555" : "#cccccc" - ctx.lineWidth = 1 - for (var gridY = 0; gridY <= 4; gridY++) { - var y = (height - 40) * (gridY / 4) + 20 - ctx.beginPath() - ctx.moveTo(20, y) - ctx.lineTo(width - 20, y) - ctx.stroke() - } - - // Draw histogram bars - var barWidth = (width - 40) / 256 - - // Draw Red channel - ctx.fillStyle = "rgba(255, 0, 0, 0.6)" - for (var i = 0; i < 256; i++) { - var value = (rData[i] || 0) / maxValue - var barHeight = (height - 40) * value - var x = 20 + i * barWidth - var y = height - 20 - barHeight - ctx.fillRect(x, y, barWidth - 1, barHeight) - } + } + + // --- Injected Properties --- + // These are set by Main.qml to decouple the component from global state + property color windowBackgroundColor: "#f4f4f4" + property color primaryTextColor: "#222222" + property color gridLineColor: "#dcdcdc" + property color dangerColor: Qt.rgba(1, 0, 0, 0.25) + + color: windowBackgroundColor + + + Component { + id: singleChannelHistogram + + Item { + property string channelName: "Channel" + property color channelColor: "white" + property var histogramData: [] + property int clipCount: 0 + property int preClipCount: 0 + + ColumnLayout { + anchors.fill: parent - // Draw Green channel - ctx.fillStyle = "rgba(0, 255, 0, 0.6)" - for (var i = 0; i < 256; i++) { - var value = (gData[i] || 0) / maxValue - var barHeight = (height - 40) * value - var x = 20 + i * barWidth - var y = height - 20 - barHeight - ctx.fillRect(x, y, barWidth - 1, barHeight) + Text { + text: channelName + color: channelColor + font.bold: true + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter } - - // Draw Blue channel - ctx.fillStyle = "rgba(0, 0, 255, 0.6)" - for (var i = 0; i < 256; i++) { - var value = (bData[i] || 0) / maxValue - var barHeight = (height - 40) * value - var x = 20 + i * barWidth - var y = height - 20 - barHeight - ctx.fillRect(x, y, barWidth - 1, barHeight) + + Canvas { + id: canvas + Layout.fillWidth: true + Layout.fillHeight: true + + onPaint: { + var ctx = getContext("2d") + ctx.clearRect(0, 0, canvas.width, canvas.height) + + if (!histogramData || histogramData.length === 0) return + + // --- Draw Grid --- + ctx.strokeStyle = gridLineColor + ctx.lineWidth = 1 + for (var i = 1; i < 4; i++) { + var y = i * canvas.height / 4 + ctx.beginPath() + ctx.moveTo(0, y) + ctx.lineTo(canvas.width, y) + ctx.stroke() + } + + // --- Draw Danger Zone --- + var dangerZoneStart = (250 / 255) * canvas.width + ctx.fillStyle = dangerColor + ctx.fillRect(dangerZoneStart, 0, canvas.width - dangerZoneStart, canvas.height) + + // --- Prepare data for drawing --- + var maxVal = 0 + for (i = 0; i < histogramData.length; i++) { + maxVal = Math.max(maxVal, histogramData[i]) + } + if (maxVal === 0) return + + // --- Draw Histogram Path --- + ctx.beginPath() + ctx.moveTo(0, canvas.height) + + for (i = 0; i < histogramData.length; i++) { + var x = (i / (histogramData.length - 1)) * canvas.width + var y = canvas.height - (histogramData[i] / maxVal) * canvas.height + ctx.lineTo(x, y) + } + + ctx.lineTo(canvas.width, canvas.height) + ctx.closePath() + + // Create gradient fill + var gradient = ctx.createLinearGradient(0, 0, 0, canvas.height) + var transparentColor = Qt.color(channelColor) + transparentColor.a = 0.0 + var semiTransparentColor = Qt.color(channelColor) + semiTransparentColor.a = 0.4 + + gradient.addColorStop(0, semiTransparentColor) + gradient.addColorStop(1, transparentColor) + + ctx.fillStyle = gradient + ctx.fill() + + // Draw outline + ctx.strokeStyle = channelColor + ctx.lineWidth = 1.5 + ctx.stroke() + } } - - // Draw axis labels - ctx.fillStyle = histogramWindow.textColor - ctx.font = "10px sans-serif" - ctx.textAlign = "center" - - // X-axis labels (0, 64, 128, 192, 255) - for (var labelX = 0; labelX <= 4; labelX++) { - var value = labelX * 64 - if (labelX === 4) value = 255 - var x = 20 + (value / 255) * (width - 40) - ctx.fillText(value.toString(), x, height - 5) + + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 15 + + Text { + text: "Pre-clip: " + preClipCount + color: primaryTextColor + font.pixelSize: 11 + } + Text { + text: "Clipped: " + clipCount + color: clipCount > 0 ? "red" : primaryTextColor + font.bold: clipCount > 0 + font.pixelSize: 11 + } } - - // Y-axis label - ctx.save() - ctx.translate(10, height / 2) - ctx.rotate(-Math.PI / 2) - ctx.textAlign = "center" - ctx.fillText("Pixel Count", 0, 0) - ctx.restore() } } - - RowLayout { - Layout.alignment: Qt.AlignHCenter - spacing: 20 - - Rectangle { - width: 20 - height: 20 - color: "red" - opacity: 0.6 - border.color: histogramWindow.textColor - border.width: 1 + } + + RowLayout { + anchors.fill: parent + anchors.margins: 15 + spacing: 15 + + Loader { + id: redLoader + Layout.fillWidth: true + Layout.fillHeight: true + sourceComponent: singleChannelHistogram + onLoaded: { + item.channelName = "Red" + item.channelColor = "#e15050" } - Text { - text: "Red" - color: histogramWindow.textColor - font.pixelSize: 12 + Connections { + target: uiState + function onHistogramDataChanged() { + if (redLoader.item && uiState.histogramData) { + redLoader.item.histogramData = uiState.histogramData.r_hist + redLoader.item.clipCount = uiState.histogramData.r_clip + redLoader.item.preClipCount = uiState.histogramData.r_preclip + // Access canvas through item: item.children[0].children[1] is fragile + redLoader.item.children[0].children[1].requestPaint() + } + } } - - Rectangle { - width: 20 - height: 20 - color: "green" - opacity: 0.6 - border.color: histogramWindow.textColor - border.width: 1 + } + + Loader { + id: greenLoader + Layout.fillWidth: true + Layout.fillHeight: true + sourceComponent: singleChannelHistogram + onLoaded: { + item.channelName = "Green" + item.channelColor = "#50e150" } - Text { - text: "Green" - color: histogramWindow.textColor - font.pixelSize: 12 + Connections { + target: uiState + function onHistogramDataChanged() { + if (greenLoader.item && uiState.histogramData) { + greenLoader.item.histogramData = uiState.histogramData.g_hist + greenLoader.item.clipCount = uiState.histogramData.g_clip + greenLoader.item.preClipCount = uiState.histogramData.g_preclip + greenLoader.item.children[0].children[1].requestPaint() + } + } } - - Rectangle { - width: 20 - height: 20 - color: "blue" - opacity: 0.6 - border.color: histogramWindow.textColor - border.width: 1 + } + + Loader { + id: blueLoader + Layout.fillWidth: true + Layout.fillHeight: true + sourceComponent: singleChannelHistogram + onLoaded: { + item.channelName = "Blue" + item.channelColor = "#5050e1" } - Text { - text: "Blue" - color: histogramWindow.textColor - font.pixelSize: 12 + Connections { + target: uiState + function onHistogramDataChanged() { + if (blueLoader.item && uiState.histogramData) { + blueLoader.item.histogramData = uiState.histogramData.b_hist + blueLoader.item.clipCount = uiState.histogramData.b_clip + blueLoader.item.preClipCount = uiState.histogramData.b_preclip + blueLoader.item.children[0].children[1].requestPaint() + } + } } } } -} +} \ No newline at end of file diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 7aa6303..88429cf 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -12,7 +12,7 @@ ApplicationWindow { height: 800 minimumWidth: 800 minimumHeight: 500 - title: "FastStack" + title: "FastStack - " + (uiState ? uiState.currentDirectory : "Loading...") Component.onCompleted: { // Initialization complete @@ -914,6 +914,9 @@ ApplicationWindow { HistogramWindow { id: histogramWindow + windowBackgroundColor: root.currentBackgroundColor + primaryTextColor: root.currentTextColor + gridLineColor: root.isDarkTheme ? "#454545" : "#dcdcdc" } ImageEditorDialog { diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 330ef3f..13ddb16 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -466,6 +466,11 @@ def preloadAllImages(self): def stack_source_raws(self): self.app_controller.stack_source_raws() + @Slot(str) + def applyFilter(self, filter_string: str): + """Applies a filter string to the image list.""" + self.app_controller.apply_filter(filter_string) + @Slot(int, int) def onDisplaySizeChanged(self, width: int, height: int): self.app_controller.on_display_size_changed(width, height) From 7ce8b6a958a4b2b80eebad55fa392ff39907a714 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 30 Nov 2025 12:36:43 -0800 Subject: [PATCH 15/15] Release v1.3 - some fixes and new features --- faststack/ChangeLog.md | 5 +- faststack/faststack/app.py | 81 +++++++++++++++++++++++----- faststack/faststack/imaging/cache.py | 7 ++- faststack/faststack/io/indexer.py | 4 +- faststack/faststack/ui/provider.py | 4 ++ 5 files changed, 83 insertions(+), 18 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 7565a3f..7200306 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -1,10 +1,13 @@ # ChangeLog -Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Add the ability to pull in images from a stack if they are taken with a camera with in-camera stacking +Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. ## [1.3.0] - 2025-11-23 - Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. +- Sorts images by time. +- Added the Stack Source Raws feature in the Action menu - if you import your images with stackcopy.py --lightroomimport (https://github.com/AlanRockefeller/faststack) and you are viewing a photo stacked in-camera, this feature will open the raw images that made this stack in Helicon Focus. +- Some fixes to the image cache - it doesn't expire when it shouldn't, does expire when it should, and warns you when the cache is full so you can consider increassing the cache size in settings. ## [1.2.0] - 2025-11-22 diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 5d23473..269b029 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -101,7 +101,12 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): # -- Caching & Prefetching -- cache_size_gb = config.getfloat('core', 'cache_size_gb', 1.5) cache_size_bytes = int(cache_size_gb * 1024**3) - self.image_cache = ByteLRUCache(max_bytes=cache_size_bytes, size_of=get_decoded_image_size) + self._has_warned_cache_full = False + self.image_cache = ByteLRUCache( + max_bytes=cache_size_bytes, + size_of=get_decoded_image_size, + on_evict=self._on_cache_evict + ) self.prefetcher = Prefetcher( image_files=self.image_files, cache_put=self.image_cache.__setitem__, @@ -197,9 +202,11 @@ def get_display_info(self): def on_display_size_changed(self, width: int, height: int): """Debounces display size change events to prevent spamming resizes.""" - if self.display_width == width and self.display_height == height: + log.debug(f"on_display_size_changed called with {width}x{height}. Current: {self.display_width}x{self.display_height}") + if width <= 0 or height <= 0: + log.debug("Ignoring invalid resize event") return - + # Debounce resize events self.pending_width = width self.pending_height = height @@ -640,9 +647,6 @@ def end_current_batch(self): log.warning("No batch start marked. Press '{' first.") self.update_status_message("No batch start marked") - def clear_all_batches(self): - """Clear all defined batches and stacks.""" - self.clear_all_stacks() def remove_from_batch_or_stack(self): """Remove current image from any batch or stack it's in.""" @@ -976,11 +980,10 @@ def _delete_temp_file(self, tmp_path: Path): log.error("Error deleting temporary file %s: %s", tmp_path, e) def clear_all_stacks(self): - log.info("Clearing all defined stacks, batches, and markers.") + log.info("Clearing all defined stacks.") self.stacks = [] self.stack_start_index = None - self.batches = [] - self.batch_start_index = None + # Do NOT clear batches here self.sidecar.data.stacks = self.stacks self.sidecar.save() @@ -989,7 +992,18 @@ def clear_all_stacks(self): self.dataChanged.emit() self.ui_state.stackSummaryChanged.emit() self.sync_ui_state() - self.update_status_message("All stacks and batches cleared") + self.update_status_message("All stacks cleared") + + def clear_all_batches(self): + """Clear all defined batches.""" + log.info("Clearing all defined batches.") + self.batches = [] + self.batch_start_index = None + + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + self.update_status_message("All batches cleared") def get_helicon_path(self): return config.get('helicon', 'exe') @@ -1265,15 +1279,39 @@ def preload_all_images(self): images_to_preload = [] already_cached_count = 0 _, _, display_gen = self.get_display_info() - + + # We want to load images furthest from the current index FIRST, + # and images closest to the current index LAST. + # This ensures that the images the user is currently looking at (and their neighbors) + # are the most recently added to the LRU cache, so they won't be evicted. + + # Calculate distance for all images + # (index, distance_from_current) + all_images_with_dist = [] for i in range(total_images): + dist = abs(i - self.current_index) + all_images_with_dist.append((i, dist)) + + # Sort by distance descending (furthest first) + all_images_with_dist.sort(key=lambda x: x[1], reverse=True) + + # Determine which images are "nearby" (e.g. within prefetch radius * 2) + # We will FORCE these to be re-cached even if they are already in cache, + # to ensure they are moved to the front of the LRU queue. + nearby_radius = self.prefetcher.prefetch_radius * 2 + + for i, dist in all_images_with_dist: cache_key = f"{i}_{display_gen}" - if cache_key in self.image_cache: + is_cached = cache_key in self.image_cache + is_nearby = dist <= nearby_radius + + if is_cached and not is_nearby: already_cached_count += 1 else: + # Add to preload list if it's not cached OR if it's nearby (to refresh LRU) images_to_preload.append(i) - log.info(f"Found {already_cached_count} cached images. Preloading {len(images_to_preload)} images.") + log.info(f"Found {already_cached_count} cached images (skipping). Preloading {len(images_to_preload)} images (including nearby refreshes).") if not images_to_preload: log.info("All images are already cached.") @@ -1282,7 +1320,7 @@ def preload_all_images(self): return # --- Setup progress tracking --- - # `completed` starts at the number of images already cached. + # `completed` starts at the number of images already cached (that we are skipping). completed = already_cached_count # Update initial progress @@ -1299,7 +1337,14 @@ def _on_done(_future): self.reporter.finished.emit() # --- Submit tasks --- + # images_to_preload is already sorted furthest -> nearest for i in images_to_preload: + # For nearby images that we are forcing to re-cache, we might need to remove them first + # to ensure the cache actually updates the LRU position (depending on cache implementation). + # ByteLRUCache (cachetools) updates LRU on access (get/set), so just overwriting is fine. + # But we need to make sure we don't skip the task in prefetcher if it thinks it's already done. + # The prefetcher checks self.futures, but we are submitting new ones. + future = self.prefetcher.submit_task(i, self.prefetcher.generation) if future: future.add_done_callback(_on_done) @@ -1561,6 +1606,14 @@ def empty_recycle_bin(self): except OSError: log.exception("Failed to empty recycle bin") + def _on_cache_evict(self): + """Callback for when the image cache evicts an item.""" + if not self._has_warned_cache_full: + self._has_warned_cache_full = True + # Use QTimer.singleShot to ensure this runs on the main thread if called from a background thread + QTimer.singleShot(0, lambda: self.update_status_message("Cache full! Consider increasing cache size in settings.")) + log.warning("Cache full, eviction started. User warned.") + def restore_all_from_recycle_bin(self): """Restores all files from recycle bin to working directory.""" if not self.recycle_bin_dir.exists(): diff --git a/faststack/faststack/imaging/cache.py b/faststack/faststack/imaging/cache.py index 08bb96c..1e74d54 100644 --- a/faststack/faststack/imaging/cache.py +++ b/faststack/faststack/imaging/cache.py @@ -9,8 +9,9 @@ class ByteLRUCache(LRUCache): """An LRU Cache that respects the size of its items in bytes.""" - def __init__(self, max_bytes: int, size_of: Callable[[Any], int] = len): + def __init__(self, max_bytes: int, size_of: Callable[[Any], int] = len, on_evict: Callable[[], None] = None): super().__init__(maxsize=max_bytes, getsizeof=size_of) + self.on_evict = on_evict log.info(f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity.") def __setitem__(self, key, value): @@ -23,6 +24,10 @@ def popitem(self): """Extend popitem to log eviction.""" key, value = super().popitem() log.debug(f"Evicted item '{key}' to free up space. Cache size: {self.currsize / 1024**2:.2f} MB") + + if self.on_evict: + self.on_evict() + # In a real Qt app, `value` would be a tuple like (numpy_buffer, qtexture_id) # and we would explicitly free the GPU texture here. return key, value diff --git a/faststack/faststack/io/indexer.py b/faststack/faststack/io/indexer.py index 8fe9dbe..d55683e 100644 --- a/faststack/faststack/io/indexer.py +++ b/faststack/faststack/io/indexer.py @@ -40,8 +40,8 @@ def find_images(directory: Path) -> List[ImageFile]: log.exception("Error scanning directory %s", directory) return [] - # Sort JPGs by filename - jpgs.sort(key=lambda x: x[0].name) + # Sort JPGs by modification time (oldest first), then filename + jpgs.sort(key=lambda x: (x[1].st_mtime, x[0].name)) image_files: List[ImageFile] = [] for jpg_path, jpg_stat in jpgs: diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 13ddb16..e15565d 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -387,6 +387,10 @@ def launch_helicon(self): def clear_all_stacks(self): self.app_controller.clear_all_stacks() + @Slot() + def clear_all_batches(self): + self.app_controller.clear_all_batches() + @Slot(result=str) def get_helicon_path(self): return self.app_controller.get_helicon_path()