From 2d2a51c02c818ed6605b2020c331a6a1ecff1654 Mon Sep 17 00:00:00 2001 From: meareabc <74286633+meareabc@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:59:54 +0800 Subject: [PATCH] Fix guide preview dimensions and show the first frame --- README.md | 14 +-- __init__.py | 21 ++++- docs/position-editor.png | Bin 3376 -> 24278 bytes pyproject.toml | 2 +- tests/test_spatial_guide.py | 56 ++++++++++++ web/guide_position.js | 174 ++++++++++++++++++++++++++++-------- 6 files changed, 219 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 815714c..9527bbc 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,12 @@ git clone https://github.com/meareabc/ComfyUI-MiniMaxH3-SpatialGuide.git **MiniMax H3 Guide Position** -- Set `guide_width`, `guide_height`, `canvas_width`, and `canvas_height`, or connect their INT inputs. -- Drag the blue guide rectangle; its size stays fixed. Hold Shift to snap movement to 32 pixels. Center and Origin buttons provide quick alignment. +- Connect the same `image` batch used by Spatial Guide to preview its **first frame**. This also works with a single image. The actual image dimensions override the guide size fields for preview. +- Connect the target AV `latent` to use its actual canvas dimensions. This accounts for alignment performed by the empty latent node. +- Click **Preview** to run this node and its upstream dependencies without running downstream sampling. Then drag the image; coordinates and placement update immediately. Hold Shift to snap movement to 32 pixels. Center and Origin buttons provide quick alignment. - Connect its `x` and `y` outputs to Add Spatial Guide's corresponding inputs. Coordinates save with the workflow and can also be edited numerically. -- Linked Primitive values update the preview immediately. Dimensions computed by other backend nodes synchronize after execution; before the first run the preview uses the saved dimensions. Enter sizes directly for an exact preview before running. +- After changing the image source or target size, refresh the preview. Old dimensions are invalidated instead of being combined with new input values. If the guide is actually larger than the canvas, the preview shows the overflow without resizing it. +- Without `image` or `latent`, set `guide_width`, `guide_height`, `canvas_width`, and `canvas_height` manually, or connect their INT inputs. For a 640×480 guide on a 960×480 canvas, `x=0,y=0` leaves 320 pixels to the right; `x=160,y=0` centers it. For a 480×640 guide on a 480×960 canvas, use `x=0,y=160` to center it vertically. @@ -35,7 +37,7 @@ For a 640×480 guide on a 960×480 canvas, `x=0,y=0` leaves 320 pixels to the ri Video guides follow native Add Guide's valid lengths: 5, 22, 39… frames; batches below 5 frames use the first image. Nonmultiples of 32 are padded at the right/bottom with replicated edges, without scaling. This is positional conditioning, so generated pixels can change inside the guide region. -CPU tests cover native-size coordinates, mixed guides/audio/references, clone isolation, registration, and a small native H3 forward. Full pretrained outpainting quality has not been validated. +CPU tests cover native-size coordinates, mixed guides/audio/references, clone isolation, registration, first-frame selection, actual latent dimensions, partial execution and cached previews, and a small native H3 forward. Browser checks cover image display, dragging, zoom, and stale input invalidation. Full pretrained outpainting quality has not been validated. ```bash python -m unittest discover -s custom_nodes/ComfyUI-MiniMaxH3-SpatialGuide/tests -v @@ -43,6 +45,8 @@ python -m unittest discover -s custom_nodes/ComfyUI-MiniMaxH3-SpatialGuide/tests ## 中文使用说明 -重启并刷新后搜索 **MiniMax H3 Guide Position**,输入参考宽高和目标画布宽高,在预览里拖动蓝框。输出的 `x/y` 接到 **Add Spatial Guide for MiniMax H3**;表示参考区域左上角坐标,原点在目标画布左上角。按住 Shift 拖动可按 32 像素对齐,也可点击居中或左上归零。 +重启并刷新后搜索 **MiniMax H3 Guide Position**,把同一份 Guide 视频/图片接入 `image`,目标 AV latent 接入 `latent`。点击“刷新预览”即可单独执行预览及其上游准备,显示视频第一帧或所选图片和真实画布尺寸;随后拖动图片,实时调整位置。修改来源或画布尺寸后再次刷新。 + +输出的 `x/y` 接到 **Add Spatial Guide for MiniMax H3**,表示参考区域左上角坐标,原点在目标画布左上角。按住 Shift 拖动可按 32 像素对齐,也可点击居中或左上归零。如果参考区域确实大于目标画布,预览会如实显示越界,需要增大画布宽高。 Spatial Guide 的 **MODEL 和 positive 两个输出都要连接到采样器**,目标 latent 直接接采样器。音频可用原生 Add Guide 单独添加。 diff --git a/__init__.py b/__init__.py index a02adfe..1b3c621 100644 --- a/__init__.py +++ b/__init__.py @@ -8,7 +8,7 @@ import comfy.conds import comfy.patcher_extension import nodes from comfy.ldm.minimax.model import FRAME_PER_TOKEN -from comfy_api.latest import ComfyExtension, io +from comfy_api.latest import ComfyExtension, io, ui GUIDE_POSITION = "minimax_h3_spatial_guide" @@ -132,7 +132,8 @@ class MiniMaxH3GuidePosition(io.ComfyNode): node_id="MiniMaxH3GuidePosition", display_name="MiniMax H3 Guide Position", category="model/conditioning/minimax", - description="Drag the guide rectangle inside the target canvas. Outputs the guide's top-left x/y in target pixels.", + description="Preview the guide's first frame and drag it within the target canvas. Run this node to refresh input dimensions without sampling.", + is_output_node=True, inputs=[ io.Int.Input("guide_width", default=640, min=1, max=nodes.MAX_RESOLUTION), io.Int.Input("guide_height", default=480, min=1, max=nodes.MAX_RESOLUTION), @@ -140,13 +141,25 @@ class MiniMaxH3GuidePosition(io.ComfyNode): io.Int.Input("canvas_height", default=480, min=1, max=nodes.MAX_RESOLUTION), io.Int.Input("x", default=0, min=-nodes.MAX_RESOLUTION, max=nodes.MAX_RESOLUTION, socketless=True), io.Int.Input("y", default=0, min=-nodes.MAX_RESOLUTION, max=nodes.MAX_RESOLUTION, socketless=True), + io.Image.Input("image", optional=True, tooltip="Connect the same image/video batch as Spatial Guide. The first frame determines the guide preview and its actual size."), + io.Latent.Input("latent", optional=True, tooltip="Target H3 AV latent. Uses its actual canvas dimensions, including the latent's size alignment."), ], outputs=[io.Int.Output(display_name="x"), io.Int.Output(display_name="y")], ) @classmethod - def execute(cls, guide_width, guide_height, canvas_width, canvas_height, x, y) -> io.NodeOutput: - return io.NodeOutput(x, y, ui={"guide_size": [guide_width, guide_height], "canvas_size": [canvas_width, canvas_height]}) + def execute(cls, guide_width, guide_height, canvas_width, canvas_height, x, y, image=None, latent=None) -> io.NodeOutput: + preview = {} + if image is not None: + guide_height, guide_width = image.shape[1:3] + preview["guide_preview"] = ui.PreviewImage(image[:1, ..., :3]).as_dict()["images"] + if latent is not None: + samples = latent["samples"] + if not samples.is_nested or len(samples.tensors) != 2 or samples.tensors[0].ndim != 5 or samples.tensors[0].shape[1] != 24: + raise ValueError("MiniMaxH3GuidePosition expects a MiniMax H3 AV latent") + canvas_height, canvas_width = [size * 16 for size in samples.tensors[0].shape[-2:]] + preview.update({"guide_size": [guide_width, guide_height], "canvas_size": [canvas_width, canvas_height]}) + return io.NodeOutput(x, y, ui=preview) class MiniMaxH3SpatialGuideExtension(ComfyExtension): diff --git a/docs/position-editor.png b/docs/position-editor.png index 53f957979ed6180b75d952ec4d21b7b33ec27aa7..a72c2e56d553df90e428ae66c74618f10b3620cd 100644 GIT binary patch literal 24278 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%0D}~`7X>-Dtp=Psvj-& zr74xWIN-IjZe5a@C}Wv#m|{}m?&o44z=U;+9%fME?!WWm`-E&y_}stee0%ZGphbll z@)0O(w0Wi=dt>|o8|=NBf9-~h87m8| zJTCCz25lJ}$iq8VS<63Kb`=AT!;FG8NXE4A!n;9wKa2qSV9f5|hd~yo83_v02-6~= ztw~_ab(6&{a>`z%tZP7GGif4)>PL%!vU(Ek;V33wF+~CNa=U+ZS<;nRUmFc%D9j^X z7rMWKt`31!Z*}C@RVr%h998XCM?~aIJ>b4J{A8_R(HP~SRUiLVq}Ye&Z$66$sIO3R zH9r+KU54Dg1c^opd6kg`&!G5sA@)=!ETLyoUnnCWgS8OMzv`tEw(3?FBKP z4&ChETEGV4{}5hB+8slP5y-)lADNFqaQ8|Q2s~hzr$+mOOsIWe1S^!WjpPvWCKG`D ztC4O36qrq4*2|K0F&kt3`fhg6^wRwhqvn89BOse%5d$YVubmO_?|UCFM`$uLA}TR^CW5 z>#A2e;F8!QEw8LG^?+fO$r%EibpN-9oF4PLh^I(zk{E41F?XmDfX3XbnW7IcGpQkP zr$Q3=o8c)67W_c_DZ091GwZJ<}T|3l;qhObMpVtl2VJP>T9NoT0rd4ZsmcS*~0 z8Z;1q2LIr2=S+|FcusW&*-gg&-wA zdK%!2)Jx_AI$s(p8F7CKw8NZ3*3^mh7#+Bk4;%eM-B;%SdaYo|91$5A0|BDHNDB

h2bV zqLT9i8@XjB=Le-ZEt8LXUf*>YD9Q{bSbvaX=+6~OF-3&P;=b?EpCbb1R|CEMzV+at zOR?s)`6L7XVxFvo)Vyu{z#jpq5)-B@L=>?nyK$WknA(Ca#^Dk)WmNTv*oBO&5h-8Y z;gbz&Py{+zO%gCHlF%6*1zi94-$QYp5B?s?!YlvX6nw=xZUw6#Su}jfXRH(>^ zVS6#A>_qPs4?o-O4$m%ncBXg}exb1RdO&%r z5n`-pJNc>akwAaoxZkd>kmx|>LQxiW(^1+VfZ~s7JYr&M>!3iR{o;Y=+I;qIhSn?D4?BfQ|M?*JCijo~526(&klL`wy;5 zjt|~9EPIz-STUG1;be?)>(5#syzr`VE=R{o@0gf*UGa&bAO1nftV!ROxV|POA=zn9 zdO_z*NqJo4M7_oRqmlSL2P7bpd*65K2_6(hq;MxBXd(psZ1`?|aCN`Yr-&)qN!gjO?A;eB zTspuTGq^npzk<(hT>pj1yefCtPI!JgWKuVyzW%d6oJTfGJslQJI7iY@n$K;x<%FFs z+sQBf1Pu+agDWz~;z!jOo6loCwJWpT_qeXLW~b?z1l^C++%(%e>TT6pM5Gu(ZmV;9 zmKPUDH1f%8-t*KN#R2Fl1}o?o7>-+Unp9NXwB0x4kb>AE6e8~9rKZsqZ;k0wNm8H8 z9#k&EBe6(<=XKfgMqixJFQYiO|(NuQT4 z#W)=GEgT#!uA1SxN7s#QUmiQV`>THh6QsU}YmvfF&9${NAwbShf$eVVgjUuoP`;3m4Mcq!7O1v~7MgxSkVORpi_;o!s-Ym_dxy?_R5 zZT z4S(z<-Sb%gY4C*K_1LcU$3xcNN)y`IgxF+0I!el;?6{2M1z*E~oDl1orz59v8`4BB zff#hB-P7^uVoa>{w)^u5TqH1`x3^f=FB1M2?ZIpGv`m#^z`?i(ZSjuUbZ(!USWQff z=XGn$Chn(^41BJ9J+BWhD!RfiIpb8e?)Im;LpJi|(y_@_Xvu|^t!lVAIk&gBZ08zF zD=Kb(hThn0_LppKZm#$mZ1i}$6wc^-TV57mFypc`x&Hlg-Na=x*LYM~m*A=_Xj$m5 zxU=adg$#v~Uu=);t+W|KqZMmI4us@uw%~tZY zudw6waD+w?ZTML4c(YigMib-x(iJI07cw{|a#nyFaR-S?Otj#RL_tGqTR$IMgc=R) zDpN|Dm)_QIN!p~C>w&kJc~;y$K3w`e(+30utav@Nc;5Z-Owi?j*v}hoeYuvsK|?_a zO|frTx)8E&xXWw25A=PWi;7Nf-HlJF`V-i8IbcoF%8xD@h(?yDQuO})`-KIOT(zUc zmYJt2zfoB!8RFm3LKdrj;o;#4`aY#yPp%{Jr3-a7N4&OeuQ7RH()un=Wy)9@EPn(Q zlT2nM;5Byh@_IC|ez9w13e2yjw?ssthk;evS+Kws4GkV zO_QyC^$-ZC~b61RwSRwNjxW5Ak|$Pwo+CkK^Ou%n4JQ0fyGp^_gQV zZ`Jqqx%h^pDE#o|!PsNgcZ$VnXPy5Lwa*_-XmM;-3Sq9w^`y?g7kqSByj0nKF;7#s z(n?1~l~Xi7lV&(Ca6CyLGswt5>b_}%_0R}@dA#j%Rn1q}yMumi-)Xz`9j(zncKF2a zbyzsT8)nxKGP={CdpTQa^8S68mB6#6&G{3Wu)2mibISp05|iP?w(amrH#6=*pf3;C z$|q|#a}g209g<&!NnBQ@)1HIE&JDYVs`w%KScUTV(iAqgfTzRp$!+q(z7$yGXW-%CVD=%x~ z;u&UJR9vhha5pM+VXCXE8x{~lBfrbWX$m0hpdfb(^ZJ)(GoU01qk>_Ab^2N|4fq%Z6_r~D z%0->+in5y8Bd~^9PCn1kX7}GiS=NlL=OmZ$ySo5}xc&a|jZuLI(FufKwhOK~IhQSm ze+YaZ&%Sw!Wd{jUQ3}e_Ak~kJuvaF=8{xr8^(8vngw4f&Rc)HQ{GB@Tmy5f7I z?xr={{0^zSVZR6$PX-=&*Z1UK)5gX_zv;&~?X(Poit`P{8g0FoXZnniN5Ef8PnsWE zJqKrJ6HnTne&_AEU)Wpi!`vToC@Q3V+>6)2^YjsI3|1(&UJ?v23KoSnXWB91ncSWl zjlxODl27 zuH4?@b|bPaSttYWv!~0L$md7vrROJj^U+os@aYGrRANHP%Xnxc5g|?&M^MYv_8eRh zbINoEZ_LdYWh7SQHzab?=f{RusD|`%BV)cjR;Zc}IB&^;_A`HM60aN!nlswhMyXQP z7YtFCstJxTLghUX?RrN;_+KX2{ETb z`%qFslgZd1tDvyU(C!_QozHH^!=FI3m(7XMm!W| zy*nQ1sf#4z*=}q=3YRbm6GAEWebz5jX}RBjvD2;pKq<(~@bOR9pYuW1HlZZ%#);A2 zzVi*%Gu_fycuZOq?iU(0+9V`6;HO1iomxBVG~ksbdA<21y3o+jUqyE(O-(yBc0l@c z`26`<_{F9^aje2*X9U2q>1;loBYI7XvL87(C@CnWOReijs#`YfR$5KgI*Cr2P0prm z7u*G`=Bq8eAFiL)`ZDyB4J)eZRxWGwCV)=?~RD#S`$GJB+ zcnJlQ$sPW1T($zn-3}?kESQLr;Y0J48;36ulO6ty#YMc*$Zw#2c?~Zb3F+|~ng%P| zv?%1J_m{E=0X>4DZH-A z+pt85QQ^s{z^AK~CVe)5Ax@T`=%4QOzYmw>XLzhbB}rMC89mQ(H4WUJlkV>WN84^? z@_C|ujo^v~51Z3M&osyPSz<*LN zNJC>jvCX`N!>ky28hl!}>?Y5I`%-g#^85Ec49dW^^K~Tcv|s4PRdwSh9ed92;Njbz z?-}Q46-%|RWKGpJxis~z)?WnMcCY!td@Vt3#r*fH{%F9QL2B2Pt2Ulx-A3n-zoz%Q z*>N_cZU?1xkU)snx%5Ipl}>rf^L<0e*_rIgSC{B__Vd;5XGeANDcm*moO0;`<9p7w zi`9k0M}ovR2llPZEcD*H9q_nJ^}w$MoEwGiE-$#Oy_`FutH#=Q&Qejog|R5e&!q3@ z`+iH%c%OYTeLWrK9tL|nYfU+6r zq!bX6I4wH?tla!KaDW|o1LV2*_}IXJpvn@j<5cT>#gu*blt4J~u;;i^Gjr79qN2i* z0%P|@8h>~cDoFhWOb@zfKjds<+2^K(gnxweEYSU8echPMXY=gwQB<`57fDmC-9qJh z*Ou#Y!)@yW84EpgaY@MtF}E58hED0X6^}f*bO4kzeAhhaTB>x5?f_mtT!s&qpd>e4 zxF%_6aE}ytY%i^2dc2JpRm|WMbU*K2UHx#=qyi*VOUooCrTFOdV^owg@Aa%~%7*Q2WE7T%Pjc;oyiP@H(EG1~m~134EWg zrqA#`1FK%lX84vpxorg@;jzsAv2Bvcbzzm;Te073xoJ`?1?tupaY^|k0d`*A!scdU zpszs|W74ozWenS?3V6gj?)ydH{o)@vIW=X_&EZ%{7n;oG)7)X0==!8Ps^jyr5m~xn zkL3H?*{{$%qk}p-1gETIWKTfmJT)( z@vGh=u^F7UbfTI`A z9Msw^O?#M>Z?JHl-YLvW1E#bd9Z734H{g&Z#lGB#6sD!2sU@vIyQ%DksrkJcf;@=rg>3XKKil}v0v0N+VF^CL{xN3$kRQu>QmT7C7R(}Ne zt))M_fzMs8Dve@EL4nmWdj8B_GVl)O=DBRXrn*LxFzn*MAfwa8IY$tRBYZ&1!!>KO z<8w|37SQGXjv(OB&YwB4pVG}pP35%NjRHF2MlAn@ZDyU5mT%G?+Jxkrn}w~K6?;qUBiZ7i26DRZ0Awq8n##cy`2`!5uXDD8X`1%t(s_k-fZqNC&H{oI`)BS{Q zLCIRu$FJ>s6TC;Z=r}lg4xfq{_@6;d_eDvNDEX8TaA>u*ZIkOsgXP*!pr6cJBckrf z9qzIa^M)m-VrPdA=lwz*#l7h~JXBo--v8^rbu`J&ri{!OvRjIs?PP6xo;I@Bg+x1ytsT6i~bwMbMRApuxzPozzf~6$*$W15mzJEJu1Oq8# zPeQ0|Dap$+d3o`$q^z`<5>m~BTF|rr0KT%NrUa%2>%sU^NmR14=1M85MgSUfl-C#W~okYyufhf{AD6?rAha)G|bNW`w>t}X%te~yzi@pU$hTexzCP#1Pu)+T;@ix9WZh4?WFc$#aUn(kekk&`B{!NimkUXAZi%A)TG;whOhd z-cavovIr#y5C}KT{Yr!m@kl&bl5_|nB#N=Td3}Jf+@g|!k1rJ6X|)|1L|V^EN$I^a zvIMBQlAYb%-QCE@2zQUgA;--)bp*OIB^zVv_E-8vrd0XAm0i+zgv@?<`pR?5 z<{h6qQ1h#xXNyfS%Z3Goh4OOY_9x_U{*~@;9bcH@FNYATs4+txiU-G_V!(l8(5P%^ z7Qkc70kGc}=)+fhUtkAoVrUf!d0d7}A_b32e-#6!*DO?1&69h+D6au_%$Y2=;Cg7& zlC-lyLqii72(fu|HJQ*3H2Ou#>#-4mbWcOOvsJe+w#A$p5SY38!M~D2mHPSNBrk@x zYDrizjnC}M^r`EL=gL#DN^UxMN-k{cO{-C&e4hM{FADetzN(Ux;m8HLqo$z|-?esm z`gwiYtDlsRsp%+HXh=&wK|W6}Phr0Bf! zS`IEL0XU3YRMOOh4EsuF_h(z?j~_erjCM$mOUV-e)FeANAQ%hf*flmWUQ9p}NWjJ< zK6Dx{Z@%|9P#+eaoEu&1*tT)xAmq1uI8uRyzb50d+62H*eRBKD^ga_xw~ zN4&#%syLa>t}Z#6?-wroEv-w-a6f+hnI?mnSLNSTHOEMjCmu?0Hf<>vIq<-}ZdHu#@3C=b_27 zbXb^_YLp1jUwWQLhrz+YjwXGipB%=PHCC`k`<80K{N!J}o?StOYBC6pwJQV1;(*1* zq720zUUUTLm>J89ihR}rzWLm{*JLTLIq(e*FzUPSy`+0T_uSv#1I$B-S`D$oNyj;w zMzz+zd_`jNoYr((SsTO5@W&gU4*l&4C^dC1iBEbBsnZ;Jjf~hU-bingI9aOm1u)Jp zGGUjQ;}(Z(4Z3W zT^_ykdQVMHCk<~C;SK3LKTA4RvfsUXmsXJ93LO{JHQo$niqlqA(Uw5qb=uSdgW+lW zDvZRY2#@$rni~LWfXr9BXIsn22#Sd4E#MpFoIGwZxpxWV!?iUddBF_?@({$c;U;e& zxo*^o^F$JGG%7=bBSN09W`yS#Qy!|U#@b<+<9SvV&ai09)4^L&m*E|S{=Y~CxAhYZ zl-NFjPYd=5Mf;;)aPJd6&38Lrwnz9J*Ei=~BzOdG7AKhFD>C>GHvg*L2;WM1T1)=w z&!XGEv{a--bvaz*0*p8}qx;mCTr=F}-?-#Gv(7uC*R;N&I9>jt302CuJmB}59ov54 zGS14b=F__vR6nc~(D~%rgOiY#pa0zA1N*>v0u~E2{X{%+-8+ma>f|({HZ$3K6VhZp z9aqXvdVbgJ{(T2s)a7V?Vmf;vbK(FKYlD)K68n9wr?i7Z)Ps&ZD=qeW0JxC{h@MSp z8eoYEJseg6+@-+%E~V>I%cRfqJG(+PkC`-AD{y10cMQOLFJosFYrGg&%?Xq+1AQK! z@EUXd8Jwt83M~Iu24-e}#LnO|b270i?a=5 z5@0LS0lE?B4TZed`FM7Y$$TI07B5&)iD!N1s|x|Hx#eV;`t`6P-TOg%$4_|r@oI$s zo9Dm~&>inag#!l#?_=M6kw`@mSpg{Ab2On?8S0MGR_gl7yAKWc!2MXKcRZ{DJa4Eu zIZf}*>sGuj7U7MuMWesdxONAZoNe@Ch|VAU27*vcMLQnAVc`AEynt-U$ou_4Mf;X5 z8`fi!zF+*rBg)wl-MwFUz=!?AgC{Aesp-i@(-s%!d{`SS%b7o5i;Jw~UbuFOxL$Ww zdl$L)$0&)!=O4Q0^$~wmOblZJeCt`8=3;_cvMzWE=b|jbI_ z?X*x@=eGN9&XcL}Y8p`bvhR!7%@$G2-^aiC88>?qJGBei`XmU5C?UbY2#BcZ{H_XN zzMq{Mc53w09!E&{5y_i$^iIuA`Z9cg_G-&G#B|&??6~m&HAL-xO_H(o?wY?mZWn`x z83kaew=Uep#l-mY7d#zJB zJ3JO>n3x+Gp4;>><3Ja%k|&?yF;&9oFZ$EK>nsk}9_tt`Br0mPr*4J&B2*S=@Sg$7 zO;31dJ`ZIi^)Dsl@+9NDv?^Ft_dzdJiTuxei=l%g~1|t)bon8)67aLjW?QIWm0(AekuNP>eR@*yP zS66lH8>Dkpcp{oBv`XH+3rJAN7-1UD5QAXkH z0zJ41xNW3-K7S6<<68y}3h>f>J%A(xP@*Av^t|*md_`GV`w?}^jy$l3 ziw&NW{TQ#b?nE3}>B_0SJa`!Rm4l)zNwd|JJ?ZmcbyP}9LK5%c8vMzGMa_!O4dq}} z03+ZwtgWZ1N=#k{0b6O-@@Jut?pp{%sMz;z>B1ZymGu68;RJ8V>4KSuHTC;a4X}lX z_dV?}u)kxl+y(TdE$52M+y1s{F7-yFls9-xGuJW2!ozLjU|%XqC$SmnN?U%FX=}xM zf(P3JM?qH!2Hs0ve*rY~O?SSx&q2?2DO^@?xPQ~#7D>NBJ+5ixZ=ml&;*kH7nV6^bqnz z)J$h4og)a9o{sLYvGs>Uw&R>P+DL})({427iB7Fr@zl;10D+!^nC+Y@zxS1S#xLU# zV_){2q}xThY|AbG_B__9H}vt4xbH^XI5;4xJ(;~5wD;v~EAiQPR*;a;HZucHU~L^g z;-h~MOj?rAL?lZDP$yJrteO536KBpz)?&(9^EsXt-71{8X_P`*wIm~!o_1Ax`VTV%7pEQmhV z*~S#be!q3^`}}ki$%V&>BxR<4_0n=Vpy$fcx}E&>qWk-=j3J-eTppHsa{dQ3Gqa{_^G9MFjG6Mig!U)|v=kS*@BgMk8?D5>`w~Pm+#Zc`Ri3VR zc9K=+Qzhxt3g zwufihXsyf6D#b`*-k+m8mYnl~o;T%sO=WwHCw+{<_+!GkJsAxz?a&v`GaTGe;#3?0 zEHVNjU#HQPBir_K;*AS;;iua)l|pAl#V-QOHUCP*CHuJrABJLpv}o-u>}oNr!%9sK z95W0HE;M?&ainDP$Sb#P9`kE^iS43yjWP4J-^Wb}`;LwcMewlhhoNDC0kp_hn&NC`-h zB3-2S-a;rLgcOpyy=(p7?z&I+>CW5i$;_JBllkpm`TjnSA3rWIua2X5ZG(DLg36(1 zq2aVY>msORmYO(#_!j~c0w_==w6{0rQS{sb6&10WYkUDKWv|sEkFtVZS5=o)*@sZe zdfrZllD7&I{03XC(o$2M=bKiEd^5TKzQH%U$lR0l{5IPel_0`ps<%yLUbRgYbtIcL zZw%AJrbpmvplFbk6nr+f>#ny6GF1||WX>^(i_FYQAC$(u)Ki4?pMzR1a5q~{W+)_S z$DGt_IMj<;^9ZCP)5)XcTR1*^l9bu zY40fAb9%3OC4l1PKY~tQ>3i}hlU9~Vr92Gv`TWie2g0Rn>-oPde!kPZ!NLmR@!nrF zUD)m%J0?FXb#*(q~=qt44~h7vAg}qxttIKbVzy1UC$mqup{9N(*mYwB#J`v zwT#U^Oy^;A_t6D>d>pmbo;sAFV zwSplCoTO`AxC8xCCZ1`)>(At(ht{CG9If&3LhT30tvH&TL8oGeCy7kFOBlRWnXwS_ zs3X_k^m_8VVw>wuRGS%#9XivzAG=r`^0J3F$JsYaxg{q}5Z$)!!V*z54I{m(4=&e8 z`ITyk9{yy?r>&i>v_-xwDW4R1JEeAMGlOsEScjA6vM~LnD=}T>&$#P){+-M?-s#?9 zEeWfIMSp-ViT+{k+{bkY-Fub$e?dqIr3dOe4lxVXD_20r1yHC_QBD38svi-dsiC1! zGyUcBjRtH-D)|C!Q8DERtX5Wb-a5wfsJfyK+OKj`)BNNMI`XTXCY?|$oLOb?)a?5hZOB#`-;zf8PDi=&RB$`h0aL;c3bgYKzYpr4M^)llX!KaT( z#&S)$IYOqdJj3d6=8cxpA9+yBDbL2rhg2xT$YZree2#%rdiQ%Y0g(L+uKLh?$%Af4?DI#}z_h3@5z^Rv)FT+EQ`1u8HO1V#mB}|$;L!7Nne!q|Z&6zGJB-H%O zZTBGmOz_7;G+Z;Da~81}YWeJJWTCr?ElgSDG?(_eWM(W(mNdvB^=2nlY2WV^eiqTx z5F58wyS6|(ykz9jD_c7#6ZB7Jx&Ei1vQ! zUvHQT#+CE6f+#;XLCWoyagr8^tVA)YNDWUUNteI_(d+q{FN70#1;Yovk$$$cWG|Xf zwCEIaf*_+KuWDY*F&`h0=p!JJEG345lOTP%K2^V1+pXK_11wNMS#EKFfXiN8A7y(s(DJBa)M??s z2f9wFBx2S%a@~a^zkNHETQto~ldN;tD^N;Xp!BAo;qZV%?6k*5b4Yx2Y|DOok0pNb zC;%N`)BAauK>=4bu!`9jusl7+AV73-)RP?6O^dFNOOgNz$Z^rJo(M3OE*h}) ztH1iVMIRD<|GNvh!lFVjJX!;kzn1D^J4kcIE<5XQegHq$e&axvQVMk&mzkU=elJAB z+lYi(VZIk9n=TbPI#76^&2&nFs5BQtcw+0B;XjbG#Hja%W*_tIpcw+DvJ58F+vBoyy5oRMb{2+QVThmQpS zPj-_z+2@q@{JG;h7VjLQq5AUP$BAR>PBkrTi&MUV3%ojLk15{27edC~)(*9zBTvJr zp;**rdxz;&9N*p3ba8dGn_BYx1FWlys!6xcSLJ z$=~UwRbPv6sy4NzK2D1$KX`bek#M{n6CcPLv`0n~-iI0X=w=yOyet@d3*;j0{9JLx z_-_9kA<+RP^C#jK5UXFY{ng4jK`kzARN;x=zu$*w8M;5#viBX6Edl_RlENPs{FJY& zOAn@JJSkpUVu5HmIq@V6`^fWPI2IJ;W#(V|WdBrcb?bbYKDLg>TehKxiP!x}Lq*Ji z`0XgS!;8@UCOP8T;n5d>K-0J*2%31m%2HywyCYA?->>QlajHjU4hj`PMwg{80Ola@ zXQ*kT=%~-fHH8T|rbXJ?MnpUe#QYFuWCX&_f7F67b5KOHy1J7YDKWou@(gdfkjVq- zMdz3jPjoUB&yE1=@H)F_(qlwUfcnWtYhisJZfWlhtlXSUO@@bFjzBoWHF;m(iQX&w zZQHF|u4sez?vUuB7e3Tc^Lk^Ru1`M`C;3{>j=n0TizD|hSB&p`o>ilqb{0<@ot$XX z;j!s_5=hcdTE3v}v)!6b+DTGt77Hs2%YD!{Z1t|S=7&=n?O#dNb@qqJ#qh;Imp1{h zzkY_jGjGwjvBb1u=-T<~3wn3&8pzVqXIdl~D;E|sMZ5=)vrT}|+c zyu?>Hy-OI6N%CQTUR`XTQ3*ZuhkD0gEQw?)wlQoX+J9A%bJfAGq8qO{$CGlYVrB?3kMG3x>YB$6-x9jMRYXE&9%4;$IoOzccKt*eB0!O3+wwx>`No1n4X~I7te+{WxVHdb7m{<^jobEVtG{1 z;4K8>w>25^>G;=3o%N)8`am4ku2G?+J05n=sjC;vgmAbrr>587FI4U@^6ep8?n0|y^mhLoTRUev4TZSwb|iLKN@k&1riBufMf8kH zlQ#3uEpg*^c5NLNa{H0=Gq8I%LW>=u&dfA@pnWJN#&-Vv@I(&Fpo$;r9gtgddsDg$ z{$caBKEgB*y1fD@{SlFw>j_<^!tP7xbe%&dVIoTQ+Hp8b*^`Mn4be0@>OiE$Cix!` z_J!a{sZU=FD#jO@PSIeheAK!+Rl~y6h4dQN;BAwr>BbzE|106ykqwhTYVTNPU<#+C6b-ViisDh?b|2(fo-kMiJw<6e>C+&?bg9*#o zcsUjF07W6EI@_qH|FI4=06%IVCRrhGv#`el!zwB^Q$Rm_zyzmkZDnx3Ygs?7>D1Of zdxs3_!z~7GFhPNx87xWfRrQ;5k}{WCJqnqq+CFv2FKxtJwPoVNkKrsEO+N$P=wBd+ z0f4U;7P9g(<~P`zV0l$Qc%bIpj9iy*5OVe|tIZfxzOFCADzi>gCi?v>Y&6gA+6GL* zL~6j#^3gn>?BMn>SJLy}zm@gq+(zq&N{;^BpA$z$CKvqM@Fy;<4M&UEkoD}JLg5-c zT_&lYn$ml^f3QIq{8jih?tEHkJPxkWXj(aI*z?{s*Wr79GO`K}Jg(NOzMB-k35#NBMRD=U6skl(2_3+jbIU_KJ)nz1yC=e;VfCU z$BHTr6nsJ)w9RAt?8+O#Hqx=Vt84FQu3xdqKxqbn*ZL}H|_#p>Q-U4PIXo^R^YAlj?qF+T1AtaW3Q znIXGGql;Somn4=igc6r<2vCdN>WS4s&+g;K)oycf-{bmZn14-P1bgs42wfj$H0C7h zsO?6@FCY_CYJ7*dR_HT_WN9S_uFvC?<^56NV%(I&&-%FJ2VuXmT7NVJTrLM(7%c;X zWgrrPi6f5r^?9Kp>;iUwqcD3QZHZO#1*2}6rFnu*v(mszb9J_h5 zKq``Hw!zNM*;xiC4sI*llsZ~FECdt(yjs+hc((0fCtPG^=otV^fYAU<-JSeG+OA}}f4Z#mo z5Q`Y({ZH?^+Hciyfl9SPmb%kl==|T zN`bj<5cidsZXo@dK65WkjnfV?dS~I;Q*$fo-ditY&w;MKbkt1i_*uAsfRJ{P6eRko zKq~EDj6A82=*kcX8Q7Cr+hH;BflbUQI^vyI{dm=EHHdYj_lM~kHyZvCPT=E}@g{+V zL%p*D^gb5kzwR$)1VMvq$n_C%LNh`w2Sho;ZKSK}%_r!Eq<{@iNX5rU9;O~2%OFL^ z6RezA6O_e$ZE1lAS?$tBM%OQfVDR5Arj%IvKXfvKcSjP8rh8<`wbg_E6Tq*rRzDLQ z6NkosG`hnbe0l=?K=M|Lh1tO6xo)KBW+^*&ZMo5e1@Bjb5Q{Qf_w}~qx0)7?PB5`5 z&fNnbD_~S&{`T$`6iQEdfh!5{EjP?RC19?2Y=~vIE!jfH4PRJ%-6?UIMqx-&PKzPL zhZu$88G$sdtdM(OuU=tWeeLW4qE(lUf6HuBwzrbhY1Fy*D8Ob_gb>hK3K)==01M)m z3$;( zo;Tvj)v2lH6x@tV)XBaZ0!f(JO-UZs%ev0PBlT+O6{Hv5R#jaoLQj{-sMmijJ8d+u zp{t{(dr z5A6N~OK`6c{{U41ksgZ8xK6BFC-4Zl3UK&T~WbjQ93XSyo{Gd&p=V z6daGBN$(t$`2j{kDehnB|6ssNIv4&kpx^|I_;d+XBe-JTGdD#ihR(MF8vXp~vbC43 z|1@g)UyQV^v}Nm$GmWH)%y%{^Y|!}w9}cmpzecxPlj8gYy;e7iGO2y`-@|Rz(l9`- z6?-tUVc0y5a#s|fa*v6+eG6ROp9}DnY{UBfUKa|@4pNJkblQw4?o~D!fy>?ZS*`;_ z@Vy8jnqL+=oi7-iWw?E1q4z}!P6JgF^bdX6BfbJMdnh{mwSD))=4%|Q?h3r#Gvj@Y zJSvgEx#F+uci2ZQ{CS3+`?Y^2=Ravy0969p|45rAsojeq9dUkWoBQ(D literal 3376 zcmd5%-w6PKHvF5@-vWR}nYy3c@dx$H%+#bk0PK>(PoMHmB+rhkVgu!YtOafX zmb1)AL9;RsHf9c#TV6u>=i3&3+q~Wl8r^#!a&?r z7`TI$usCSn-ab6nu@R&R#E;+G4x~B$*sRfanh!*HxOAfH5Raj3vW({-+3{uC;rY~r z$IQwjvrr5wyfOL^5Ye4YEk^X;eYM*s3dk754_Y!`M`c$~8yEp}4bUa&g+?Px*pZ*d zU^@Zm6yL6tQ+4F$l0Rqt0nkpy@#%Lzi}^-l#}jG{&$sOIb^UFC{#6v8z?*=>G_m5Nm&nos^jcr-Of#O=I;oqWc`x%fpC7~k z&(7tsM>0lnQz)QwBceTqw^pcmgXO5}CXhZ91SE^&caVs> zz$y_MU?)+GJIk1_hdFJJ8tCIQlfe8_n}A03cH3qQEK!L+?EP|X7XbFirI}@ABktf< zjt8`#90hg-|A7N7fr48dwf0gf_CDoHuzyF3pO`FzWOxm& zJ%4`C+YWs$=p9DgY6)5W5Az%R;YE5%L!X5fGNQk3$c+79N{+D*Be(q%(%*dhPl$g6 z%NMPuG!8vu`1^%-IdtTC=G5ht*KjwG7oawvjJt?_D4Rk#x31J@*BBHH+FKL3R?4V` z@OblLUvV7+k`6+Djs4El4@1R5m>^fu`$26_4|f(*e5bM`GqepQ&4Mz0F(e36r;*R` zM`uw}jJ@ojw3Z(}^gk9_G-HF) zOJO6ZVDOSf^)c>-8dcXqv0LfzST|lurctcZ`(_gVel%Uj)n@9LR*$`yu-8?O;sCp` zMOqT0!HnH1G*~jNu6ZE?p|k2O*mTpVxxFO#Ib=SPiuH%LsfMh{^2~Z%!lvHW!Dpjfw_fC(#ZEA5*)(o(~ zDN`@(+8rUU;y9L;!e5wI46TH|Ya2ABWSyD8sN@?Kjt|thg20&Am@sO|>l3!4F)WZ> zveH00#QM&ipLTyihH!~np#Id6wEkkUK-^AzJI#BhaeyYMJNa!T6vx(!5UkN8%jej6>Ia_CQ5iciit}a+PV=)iLDV%iCWkhBnxL7%9KnhaE-Ujm)$Gi&L=86Ka=Fb3XXK@@ zEC}~DPw)aHs^brQZhl*}c|98>>h);Ms}P}QNkPQ#lg;1+y{`8^Jsai(D|&@WY*t(- zqDoPwIQCW^)7iIL5FR`uUZW{{FM=J{?rxvVc3OX~5SbXJE;H=VHwzBz5C6V$&M`S+ z?#+y#$GI^-9YhOg5lG%wAxc&=H+B=>-Cd(w$edvKImP(X5%qZiQ98~oad5#!Tub9h zcE26f2I&v)FW8*=G}vCbpbJY`tXgahctT&D7#R+7*htpvGN*06>8`&~>XZ^6`C(FK z)jA#(b~wFJ&>k;hMm4rm3GKbi)Jk)0Ak?h$Lm&6aVXEC+rsLwSue^I?+ ziHSm#q3GmH~7~Ef1W=kI;RrCo{pYq#c`6*WckZGb^)PTywOt# z$TvI(X&Zw#v~I6f8gA%wS!x^C=e_Dh#xniYHIdg;gwx_UU`ifC=H^hhbsk__T4Rb$ zauXPH);KTsItZtUVv%ehxn8=>MlT7Lq2p?L>r1XIx!4Fk(Ms#3Qz+_UU=1nar_4^4 z$TRy4S9Yist-FI@F(_k`Yrq$d}5>yU0k(nlSqZpQWL?)vr-?-?lc zB{mWlysp{j_HiTsJ1KHb?Ey};4@F7K;G?re?39T%E4FnyW#s8s0c4pA+_joQud63F z-%mnspL1D!XEhj;Ys2%!gk~ArI)XCT`?;S?XBg)h@aHOSk8}$xXG5tBTPEm8!Wh5d z+LrQ&T%ItXsh)uyIx1vlJ=SH$VWmE9B^#eTpNHpM_Cw}}l@4!7kMbyVin1St8aIk& z item.name === name); } +function inputSlot(node, name) { + return node.inputs?.findIndex((input) => input.name === name) ?? -1; +} + +function connected(node, name) { + const slot = inputSlot(node, name); + return slot >= 0 && node.inputs[slot].link != null; +} + function inputValue(node, name, visited = new Set()) { if (visited.has(node)) return undefined; visited = new Set(visited).add(node); - const slot = node.inputs?.findIndex((input) => input.name === name) ?? -1; + const slot = inputSlot(node, name); if (slot < 0 || node.inputs[slot].link == null) return widget(node, name)?.value; const source = node.getInputNode(slot); const link = node.getInputLink(slot); @@ -18,7 +28,8 @@ function inputValue(node, name, visited = new Set()) { const type = source.comfyClass || source.type; if (type === "ComfySwitchNode") { const selected = inputValue(source, "switch", visited); - return selected == null ? undefined : inputValue(source, selected ? "on_true" : "on_false", visited); + if (typeof selected !== "boolean") return undefined; + return inputValue(source, selected ? "on_true" : "on_false", visited); } if (type === "Reroute") return inputValue(source, source.inputs[0].name, visited); if (["PrimitiveInt", "PrimitiveBoolean", "PrimitiveNode", "easy int"].includes(type)) { @@ -27,17 +38,47 @@ function inputValue(node, name, visited = new Set()) { return undefined; } +function inputSignature(node, name, visited = new Set()) { + const slot = inputSlot(node, name); + if (slot < 0 || node.inputs[slot].link == null) return widget(node, name)?.value; + const source = node.getInputNode(slot); + const link = node.getInputLink(slot); + if (!source || !link || visited.has(source)) return node.inputs[slot].link; + visited = new Set(visited).add(source); + const type = source.comfyClass || source.type; + const identity = [node.inputs[slot].link, source.id, link.origin_slot, type]; + if (type === "ComfySwitchNode") { + const selected = inputValue(source, "switch"); + if (typeof selected === "boolean") { + return [...identity, selected, inputSignature(source, selected ? "on_true" : "on_false", visited)]; + } + } + if (type === "EmptyMiniMaxH3LatentAV") { + return [...identity, inputSignature(source, "width", visited), inputSignature(source, "height", visited)]; + } + if (type === "ImageFromBatch") { + return [...identity, inputSignature(source, "image", visited), inputSignature(source, "batch_index", visited)]; + } + return [...identity, + source.widgets?.map(({ name, value }) => [name, ["string", "number", "boolean"].includes(typeof value) ? value : null]), + source.inputs?.map((input) => inputSignature(source, input.name, visited))]; +} + +function validDimension(value) { + return typeof value === "number" && Number.isInteger(value) && value > 0; +} + function createPositionEditor(node) { const root = document.createElement("div"); root.className = "h3-guide-position"; - root.style.cssText = "width:100%;height:290px;display:flex;flex-direction:column;gap:6px;box-sizing:border-box;padding:6px;color:#dce5ef;font:12px sans-serif;"; + root.style.cssText = `width:100%;height:${HEIGHT}px;display:flex;flex-direction:column;gap:6px;box-sizing:border-box;padding:6px;color:#dce5ef;font:12px sans-serif;`; const canvas = document.createElement("canvas"); - canvas.style.cssText = "width:100%;height:218px;flex:1;min-height:100px;border:1px solid #435066;border-radius:5px;box-sizing:border-box;touch-action:none;cursor:grab;"; + canvas.style.cssText = "width:100%;flex:1;min-height:100px;border:1px solid #435066;border-radius:5px;box-sizing:border-box;touch-action:none;cursor:grab;"; canvas.setAttribute("aria-label", "Drag guide position / 拖动参考区域"); const row = document.createElement("div"); - row.style.cssText = "display:flex;gap:6px;align-items:center;"; + row.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;flex-shrink:0;"; const status = document.createElement("div"); - status.style.cssText = "white-space:nowrap;font-size:11px;color:#a9b8cb;min-height:14px;"; + status.style.cssText = "font-size:11px;line-height:15px;color:#a9b8cb;min-height:30px;flex-shrink:0;"; const coordinates = document.createElement("span"); coordinates.style.marginLeft = "auto"; const context = canvas.getContext("2d"); @@ -46,14 +87,28 @@ function createPositionEditor(node) { let liveKey; let frame; let removed = false; + let executed; + let preview; + let queuedSignatures; + let previewError = ""; - function dimensions() { + function signatures() { + return { + guide: JSON.stringify(connected(node, "image") ? inputSignature(node, "image") : DIMENSIONS.slice(0, 2).map((name) => inputSignature(node, name))), + canvas: JSON.stringify(connected(node, "latent") ? inputSignature(node, "latent") : DIMENSIONS.slice(2).map((name) => inputSignature(node, name))), + }; + } + + function dimensions(keys = signatures()) { + const guide = executed?.signatures.guide === keys.guide ? executed.guide : []; + const target = executed?.signatures.canvas === keys.canvas ? executed.canvas : []; const values = DIMENSIONS.map((name, index) => { - const linked = inputValue(node, name); - const stored = node.properties?.h3GuideSize?.[index]; - return Number(linked ?? stored ?? widget(node, name)?.value); + const actual = index < 2 ? guide[index] : target[index - 2]; + if (connected(node, index < 2 ? "image" : "latent")) return actual; + const live = inputValue(node, name); + return live === undefined ? actual : live; }); - return values.every((value) => Number.isFinite(value) && value > 0) ? values : null; + return values.every(validDimension) ? values : null; } function position() { @@ -84,14 +139,17 @@ function createPositionEditor(node) { context.setTransform(dpr, 0, 0, dpr, 0, 0); context.fillStyle = "#111923"; context.fillRect(0, 0, bounds.width, bounds.height); - const sizes = dimensions(); + const keys = signatures(); + const sizes = dimensions(keys); + const [x, y] = position(); + coordinates.textContent = `x ${x} · y ${y}`; if (!sizes) { - status.textContent = "请填写参考宽高和画布宽高 / Enter dimensions"; + view = null; + status.textContent = previewError || "尺寸尚未同步或来源已更改,点击“刷新预览”读取实际尺寸。"; return; } const [gw, gh, cw, ch] = sizes; - const [x, y] = position(); - if (!drag) { + if (!drag || !view) { const left = Math.min(0, x), top = Math.min(0, y); const right = Math.max(cw, x + gw), bottom = Math.max(ch, y + gh); const scale = Math.min((bounds.width - 36) / (right - left), (bounds.height - 36) / (bottom - top)); @@ -118,18 +176,21 @@ function createPositionEditor(node) { context.stroke(); } context.restore(); - context.fillStyle = "rgba(64, 164, 217, 0.35)"; - context.fillRect(ox + x * scale, oy + y * scale, gw * scale, gh * scale); + if (preview?.signature === keys.guide && preview.image.complete && preview.image.naturalWidth) { + context.drawImage(preview.image, ox + x * scale, oy + y * scale, gw * scale, gh * scale); + } else { + context.fillStyle = "rgba(64, 164, 217, 0.35)"; + context.fillRect(ox + x * scale, oy + y * scale, gw * scale, gh * scale); + context.fillStyle = "#d2f5ff"; + context.font = "12px sans-serif"; + context.textAlign = "center"; + context.fillText("Guide", ox + (x + gw / 2) * scale, oy + (y + gh / 2) * scale + 4); + } context.strokeStyle = "#65d5ff"; context.lineWidth = 2; context.strokeRect(ox + x * scale, oy + y * scale, gw * scale, gh * scale); - context.fillStyle = "#d2f5ff"; - context.font = "12px sans-serif"; - context.textAlign = "center"; - context.fillText("Guide", ox + (x + gw / 2) * scale, oy + (y + gh / 2) * scale + 4); - coordinates.textContent = `x ${x} · y ${y}`; - const unresolved = DIMENSIONS.some((name) => inputValue(node, name) == null); - status.textContent = `Guide ${gw}×${gh} → ${cw}×${ch}${unresolved ? " · 连线尺寸运行后同步" : ""}`; + const overflow = [gw > cw ? "宽度" : "", gh > ch ? "高度" : ""].filter(Boolean).join("和"); + status.textContent = previewError || `Guide ${gw}×${gh} → 画布 ${cw}×${ch}${overflow ? ` · Guide ${overflow}超出画布` : ""}`; } function button(label, action) { @@ -137,20 +198,40 @@ function createPositionEditor(node) { element.type = "button"; element.textContent = label; element.style.cssText = "background:#26364b;color:#e1efff;border:1px solid #506783;border-radius:4px;padding:4px 8px;cursor:pointer;"; - element.addEventListener("click", () => { + element.addEventListener("click", action); + row.appendChild(element); + return element; + } + const refreshButton = button("刷新预览 / Preview", async () => { + refreshButton.disabled = true; + previewError = ""; + queuedSignatures = signatures(); + try { + await app.queuePrompt(0, 1, [String(node.id)]); + } catch (error) { + queuedSignatures = null; + previewError = "预览失败,请重试。"; + refreshButton.title = String(error); + } finally { + refreshButton.disabled = false; + draw(); + } + }); + refreshButton.title = "只运行此节点及其上游,读取视频第一帧或参考图片。"; + function positionButton(label, action) { + button(label, () => { const sizes = dimensions(); if (!sizes) return; node.graph?.beforeChange(); action(sizes); node.graph?.afterChange(); }); - row.appendChild(element); } - button("居中 / Center", ([gw, gh, cw, ch]) => setPosition((cw - gw) / 2, (ch - gh) / 2)); - button("左上 / Origin", () => setPosition(0, 0)); + positionButton("居中 / Center", ([gw, gh, cw, ch]) => setPosition((cw - gw) / 2, (ch - gh) / 2)); + positionButton("左上 / Origin", () => setPosition(0, 0)); row.appendChild(coordinates); root.append(canvas, row, status); - root.title = "拖动蓝框调整左上角坐标;按住 Shift 以 32 像素对齐。"; + root.title = "拖动参考图调整左上角坐标;按住 Shift 以 32 像素对齐。"; for (const type of ["pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick"]) { root.addEventListener(type, (event) => event.stopPropagation()); } @@ -169,7 +250,7 @@ function createPositionEditor(node) { canvas.style.cursor = "grabbing"; }); canvas.addEventListener("pointermove", (event) => { - if (!drag || event.pointerId !== drag.id) return; + if (!drag || event.pointerId !== drag.id || !view) return; const step = event.shiftKey ? 32 : 1; setPosition(Math.round((drag.x + (event.clientX - drag.px) / view.scale) / step) * step, Math.round((drag.y + (event.clientY - drag.py) / view.scale) / step) * step); @@ -201,11 +282,11 @@ function createPositionEditor(node) { return result; }; } - // Linked primitives may change without calling this node's widget callbacks. + // Linked upstream widgets and graph zoom may change without this node's callbacks. function refresh() { if (removed) return; const bounds = canvas.getBoundingClientRect(); - const key = JSON.stringify([dimensions(), position(), bounds.width, bounds.height]); + const key = JSON.stringify([signatures(), position(), bounds.width, bounds.height]); if (key !== liveKey) { liveKey = key; draw(); @@ -216,6 +297,27 @@ function createPositionEditor(node) { node.setSize([Math.max(node.size[0], 390), Math.max(node.size[1], node.computeSize()[1])]); return { draw, + executed(message) { + const keys = queuedSignatures || signatures(); + queuedSignatures = null; + previewError = ""; + executed = { guide: message.guide_size, canvas: message.canvas_size, signatures: keys }; + preview = null; + const descriptor = message.guide_preview?.[0]; + if (descriptor) { + const image = new Image(); + const loaded = { image, signature: keys.guide }; + preview = loaded; + image.onload = () => { if (preview === loaded) draw(); }; + image.onerror = () => { + if (preview !== loaded) return; + previewError = "预览图片加载失败,请刷新预览。"; + draw(); + }; + image.src = api.apiURL(`/view?${new URLSearchParams(descriptor)}`); + } + draw(); + }, remove() { removed = true; cancelAnimationFrame(frame); @@ -243,11 +345,7 @@ app.registerExtension({ const executed = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message, ...args) { const result = executed?.call(this, message, ...args); - if (message.guide_size && message.canvas_size) { - this.properties ??= {}; - this.properties.h3GuideSize = [...message.guide_size, ...message.canvas_size]; - this.h3PositionEditor?.draw(); - } + if (message.guide_size && message.canvas_size) this.h3PositionEditor?.executed(message); return result; }; const removed = nodeType.prototype.onRemoved;