Compare commits

..

1 Commits

Author SHA1 Message Date
Yourz
34ccb7eaae feat: restyle about page sections
- StorySection: split OUR INVESTORS badge with node SVGs, use investor
  SVG logos, merge QuoteSection into StorySection
- OurValuesSection: new section with NodeBadge component for value cards
  (SHIP IT, SHARE IT, OPEN-SOURCE IT, RESPECT THE CRAFT) with connector
  lines between rows
- ValuesSection: redesign as node-graph flowchart with dynamic SVG wires
  connecting IF YOU dot to reason dots and Comfy badge
- CareersSection: remove duplicated flowchart content (moved to ValuesSection)
- Use logo.svg with CSS mask for Comfy branding throughout
- Add i18n keys for split Why join title

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019d96e6-9b9f-72b9-989d-e227fec10ff3
2026-04-17 00:06:26 +08:00
72 changed files with 1380 additions and 3537 deletions

View File

@@ -0,0 +1,15 @@
<svg width="125" height="23" viewBox="0 0 125 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M102.624 13.5714H102.72L105.521 4.7427H109.112L104.492 17.6087C102.983 21.8406 101.427 22.4 99.2481 22.4C98.4106 22.4 97.6921 22.3513 97.2372 22.3027V19.5059L97.4957 19.5312C97.7832 19.562 98.1651 19.6032 98.7218 19.6032C99.8707 19.6032 100.326 19.1652 100.828 17.9005L100.996 17.5114L95.9202 4.7427H99.5359L102.624 13.5714Z" fill="#C2BFB9"/>
<path d="M6.34494 4.45081C10.3191 4.45087 11.8513 6.95585 12.3302 8.51244L9.07418 9.24216C8.79582 8.34683 8.13559 7.08628 6.50571 7.00832L6.34494 7.00484C4.57317 7.00484 3.42365 8.41543 3.42365 11.1881C3.42369 13.9606 4.5732 15.3713 6.34494 15.3713C8.16444 15.3713 8.85871 14.009 9.12207 12.866L12.5218 13.2795C11.9951 15.4197 10.3193 17.9248 6.34494 17.9249C2.41839 17.9249 6.38906e-05 15.3226 0 11.1881C0 7.05343 2.41834 4.45081 6.34494 4.45081Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.1848 4.4528C37.0563 4.53422 39.6265 7.17738 39.3201 11.9178H30.0783C30.1263 14.0337 31.2519 15.5173 32.9996 15.5173C34.8431 15.5172 35.5373 14.3496 35.8007 13.5227L39.2009 13.7903C38.6981 15.6144 37.0219 17.9248 32.9996 17.9249C29.121 17.9249 26.6547 15.2983 26.6547 11.2124C26.6547 7.0777 29.0491 4.45081 32.9996 4.45081L33.1848 4.4528ZM32.9996 6.81025C31.2279 6.81025 30.246 8.12338 30.1262 9.70432H35.8965C35.7767 8.12342 34.7953 6.81029 32.9996 6.81025Z" fill="#C2BFB9"/>
<path d="M71.9332 4.45081C74.878 4.45081 77.129 5.95871 77.5359 8.19622L74.4474 8.63406C74.1924 7.40883 73.2402 6.75409 72.049 6.71444L71.9332 6.71295C70.7363 6.71296 69.874 7.24781 69.874 8.22055C69.874 9.16907 70.8078 9.41244 72.1008 9.65567L73.3939 9.85026C75.6682 10.2394 77.8711 10.9204 77.8711 13.8146C77.8711 16.6116 75.357 17.9248 72.0529 17.9249C68.7253 17.9249 66.4501 16.3684 66.0193 13.8632L69.1561 13.4254L69.1854 13.5669C69.5113 15.0012 70.5693 15.6632 72.0769 15.6632C73.5135 15.6632 74.4474 15.0307 74.4474 13.9362C74.4474 13.0364 73.5135 12.6713 72.0769 12.4281L70.6881 12.2097C68.653 11.8692 66.4982 11.1391 66.4982 8.36649C66.4983 5.9101 68.9644 4.45082 71.9332 4.45081Z" fill="#C2BFB9"/>
<path d="M112.823 17.6871H109.232V14.0385H112.823V17.6871Z" fill="#C2BFB9"/>
<path d="M116.914 17.6871H113.323V14.039H116.914V17.6871Z" fill="#C2BFB9"/>
<path d="M124.6 17.6871H117.382V14.039H124.6V17.6871Z" fill="#C2BFB9"/>
<path d="M83.5192 4.7427H86.1531V7.02916H83.5192V13.693C83.5192 14.8604 83.9504 14.9821 85.0038 14.9821C85.339 14.9821 85.7696 14.9822 86.0569 14.9578V17.6087C85.7454 17.6573 84.6924 17.6573 83.8784 17.6573C81.2928 17.6573 80.1674 17.0981 80.1674 14.5686V7.02916H78.156V4.7427H80.1674V1.14323H83.5192V4.7427Z" fill="#C2BFB9"/>
<path d="M55.8226 4.45081C58.121 4.45081 60.0604 5.88579 60.0604 9.14486V17.633H56.7325V10.0205C56.7325 7.95318 55.9182 7.19943 54.5296 7.19943C53.2366 7.19953 52.0638 8.31817 52.0638 10.6043V17.633H48.7115V10.0205C48.7115 7.95321 47.9216 7.19945 46.533 7.19943C45.24 7.19943 44.0666 8.31807 44.0666 10.6043V17.633H40.7148V4.7427H43.9229V6.46971H44.0187C44.8807 5.2293 46.1261 4.45081 47.826 4.45081C49.3822 4.45087 50.795 5.10777 51.513 6.56701C52.5428 5.35093 53.7876 4.45086 55.8226 4.45081Z" fill="#C2BFB9"/>
<path d="M16.9677 6.44539H17.0635C17.9493 5.20501 19.2662 4.45085 21.0139 4.45081C23.3603 4.45081 25.3714 5.88579 25.3714 9.14486V17.6087H22.0436V10.0205C22.0436 7.95328 21.1817 7.19951 19.6496 7.19943C18.3088 7.19943 16.9677 8.46404 16.9677 10.7746V17.6087H13.6159V0H16.9677V6.44539Z" fill="#C2BFB9"/>
<path d="M65.0639 17.6087H61.7121V4.7427H65.0639V17.6087Z" fill="#C2BFB9"/>
<path d="M94.6844 4.45578C94.9211 4.46603 95.1545 4.49342 95.3338 4.54811V7.73407C95.0226 7.68543 94.5196 7.63677 94.0173 7.63677C92.1019 7.6368 90.6895 8.4884 90.6895 11.1151V17.6087H87.3372V4.7427H90.5458V6.71295H90.6411C91.3116 5.39959 92.5809 4.45081 94.4484 4.45081L94.6844 4.45578Z" fill="#C2BFB9"/>
<path d="M65.0639 2.94272H61.7121V0H65.0639V2.94272Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,7 @@
<svg width="105" height="15" viewBox="0 0 105 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.539 0.0285426C22.6544 -0.0351846 25.988 0.0279171 29.1212 0.0264352L32.1742 0.0199332C32.9185 0.018159 33.8039 -0.0152019 34.5256 0.11456C35.3509 0.266315 36.1211 0.634326 36.7577 1.18105C37.6967 1.97593 38.2784 3.11376 38.3729 4.3405C38.4839 5.82246 37.9664 7.19079 36.8332 8.15921C36.5415 8.40857 35.9614 8.79166 35.7424 9.04646C35.7383 9.43601 36.8893 11.0059 37.1467 11.4269C37.4643 11.9465 38.4111 11.7772 38.9199 11.7756C39.3943 11.7772 39.8685 11.793 40.342 11.8229C40.4037 12.5345 40.3634 13.9093 40.3517 14.664C39.0437 14.7314 37.3846 14.6838 36.0336 14.6838C34.657 14.6837 32.979 14.7319 31.644 14.6624C31.6103 14.2318 31.6332 13.4746 31.6327 13.02L31.6361 11.814C32.1247 11.7835 32.5976 11.8061 33.0846 11.7834C33.319 11.7725 33.4799 11.7405 33.5165 11.4949C33.4337 11.2769 33.1746 10.9388 33.0331 10.747C32.7459 10.3576 32.5042 9.90935 32.142 9.58871C31.967 9.43386 31.4996 9.39602 31.2878 9.39494C29.871 9.38766 28.4576 9.39504 27.0412 9.40171C26.4322 9.40545 25.8162 9.39617 25.2105 9.46037C25.198 9.95812 24.9581 11.8037 25.6817 11.7886C26.5399 11.7706 27.404 11.7843 28.2643 11.8099C28.2959 12.6677 28.2852 13.8088 28.2613 14.6625C27.0013 14.7337 25.3367 14.6856 24.0277 14.6841C22.6169 14.6825 20.913 14.7327 19.5435 14.6627C19.5121 13.7618 19.5376 12.7254 19.539 11.8145C20.0584 11.7729 21.8513 11.8658 22.1261 11.6122C22.2789 11.0808 22.2133 8.34042 22.2097 7.6769C22.206 6.9765 22.2904 3.60502 22.0911 3.13566C21.8255 2.99389 19.9813 3.03216 19.5159 3.00974C19.4729 2.62606 19.453 0.3621 19.539 0.0285426ZM25.1781 6.41333C25.7864 6.42789 26.403 6.43338 27.0112 6.4283C29.3214 6.40892 31.6425 6.48496 33.9495 6.40241C34.0841 6.39759 34.5121 6.16284 34.6315 6.08213C35.0228 5.71678 35.2992 5.35081 35.3173 4.7881C35.3308 4.32373 35.1606 3.87283 34.8436 3.53331C34.4239 3.08989 33.9742 2.99621 33.3906 2.99441C30.7387 2.98624 28.0784 3.00441 25.4273 3.02022C25.2542 3.04728 25.2743 3.01516 25.1877 3.12383C25.1324 3.75496 25.1485 5.73442 25.1781 6.41333Z" fill="#C2BFB9"/>
<path d="M50.2724 0.138631C51.6828 0.03579 53.2496 0.182878 54.6625 0.122495C56.7584 0.0329492 58.1473 0.23673 59.6382 1.82499C61.3824 3.68336 61.5311 5.19749 61.5316 7.61398C61.5332 8.59624 61.528 9.57851 61.5157 10.5607C61.5127 10.8878 61.4609 11.4889 61.627 11.7565C61.8023 11.9292 64.2099 11.9103 64.6493 11.9184C64.6509 12.866 64.6478 13.8136 64.6391 14.7611C63.7008 14.8175 62.2832 14.7743 61.2886 14.7733C59.5065 14.7717 57.6654 14.7977 55.8904 14.7644C55.8576 13.8841 55.8771 12.8157 55.892 11.9268C56.3996 11.897 58.1832 11.9618 58.435 11.7296C58.6047 11.3174 58.598 9.85935 58.4298 9.4396C58.1735 9.22562 57.086 9.31059 56.6785 9.31623C55.7853 9.3153 48.7631 9.24582 48.3863 9.39335C48.275 9.53964 48.2383 9.77249 48.2404 9.94037C48.2611 11.6716 47.8088 11.991 49.9209 11.8975C50.1037 11.8895 50.661 11.9185 50.8594 11.9251C50.9014 12.7546 50.8803 13.9221 50.861 14.7637C50.0044 14.8179 48.6992 14.7732 47.7992 14.7732L42.1365 14.7669C42.1124 13.8551 42.1244 12.8371 42.14 11.9221C42.7563 11.9001 44.5286 11.9586 44.9981 11.8351C45.043 11.8233 45.0865 11.8065 45.1277 11.7851C45.1698 11.7632 45.2281 11.6483 45.2321 11.6095C45.5513 8.48866 44.4939 5.07258 46.6263 2.39668C47.5759 1.20517 48.672 0.362 50.2724 0.138631ZM55.8069 6.37984C56.2334 6.38256 57.2317 6.32251 57.6008 6.39251C59.4224 6.73786 58.1325 4.41869 57.5218 3.82117C57.1583 3.46885 56.7077 3.22021 56.2155 3.10105C55.5844 2.94746 52.2657 2.99145 51.4515 3.00183C51.3418 3.00391 51.2319 3.00822 51.1221 3.01476C50.2804 3.08849 49.7204 3.39435 49.1449 4.0204C48.8448 4.34691 48.0275 5.86061 48.3348 6.28005C48.8624 6.47634 54.8163 6.38015 55.8069 6.37984Z" fill="#C2BFB9"/>
<path d="M66.1008 0.132078C67.4815 0.0948407 69.0088 0.126689 70.3982 0.126274L82.7239 0.124817C83.3335 0.124915 84.5481 0.0906206 85.1084 0.193006C85.2351 0.383285 85.1848 4.29567 85.1618 4.8387L82.2455 4.84347C82.1717 4.37331 82.2558 3.44598 82.0697 3.11536C82.0035 3.09915 81.9364 3.08718 81.8692 3.07952C81.153 2.99854 72.2869 2.97826 72.0639 3.06634C72.0337 3.07839 71.9855 3.12886 71.9578 3.15083C71.7579 3.52942 71.9947 4.54083 71.8983 5.04801C71.8814 5.21615 71.8947 5.68477 72.0527 5.78369C72.4966 6.06094 73.6589 5.94967 74.1921 5.9527L78.3526 5.97392C78.488 5.97259 78.6126 5.9569 78.7177 6.0434C78.8084 6.24682 78.7966 8.46194 78.7074 8.77012C78.2993 9.11434 72.3674 8.65905 72.0398 9.01363C71.8799 9.32283 71.8573 11.4816 71.9209 11.7506C72.1834 11.9303 74.4105 11.9116 74.8474 11.9209C74.8597 12.8671 74.8607 13.8133 74.8504 14.7595C73.9742 14.8203 72.4085 14.7764 71.4569 14.7732C69.7091 14.7674 67.8562 14.8074 66.1218 14.7628C66.0777 14.1145 66.0895 12.5701 66.1223 11.9225C66.6253 11.8922 68.4592 11.9381 68.774 11.7648C68.9098 11.2104 68.916 3.76877 68.7852 3.15653C68.5156 2.97876 66.5478 3.00636 66.091 2.9977C66.0746 2.15515 66.0464 0.952851 66.1008 0.132078Z" fill="#C2BFB9"/>
<path d="M0 3.97075C0.680008 2.4775 1.72004 1.09533 3.30995 0.49908C3.73281 0.343706 4.17582 0.249971 4.62537 0.220763C5.88343 0.131726 17.0718 0.0347761 17.432 0.19913C17.4429 0.204062 17.4534 0.209482 17.4639 0.215036C17.5863 0.617033 17.5561 4.23071 17.5217 4.84327L14.4665 4.84676C14.4511 4.57601 14.4351 3.33928 14.2915 3.1986C14.1017 3.13857 13.8405 3.10635 13.6413 3.10753C10.8734 3.12384 8.12437 3.13586 5.3552 3.16177C4.03315 3.17415 3.10523 4.28538 2.72969 5.46977C2.44858 6.35631 2.58788 7.38144 2.55855 8.30232C2.53008 9.63214 3.18983 11.1025 4.41563 11.7148C4.93376 11.9737 5.57212 11.9398 6.13799 11.9348C8.63538 11.9151 11.1334 11.9289 13.6307 11.9075C13.8341 11.9057 14.0499 11.8752 14.2419 11.8084C14.4871 11.6127 14.4224 10.4672 14.473 10.0944C14.9745 10.0805 17.1263 10.0064 17.4428 10.1774C17.6064 10.5158 17.5389 14.096 17.5232 14.7353C14.5846 14.7664 11.6458 14.7797 8.707 14.775C7.45695 14.7749 6.20151 14.7635 4.95241 14.7698C2.61924 14.7814 0.911018 13.0104 0 11.0108V3.97075Z" fill="#C2BFB9"/>
<path d="M105 0.127759V4.84265L102.537 4.8407C102.524 4.57401 102.556 3.11933 102.308 3.10405C101.561 3.05828 98.278 2.90385 97.7617 3.1579C97.63 3.63603 97.65 11.2212 97.7817 11.7547C98.077 11.9306 100.223 11.9104 100.712 11.9221C100.78 12.5649 100.734 14.0796 100.726 14.7638C99.8423 14.8242 98.1319 14.779 97.1696 14.7744C95.4085 14.766 93.4956 14.8141 91.7519 14.7607C91.7058 14.02 91.7155 12.6763 91.755 11.9299C92.2841 11.8996 94.2175 11.9179 94.5492 11.7937C94.7056 11.5281 94.6933 4.01885 94.6338 3.22394C94.4123 2.94404 90.2026 2.98239 89.9437 3.13285C89.7483 3.44251 89.7724 4.42536 89.7606 4.83963L86.826 4.84542C86.7767 3.83337 86.7685 1.13399 86.8301 0.135563C88.7768 0.0695231 91.0501 0.126784 93.0168 0.126836L105 0.127759Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,12 @@
<svg width="118" height="22" viewBox="0 0 118 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0298 0C13.03 1.33223 13.0823 3.05715 13.0203 4.35674C12.5297 4.40883 12.0611 4.39599 11.5697 4.39984C9.20775 4.41837 6.83709 4.37161 4.47602 4.40663C4.47935 4.47148 4.491 4.57304 4.47011 4.63014C4.50477 5.73869 4.52926 7.37998 4.47277 8.47402C4.47914 8.53726 4.49478 8.63703 4.48015 8.69487C6.52067 8.8156 8.81129 8.6622 10.8741 8.73886C10.9067 10.0161 10.9269 11.7943 10.8809 13.0738C8.91015 13.2295 6.50525 13.0238 4.47188 13.1369C4.47661 13.2056 4.4903 13.3077 4.46952 13.3687C4.50565 14.4103 4.52334 16.0974 4.47513 17.1432L4.48842 17.3416C4.92992 17.4348 6.22159 17.4012 6.73735 17.4004L10.6232 17.3942C11.3704 17.3932 12.2867 17.3668 13.0153 17.4272C13.0649 18.8228 13.0311 20.3622 13.0274 21.7683H0V0H13.0298Z" fill="#C2BFB9"/>
<path d="M28.9816 0C29.6882 0.472806 30.3169 0.809757 30.4166 1.78273C30.5559 3.14391 30.5099 5.12562 30.4644 6.50056C29.5433 6.55432 27.0931 6.59113 26.2083 6.48786C26.1383 6.55342 26.1721 6.54075 26.0761 6.5419C25.9394 6.30164 25.9933 4.72191 26.0566 4.41106C25.0069 4.34402 22.8858 4.37764 21.7937 4.4087C21.6985 5.48716 21.767 7.57495 21.7733 8.71672C23.3169 8.73062 24.8608 8.73205 26.4044 8.72085C27.8463 8.71837 29.7895 8.36939 30.3563 10.1581C30.4227 10.4794 30.4805 10.843 30.4827 11.1712C30.4998 13.7868 30.4908 16.4035 30.4871 19.0192C30.4866 19.3602 30.4337 19.6526 30.4033 19.9794C30.3023 21.063 29.7051 21.1984 28.9943 21.7683H18.9183C18.8277 21.6461 18.7602 21.6535 18.6522 21.5741C17.9823 21.0811 17.6231 20.832 17.4326 20.032C17.3996 19.8865 17.3935 19.8353 17.3003 19.7157C17.2138 18.6643 17.2794 16.6781 17.2867 15.594C17.2612 15.4679 17.2567 15.3394 17.3198 15.2326L17.4169 15.4618L17.478 15.4889L17.5471 15.1889C18.9397 15.2079 20.3325 15.2037 21.7249 15.1759C21.8084 15.9193 21.7461 16.6513 21.7775 17.3841C22.2776 17.3946 25.7281 17.4316 26.082 17.3706C25.9175 16.2884 26.1722 14.199 26.0188 13.0894C24.7334 13.1336 23.4596 13.123 22.1082 13.1151C21.0343 13.1088 19.1306 13.2867 18.1955 12.6202C17.9119 12.4182 17.4843 11.7548 17.4633 11.4065C17.4064 11.2039 17.2645 11.0063 17.2908 10.8145C17.2246 8.97126 17.3079 7.0791 17.2764 5.23098C17.2595 4.24575 17.2648 3.21037 17.2929 2.22649C17.2951 2.24676 17.4457 1.70297 17.4571 1.65843C17.4876 1.59418 17.5087 1.43756 17.5781 1.30029C17.8952 0.672774 18.3737 0.41943 18.8527 0H28.9816Z" fill="#C2BFB9"/>
<path d="M46.4198 0C47.6718 0.899999 47.8824 1.08409 47.7918 2.72901C47.731 3.83475 47.8975 5.22667 47.7691 6.30865L47.7328 6.48048C47.5838 6.54671 47.2595 6.56281 47.0971 6.55312C45.9297 6.48345 44.7504 6.58741 43.591 6.53451C43.5603 6.5326 43.5093 6.32802 43.4977 6.29064C43.5445 6.12646 43.5402 5.29844 43.5414 5.07184L43.5405 4.90797C43.5395 4.73649 43.5308 4.55004 43.4534 4.42464C43.3127 4.3605 39.6586 4.39856 39.2372 4.42022C39.2346 5.54116 39.3017 7.62032 39.2227 8.65767C39.8819 8.77575 40.8548 8.72141 41.5473 8.7306C43.044 8.75047 44.5873 8.68661 46.0811 8.74152C48.0574 9.07225 47.7898 10.6883 47.7912 12.2335L47.7927 15.4922L47.7906 18.4807C47.7898 19.0263 47.8107 19.7717 47.7369 20.2956C47.6406 20.9796 46.8796 21.3767 46.4304 21.7683H36.3121C36.2027 21.6149 36.1134 21.6327 35.9738 21.5357C35.4292 21.1576 35.1345 20.9382 34.9687 20.2924C34.6904 19.5446 34.7691 16.1426 34.8167 15.1904C36.2579 15.1823 37.6992 15.1826 39.1404 15.1919C39.2267 15.6996 39.3276 16.8692 39.1858 17.3694C40.237 17.4621 42.3595 17.3889 43.5119 17.4104C43.5949 16.2224 43.5334 14.3279 43.5243 13.1009C41.5274 13.1176 39.5169 13.1161 37.5218 13.1107C36.7968 13.0849 36.0163 13.0516 35.4972 12.4773C35.0872 12.0412 34.8084 11.3444 34.8046 10.7424C34.786 7.85839 34.7995 4.96898 34.796 2.08477C34.7955 1.55989 35.288 0.746369 35.7131 0.426639C35.9237 0.268216 36.1019 0.195041 36.308 0H46.4198Z" fill="#C2BFB9"/>
<path d="M65.3009 0C65.3014 1.3829 65.3416 2.98525 65.2911 4.35024C64.7422 4.42078 64.1999 4.39321 63.6492 4.40102C61.3208 4.43408 58.966 4.34497 56.6399 4.4152C56.5222 5.10504 56.5466 7.89558 56.6075 8.64675C58.371 8.91365 61.2956 8.57726 63.155 8.75451C63.1619 9.33031 63.2364 12.8048 63.09 12.9914C62.4245 13.2103 57.8158 13.0747 56.6399 13.1346C56.5319 13.8193 56.5356 16.6111 56.6157 17.339C57.2675 17.4335 58.3879 17.3993 59.0861 17.3983L63.2055 17.393C63.7303 17.3926 64.8035 17.3652 65.2864 17.4568C65.3277 18.8678 65.3055 20.3516 65.3032 21.7683H52.2658C52.2263 20.3012 52.2575 18.6949 52.2587 17.2176L52.2575 3.29826C52.2575 2.27844 52.2221 0.986496 52.2826 0H65.3009Z" fill="#C2BFB9"/>
<path d="M69.709 15.9277C69.7095 16.8548 69.8347 20.9048 69.6665 21.5699L69.6585 21.7683H69.577C69.5012 19.154 69.5705 16.1626 69.5699 13.5128L69.5764 0H69.7169L69.709 15.9277Z" fill="#C2BFB9"/>
<path d="M81.279 0C82.0553 0.525496 82.5295 0.826058 82.7116 1.85212C82.8281 2.50704 82.7847 3.43871 82.7824 4.12614L82.7721 7.6237L82.783 21.7683H78.3371C78.309 21.0093 78.3119 20.1751 78.3274 19.4155C78.4256 14.4355 78.2023 9.37025 78.3424 4.39689C77.0235 4.39391 75.3819 4.36073 74.0784 4.41726C73.9687 6.15742 74.0513 8.78053 74.0518 10.5718L74.0583 21.7683H69.8049C69.7745 20.9202 69.7819 20.0099 69.7922 19.1574C69.8674 12.7851 69.6826 6.36743 69.8049 0H81.279Z" fill="#C2BFB9"/>
<path d="M98.6817 0C99.245 0.426776 100.072 0.950614 100.076 1.71305C100.082 2.69656 100.198 4.9897 100.101 5.87906C100.108 6.08496 100.138 6.32456 100.048 6.50617C99.3623 6.64648 96.5942 6.5453 95.7918 6.52743C95.7591 5.867 95.7855 5.10108 95.7614 4.40634C94.6379 4.39042 93.2518 4.37205 92.0758 4.40604L91.5862 4.42405L91.5157 4.78899L91.5234 4.90679C91.6004 6.42571 91.5939 17.0774 91.4593 17.3723C92.8277 17.4476 94.3649 17.3785 95.7505 17.3995C95.7999 16.7033 95.7632 15.8196 95.8556 15.1854C96.3432 15.1902 99.8544 15.1482 100.029 15.2796C100.318 16.4092 100.014 18.9486 100.065 20.2064C100.093 20.8885 98.912 21.4413 98.6817 21.7683H88.6216C88.5188 21.6378 87.6483 21.2069 87.4019 20.784C87.159 20.3662 87.1188 20.0112 87.1073 19.5173C87.0774 18.1858 87.0901 16.8285 87.0896 15.4969L87.0907 3.82617C87.089 1.84144 86.7898 1.18487 88.5549 0H98.6817Z" fill="#C2BFB9"/>
<path d="M117.6 4.31363C117.046 4.47618 109.809 4.45517 108.864 4.39453C108.808 5.53308 108.812 7.56345 108.857 8.71702C110.286 8.73247 111.715 8.73471 113.144 8.72351C113.709 8.72259 114.886 8.69262 115.41 8.77223C115.43 9.38599 115.515 12.6365 115.34 12.9828C114.816 13.2169 109.892 13.0772 108.863 13.1343C108.813 14.2134 108.806 16.3023 108.861 17.3847C110.832 17.4316 112.839 17.3716 114.811 17.3915C115.45 17.398 117.074 17.346 117.6 17.4671V21.7683H104.542C104.501 21.3743 104.548 19.6198 104.549 19.1359V4.65465C104.548 3.29906 104.486 1.29018 104.57 0H117.6V4.31363Z" fill="#C2BFB9"/>
<path d="M48.0177 10.366L48 10.4894C48.106 10.8938 48.0982 19.744 48.0038 20.0249C47.9833 20.0942 47.9735 20.1302 47.9409 20.1958L47.8721 20.1374C47.806 19.7354 47.8297 18.9655 47.8299 18.5327L47.8326 12.1983C47.832 11.8575 47.7421 10.4752 47.9374 10.2993L48.0177 10.366Z" fill="#C2BFB9"/>
<path d="M48.0186 1.90172C48.1014 2.28871 48.0969 6.19803 48.0053 6.44033C47.9765 6.42888 47.9313 6.38877 47.9046 6.3677C47.8471 5.53577 47.9104 4.49595 47.8922 3.6437C47.8847 3.29234 47.8659 2.12203 47.9079 1.84975L47.997 1.82702L48.0186 1.90172Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

@@ -0,0 +1,18 @@
<svg width="114" height="23" viewBox="0 0 114 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M59.7261 19.7973C59.7261 21.2999 60.3252 22.0063 61.7621 22.0063C63.1991 22.0063 63.7905 21.2999 63.7905 19.7973V16.8824H64.7098V19.5511C64.7098 21.9979 63.6425 22.7617 61.7621 22.7617C59.8817 22.7617 58.8063 21.9979 58.8063 19.5511V16.8824H59.7261V19.7973Z" fill="#C2BFB9"/>
<path d="M88.2206 16.7512C90.0927 16.7513 91.3324 17.5068 91.349 18.6973H90.4045C90.4127 17.9584 89.5177 17.5146 88.2696 17.5146C86.9233 17.5146 86.2004 17.835 86.2004 18.4097C86.2004 19.8713 91.4883 18.3443 91.4883 20.98C91.4881 22.154 90.1824 22.7617 88.2943 22.7617C86.3566 22.7616 85.125 21.9733 85.1494 20.8155H86.0853C86.0771 21.5463 86.9724 21.9978 88.2287 21.9978C89.7391 21.9896 90.5603 21.6778 90.5604 21.0209C90.5604 19.3295 85.2561 20.9633 85.2558 18.5167C85.2558 17.359 86.4717 16.7512 88.2206 16.7512Z" fill="#C2BFB9"/>
<path d="M25.8672 22.3676L28.6832 16.8824H29.8165L26.7865 22.63H24.9393L21.9093 16.8824H23.0426L25.8672 22.3676Z" fill="#C2BFB9"/>
<path d="M37.4926 17.6463H33.174V19.3624H37.4189V20.0602H33.174V21.8666H37.4926V22.63H32.2541V16.8824H37.4926V17.6463Z" fill="#C2BFB9"/>
<path d="M46.0695 21.5134V16.8824H46.9889V22.63H46.0448L41.4633 18.0076V22.63H40.5435V16.8824H41.4794L46.0695 21.5134Z" fill="#C2BFB9"/>
<path d="M56.2693 17.6463H53.3463V22.63H52.427V17.6463H49.5121V16.8824H56.2693V17.6463Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.3161 16.8824C73.5067 16.8824 74.1638 17.5231 74.1638 18.4017C74.1637 19.3212 73.4244 19.8468 72.3161 19.8468H71.7081C73.0301 19.937 73.5643 20.758 74.5821 22.63H73.5805C72.5131 20.7169 72.2256 20.1258 71.1338 20.1258H68.7694L68.777 22.63H67.8577V16.8824H72.3161ZM68.7694 19.3376H72.0285C72.8987 19.3376 73.2439 19.0175 73.244 18.5495C73.244 17.9255 72.8827 17.6544 72.0285 17.6544H68.7608L68.7694 19.3376Z" fill="#C2BFB9"/>
<path d="M82.4683 17.6463H78.1492V19.3624H82.3942V20.0602H78.1492V21.8666H82.4683V22.63H77.2299V16.8824H82.4683V17.6463Z" fill="#C2BFB9"/>
<path d="M6.82474 0C10.6948 4.35571e-05 12.5148 1.24639 12.5148 1.24639V3.24338C12.5148 3.24338 10.6948 1.65668 6.9683 1.65663C4.07267 1.65663 2.17477 2.33495 2.17477 3.61749C2.17519 7.145 13.5522 3.35015 13.5525 9.15639C13.5525 11.688 10.9865 13.041 6.95119 13.041C2.69336 13.041 0.0167388 11.2525 0 11.2413V9.33892C0 9.33892 3.01305 11.2367 6.75677 11.2385C9.99295 11.2385 11.5455 10.7091 11.5455 9.2486C11.5453 5.47073 0.14838 9.46271 0.148312 3.86753C0.148312 1.33587 3.03945 0 6.82474 0Z" fill="#C2BFB9"/>
<path d="M113.4 1.95088H103.178V5.3516H111.851V7.061H103.178V11.1101H113.4V12.7677H101.186V0.293297H113.4V1.95088Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.5959 0.293297C44.1736 0.293325 45.7748 1.89222 45.7748 3.82142C45.7721 6.16157 43.9928 7.33195 41.5931 7.33195H35.2204V12.7667H33.2476V0.293297H41.5959ZM35.2175 5.62256H41.1476C42.9965 5.62256 43.7788 5.08577 43.7788 3.80954C43.7796 2.53432 42.9973 1.96799 41.1476 1.96799H35.2175V5.62256Z" fill="#C2BFB9"/>
<path d="M65.2223 5.34162H74.1286V0.284741H76.1208V12.7577H74.1286V6.99778H65.2223V12.7577H63.2295V0.284741H65.2223V5.34162Z" fill="#C2BFB9"/>
<path d="M81.6388 12.7577H79.6466V0.284741H81.6388V12.7577Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M94.0372 0.270955C96.6238 0.270955 98.0511 1.66293 98.0511 3.57138C98.051 5.56913 96.3736 6.71066 94.0372 6.71066L92.7171 6.72872V6.7639C95.589 6.95987 96.7305 8.69061 98.9424 12.7577H96.7125C94.3941 8.60131 93.5165 7.31681 91.1617 7.31674H87.1444L87.1611 12.7577H85.1627V0.270955H94.0372ZM87.1444 5.6045H93.4135C95.304 5.60445 96.0536 4.90875 96.0536 3.89177C96.0536 2.53619 95.2684 1.94711 93.4135 1.94707H87.1264L87.1444 5.6045Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M56.5869 0.284741C59.1648 0.284762 60.7658 1.88318 60.7658 3.81239C60.7633 6.15432 58.9847 7.32241 56.584 7.32245H50.2113V12.7568H48.2386V0.284741H56.5869ZM50.2113 5.61495H56.1415C57.9902 5.61495 58.7726 5.07773 58.7726 3.80241C58.7726 2.52713 57.9902 1.96087 56.1415 1.96086H50.2113V5.61495Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.3638 12.7506H29.1187L27.2182 9.18919H18.8124L16.9076 12.7506H14.6458L17.5712 7.56773V7.55632H17.5783L21.6874 0.284741H24.3399L31.3638 12.7506ZM19.6847 7.55632H26.3468L23.0203 1.31675L19.6847 7.55632Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,19 @@
<svg width="122" height="23" viewBox="0 0 122 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.0498 2.08858C19.7947 2.09487 16.472 2.12329 13.2206 2.08944L19.5173 14.6881C20.6762 17.0087 21.9537 19.455 23.0556 21.7839C22.537 22.0518 21.7501 22.4192 21.2755 22.7035H21.1798C19.1216 18.6948 17.1673 14.5864 15.1189 10.569C14.2784 8.92062 13.4613 7.16352 12.5891 5.54081L12.586 22.7035H10.477L10.4773 11.7188C10.4775 9.68092 10.5065 7.56743 10.4584 5.53586L1.879 22.7035H1.78627C1.37028 22.4447 0.456336 22.0205 0 21.7985V21.6561C0.298159 21.359 1.90123 17.9578 2.20881 17.3451L9.82774 2.08739C6.60428 2.14206 3.23535 2.09182 0 2.0922V0H23.0537L23.0498 2.08858Z" fill="#C2BFB9"/>
<path d="M94.2751 16.0714C94.8228 16.0509 95.6125 16.4099 95.8079 16.974C95.859 17.1221 95.8464 17.3599 95.7353 17.4778C94.9441 17.4744 95.3563 17.2157 94.7999 16.742C94.4876 16.4761 93.7085 16.688 93.5059 16.9783C93.4182 17.1046 93.386 17.261 93.4152 17.4116C93.5741 18.2024 95.8371 17.9368 95.9443 19.0356C96.0388 20.0007 95.4829 20.4916 94.5864 20.6572C93.442 20.6721 92.873 20.2825 92.6806 19.144C93.7787 19.1232 93.1278 19.501 93.8006 19.9861C94.2084 20.2804 95.5531 19.9466 95.3158 19.1757C95.1965 18.7889 94.6712 18.6885 94.3185 18.5928C93.1658 18.3725 92.2635 17.7413 93.1024 16.5256C93.254 16.3062 93.6447 16.1662 93.9224 16.1013C94.0398 16.0869 94.1572 16.0769 94.2751 16.0714Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M87.03 16.125C88.0473 16.1227 89.6049 15.8493 90.0745 16.9793C90.2104 17.3105 90.2109 17.6818 90.076 18.0132C89.8962 18.4453 89.6243 18.6292 89.2175 18.7984L90.2889 20.6066C90.0818 20.6103 89.6828 20.6442 89.5274 20.5412L88.591 18.8506L87.6132 18.8521L87.6107 20.6034L87.03 20.6085V16.125ZM89.3618 16.9662C88.8692 16.6146 88.1866 16.7062 87.6137 16.7043V18.3109C88.2129 18.2761 88.895 18.3981 89.3535 18.0764C89.6078 17.7357 89.7646 17.2535 89.3618 16.9662Z" fill="#C2BFB9"/>
<path d="M74.945 16.1241C75.3893 16.1093 75.4195 16.0599 75.6924 16.4364C76.4349 17.4613 77.1779 18.4863 77.9208 19.511C77.8838 18.4414 77.912 17.2058 77.9116 16.1238C78.0699 16.1213 78.3398 16.0998 78.4733 16.1611C78.5576 17.3417 78.4913 19.3624 78.4899 20.6053C78.3773 20.6083 78.2209 20.6266 78.1123 20.6022C77.8794 20.5501 75.7956 17.5604 75.516 17.1475C75.5374 17.5867 75.5457 20.2559 75.4946 20.5576C75.3523 20.6301 75.1131 20.6075 74.9455 20.6044L74.945 16.1241Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M63.5806 16.1239C64.5886 16.1281 66.1603 15.8447 66.6154 16.9954C66.7479 17.3335 66.7405 17.7104 66.5949 18.0429C66.4161 18.4445 66.1481 18.6404 65.752 18.7915L66.8117 20.606C66.553 20.6109 66.344 20.618 66.0848 20.59C65.7954 20.0237 65.4349 19.428 65.1304 18.8499L64.1569 18.853L64.1564 20.6057L63.5801 20.6027L63.5806 16.1239ZM65.9084 18.0546C66.8258 16.6747 64.9638 16.6908 64.1555 16.7031L64.1545 18.3049C64.7752 18.285 65.4324 18.402 65.9084 18.0546Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M51.7406 16.1269C52.5533 16.1266 53.6914 15.9686 54.3052 16.4693C54.5941 16.7039 54.7754 17.0454 54.8085 17.4159C54.9055 18.4316 54.1839 18.9529 53.2568 19.029L52.3268 19.0155C52.3268 19.364 52.3555 20.2741 52.3 20.5673C52.1752 20.6234 51.8907 20.6075 51.7402 20.6076L51.7406 16.1269ZM53.953 16.9717C53.4839 16.6501 52.8641 16.7142 52.3263 16.7152L52.3268 18.435C52.8451 18.4403 53.5789 18.5221 53.973 18.1933C54.2682 17.8073 54.4061 17.2823 53.953 16.9717Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M58.5497 16.1283C58.6715 16.1251 59.0277 16.1108 59.0525 16.1591C59.5056 17.049 60.6432 19.8474 60.9526 20.6056L60.3222 20.6069C60.1088 20.0731 59.9032 19.5364 59.7054 18.9968L59.2557 18.9939L57.9637 18.9952L57.3439 20.6039L56.7155 20.6089L58.5497 16.1283ZM58.807 16.8857C58.6043 17.3937 58.4026 17.9099 58.1931 18.4144L58.9634 18.4048L59.4506 18.4026C59.3634 18.1838 58.9078 16.9853 58.807 16.8857Z" fill="#C2BFB9"/>
<path d="M72.2907 16.1257L72.2888 16.7052L70.9475 16.7019V20.6017L70.3639 20.6083L70.3634 16.6974L69.0397 16.7083L69.0358 16.1253L72.2907 16.1257Z" fill="#C2BFB9"/>
<path d="M81.6099 16.1299C82.3422 16.0911 83.4564 16.1258 84.2169 16.1259L84.2179 16.7058L82.1906 16.7014V18.0281C82.558 18.0232 83.8613 17.985 84.155 18.0495C84.2237 18.1934 84.2023 18.4296 84.1989 18.5961L82.1941 18.5926C82.1756 19.0168 82.1921 19.5898 82.1911 20.0252C82.8376 19.9896 83.5504 20.0059 84.2033 20.0093L84.2038 20.6012L81.6114 20.6039L81.6099 16.1299Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M98.048 2.01512C101.256 1.6316 104.166 3.92364 104.545 7.13201C104.924 10.3404 102.628 13.2476 99.4195 13.6222C96.2171 13.9961 93.3168 11.7055 92.9382 8.50345C92.5602 5.30137 94.8467 2.39791 98.048 2.01512ZM98.6867 3.10704C96.0788 3.1376 93.9916 5.28081 94.0306 7.88864C94.0691 10.4965 96.2186 12.5769 98.8266 12.5301C101.423 12.4836 103.492 10.3453 103.453 7.74895C103.415 5.15252 101.284 3.07662 98.6867 3.10704Z" fill="#C2BFB9"/>
<path d="M56.334 2.23936L56.3354 7.25333C56.3374 8.25301 56.4163 9.6123 56.1474 10.5587C55.2582 13.6866 51.1053 14.6067 48.8433 12.3446C48.2999 11.7962 47.9137 11.1118 47.7254 10.3629C47.5005 9.45968 47.5651 7.80546 47.5654 6.80158L47.5691 2.24145L49.4849 2.24193L49.4795 8.18762C49.4776 9.16714 49.4898 10.3964 50.2425 11.0251C52.1474 12.6169 54.6429 11.2013 54.4202 8.74805C54.3774 8.27888 54.4144 7.46752 54.4139 7.02125L54.4149 2.23912L56.334 2.23936Z" fill="#C2BFB9"/>
<path d="M64.2963 2.24198C64.4346 2.60897 64.5974 2.98453 64.7523 3.34541C66.1905 6.68834 67.541 10.0817 68.9997 13.4146C68.6109 13.4031 68.1783 13.4146 67.7866 13.4166C67.3427 12.3577 66.9111 11.1524 66.4823 10.0707C65.6556 8.00572 64.8181 5.94491 63.9704 3.88842C63.9002 3.7518 63.8032 3.46465 63.7462 3.31049C63.4787 4.19435 62.7523 5.83983 62.3714 6.78616C61.4749 8.9828 60.6101 11.1921 59.7765 13.4133C59.3853 13.4134 58.9824 13.4254 58.5907 13.432L63.1572 2.24117L64.2963 2.24198Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M71.6301 2.24031C72.8651 2.25193 74.1007 2.22964 75.3357 2.23708C75.7045 2.23929 76.0758 2.23642 76.4417 2.28679C76.8475 2.33928 77.2431 2.45454 77.6134 2.62845C79.856 3.69654 80.1371 6.73413 78.1517 8.17892C77.7108 8.49963 77.3459 8.63518 76.8344 8.81442C77.685 10.1576 78.7934 12.0261 79.568 13.4145C79.1047 13.4104 78.6414 13.4155 78.1786 13.4297L75.6593 8.91114C74.6878 8.9184 73.7168 8.91904 72.7453 8.91319L72.7472 13.4206C72.3969 13.4055 71.9843 13.4234 71.6291 13.4305L71.6301 2.24031ZM77.6153 3.93647C76.8548 3.20318 75.7304 3.2863 74.7623 3.29212C74.0948 3.29205 73.4059 3.31248 72.7453 3.29174L72.7482 7.88555C73.3845 7.8645 74.0242 7.87144 74.6605 7.8787C75.6919 7.89049 76.8388 7.97424 77.6534 7.19857C78.54 6.24858 78.5785 4.86534 77.6153 3.93647Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M85.6478 2.23698C86.2003 2.23632 86.9369 2.21451 87.465 2.2867C87.8694 2.33854 88.2636 2.45389 88.6324 2.6285C90.8706 3.69892 91.1668 6.75605 89.1347 8.20509C88.7079 8.50973 88.3523 8.64067 87.8578 8.8129L90.6016 13.422C90.149 13.4092 89.6477 13.4173 89.1917 13.4154L88.9598 13.012C88.342 11.7801 87.3681 10.1562 86.6729 8.91086C85.7024 8.92129 84.7319 8.92301 83.7609 8.91614L83.7584 13.413C83.3945 13.411 83.0145 13.4228 82.6491 13.4286L82.6476 2.24102L85.6478 2.23698ZM89.3369 5.58386C89.2385 3.46873 87.4743 3.19518 85.7711 3.29355C85.1918 3.32701 84.3348 3.30925 83.7604 3.29512L83.7619 7.88569C84.4386 7.85368 85.165 7.87691 85.8442 7.87818C86.9671 7.88032 87.6848 7.96626 88.6105 7.253C89.0821 6.7754 89.3676 6.24411 89.3369 5.58386Z" fill="#C2BFB9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.6959 2.2405C38.0088 2.23962 41.1262 2.08233 42.1825 2.44885C43.071 2.75713 43.818 3.37736 44.2302 4.22851C44.6343 5.08458 44.6874 6.06475 44.3782 6.95954C44.0294 7.95825 43.3762 8.54797 42.4479 8.99488C43.2341 10.4347 44.1699 11.9925 44.9966 13.4126C44.2553 13.4093 43.5139 13.4123 42.7725 13.4216C42.6669 13.375 40.7449 9.78423 40.4665 9.2744C39.8363 9.29482 39.2057 9.29505 38.5756 9.27502L38.5742 13.4089C37.9485 13.4032 37.3228 13.4096 36.6973 13.4282L36.6959 2.2405ZM42.6604 5.66365C42.5015 3.61229 40.0352 4.19297 38.6077 4.11636C38.5295 4.47411 38.5793 6.90255 38.5753 7.43461C39.7802 7.38691 41.3058 7.66269 42.1911 6.95226C42.5306 6.55729 42.7023 6.20474 42.6604 5.66365Z" fill="#C2BFB9"/>
<path d="M107.485 2.24083L110.416 11.7167C111.247 9.42164 112.937 4.86395 113.836 2.33171C113.871 2.31265 113.901 2.29626 113.926 2.28261C113.993 2.27922 114.106 2.26986 114.165 2.29626C114.177 2.30151 114.235 2.33516 114.238 2.34103C115.238 4.99759 116.839 9.22764 117.773 11.7282C118.689 8.61534 119.661 5.32554 120.647 2.24083L121.8 2.2406V2.32586C121.717 2.4931 121.511 3.18662 121.446 3.39579L118.316 13.4202C118.001 13.409 117.609 13.4225 117.287 13.4247C117 12.5099 116.52 11.3168 116.181 10.4001L114.048 4.62407C113.126 7.44773 111.905 10.5943 110.885 13.4162L109.871 13.4258C109.707 12.9473 109.552 12.452 109.402 11.9688C108.394 8.72176 107.293 5.49167 106.305 2.23965L107.485 2.24083Z" fill="#C2BFB9"/>
<path d="M34.0608 2.2406L34.0592 4.08982L30.9074 4.08301L30.9068 13.4006L29.0442 13.4035L29.0456 4.07811L25.884 4.08896L25.8833 2.23907L34.0608 2.2406Z" fill="#C2BFB9"/>
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="37" height="48" viewBox="0 0 37 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M37 48H23.3135C17.0346 47.9999 13.3532 43.0774 15.0801 37.0127L16.7461 31.1621C17.458 28.6708 15.5955 26.1868 13.0205 26.1865C12.1061 26.1865 11.4659 26.9844 11.2399 27.8705L9.74121 33.748C8.5757 38.3031 4.21923 42 0 42V6H9.18164C13.4008 6.00006 15.8743 9.69691 14.7139 14.252L14.0983 16.6643C13.497 19.0203 13.9592 22.0946 16.3906 22.0947C18.125 22.0946 19.6625 20.9366 20.1396 19.2529L22.4951 10.9873H22.5107C24.2377 4.92254 30.7286 1.98986e-06 37 0V48Z" fill="#F2FF59"/>
</svg>

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div
class="bg-transparency-white-t4 rounded-5xl mx-auto mt-20 flex flex-col gap-12 p-2 lg:flex-row lg:items-stretch lg:gap-8"
>
<!-- Team photo placeholder -->
<div
class="aspect-video w-full overflow-hidden rounded-4xl bg-black/30 lg:w-1/2"
>
<div
class="flex h-full items-center justify-center rounded-4xl border border-white/10"
>
<span class="text-primary-warm-gray text-sm">Team photo</span>
</div>
</div>
<!-- Join text -->
<div class="flex flex-col justify-between p-6 lg:w-1/2">
<div>
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.careers.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-4 text-3xl font-light lg:text-5xl"
>
{{ t('about.careers.heading', locale) }}
</h2>
</div>
<div>
<BrandButton
:href="locale === 'zh-CN' ? '/zh-CN/careers' : '/careers'"
:label="t('about.careers.cta', locale)"
variant="solid"
class-name="mt-8 self-start rounded-2xl"
/>
<p class="text-primary-warm-gray mt-6 text-sm">
{{ t('about.careers.noRole', locale) }}
<a
href="mailto:hiring@comfy.org"
class="text-primary-comfy-yellow hover:underline"
>
hiring@comfy.org
</a>
</p>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section
class="flex flex-col items-center pt-32 pb-16 lg:flex-row lg:items-center lg:pt-40 lg:pb-24"
>
<!-- Graphic -->
<div class="order-2 mt-12 w-full lg:order-1 lg:mt-0 lg:w-1/2">
<img
src="/images/about/c.webp"
alt="Comfy 3D logo"
class="mx-auto w-full max-w-lg lg:max-w-none"
/>
</div>
<!-- Text -->
<div
class="order-1 flex flex-col items-center text-center lg:order-2 lg:w-1/2 lg:items-start lg:pl-12 lg:text-left"
>
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.hero.label', locale) }}
</span>
<h1
class="text-primary-comfy-canvas mt-4 text-4xl/tight font-light lg:text-6xl"
>
{{ t('about.hero.heading', locale) }}
</h1>
<p class="text-primary-warm-gray mt-6 max-w-sm text-base">
{{ t('about.hero.body', locale) }}
</p>
<BrandButton
:href="locale === 'zh-CN' ? '/zh-CN/careers' : '/careers'"
:label="t('about.hero.cta', locale)"
variant="outline"
class-name="mt-8 rounded-full"
/>
</div>
</section>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import NodeBadge from '../common/NodeBadge.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type TranslationKey = Parameters<typeof t>[0]
const values: {
segments: Array<{ text: string }>
bodyKey: TranslationKey
}[] = [
{
segments: [{ text: 'SHIP' }, { text: 'IT' }],
bodyKey: 'about.values.card1.body'
},
{
segments: [{ text: 'SHARE' }, { text: 'IT' }],
bodyKey: 'about.values.card2.body'
},
{
segments: [{ text: 'OPEN-SOURCE' }, { text: 'IT' }],
bodyKey: 'about.values.card3.body'
},
{
segments: [{ text: 'RESPECT' }, { text: 'THE CRAFT' }],
bodyKey: 'about.values.card4.body'
}
]
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto max-w-5xl text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.values.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
>
{{ t('about.values.headingBefore', locale)
}}<span class="text-primary-comfy-yellow font-semibold">{{
t('about.values.headingHighlight', locale)
}}</span
>{{ t('about.values.headingAfter', locale) }}
</h2>
</div>
<div class="mx-auto mt-16 max-w-5xl">
<!-- Desktop layout -->
<div class="hidden lg:block">
<!-- Row 1: SHIP IT + SHARE IT -->
<div class="grid grid-cols-2 gap-6">
<div
v-for="value in values.slice(0, 2)"
:key="value.segments[0].text"
class="rounded-3xl border border-white/10 bg-white/5 p-8"
>
<NodeBadge :segments="value.segments" segment-class="px-3" />
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(value.bodyKey, locale) }}
</p>
</div>
</div>
<!-- Connector line -->
<div class="flex justify-end pr-12">
<div class="bg-primary-comfy-yellow h-10 w-0.5" />
</div>
<!-- Row 2: OPEN-SOURCE IT -->
<div class="rounded-3xl border border-white/10 bg-white/5 p-8">
<NodeBadge :segments="values[2].segments" segment-class="px-3" />
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(values[2].bodyKey, locale) }}
</p>
</div>
<!-- Connector line -->
<div class="flex justify-center">
<div class="bg-primary-comfy-yellow h-10 w-0.5" />
</div>
<!-- Row 3: RESPECT THE CRAFT -->
<div class="rounded-3xl border border-white/10 bg-white/5 p-8">
<NodeBadge :segments="values[3].segments" segment-class="px-3" />
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(values[3].bodyKey, locale) }}
</p>
</div>
</div>
<!-- Mobile: stacked cards -->
<div class="flex flex-col gap-6 lg:hidden">
<div
v-for="value in values"
:key="value.segments[0].text"
class="rounded-3xl border border-white/10 bg-white/5 p-8"
>
<NodeBadge :segments="value.segments" segment-class="px-3" />
<p class="text-primary-warm-white mt-4 text-sm/relaxed">
{{ t(value.bodyKey, locale) }}
</p>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const investors = [
{ name: 'CRAFT', icon: '/icons/investors/craft.svg' },
{ name: 'PACE CAPITAL', icon: '/icons/investors/pace-capital.svg' },
{ name: 'chemistry_', icon: '/icons/investors/chemistry.svg' },
{ name: 'TRUARROW PARTNERS', icon: '/icons/investors/truarrow-partners.svg' },
{ name: 'SAPPHIRE VENTURES', icon: '/icons/investors/sapphire-ventures.svg' },
{ name: 'ESSENCE', icon: '/icons/investors/essence.svg' }
]
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<div class="mx-auto text-center">
<span
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
>
{{ t('about.story.label', locale) }}
</span>
<h2
class="text-primary-comfy-canvas mt-6 text-3xl font-light lg:text-5xl"
>
{{ t('about.story.headingBefore', locale)
}}<span class="text-primary-comfy-yellow font-semibold">{{
t('about.story.headingHighlight', locale)
}}</span
>{{ t('about.story.headingAfter', locale) }}
</h2>
<p class="text-primary-warm-white mt-8 text-base/relaxed lg:text-lg">
{{ t('about.story.body', locale) }}
</p>
</div>
<!-- Investor card -->
<div
class="mx-auto mt-16 max-w-5xl rounded-4xl border border-white/10 bg-black/30 p-8 lg:p-12"
>
<div class="inline-flex items-center">
<!-- OUR badge (shorter) -->
<div class="relative z-10 flex h-9 items-center">
<img src="/icons/node-left.svg" alt="" class="h-full w-auto" />
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-2 text-sm font-bold tracking-wider"
>
OUR
</span>
</div>
<!-- Union connector (overlaps both badges to eliminate seams) -->
<img
src="/icons/node-union-2size-reverse.svg"
alt=""
class="relative z-20 -mx-px h-12 w-auto"
/>
<!-- INVESTORS badge (taller) -->
<div class="relative z-10 flex h-12 items-center">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink flex h-full items-center px-3 text-lg font-bold tracking-wider"
>
INVESTORS
</span>
<img src="/icons/node-right.svg" alt="" class="h-full w-auto" />
</div>
</div>
<p
class="text-primary-warm-white mt-6 max-w-3xl text-sm/relaxed lg:text-base"
>
{{ t('about.story.investorsBody', locale) }}
</p>
<div class="mt-10 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:gap-6">
<div
v-for="investor in investors"
:key="investor.name"
class="flex h-16 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4"
>
<img
:src="investor.icon"
:alt="investor.name"
class="max-h-8 w-auto"
/>
</div>
</div>
</div>
<!-- Quote card -->
<div
class="bg-primary-comfy-yellow mx-auto mt-12 max-w-5xl rounded-4xl p-10 lg:p-16"
>
<p class="text-primary-comfy-ink text-xl/relaxed font-medium lg:text-3xl">
{{ t('about.quote.text', locale) }}
</p>
<p
class="text-primary-comfy-ink/70 mt-8 text-sm font-semibold lg:text-base"
>
{{ t('about.quote.attribution', locale) }}
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,230 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { onMounted, ref } from 'vue'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type TranslationKey = Parameters<typeof t>[0]
const reasons: TranslationKey[] = [
'about.careers.reason1',
'about.careers.reason2',
'about.careers.reason3',
'about.careers.reason4'
]
const containerRef = ref<HTMLElement>()
const ifYouDotRef = ref<HTMLElement>()
const reasonDots = ref<HTMLElement[]>([])
const reasonOutputDotRef = ref<HTMLElement>()
const comfyDotRef = ref<HTMLElement>()
const wirePaths = ref<string[]>([])
const comfyWirePath = ref('')
function center(el: HTMLElement, container: DOMRect) {
const r = el.getBoundingClientRect()
return {
x: r.left + r.width / 2 - container.left,
y: r.top + r.height / 2 - container.top
}
}
function computeWires() {
const c = containerRef.value
const dot = ifYouDotRef.value
if (!c || !dot) return
const cRect = c.getBoundingClientRect()
const s = center(dot, cRect)
wirePaths.value = reasonDots.value.map((el) => {
const e = center(el, cRect)
const midX = s.x + (e.x - s.x) * 0.45
return `M${s.x},${s.y} C${midX},${s.y} ${midX},${e.y} ${e.x},${e.y}`
})
const outputDot = reasonOutputDotRef.value
const comfyDot = comfyDotRef.value
if (outputDot && comfyDot) {
const s2 = center(outputDot, cRect)
const e2 = center(comfyDot, cRect)
const midX = s2.x + (e2.x - s2.x) * 0.5
comfyWirePath.value = `M${s2.x},${s2.y} C${midX},${s2.y} ${midX},${e2.y} ${e2.x},${e2.y}`
}
}
onMounted(() => {
requestAnimationFrame(computeWires)
})
</script>
<template>
<section class="px-6 py-24 lg:px-20 lg:py-32">
<!-- Desktop layout -->
<div ref="containerRef" class="relative mx-auto hidden max-w-6xl lg:block">
<!-- SVG wires overlay -->
<svg
class="pointer-events-none absolute inset-0 z-10 size-full overflow-visible"
>
<path
v-for="(d, i) in wirePaths"
:key="'wire-' + i"
:d="d"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
<path
v-if="comfyWirePath"
:d="comfyWirePath"
stroke="#F2FF59"
stroke-width="1.5"
fill="none"
/>
</svg>
<div class="flex items-start gap-8">
<!-- Left column: Why + IF YOU -->
<div class="flex w-64 shrink-0 flex-col gap-3">
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
<p class="text-primary-warm-white text-2xl font-light">
{{ t('about.careers.whyTitleBefore', locale) }}
<br />
<span
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>{{ t('about.careers.whyTitleAfter', locale) }}
</p>
</div>
<div
class="flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
>
<span
class="text-primary-warm-white text-xs font-bold tracking-wider"
>
{{ t('about.careers.whyLabel', locale) }}
</span>
<span
ref="ifYouDotRef"
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
/>
</div>
</div>
<!-- Center column: Reasons card -->
<div class="relative flex-1">
<span
ref="reasonOutputDotRef"
class="bg-primary-comfy-yellow absolute top-1/3 right-0 z-20 size-3 translate-x-1/2 -translate-y-1/2 rounded-full"
/>
<div class="rounded-3xl border border-white/10 bg-white/5 px-10 py-8">
<div class="flex flex-col gap-6">
<div
v-for="reason in reasons"
:key="reason"
class="flex items-start gap-3"
>
<span
:ref="
(el) => {
if (el) reasonDots.push(el as HTMLElement)
}
"
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
/>
<p class="text-primary-warm-white text-base">
{{ t(reason, locale) }}
</p>
</div>
</div>
</div>
</div>
<!-- Right column: Comfy logo card -->
<div
class="w-64 shrink-0 rounded-3xl border border-white/10 bg-white/5 p-6"
>
<span
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
>
<span
ref="comfyDotRef"
class="bg-primary-comfy-ink size-1.5 rounded-full"
/>
<span
class="bg-primary-comfy-ink h-4 w-20"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>
</span>
<img
src="/images/about/c.webp"
alt="Comfy logo"
class="mt-6 w-full"
/>
</div>
</div>
</div>
<!-- Mobile layout -->
<div class="mx-auto max-w-6xl lg:hidden">
<div class="rounded-2xl border border-white/10 bg-white/5 px-6 py-5">
<p class="text-primary-warm-white text-2xl font-light">
{{ t('about.careers.whyTitleBefore', locale) }}
<br />
<span
class="bg-primary-comfy-yellow mb-0.5 inline-block h-5 w-16 align-middle"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>{{ t('about.careers.whyTitleAfter', locale) }}
</p>
</div>
<div
class="mt-3 flex items-center justify-end rounded-xl bg-white/5 px-5 py-3"
>
<span class="text-primary-warm-white text-xs font-bold tracking-wider">
{{ t('about.careers.whyLabel', locale) }}
</span>
<span
class="bg-primary-comfy-yellow ml-3 size-3 shrink-0 rounded-full"
/>
</div>
<div class="mt-12 rounded-3xl border border-white/10 bg-white/5 p-8">
<div class="flex flex-col gap-6">
<div
v-for="reason in reasons"
:key="reason"
class="flex items-start justify-between gap-4"
>
<p class="text-primary-warm-white text-base">
{{ t(reason, locale) }}
</p>
<span
class="bg-primary-comfy-yellow mt-1.5 size-2.5 shrink-0 rounded-full"
/>
</div>
</div>
</div>
<div class="mt-12 rounded-3xl border border-white/10 bg-white/5 p-6">
<span
class="bg-primary-comfy-yellow inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5"
>
<span class="bg-primary-comfy-ink size-1.5 rounded-full" />
<span
class="bg-primary-comfy-ink h-4 w-20"
style="mask: url(/icons/logo.svg) no-repeat center / contain"
/>
</span>
<img
src="/images/about/c.webp"
alt="Comfy logo"
class="mt-6 w-full max-w-xs"
/>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
</script>
<template>
<section class="px-6 py-12 lg:px-20 lg:py-24">
<div
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10 bg-black"
>
<!-- Video placeholder area -->
<div class="size-full" />
<!-- Bottom control bar -->
<div
class="absolute inset-x-0 bottom-0 flex items-center gap-3 p-4 lg:px-6 lg:py-5"
>
<!-- Play button -->
<button
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-full lg:size-10"
:aria-label="locale === 'zh-CN' ? '播放' : 'Play'"
>
<svg
class="ml-0.5 size-3 lg:size-4"
viewBox="0 0 24 24"
fill="#211927"
>
<path d="M8 5v14l11-7z" />
</svg>
</button>
<!-- Progress bar -->
<div class="flex flex-1 items-center">
<div class="h-1 w-full rounded-full bg-white/20">
<div class="bg-primary-comfy-yellow h-full w-1/6 rounded-full" />
</div>
</div>
<!-- Timestamp -->
<span class="shrink-0 text-xs text-white/80 lg:text-sm">00:13</span>
<!-- Fullscreen button -->
<button
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-lg lg:size-10"
:aria-label="locale === 'zh-CN' ? '全屏' : 'Fullscreen'"
>
<svg
class="size-3.5 lg:size-4"
viewBox="0 0 24 24"
fill="none"
stroke="#211927"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
</svg>
</button>
<!-- Mute button -->
<button
class="bg-primary-comfy-yellow flex size-8 shrink-0 items-center justify-center rounded-lg lg:size-10"
:aria-label="locale === 'zh-CN' ? '静音' : 'Mute'"
>
<svg
class="size-3.5 lg:size-4"
viewBox="0 0 24 24"
fill="#211927"
stroke="#211927"
stroke-width="1.5"
>
<path
d="M11 5L6 9H2v6h4l5 4V5z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line x1="23" y1="9" x2="17" y2="15" stroke-width="2.5" />
<line x1="17" y1="9" x2="23" y2="15" stroke-width="2.5" />
</svg>
</button>
</div>
</div>
</section>
</template>

View File

@@ -222,6 +222,340 @@ const translations = {
'buildWhat.row2a': { en: "DOESN'T EXIST", 'zh-CN': '尚不存在的' },
'buildWhat.row2b': { en: 'YET', 'zh-CN': '事物' },
// PriceSection
'pricing.title': { en: 'Pricing', 'zh-CN': '价格' },
'pricing.subtitle': {
en: 'Access cloud-powered ComfyUI workflows with straightforward, usage-based pricing.',
'zh-CN': '通过简单透明、按使用量计费的方式,访问云端 ComfyUI 工作流。'
},
'pricing.badge.popular': { en: 'MOST POPULAR', 'zh-CN': '最受欢迎' },
'pricing.plan.period': { en: '/month', 'zh-CN': '/月' },
'pricing.plan.free.label': { en: 'FREE', 'zh-CN': '免费版' },
'pricing.plan.free.summary': {
en: "Explore Comfy's possibilities",
'zh-CN': '探索 Comfy 的可能性'
},
'pricing.plan.free.price': { en: '$0', 'zh-CN': '$0' },
'pricing.plan.free.credits': {
en: 'Includes 400 monthly credits',
'zh-CN': '每月包含 400 积分'
},
'pricing.plan.free.estimate': {
en: '~35 5s videos using the Wan 2.2 Image-to-Video template*',
'zh-CN': '约可生成 35 个 5 秒视频(使用 Wan 2.2 图生视频模板)*'
},
'pricing.plan.free.cta': { en: 'START FREE', 'zh-CN': '免费开始' },
'pricing.plan.free.feature1': {
en: '10-minute max runtime per workflow',
'zh-CN': '单个工作流最长运行 10 分钟'
},
'pricing.plan.free.feature2': {
en: 'Runs on RTX 6000 Pro GPUs (96GB VRAM)',
'zh-CN': '运行于 RTX 6000 Pro GPU96GB 显存)'
},
'pricing.plan.standard.label': { en: 'STANDARD', 'zh-CN': '标准版' },
'pricing.plan.standard.summary': {
en: 'For individuals creating workflows',
'zh-CN': '面向个人工作流创作者'
},
'pricing.plan.standard.price': { en: '$20', 'zh-CN': '$20' },
'pricing.plan.standard.credits': {
en: 'Includes 4,200 monthly credits with top-ups available',
'zh-CN': '每月包含 4,200 积分,并支持充值'
},
'pricing.plan.standard.estimate': {
en: '~380 5s videos using the Wan 2.2 Image-to-Video template*',
'zh-CN': '约可生成 380 个 5 秒视频(使用 Wan 2.2 图生视频模板)*'
},
'pricing.plan.standard.cta': {
en: 'SUBSCRIBE TO STANDARD',
'zh-CN': '订阅标准版'
},
'pricing.plan.standard.featureIntro': {
en: 'Everything in Free, plus:',
'zh-CN': '包含免费版全部能力,另加:'
},
'pricing.plan.standard.feature1': {
en: '30-minute max runtime per workflow',
'zh-CN': '单个工作流最长运行 30 分钟'
},
'pricing.plan.standard.feature2': {
en: 'Add more credits anytime',
'zh-CN': '可随时增加积分'
},
'pricing.plan.creator.label': { en: 'CREATOR', 'zh-CN': '创作者版' },
'pricing.plan.creator.summary': {
en: 'Professionals and small teams building fine-tuned, repeatable workflows',
'zh-CN': '面向专业人士与小团队,构建可复用、精细调优的工作流'
},
'pricing.plan.creator.price': { en: '$35', 'zh-CN': '$35' },
'pricing.plan.creator.credits': {
en: 'Includes 7,400 monthly credits with top-ups available',
'zh-CN': '每月包含 7,400 积分,并支持充值'
},
'pricing.plan.creator.estimate': {
en: '~670 5s videos using the Wan 2.2 Image-to-Video template*',
'zh-CN': '约可生成 670 个 5 秒视频(使用 Wan 2.2 图生视频模板)*'
},
'pricing.plan.creator.cta': {
en: 'SUBSCRIBE TO CREATOR',
'zh-CN': '订阅创作者版'
},
'pricing.plan.creator.featureIntro': {
en: 'Everything in Standard, plus:',
'zh-CN': '包含标准版全部能力,另加:'
},
'pricing.plan.creator.feature1': {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
'pricing.plan.creator.feature2': {
en: 'Up to 5 seats per workspace (coming soon!)',
'zh-CN': '每个工作区最多 5 个席位(即将上线)'
},
'pricing.plan.creator.nextUp': {
en: 'Next Up: Team Collaboration Features',
'zh-CN': '下一步:团队协作功能'
},
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
en: 'For growing teams running Comfy in production',
'zh-CN': '面向在生产环境使用 Comfy 的成长型团队'
},
'pricing.plan.pro.price': { en: '$100', 'zh-CN': '$100' },
'pricing.plan.pro.credits': {
en: 'Includes 21,100 monthly credits with top-ups available',
'zh-CN': '每月包含 21,100 积分,并支持充值'
},
'pricing.plan.pro.estimate': {
en: '~1,915 5s videos using the Wan 2.2 Image-to-Video template*',
'zh-CN': '约可生成 1,915 个 5 秒视频(使用 Wan 2.2 图生视频模板)*'
},
'pricing.plan.pro.cta': { en: 'SUBSCRIBE TO PRO', 'zh-CN': '订阅专业版' },
'pricing.plan.pro.featureIntro': {
en: 'Everything in Creator, plus:',
'zh-CN': '包含创作者版全部能力,另加:'
},
'pricing.plan.pro.feature1': {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
'pricing.plan.pro.feature2': {
en: 'Up to 20 seats per workspace (coming soon!)',
'zh-CN': '每个工作区最多 20 个席位(即将上线)'
},
'pricing.plan.pro.nextUp': {
en: 'Next Up: Advanced Team Collaboration Features',
'zh-CN': '下一步:高级团队协作功能'
},
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {
en: 'Looking for Enterprise Solutions?',
'zh-CN': '在寻找企业级解决方案?'
},
'pricing.enterprise.description': {
en: 'For teams running Comfy in production, and at scale.',
'zh-CN': '面向在生产环境和规模化场景中运行 Comfy 的团队。'
},
'pricing.enterprise.cta': { en: 'CONTACT US', 'zh-CN': '联系我们' },
'pricing.enterprise.featureIntro': {
en: 'Everything in Pro, plus:',
'zh-CN': '包含专业版全部能力,另加:'
},
'pricing.enterprise.feature1': {
en: 'Annual commitments with bulk pricing and custom compute packages',
'zh-CN': '支持年度承诺、批量定价与定制算力套餐'
},
'pricing.enterprise.feature2': {
en: 'Onboarding and priority support via Slack',
'zh-CN': '通过 Slack 提供上手辅导与优先支持'
},
'pricing.enterprise.feature3': {
en: 'Advanced execution (concurrent processing, longer-running jobs, etc)',
'zh-CN': '高级执行能力(并发处理、长时任务等)'
},
'pricing.enterprise.feature4': {
en: 'Enterprise security and access controls (permissions, audit logs, SSO, etc)',
'zh-CN': '企业级安全与访问控制权限、审计日志、SSO 等)'
},
'pricing.enterprise.andMore': { en: 'And more...', 'zh-CN': '以及更多…' },
'pricing.footnote': {
en: '*Based on 5s videos created with the Wan 2.2 Image-to-Video template using default settings (81 frames, 18fps, 640x640, 4-step sampler)',
'zh-CN':
'*基于默认设置81 帧、18fps、640x640、4-step sampler使用 Wan 2.2 图生视频模板生成 5 秒视频估算'
},
'pricing.included.heading': {
en: "What's included\nin the Comfy plan",
'zh-CN': 'Comfy 计划\n包含哪些内容'
},
// GalleryHeroSection
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
'gallery.heroTitle.before': {
en: 'Built, tweaked, and dreamed in',
'zh-CN': '在 ComfyUI 中构建、调整与创想'
},
'gallery.heroSubtitle': {
en: 'A small glimpse of what\u2019s being created with ComfyUI by the community.',
'zh-CN': '社区使用 ComfyUI 创作成果的一小部分展示。'
},
// GalleryCard
'gallery.card.by': { en: 'By', 'zh-CN': '作者' },
'gallery.card.and': { en: 'and', 'zh-CN': '和' },
'gallery.card.teamUsing': { en: 'team using', 'zh-CN': '团队使用' },
'gallery.detail.visitHub': {
en: 'VISIT COMMUNITY HUB',
'zh-CN': '访问社区中心'
},
// ContactSection
'gallery.contact.label': { en: 'CONTACT', 'zh-CN': '联系' },
'gallery.contact.heading': {
en: 'Questions? Reach out!',
'zh-CN': '有问题?联系我们!'
},
'gallery.contact.cta': { en: 'CONTACT US', 'zh-CN': '联系我们' },
// AboutHeroSection
'about.hero.label': { en: 'ABOUT', 'zh-CN': '关于' },
'about.hero.heading': {
en: 'Build the tools that reward creative skill',
'zh-CN': '打造奖励创造力的工具'
},
'about.hero.body': {
en: 'The team behind Comfy is small, intense, and building what we intend to be our life\u2019s work.',
'zh-CN': 'Comfy 背后的团队规模虽小,但充满热情,致力于打造我们毕生的事业。'
},
'about.hero.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
// AboutStorySection
'about.story.label': { en: 'OUR STORY', 'zh-CN': '我们的故事' },
'about.story.headingBefore': {
en: 'An open-source project that became the ',
'zh-CN': '一个开源项目成为了视觉 AI 的'
},
'about.story.headingHighlight': {
en: 'professional standard',
'zh-CN': '行业标准'
},
'about.story.headingAfter': {
en: ' for visual AI.',
'zh-CN': '。'
},
'about.story.body': {
en: 'In January, 2023, no tool on the market could chain two AI models together into a repeatable workflow. One developer in Quebec City built his own in two weeks and open-sourced it.',
'zh-CN':
'2023 年 1 月,市场上没有任何工具能将两个 AI 模型串联成可重复的工作流。一位魁北克市的开发者用两周时间自己构建了一个,并将其开源。'
},
'about.story.investorsLabel': {
en: 'OUR INVESTORS',
'zh-CN': '我们的投资者'
},
'about.story.investorsBody': {
en: 'If it can be open, it should be open. That\u2019s how we build an ecosystem that moves faster than any company could think for, and it\u2019s how we think about code, content, and skins. We make money by building things worth paying for, not by controlling what we believe should be free.',
'zh-CN':
'如果能开放,就应该开放。这是我们建立一个比任何公司都更快发展的生态系统的方式,也是我们对代码、内容和皮肤的思考方式。我们通过构建值得付费的产品来赚钱,而不是通过控制我们认为应该免费的东西。'
},
// AboutQuoteSection
'about.quote.text': {
en: '\u2018Comfy has innovated a new and powerful ecosystem for creativity without compromising creative control. It has been amazing to watch technical artists and curious creative minds leverage Comfy to explore the full surface area of their ideas.\u2019',
'zh-CN':
'\u201CComfy 在不牺牲创作控制力的前提下,开创了一个全新而强大的创意生态系统。看到技术艺术家和充满好奇心的创意人士利用 Comfy 探索他们想法的全部可能性,真是令人惊叹。\u201D'
},
'about.quote.attribution': {
en: 'Scott Belsky, Founder of Behance',
'zh-CN': 'Scott BelskyBehance 创始人'
},
// AboutValuesSection
'about.values.label': { en: 'OUR VALUES', 'zh-CN': '我们的价值观' },
'about.values.headingBefore': {
en: 'We believe ',
'zh-CN': '我们相信'
},
'about.values.headingHighlight': {
en: 'everyone deserves to design exactly what they dream',
'zh-CN': '每个人都值得设计出自己梦想中的作品'
},
'about.values.headingAfter': {
en: ', own their process, and never compromise because of their software.',
'zh-CN': ',掌握自己的流程,永远不因软件而妥协。'
},
'about.values.card1.title': { en: 'SHIP IT', 'zh-CN': 'SHIP IT' },
'about.values.card1.body': {
en: 'Work hard for what makes it into the world. Ideas don\u2019t substitute for output.',
'zh-CN': '为能交付到世界的成果而努力。想法不能替代产出。'
},
'about.values.card2.title': { en: 'SHARE IT', 'zh-CN': 'SHARE IT' },
'about.values.card2.body': {
en: 'Every decision and its reasoning gets shared with the team.',
'zh-CN': '每个决策及其理由都会与团队共享。'
},
'about.values.card3.title': {
en: 'OPEN SOURCE IT',
'zh-CN': 'OPEN SOURCE IT'
},
'about.values.card3.body': {
en: 'If it can be open, it should be open. That\u2019s how we build an ecosystem that moves faster than any company could imagine.',
'zh-CN':
'如果能开放,就应该开放。这是我们构建一个比任何公司都更快发展的生态系统的方式。'
},
'about.values.card4.title': {
en: 'RESPECT THE CRAFT',
'zh-CN': 'RESPECT THE CRAFT'
},
'about.values.card4.body': {
en: 'We build for people who prefer precision over shortcuts. The learning curve is high but the payoff is worth it.',
'zh-CN': '我们为追求精确而非捷径的人而构建。学习曲线虽然陡峭,但回报值得。'
},
// AboutCareersSection
'about.careers.whyLabel': { en: 'IF YOU...', 'zh-CN': '如果你...' },
'about.careers.whyTitle': {
en: 'Why join Comfy?',
'zh-CN': '为什么加入 Comfy'
},
'about.careers.whyTitleBefore': {
en: 'Why join',
'zh-CN': '为什么加入'
},
'about.careers.whyTitleAfter': {
en: '?',
'zh-CN': ''
},
'about.careers.reason1': {
en: 'Want to build tools that empower others to create.',
'zh-CN': '想要构建赋能他人创作的工具。'
},
'about.careers.reason2': {
en: 'Want your output to matter more than your title.',
'zh-CN': '想要你的产出比头衔更重要。'
},
'about.careers.reason3': {
en: 'Want high agency in a flat organization.',
'zh-CN': '想要在扁平组织中拥有高度自主权。'
},
'about.careers.reason4': {
en: 'Would rather ship and learn than plan and wait.',
'zh-CN': '宁愿交付与学习,也不愿计划与等待。'
},
'about.careers.label': { en: 'CAREERS', 'zh-CN': '招聘' },
'about.careers.heading': {
en: 'Join the team',
'zh-CN': '加入团队'
},
'about.careers.cta': { en: 'SEE OPEN ROLES', 'zh-CN': '查看开放职位' },
'about.careers.noRole': {
en: 'Don\u2019t see your dream role here?',
'zh-CN': '没找到你理想的职位?'
},
// SiteNav
'nav.products': { en: 'PRODUCTS', 'zh-CN': '产品' },
'nav.pricing': { en: 'PRICING', 'zh-CN': '价格' },

View File

@@ -1,8 +1,18 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ComingSoon from '../components/common/ComingSoon.astro'
import HeroSection from '../components/about/HeroSection.vue'
import VideoSection from '../components/about/VideoSection.vue'
import StorySection from '../components/about/StorySection.vue'
import OurValuesSection from '../components/about/OurValuesSection.vue'
import ValuesSection from '../components/about/ValuesSection.vue'
import CareersSection from '../components/about/CareersSection.vue'
---
<BaseLayout title="About Us — Comfy">
<ComingSoon />
<HeroSection />
<VideoSection />
<StorySection />
<OurValuesSection />
<ValuesSection client:visible />
<CareersSection />
</BaseLayout>

View File

@@ -1,171 +1,18 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
const team = [
{ name: 'comfyanonymous', role: 'ComfyUI 创始人、联合创始人' },
{ name: 'Dr.Lt.Data', role: 'ComfyUI-Manager 和 Impact/Inspire Pack 作者' },
{ name: 'pythongosssss', role: '核心贡献者、ComfyUI-Custom-Scripts 作者' },
{ name: 'yoland68', role: 'ComfyCLI 作者、联合创始人、前 Google' },
{ name: 'robinjhuang', role: 'Comfy Registry 维护者、联合创始人、前 Google Cloud' },
{ name: 'jojodecay', role: 'ComfyUI 活动主持人、社区与合作关系' },
{ name: 'christian-byrne', role: '全栈开发工程师' },
{ name: 'Kosinkadink', role: 'AnimateDiff-Evolved 和 Advanced-ControlNet 作者' },
{ name: 'webfiltered', role: 'Litegraph 库重构者' },
{ name: 'Pablo', role: '产品设计、前 AI 初创公司创始人' },
{ name: 'ComfyUI Wiki (Daxiong)', role: '官方文档和模板' },
{ name: 'ctrlbenlu (Ben)', role: '软件工程师、前机器人领域' },
{ name: 'Purz Beats', role: '动效设计师和机器学习工程师' },
{ name: 'Ricyu (Rich)', role: '软件工程师、前 Meta' },
]
const collaborators = [
{ name: 'Yogo', role: '协作者' },
{ name: 'Fill (Machine Delusions)', role: '协作者' },
{ name: 'Julien (MJM)', role: '协作者' },
]
const projects = [
{ name: 'ComfyUI', description: '生成式 AI 工作流的核心节点式界面。' },
{ name: 'ComfyUI Manager', description: '一键安装、更新和管理自定义节点。' },
{ name: 'Comfy Registry', description: '发布和发现自定义节点的官方注册表。' },
{ name: 'Frontends', description: '驱动 ComfyUI 体验的桌面端和 Web 前端。' },
{ name: 'Docs', description: '官方文档、指南和教程。' },
]
const faqs = [
{
q: 'ComfyUI 免费吗?',
a: '是的。ComfyUI 是免费开源的,基于 GPL-3.0 许可证。您可以将其用于个人和商业项目。',
},
{
q: '谁在开发 ComfyUI',
a: 'ComfyUI 由 comfyanonymous 创建,由一个小而专注的开发团队和社区贡献者共同维护。',
},
{
q: '如何参与贡献?',
a: '查看我们的 GitHub 仓库来报告问题、提交 Pull Request 或构建自定义节点。加入我们的 Discord 社区与其他贡献者交流。',
},
{
q: '未来有什么计划?',
a: '我们专注于让 ComfyUI 成为生成式 AI 的操作系统——提升性能、扩展模型支持,为创作者和开发者打造更好的工具。',
},
]
import HeroSection from '../../components/about/HeroSection.vue'
import VideoSection from '../../components/about/VideoSection.vue'
import StorySection from '../../components/about/StorySection.vue'
import OurValuesSection from '../../components/about/OurValuesSection.vue'
import ValuesSection from '../../components/about/ValuesSection.vue'
import CareersSection from '../../components/about/CareersSection.vue'
---
<BaseLayout title="关于我们 — Comfy" description="了解 ComfyUI 背后的团队和使命——开源的生成式 AI 平台。">
<div>
<!-- 主页横幅 -->
<section class="px-6 pb-24 pt-40 text-center">
<h1 class="mx-auto max-w-4xl text-4xl font-bold leading-tight md:text-6xl">
开创视觉与音频媒体的下一个前沿
</h1>
<p class="mx-auto mt-6 max-w-2xl text-lg text-smoke-700">
一个开源社区和公司,致力于为生成式 AI 创作者打造最强大的工具。
</p>
</section>
<!-- 我们的使命 -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-sm font-semibold uppercase tracking-widest text-brand-yellow">我们的使命</h2>
<p class="mt-6 text-3xl font-bold md:text-4xl">
我们想打造生成式 AI 的操作系统。
</p>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
我们正在构建让创作者完全掌控生成式 AI 的基础工具。
从图像和视频合成到音频生成ComfyUI 提供了一个模块化的
节点式环境,让专业人士和爱好者可以创建、迭代
和部署生产级工作流——没有黑箱。
</p>
</div>
</section>
<!-- 我们做什么? -->
<section class="px-6 py-24">
<div class="mx-auto max-w-5xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">我们做什么?</h2>
<div class="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div class="rounded-xl border border-white/10 bg-charcoal-600 p-6">
<h3 class="text-lg font-semibold">{project.name}</h3>
<p class="mt-2 text-sm text-smoke-700">{project.description}</p>
</div>
))}
</div>
</div>
</section>
<!-- 我们是谁 -->
<section class="bg-charcoal-800 px-6 py-24">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold md:text-4xl">我们是谁</h2>
<p class="mt-6 text-lg leading-relaxed text-smoke-700">
ComfyUI 最初是 comfyanonymous 的个人项目,后来发展成为一个全球性的
创作者、开发者和研究者社区。今天Comfy Org 是一个位于旧金山的小型扁平化团队,
由相信开源 AI 工具的投资者支持。我们与令人难以置信的贡献者社区一起工作,
他们构建自定义节点、分享工作流,并不断突破生成式 AI 的边界。
</p>
</div>
</section>
<!-- 团队 -->
<section class="px-6 py-24">
<div class="mx-auto max-w-6xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">团队</h2>
<div class="mt-12 grid gap-6 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{team.map((member) => (
<div class="rounded-xl border border-white/10 p-5 text-center">
<div class="mx-auto h-16 w-16 rounded-full bg-charcoal-600" />
<h3 class="mt-4 font-semibold">{member.name}</h3>
<p class="mt-1 text-sm text-smoke-700">{member.role}</p>
</div>
))}
</div>
</div>
</section>
<!-- 协作者 -->
<section class="bg-charcoal-800 px-6 py-16">
<div class="mx-auto max-w-4xl text-center">
<h2 class="text-2xl font-bold">协作者</h2>
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
{collaborators.map((person) => (
<div class="text-center">
<div class="mx-auto h-14 w-14 rounded-full bg-charcoal-600" />
<p class="mt-3 font-semibold">{person.name}</p>
</div>
))}
</div>
</div>
</section>
<!-- 常见问题 -->
<section class="px-6 py-24">
<div class="mx-auto max-w-3xl">
<h2 class="text-center text-3xl font-bold md:text-4xl">常见问题</h2>
<div class="mt-12 space-y-10">
{faqs.map((faq) => (
<div>
<h3 class="text-lg font-semibold">{faq.q}</h3>
<p class="mt-2 text-smoke-700">{faq.a}</p>
</div>
))}
</div>
</div>
</section>
<!-- 加入我们 CTA -->
<section class="bg-charcoal-800 px-6 py-24 text-center">
<h2 class="text-3xl font-bold md:text-4xl">加入我们的团队</h2>
<p class="mx-auto mt-4 max-w-xl text-smoke-700">
我们正在寻找热衷于开源、生成式 AI 和打造优秀开发者工具的人。
</p>
<a
href="/careers"
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
>
查看开放职位
</a>
</section>
</div>
<HeroSection locale="zh-CN" />
<VideoSection locale="zh-CN" />
<StorySection locale="zh-CN" />
<OurValuesSection locale="zh-CN" />
<ValuesSection locale="zh-CN" client:visible />
<CareersSection locale="zh-CN" />
</BaseLayout>

View File

@@ -1,197 +0,0 @@
{
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [627.5973510742188, 423.0972900390625],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [1],
"pos": {
"0": 447.9044189453125,
"1": 437.3822326660156
}
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": {
"0": 912.5973510742188,
"1": 436.0972900390625
}
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [554.8743286132812, 100.95539093017578],
"size": [270, 262],
"flags": { "collapsed": true },
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "VAEEncode",
"pos": [685.1265869140625, 439.1734619140625],
"size": [140, 46],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": null
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [4]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.8894351682943402,
"offset": [58.7671207025881, 137.7124650620126]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -6,7 +6,7 @@
"id": 1,
"type": "ImageCropV2",
"pos": [50, 50],
"size": [400, 550],
"size": [400, 500],
"flags": {},
"order": 0,
"mode": 0,
@@ -27,7 +27,14 @@
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
}
]
}
],
"links": [],

View File

@@ -35,13 +35,6 @@ export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
}
export async function triggerSerialization(page: Page): Promise<void> {
await page.waitForFunction(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
const widget = node?.widgets?.find((w) => w.name === 'mask')
return typeof widget?.serializeValue === 'function'
})
await page.evaluate(async () => {
const graph = window.graph as TestGraphAccess | undefined
if (!graph) {
@@ -57,14 +50,9 @@ export async function triggerSerialization(page: Page): Promise<void> {
)
}
const widgetIndex = node.widgets?.findIndex((w) => w.name === 'mask') ?? -1
if (widgetIndex === -1) {
throw new Error('Widget "mask" not found on target node 1.')
}
const widget = node.widgets?.[widgetIndex]
const widget = node.widgets?.find((w) => w.name === 'mask')
if (!widget) {
throw new Error(`Widget index ${widgetIndex} not found on target node 1.`)
throw new Error('Widget "mask" not found on target node 1.')
}
if (typeof widget.serializeValue !== 'function') {
@@ -73,6 +61,6 @@ export async function triggerSerialization(page: Page): Promise<void> {
)
}
await widget.serializeValue(node, widgetIndex)
await widget.serializeValue(node, 0)
})
}

View File

@@ -54,7 +54,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.captureCanvasState()
).workflow.activeWorkflow?.changeTracker.checkState()
}, value)
}

View File

@@ -30,7 +30,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
window.app!.graph!.setDirtyCanvas(true, true)
;(
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker?.captureCanvasState()
).workflow.activeWorkflow?.changeTracker?.checkState()
})
await expect
.poll(() => comfyPage.page.title())

View File

@@ -71,7 +71,7 @@ async function waitForChangeTrackerSettled(
) {
// Visible node flags can flip before undo finishes loadGraphData() and
// updates the tracker. Poll the tracker's own settled state so we do not
// start the next transaction while captureCanvasState() is still gated.
// start the next transaction while checkState() is still gated.
await expect
.poll(() => getChangeTrackerDebugState(comfyPage))
.toMatchObject({
@@ -272,42 +272,4 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
test('Undo preserves viewport offset', async ({ comfyPage }) => {
// Pan to a distinct offset so we can detect drift
await comfyPage.canvasOps.pan({ x: 200, y: 150 })
const viewportBefore = await comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
})
// Make a graph change so we have something to undo
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
// Undo the collapse — viewport should be preserved
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
const ds = window.app!.canvas.ds
return { scale: ds.scale, offset: [...ds.offset] }
}),
{ timeout: 2_000 }
)
.toEqual({
scale: expect.closeTo(viewportBefore.scale, 2),
offset: [
expect.closeTo(viewportBefore.offset[0], 0),
expect.closeTo(viewportBefore.offset[1], 0)
]
})
})
})

View File

@@ -12,7 +12,7 @@ test.describe(
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Prevents captureCanvasState from corrupting workflow state during tab switch', async ({
test('Prevents checkState from corrupting workflow state during tab switch', async ({
comfyPage
}) => {
// Tab 0: default workflow (7 nodes)
@@ -21,9 +21,9 @@ test.describe(
// Save tab 0 so it has a unique name for tab switching
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
// Register an extension that forces captureCanvasState during graph loading.
// Register an extension that forces checkState during graph loading.
// This simulates the bug scenario where a user clicks during graph loading
// which triggers a captureCanvasState call on the wrong graph, corrupting the activeState.
// which triggers a checkState call on the wrong graph, corrupting the activeState.
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestCheckStateDuringLoad',
@@ -35,7 +35,7 @@ test.describe(
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
// Simulate the user clicking during graph loading
workflow.changeTracker.captureCanvasState()
workflow.changeTracker.checkState()
}
})
})

View File

@@ -64,29 +64,3 @@ test.describe(
})
}
)
test.describe(
'Collapsed node links inside subgraph on first entry',
{ tag: ['@canvas', '@node', '@vue-nodes', '@subgraph', '@screenshot'] },
() => {
test('renders collapsed node links correctly after fitView on first subgraph entry', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-collapsed-node'
)
await comfyPage.nextFrame()
await comfyPage.vueNodes.enterSubgraph('2')
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
// fitView runs on first entry and re-syncs slot layouts for the
// pre-collapsed KSampler. Screenshot captures the rendered canvas
// links to guard against regressing the stale-coordinate bug.
await expect(comfyPage.canvas).toHaveScreenshot(
'subgraph-entry-collapsed-node-links.png'
)
})
}
)

View File

@@ -1,122 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Shows empty state when no input image is connected',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('No input image connected')).toBeVisible()
await expect(node.locator('img[alt="Crop preview"]')).toHaveCount(0)
}
)
test(
'Renders bounding box coordinate inputs',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('X')).toBeVisible()
await expect(node.getByText('Y')).toBeVisible()
await expect(node.getByText('Width')).toBeVisible()
await expect(node.getByText('Height')).toBeVisible()
}
)
test(
'Renders ratio selector and lock button',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Ratio')).toBeVisible()
await expect(node.getByRole('button', { name: /lock/i })).toBeVisible()
}
)
test(
'Lock button toggles aspect ratio lock',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const lockButton = node.getByRole('button', {
name: 'Lock aspect ratio'
})
await expect(lockButton).toBeVisible()
await lockButton.click()
await expect(
node.getByRole('button', { name: 'Unlock aspect ratio' })
).toBeVisible()
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
await expect(
node.getByRole('button', { name: 'Lock aspect ratio' })
).toBeVisible()
}
)
test(
'Ratio selector offers expected presets',
{ tag: '@ui' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const trigger = node.getByRole('combobox')
await trigger.click()
const expectedRatios = ['1:1', '3:4', '4:3', '16:9', '9:16', 'Custom']
for (const label of expectedRatios) {
await expect(
comfyPage.page.getByRole('option', { name: label, exact: true })
).toBeVisible()
}
}
)
test(
'Programmatically setting widget value updates bounding box inputs',
{ tag: '@ui' },
async ({ comfyPage }) => {
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
await comfyPage.page.evaluate(
({ bounds }) => {
const node = window.app!.graph.getNodeById(1)
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
if (widget) {
widget.value = bounds
widget.callback?.(bounds)
}
},
{ bounds: newBounds }
)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeLocator('1')
const inputs = node.locator('input[inputmode="decimal"]')
await expect.poll(() => inputs.nth(0).inputValue()).toBe('50')
await expect.poll(() => inputs.nth(1).inputValue()).toBe('100')
await expect.poll(() => inputs.nth(2).inputValue()).toBe('200')
await expect.poll(() => inputs.nth(3).inputValue()).toBe('300')
}
)
})

View File

@@ -1,4 +1,3 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
@@ -28,85 +27,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
}
}
async function openMaskEditorDialog(comfyPage: ComfyPage) {
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.getByRole('region').hover()
await comfyPage.page.getByLabel('Edit or mask image').click()
const dialog = comfyPage.page.locator('.mask-editor-dialog')
await expect(dialog).toBeVisible()
await expect(
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
await expect(canvasContainer).toBeVisible()
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
return dialog
}
async function getMaskCanvasPixelData(page: Page) {
return page.evaluate(() => {
const canvases = document.querySelectorAll(
'#maskEditorCanvasContainer canvas'
)
// The mask canvas is the 3rd canvas (index 2, z-30)
const maskCanvas = canvases[2] as HTMLCanvasElement
if (!maskCanvas) return null
const ctx = maskCanvas.getContext('2d')
if (!ctx) return null
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
let nonTransparentPixels = 0
for (let i = 3; i < data.data.length; i += 4) {
if (data.data[i] > 0) nonTransparentPixels++
}
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
})
}
function pollMaskPixelCount(page: Page): Promise<number> {
return getMaskCanvasPixelData(page).then(
(d) => d?.nonTransparentPixels ?? 0
)
}
async function drawStrokeOnPointerZone(
page: Page,
dialog: ReturnType<typeof page.locator>
) {
const pointerZone = dialog.locator(
'.maskEditor-ui-container [class*="w-[calc"]'
)
await expect(pointerZone).toBeVisible()
const box = await pointerZone.boundingBox()
if (!box) throw new Error('Pointer zone bounding box not found')
const startX = box.x + box.width * 0.3
const startY = box.y + box.height * 0.5
const endX = box.x + box.width * 0.7
const endY = box.y + box.height * 0.5
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(endX, endY, { steps: 10 })
await page.mouse.up()
return { startX, startY, endX, endY, box }
}
async function drawStrokeAndExpectPixels(
comfyPage: ComfyPage,
dialog: ReturnType<typeof comfyPage.page.locator>
) {
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
}
test(
'opens mask editor from image preview button',
{ tag: ['@smoke', '@screenshot'] },
@@ -132,7 +52,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
await expect(dialog.getByText('Save')).toBeVisible()
await expect(dialog.getByText('Cancel')).toBeVisible()
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
}
)
@@ -159,245 +79,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
dialog.getByRole('heading', { name: 'Mask Editor' })
).toBeVisible()
await comfyPage.expectScreenshot(
dialog,
await expect(dialog).toHaveScreenshot(
'mask-editor-dialog-from-context-menu.png'
)
}
)
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
expect(dataBefore!.nonTransparentPixels).toBe(0)
await drawStrokeAndExpectPixels(comfyPage, dialog)
})
test('undo reverts a brush stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await expect(undoButton).toBeVisible()
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('redo restores an undone stroke', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const undoButton = dialog.locator('button[title="Undo"]')
await undoButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
const redoButton = dialog.locator('button[title="Redo"]')
await expect(redoButton).toBeVisible()
await redoButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(0)
})
test('clear button removes all mask content', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const clearButton = dialog.getByRole('button', { name: 'Clear' })
await expect(clearButton).toBeVisible()
await clearButton.click()
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
await cancelButton.click()
await expect(dialog).toBeHidden()
})
test('invert button inverts the mask', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
expect(dataBefore).not.toBeNull()
const pixelsBefore = dataBefore!.nonTransparentPixels
const invertButton = dialog.getByRole('button', { name: 'Invert' })
await expect(invertButton).toBeVisible()
await invertButton.click()
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeGreaterThan(pixelsBefore)
})
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
await comfyPage.page.keyboard.press(modifier)
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
})
test(
'tool panel shows all five tools',
{ tag: ['@smoke'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolPanel = dialog.locator('.maskEditor-ui-container')
await expect(toolPanel).toBeVisible()
// The tool panel should contain exactly 5 tool entries
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
// First tool (MaskPen) should be selected by default
const selectedTool = dialog.locator(
'.maskEditor_toolPanelContainerSelected'
)
await expect(selectedTool).toHaveCount(1)
}
)
test('switching tools updates the selected indicator', async ({
comfyPage
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await expect(toolEntries).toHaveCount(5)
// Click the third tool (Eraser, index 2)
await toolEntries.nth(2).click()
// The third tool should now be selected
const selectedTool = dialog.locator(
'.maskEditor_toolPanelContainerSelected'
)
await expect(selectedTool).toHaveCount(1)
// Verify it's the eraser (3rd entry)
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
})
test('brush settings panel is visible with thickness controls', async ({
comfyPage
}) => {
const dialog = await openMaskEditorDialog(comfyPage)
// The side panel should show brush settings by default
const thicknessLabel = dialog.getByText('Thickness')
await expect(thicknessLabel).toBeVisible()
const opacityLabel = dialog.getByText('Opacity').first()
await expect(opacityLabel).toBeVisible()
const hardnessLabel = dialog.getByText('Hardness')
await expect(hardnessLabel).toBeVisible()
})
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
let maskUploadCount = 0
let imageUploadCount = 0
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-mask-${maskUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadCount++
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: `test-image-${imageUploadCount}.png`,
subfolder: 'clipspace',
type: 'input'
})
})
})
const saveButton = dialog.getByRole('button', { name: 'Save' })
await expect(saveButton).toBeVisible()
await saveButton.click()
await expect(dialog).toBeHidden()
// The save pipeline uploads multiple layers (mask + image variants)
expect(
maskUploadCount + imageUploadCount,
'save should trigger upload calls'
).toBeGreaterThan(0)
})
test('save failure keeps dialog open', async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Fail all upload routes
await comfyPage.page.route('**/upload/mask', (route) =>
route.fulfill({ status: 500 })
)
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill({ status: 500 })
)
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
// Dialog should remain open when save fails
await expect(dialog).toBeVisible()
})
test(
'eraser tool removes mask content',
{ tag: ['@screenshot'] },
async ({ comfyPage }) => {
const dialog = await openMaskEditorDialog(comfyPage)
// Draw a stroke with the mask pen (default tool)
await drawStrokeAndExpectPixels(comfyPage, dialog)
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
// Switch to eraser tool (3rd tool, index 2)
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
await toolEntries.nth(2).click()
// Draw over the same area with the eraser
await drawStrokeOnPointerZone(comfyPage.page, dialog)
await expect
.poll(() => pollMaskPixelCount(comfyPage.page))
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
}
)
})

View File

@@ -1,7 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import { expect } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -17,24 +16,21 @@ function hasCanvasContent(canvas: Locator): Promise<boolean> {
})
}
function getMinimapLocators(comfyPage: ComfyPage) {
const container = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
return {
container,
canvas: comfyPage.page.getByTestId(TestIds.canvas.minimapCanvas),
viewport: comfyPage.page.getByTestId(TestIds.canvas.minimapViewport),
toggleButton: comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
),
closeButton: comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton)
}
}
async function clickMinimapAt(
overlay: Locator,
page: Page,
relX: number,
relY: number
) {
const box = await overlay.boundingBox()
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
function getCanvasOffset(page: Page): Promise<[number, number]> {
return page.evaluate(() => {
const ds = window.app!.canvas.ds
return [ds.offset[0], ds.offset[1]] as [number, number]
})
// Click area — avoiding the settings button (top-left, 32×32px)
// and close button (top-right, 32×32px)
await page.mouse.click(
box!.x + box!.width * relX,
box!.y + box!.height * relY
)
}
test.describe('Minimap', { tag: '@canvas' }, () => {
@@ -46,13 +42,23 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap is visible by default', async ({ comfyPage }) => {
const { container, canvas, viewport } = getMinimapLocators(comfyPage)
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(container).toBeVisible()
await expect(canvas).toBeVisible()
await expect(viewport).toBeVisible()
await expect(minimapContainer).toBeVisible()
await expect(container).toHaveCSS('position', 'relative')
const minimapCanvas = minimapContainer.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
const minimapViewport = minimapContainer.getByTestId(
TestIds.canvas.minimapViewport
)
await expect(minimapViewport).toBeVisible()
await expect(minimapContainer).toHaveCSS('position', 'relative')
// position and z-index validation moved to the parent container of the minimap
const minimapMainContainer = comfyPage.page.locator(
@@ -63,53 +69,59 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
})
test('Validate minimap toggle button state', async ({ comfyPage }) => {
const { container, toggleButton } = getMinimapLocators(comfyPage)
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
await expect(container).toBeVisible()
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
const { container, toggleButton } = getMinimapLocators(comfyPage)
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(container).toBeVisible()
await expect(minimapContainer).toBeVisible()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(container).toBeHidden()
await expect(minimapContainer).toBeHidden()
await toggleButton.click()
await comfyPage.nextFrame()
await expect(container).toBeVisible()
await expect(minimapContainer).toBeVisible()
})
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
const { container } = getMinimapLocators(comfyPage)
const minimapContainer = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(container).toBeVisible()
await expect(minimapContainer).toBeVisible()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(container).toBeHidden()
await expect(minimapContainer).toBeHidden()
await comfyPage.page.keyboard.press('Alt+KeyM')
await comfyPage.nextFrame()
await expect(container).toBeVisible()
await expect(minimapContainer).toBeVisible()
})
test('Close button hides minimap', async ({ comfyPage }) => {
const { container, toggleButton, closeButton } =
getMinimapLocators(comfyPage)
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
await expect(minimap).toBeVisible()
await expect(container).toBeVisible()
await closeButton.click()
await expect(container).toBeHidden()
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
await expect(minimap).toBeHidden()
const toggleButton = comfyPage.page.getByTestId(
TestIds.canvas.toggleMinimapButton
)
await expect(toggleButton).toBeVisible()
})
@@ -117,10 +129,12 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
'Panning canvas moves minimap viewport',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
await comfyPage.expectScreenshot(container, 'minimap-before-pan.png')
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
@@ -129,192 +143,155 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
canvas.ds.offset[1] = -600
canvas.setDirty(true, true)
})
await comfyPage.expectScreenshot(container, 'minimap-after-pan.png')
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
}
)
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const minimapBox = await container.boundingBox()
const viewportBox = await viewport.boundingBox()
expect(minimapBox).toBeTruthy()
expect(viewportBox).toBeTruthy()
expect(viewportBox!.width).toBeGreaterThan(0)
expect(viewportBox!.height).toBeGreaterThan(0)
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
minimapBox!.y
)
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
await comfyPage.expectScreenshot(container, 'minimap-with-viewport.png')
}
)
test('Clicking on minimap pans the canvas to that position', async ({
test('Minimap canvas is non-empty for a workflow with nodes', async ({
comfyPage
}) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const offsetBefore = await getCanvasOffset(comfyPage.page)
const minimapBox = await container.boundingBox()
expect(minimapBox).toBeTruthy()
// Click the top-left quadrant — canvas should pan so that region
// becomes centered, meaning offset increases (moves right/down)
await comfyPage.page.mouse.click(
minimapBox!.x + minimapBox!.width * 0.2,
minimapBox!.y + minimapBox!.height * 0.2
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await comfyPage.nextFrame()
await expect(minimapCanvas).toBeVisible()
await expect
.poll(() => getCanvasOffset(comfyPage.page))
.not.toEqual(offsetBefore)
})
test('Dragging on minimap continuously pans the canvas', async ({
comfyPage
}) => {
const { container } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
const minimapBox = await container.boundingBox()
expect(minimapBox).toBeTruthy()
const startX = minimapBox!.x + minimapBox!.width * 0.3
const startY = minimapBox!.y + minimapBox!.height * 0.3
const endX = minimapBox!.x + minimapBox!.width * 0.7
const endY = minimapBox!.y + minimapBox!.height * 0.7
const offsetBefore = await getCanvasOffset(comfyPage.page)
// Drag from top-left toward bottom-right on the minimap
await comfyPage.page.mouse.move(startX, startY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(endX, endY, { steps: 10 })
// Mid-drag: offset should already differ from initial state
const offsetMidDrag = await getCanvasOffset(comfyPage.page)
expect(
offsetMidDrag[0] !== offsetBefore[0] ||
offsetMidDrag[1] !== offsetBefore[1]
).toBe(true)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
// Final offset should also differ (drag was not discarded on mouseup)
await expect
.poll(() => getCanvasOffset(comfyPage.page))
.not.toEqual(offsetBefore)
})
test('Minimap viewport updates when canvas is zoomed', async ({
comfyPage
}) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const viewportBefore = await viewport.boundingBox()
expect(viewportBefore).toBeTruthy()
// Zoom in significantly
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.scale = 3
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
// Viewport rectangle should shrink when zoomed in
await expect
.poll(async () => {
const box = await viewport.boundingBox()
return box?.width ?? 0
})
.toBeLessThan(viewportBefore!.width)
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
})
test('Minimap canvas is empty after all nodes are deleted', async ({
comfyPage
}) => {
const { canvas } = getMinimapLocators(comfyPage)
await expect(canvas).toBeVisible()
// Minimap should have content before deletion
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
// Remove all nodes
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('Delete')
await comfyPage.nextFrame()
const minimapCanvas = comfyPage.page.getByTestId(
TestIds.canvas.minimapCanvas
)
await expect(minimapCanvas).toBeVisible()
await comfyPage.keyboard.selectAll()
await comfyPage.vueNodes.deleteSelected()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Minimap canvas should be empty — no nodes means nothing to render
await expect
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
.toBe(false)
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
})
test('Minimap re-renders after loading a different workflow', async ({
test('Clicking minimap corner pans the main canvas', async ({
comfyPage
}) => {
const { canvas } = getMinimapLocators(comfyPage)
await expect(canvas).toBeVisible()
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
await expect(minimap).toBeVisible()
// Default workflow has content
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
// Load a very different workflow
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
await comfyPage.nextFrame()
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
// Minimap should still have content (different workflow, still has nodes)
await expect
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
.toBe(true)
.poll(() =>
comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
)
.not.toStrictEqual(before)
await expect
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
.not.toBe(transformBefore)
})
test('Minimap viewport position reflects canvas pan state', async ({
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
comfyPage
}) => {
const { container, viewport } = getMinimapLocators(comfyPage)
await expect(container).toBeVisible()
await expect(viewport).toBeVisible()
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
const overlay = comfyPage.page.getByTestId(
TestIds.canvas.minimapInteractionOverlay
)
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(minimap).toBeVisible()
const positionBefore = await viewport.boundingBox()
expect(positionBefore).toBeTruthy()
// Pan the canvas by a large amount to the right and down
await comfyPage.page.evaluate(() => {
const canvas = window.app!.canvas
canvas.ds.offset[0] -= 500
canvas.ds.offset[1] -= 500
canvas.ds.offset[0] -= 1000
canvas.setDirty(true, true)
})
await comfyPage.nextFrame()
// The viewport indicator should have moved within the minimap
const transformBefore = await viewport.evaluate(
(el: HTMLElement) => el.style.transform
)
await comfyPage.page.evaluate(() => {
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
})
await expect
.poll(async () => {
const box = await viewport.boundingBox()
if (!box || !positionBefore) return false
return box.x !== positionBefore.x || box.y !== positionBefore.y
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
timeout: 2000
})
.toBe(true)
.not.toBe(transformBefore)
await comfyPage.nextFrame()
const before = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
await comfyPage.nextFrame()
const after = await comfyPage.page.evaluate(() => ({
x: window.app!.canvas.ds.offset[0],
y: window.app!.canvas.ds.offset[1]
}))
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
const TOLERANCE = 50
expect(
Math.abs(after.x - before.x),
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
expect(
Math.abs(after.y - before.y),
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
).toBeLessThan(TOLERANCE)
})
test(
'Viewport rectangle is visible and positioned within minimap',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const minimap = comfyPage.page.getByTestId(
TestIds.canvas.minimapContainer
)
await expect(minimap).toBeVisible()
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
await expect(viewport).toBeVisible()
await expect(async () => {
const vb = await viewport.boundingBox()
const mb = await minimap.boundingBox()
expect(vb).toBeTruthy()
expect(mb).toBeTruthy()
expect(vb!.width).toBeGreaterThan(0)
expect(vb!.height).toBeGreaterThan(0)
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
}).toPass({ timeout: 5000 })
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
}
)
})

View File

@@ -370,64 +370,4 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
})
})
test.describe('Eraser', () => {
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect
.poll(
() =>
canvas.evaluate((el: HTMLCanvasElement) => {
const ctx = el.getContext('2d')
if (!ctx) return false
const cx = Math.floor(el.width / 2)
const cy = Math.floor(el.height / 2)
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
return data.every((v, i) => i % 4 !== 3 || v === 0)
}),
{ message: 'erased area should be transparent' }
)
.toBe(true)
})
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
const painterWidget = node.locator('.widget-expands')
const canvas = painterWidget.locator('canvas')
await expect(canvas).toBeVisible()
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
await drawStroke(comfyPage.page, canvas)
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(false)
})
})
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
const canvas = comfyPage.vueNodes
.getNodeLocator('1')
.locator('.widget-expands canvas')
await expect(canvas).toBeVisible()
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
await comfyPage.nextFrame()
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
})
})

View File

@@ -121,9 +121,9 @@ test.describe('Workflow Persistence', () => {
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
@@ -388,7 +388,7 @@ test.describe('Workflow Persistence', () => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflow called captureCanvasState on inactive tab, serializing the active graph instead'
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
})
await comfyPage.settings.setSetting(
@@ -419,13 +419,13 @@ test.describe('Workflow Persistence', () => {
.toBe(nodeCountA + 1)
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
// Trigger captureCanvasState so isModified is set
// Trigger checkState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
// Switch to A via topbar tab (making B inactive)
@@ -464,7 +464,7 @@ test.describe('Workflow Persistence', () => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflowAs called captureCanvasState on inactive temp tab, serializing the active graph'
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
})
await comfyPage.settings.setSetting(
@@ -488,13 +488,13 @@ test.describe('Workflow Persistence', () => {
})
await comfyPage.nextFrame()
// Trigger captureCanvasState so isModified is set
// Trigger checkState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)

View File

@@ -5,18 +5,14 @@ history by comparing serialized graph snapshots.
## How It Works
`captureCanvasState()` is the core method. It:
`checkState()` is the core method. It:
1. Serializes the current graph via `app.rootGraph.serialize()`
2. Deep-compares the result against the last known `activeState`
3. If different, pushes `activeState` onto `undoQueue` and replaces it
**It is not reactive.** Changes to the graph (widget values, node positions,
links, etc.) are only captured when `captureCanvasState()` is explicitly triggered.
**INVARIANT:** `captureCanvasState()` asserts that it is called on the active
workflow's tracker. Calling it on an inactive tracker logs a warning and
returns early, preventing cross-workflow data corruption.
links, etc.) are only captured when `checkState()` is explicitly triggered.
## Automatic Triggers
@@ -35,7 +31,7 @@ These are set up once in `ChangeTracker.init()`:
| Graph cleared | `api` `graphCleared` event | Full graph clear |
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
## When You Must Call `captureCanvasState()` Manually
## When You Must Call `checkState()` Manually
The automatic triggers above are designed around LiteGraph's native DOM
rendering. They **do not cover**:
@@ -54,42 +50,24 @@ rendering. They **do not cover**:
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
// After mutating the graph:
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
```
### Existing Manual Call Sites
These locations call `captureCanvasState()` directly:
These locations already call `checkState()` explicitly:
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
- `ColorPickerButton.vue` — After changing node colors
- `NodeSearchBoxPopover.vue` — After adding a node from search
- `builderViewOptions.ts` — After setting default view
- `useAppSetDefaultView.ts` — After setting default view
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
- `useGroupMenuOptions.ts` — After group operations
- `useSubgraphOperations.ts` — After subgraph enter/exit
- `useCanvasRefresh.ts` — After canvas refresh
- `useCoreCommands.ts` — After metadata/subgraph commands
- `appModeStore.ts` — After app mode transitions
`workflowService.ts` calls `captureCanvasState()` indirectly via
`deactivate()` and `prepareForSave()` (see Lifecycle Methods below).
> **Deprecated:** `checkState()` is an alias for `captureCanvasState()` kept
> for extension compatibility. Extension authors should migrate to
> `captureCanvasState()`. See the `@deprecated` JSDoc on the method.
## Lifecycle Methods
| Method | Caller | Purpose |
| ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. |
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` (skipped during undo/redo) + `store()`. Freezes state for tab switch. Must be called while this workflow is still active. |
| `prepareForSave()` | Save paths only | Active: calls `captureCanvasState()`. Inactive: no-op (state was frozen by `deactivate()`). |
| `store()` | Internal to `deactivate()` | Saves viewport scale/offset, node outputs, subgraph navigation. |
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. |
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks workflow as "clean"). |
- `workflowService.ts` — After workflow service operations
## Transaction Guards
@@ -98,7 +76,7 @@ For operations that make multiple changes that should be a single undo entry:
```typescript
changeTracker.beforeChange()
// ... multiple graph mutations ...
changeTracker.afterChange() // calls captureCanvasState() when nesting count hits 0
changeTracker.afterChange() // calls checkState() when nesting count hits 0
```
The `litegraph:canvas` custom event also supports this with `before-change` /
@@ -106,12 +84,8 @@ The `litegraph:canvas` custom event also supports this with `before-change` /
## Key Invariants
- `captureCanvasState()` asserts it is called on the active workflow's tracker;
inactive trackers get an early return (and a warning log)
- `captureCanvasState()` is a no-op during `loadGraphData` (guarded by
- `checkState()` is a no-op during `loadGraphData` (guarded by
`isLoadingGraph`) to prevent cross-workflow corruption
- `captureCanvasState()` is a no-op during undo/redo (guarded by
`_restoringState`) to prevent undo history corruption
- `captureCanvasState()` is a no-op when `changeCount > 0` (inside a transaction)
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing

View File

@@ -46,7 +46,7 @@ const mockActiveWorkflow = ref<{
isTemporary: boolean
initialMode?: string
isModified?: boolean
changeTracker?: { captureCanvasState: () => void }
changeTracker?: { checkState: () => void }
} | null>({
isTemporary: true,
initialMode: 'app'

View File

@@ -49,10 +49,10 @@ describe('setWorkflowDefaultView', () => {
expect(app.rootGraph.extra.linearMode).toBe(false)
})
it('calls changeTracker.captureCanvasState', () => {
it('calls changeTracker.checkState', () => {
const workflow = createMockLoadedWorkflow()
setWorkflowDefaultView(workflow, true)
expect(workflow.changeTracker.captureCanvasState).toHaveBeenCalledOnce()
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
})
it('tracks telemetry with correct default_view', () => {

View File

@@ -9,7 +9,7 @@ export function setWorkflowDefaultView(
workflow.initialMode = openAsApp ? 'app' : 'graph'
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.captureCanvasState()
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})

View File

@@ -31,7 +31,7 @@ function createMockWorkflow(
const changeTracker = Object.assign(
new ChangeTracker(workflow, structuredClone(defaultGraph)),
{
captureCanvasState: vi.fn() as Mock
checkState: vi.fn() as Mock
}
)

View File

@@ -125,7 +125,7 @@ const applyColor = (colorOption: ColorOption | null) => {
canvasStore.canvas?.setDirty(true, true)
currentColorOption.value = canvasColorOption
showColorPicker.value = false
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker.checkState()
}
const currentColorOption = ref<CanvasColorOption | null>(null)

View File

@@ -143,7 +143,7 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
disconnectOnReset = false
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
window.requestAnimationFrame(closeDialog)
}

View File

@@ -13,7 +13,7 @@ export function useCanvasRefresh() {
canvasStore.canvas?.setDirty(true, true)
canvasStore.canvas?.graph?.afterChange()
canvasStore.canvas?.emitAfterChange()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
return {

View File

@@ -36,7 +36,7 @@ export function useGroupMenuOptions() {
groupContext.resizeTo(groupContext.children, padding)
groupContext.graph?.change()
canvasStore.canvas?.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
})
@@ -119,7 +119,7 @@ export function useGroupMenuOptions() {
})
canvasStore.canvas?.setDirty(true, true)
groupContext.graph?.change()
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
bump()
}
})

View File

@@ -23,7 +23,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodeCollapse = () => {
@@ -33,7 +33,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodePin = () => {
@@ -43,7 +43,7 @@ export function useSelectedNodeActions() {
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const toggleNodeBypass = () => {

View File

@@ -47,7 +47,7 @@ export function useSelectionOperations() {
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const duplicateSelection = () => {
@@ -73,7 +73,7 @@ export function useSelectionOperations() {
canvas.pasteFromClipboard({ connectInputs: false })
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const deleteSelection = () => {
@@ -92,7 +92,7 @@ export function useSelectionOperations() {
canvas.setDirty(true, true)
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const renameSelection = async () => {
@@ -122,7 +122,7 @@ export function useSelectionOperations() {
const titledItem = item as { title: string }
titledItem.title = newTitle
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
}
return
@@ -145,7 +145,7 @@ export function useSelectionOperations() {
}
})
app.canvas.setDirty(true, true)
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
return
}

View File

@@ -31,7 +31,7 @@ export function useSubgraphOperations() {
canvas.select(node)
canvasStore.updateSelectedItems()
// Trigger change tracking
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const doUnpack = (
@@ -46,7 +46,7 @@ export function useSubgraphOperations() {
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
graph.unpackSubgraph(subgraphNode, { skipMissingNodes })
}
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
const unpackSubgraph = () => {

View File

@@ -94,7 +94,7 @@ vi.mock('@/stores/toastStore', () => ({
}))
const mockChangeTracker = vi.hoisted(() => ({
captureCanvasState: vi.fn()
checkState: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: {
@@ -382,7 +382,7 @@ describe('useCoreCommands', () => {
expect(mockDialogService.prompt).toHaveBeenCalled()
expect(mockSubgraph.extra.BlueprintDescription).toBe('Test description')
expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled()
expect(mockChangeTracker.checkState).toHaveBeenCalled()
})
it('should not set description when user cancels', async () => {
@@ -397,7 +397,7 @@ describe('useCoreCommands', () => {
await setDescCommand.function()
expect(mockSubgraph.extra.BlueprintDescription).toBeUndefined()
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
expect(mockChangeTracker.checkState).not.toHaveBeenCalled()
})
})
@@ -432,7 +432,7 @@ describe('useCoreCommands', () => {
'alias2',
'alias3'
])
expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled()
expect(mockChangeTracker.checkState).toHaveBeenCalled()
})
it('should trim whitespace and filter empty strings', async () => {
@@ -478,7 +478,7 @@ describe('useCoreCommands', () => {
await setAliasesCommand.function()
expect(mockSubgraph.extra.BlueprintSearchAliases).toBeUndefined()
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
expect(mockChangeTracker.checkState).not.toHaveBeenCalled()
})
})
})

View File

@@ -1164,7 +1164,7 @@ export function useCoreCommands(): ComfyCommand[] {
if (description === null) return
extra.BlueprintDescription = description.trim() || undefined
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
},
{
@@ -1201,7 +1201,7 @@ export function useCoreCommands(): ComfyCommand[] {
}
extra.BlueprintSearchAliases = aliases.length > 0 ? aliases : undefined
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
},
{

View File

@@ -2674,13 +2674,6 @@
"placeholderUnknown": "Select media...",
"maxSelectionReached": "Maximum selection limit reached"
},
"remoteCombo": {
"loading": "Loading...",
"loadFailed": "Failed to load options",
"itemsLoaded": "{count} item loaded... | {count} items loaded...",
"playAudioPreview": "Play audio preview",
"pauseAudioPreview": "Pause audio preview"
},
"valueControl": {
"header": {
"prefix": "Automatically update the value",

View File

@@ -140,7 +140,7 @@ export const useWorkflowService = () => {
}
if (isSelfOverwrite) {
workflow.changeTracker?.prepareForSave()
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
await saveWorkflow(workflow)
} else {
let target: ComfyWorkflow
@@ -157,7 +157,8 @@ export const useWorkflowService = () => {
app.rootGraph.extra.linearMode = isApp
target.initialMode = isApp ? 'app' : 'graph'
}
target.changeTracker?.prepareForSave()
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
await workflowStore.saveWorkflow(target)
}
@@ -173,7 +174,8 @@ export const useWorkflowService = () => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
workflow.changeTracker?.prepareForSave()
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
const isApp = workflow.initialMode === 'app'
const expectedPath =
workflow.directory +
@@ -368,7 +370,7 @@ export const useWorkflowService = () => {
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker?.deactivate()
activeWorkflow.changeTracker.store()
if (settingStore.get('Comfy.Workflow.Persist') && activeWorkflow.path) {
const activeState = activeWorkflow.activeState
if (activeState) {

View File

@@ -1,290 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '@testing-library/vue'
import axios, { AxiosError, AxiosHeaders } from 'axios'
import type * as AxiosModule from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import type { ComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { createMockWidget } from './widgetTestUtils'
// Preserve everything axios exports — only `default.get` is the call site we
// drive. Other modules in the import graph (e.g. workspaceApi) call
// axios.create() at module-load time, so we can't replace the default outright.
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof AxiosModule>()
return {
...actual,
default: { ...actual.default, get: vi.fn() }
}
})
// All four auth-related composables are mocked at module level so the SFC's
// imports never pull in firebase / vuefire. Their return shapes only need to
// satisfy the call sites the widget actually hits.
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { teamWorkspacesEnabled: false } })
}))
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({ currentWorkspace: null })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
userId: undefined,
getAuthHeader: vi.fn(() => Promise.resolve(null))
})
}))
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({ getApiKey: () => null })
}))
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
// Minimal stub: surfaces the props the widget binds (so we can assert on them)
// and exposes click affordances that emit `update:selected` for the user-action
// tests. The real FormDropdown's rendering is tested in its own suite.
const FormDropdownStub = {
name: 'FormDropdown',
props: [
'selected',
'items',
'placeholder',
'multiple',
'showSort',
'showLayoutSwitcher',
'searcher',
'layoutMode'
],
emits: ['update:selected', 'update:layoutMode'],
template: `
<div data-testid="dropdown">
<span data-testid="placeholder">{{ placeholder }}</span>
<span data-testid="items-count">{{ items.length }}</span>
<button
v-for="item in items"
:key="item.id"
:data-testid="'item-' + item.id"
@click="$emit('update:selected', new Set([item.id]))"
>
{{ item.name }}
</button>
<button
data-testid="deselect"
@click="$emit('update:selected', new Set())"
>×</button>
</div>
`
}
const baseSchema: RemoteItemSchema = {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
function buildWidget(
remoteCombo: Partial<Omit<RemoteComboConfig, 'route' | 'item_schema'>> = {},
value: string | undefined = undefined
): SimplifiedWidget<string | undefined> {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'voice',
remote_combo: {
route: '/voices',
item_schema: baseSchema,
...remoteCombo
}
}
return createMockWidget<string | undefined>({
name: 'voice',
type: 'COMBO',
value,
spec
})
}
function renderWidget(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined = undefined
) {
return render(RichComboWidget, {
props: {
widget,
modelValue: modelValue ?? widget.value
},
global: {
plugins: [createTestingPinia(), i18n],
stubs: { FormDropdown: FormDropdownStub }
}
})
}
function mockAxiosResponseOnce(data: unknown) {
vi.mocked(axios.get).mockResolvedValueOnce({ data })
}
function mockAxiosErrorOnce(status: number) {
vi.mocked(axios.get).mockRejectedValueOnce(
new AxiosError(`HTTP ${status}`, 'ERR_BAD_RESPONSE', undefined, undefined, {
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
})
)
}
function mockAxiosNetworkErrorOnce() {
vi.mocked(axios.get).mockRejectedValueOnce(
new AxiosError('Network Error', 'ERR_NETWORK')
)
}
beforeEach(() => {
vi.clearAllMocks()
// Cache API isn't in happy-dom by default. Stub a no-op cache so getCached
// always returns null (forces a fetch) and setCache/clearCache resolve.
vi.stubGlobal('caches', {
open: vi.fn(() =>
Promise.resolve({
match: vi.fn(() => Promise.resolve(undefined)),
put: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve(true))
})
)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('RichComboWidget', () => {
it('mounts, fetches, and renders the items returned from the route', async () => {
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(screen.getByText('Alice')).toBeTruthy()
expect(screen.getByText('Bob')).toBeTruthy()
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('shows the load-failed placeholder on a non-retriable 404 without retrying', async () => {
mockAxiosErrorOnce(404)
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.remoteCombo.loadFailed'
)
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('shows the load-failed placeholder when retries are exhausted', async () => {
// max_retries=1 lets us assert exhaustion without sleeping through the
// exponential backoff (`attempts++` then `attempts >= maxRetries` breaks
// before any setTimeout call).
mockAxiosNetworkErrorOnce()
renderWidget(buildWidget({ max_retries: 1 }))
await waitFor(() =>
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.remoteCombo.loadFailed'
)
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(1)
})
it('terminates pagination when the server reports has_more=false', async () => {
mockAxiosResponseOnce({
items: [{ id: 'a', name: 'Alice' }],
has_more: true
})
mockAxiosResponseOnce({
items: [{ id: 'b', name: 'Bob' }],
has_more: false
})
renderWidget(buildWidget({ page_size: 1 }))
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('refetches when the refresh button is clicked', async () => {
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
renderWidget(buildWidget())
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('1')
)
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
await userEvent.click(screen.getByLabelText('g.refresh'))
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
expect(vi.mocked(axios.get)).toHaveBeenCalledTimes(2)
})
it('clears modelValue to undefined when the selected item is toggled off (B1 regression)', async () => {
mockAxiosResponseOnce([{ id: 'a', name: 'Alice' }])
const { emitted } = renderWidget(buildWidget(), 'a')
expect(await screen.findByTestId('item-a')).toBeTruthy()
await userEvent.click(screen.getByTestId('deselect'))
const updates = emitted('update:modelValue')
expect(updates).toBeTruthy()
expect(updates!.at(-1)).toEqual([undefined])
})
it('preserves a stale modelValue when the fetched items do not contain that id', async () => {
mockAxiosResponseOnce([
{ id: 'a', name: 'Alice' },
{ id: 'b', name: 'Bob' }
])
const { emitted } = renderWidget(buildWidget(), 'stale-id')
await waitFor(() =>
expect(screen.getByTestId('items-count').textContent).toBe('2')
)
// The selection sync watcher only mutates the internal selectedSet — it
// never writes to modelValue, so the stale id round-trips intact when the
// workflow is later saved.
expect(emitted('update:modelValue')).toBeFalsy()
expect(screen.getByTestId('placeholder').textContent).toBe(
'widgets.uploadSelect.placeholder'
)
})
})

View File

@@ -1,481 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type {
RemoteComboConfig,
RemoteItemSchema
} from '@/schemas/nodeDefSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useAuthStore } from '@/stores/authStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
import { AssetKindKey } from './form/dropdown/types'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem
} from '../utils/itemSchemaUtils'
import { fetchRemoteRoute } from '../utils/fetchRemoteRoute'
import {
buildCacheKey,
getBackoff,
isRetriableError,
summarizeError,
summarizePayload
} from '../utils/richComboHelpers'
const DEFAULT_MAX_RETRIES = 5
const DEFAULT_TIMEOUT = 30000
// --- Persistent cache using browser Cache API (survives page reloads) ---
const CACHE_NAME = 'comfy-remote-widget'
// Mirrors useAuthStore().getAuthHeader()'s priority chain so the cache is
// partitioned by the *active* auth context, not just the firebase user.
// Same firebase user across two workspaces, or across workspace ↔ personal,
// would otherwise share a cache and bleed data.
//
// Returns an opaque, non-secret identifier. The API-key branch deliberately
// returns a constant rather than the key value or a hash of it: hashing is
// async (SubtleCrypto), and grouping all keys on one machine under a single
// scope is an acceptable tradeoff for the rare key-rotation case.
function getAuthScope(): string {
const { flags } = useFeatureFlags()
if (flags.teamWorkspacesEnabled) {
const wsId = useWorkspaceAuthStore().currentWorkspace?.id
if (wsId) return `ws:${wsId}`
}
const uid = useAuthStore().userId
if (uid) return `fb:${uid}`
return useApiKeyAuthStore().getApiKey() ? 'apikey' : 'anon'
}
function cacheKeyFor(config: RemoteComboConfig): string {
// Mirror the original lazy lookup: only resolve the auth scope when it
// actually contributes to the key (use_comfy_api routes).
const authScope = config.use_comfy_api ? getAuthScope() : undefined
return buildCacheKey(config, authScope)
}
async function getCached(config: RemoteComboConfig): Promise<unknown[] | null> {
try {
const cache = await caches.open(CACHE_NAME)
const resp = await cache.match(cacheKeyFor(config))
if (!resp) return null
const entry = await resp.json()
const ttl = config.refresh
if (!ttl || ttl <= 0) return entry.data
if (Date.now() - entry.timestamp < ttl) return entry.data
return null
} catch {
return null
}
}
async function clearCache(config: RemoteComboConfig) {
try {
const cache = await caches.open(CACHE_NAME)
await cache.delete(cacheKeyFor(config))
} catch {
// ignore
}
}
async function setCache(config: RemoteComboConfig, data: unknown[]) {
try {
const cache = await caches.open(CACHE_NAME)
const body = JSON.stringify({ data, timestamp: Date.now() })
await cache.put(cacheKeyFor(config), new Response(body))
} catch {
// Cache API unavailable — widget still works, just no persistence
}
}
const { widget } = defineProps<{
widget: SimplifiedWidget<string | undefined>
}>()
const modelValue = defineModel<string>()
const { t } = useI18n()
const comboSpec = computed(() => {
if (widget.spec && isComboInputSpec(widget.spec)) {
return widget.spec
}
return undefined
})
const remoteConfig = computed<RemoteComboConfig | undefined>(
() => comboSpec.value?.remote_combo
)
const itemSchema = computed<RemoteItemSchema | undefined>(
() => remoteConfig.value?.item_schema
)
// --- Fetch state ---
const rawItems = ref<unknown[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const error = ref<string | null>(null)
let abortController: AbortController | undefined
// --- Auto-select policy ---
// Only sets modelValue when it's empty; never overrides an existing value
// (valid or stale) — user intent and workflow portability are preserved.
// 'first' may fire as soon as items exist (per-page in paginated mode);
// 'last' fires only after terminal success, since the actual last item
// isn't known until all pages have loaded.
function applyAutoSelect(config: RemoteComboConfig) {
if (modelValue.value) return
const list = items.value
if (list.length === 0) return
if (config.auto_select === 'first') {
modelValue.value = list[0].id
} else if (config.auto_select === 'last') {
modelValue.value = list[list.length - 1].id
}
}
// --- Single-page fetch (non-paginated mode) ---
async function fetchAll(config: RemoteComboConfig) {
const controller = abortController!
const maxRetries = config.max_retries ?? DEFAULT_MAX_RETRIES
loading.value = true
error.value = null
let attempts = 0
while (!controller.signal.aborted) {
try {
const res = await fetchRemoteRoute(config.route, {
timeout: config.timeout ?? DEFAULT_TIMEOUT,
signal: controller.signal,
useComfyApi: config.use_comfy_api
})
if (controller.signal.aborted) return
const fetchedItems = extractItems(res.data, config.response_key)
if (fetchedItems === null) {
console.error('RichComboWidget: expected array response', {
route: config.route,
responseKey: config.response_key,
received: summarizePayload(res.data)
})
error.value = t('widgets.remoteCombo.loadFailed')
break
}
await setCache(config, fetchedItems)
if (controller.signal.aborted) return
rawItems.value = fetchedItems
applyAutoSelect(config)
break
} catch (err: unknown) {
if (controller.signal.aborted) return
console.error('RichComboWidget: fetch error', {
route: config.route,
error: summarizeError(err)
})
if (!isRetriableError(err)) {
error.value = t('widgets.remoteCombo.loadFailed')
break
}
attempts++
if (attempts >= maxRetries) {
error.value = t('widgets.remoteCombo.loadFailed')
break
}
const delay = getBackoff(attempts)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
if (!controller.signal.aborted) {
loading.value = false
}
}
// --- Progressive fetch (paginated mode) ---
async function fetchPaginated(config: RemoteComboConfig) {
const controller = abortController!
const pageSize = config.page_size!
const maxRetries = config.max_retries ?? DEFAULT_MAX_RETRIES
let page = 0
let consecutiveErrors = 0
let terminalSuccess = false
// First page shows loading indicator
loading.value = true
error.value = null
while (!controller.signal.aborted) {
try {
const params = {
page: String(page),
page_size: String(pageSize)
}
const res = await fetchRemoteRoute(config.route, {
params,
timeout: config.timeout ?? DEFAULT_TIMEOUT,
signal: controller.signal,
useComfyApi: config.use_comfy_api
})
if (controller.signal.aborted) return
if (
!res.data ||
typeof res.data !== 'object' ||
Array.isArray(res.data)
) {
console.error(
'RichComboWidget: expected { items, has_more } response',
{
route: config.route,
page,
received: summarizePayload(res.data)
}
)
break
}
const pageItems: unknown[] = Array.isArray(res.data.items)
? res.data.items
: []
const hasMore: boolean = res.data.has_more === true
rawItems.value = [...rawItems.value, ...pageItems]
consecutiveErrors = 0
// After first page, switch from "loading" to "loading more"
if (page === 0) {
loading.value = false
loadingMore.value = true
}
// 'first' is known as soon as we have any items; idempotent thereafter.
if (config.auto_select === 'first') applyAutoSelect(config)
if (!hasMore || pageItems.length === 0) {
terminalSuccess = true
break
}
page++
} catch (err: unknown) {
if (controller.signal.aborted) return
if (!isRetriableError(err)) {
console.error(`RichComboWidget: non-retriable error on page ${page}`, {
route: config.route,
error: summarizeError(err)
})
break
}
consecutiveErrors++
if (consecutiveErrors >= maxRetries) {
console.error(
`RichComboWidget: giving up after ${maxRetries} consecutive errors on page ${page}`,
{ route: config.route, error: summarizeError(err) }
)
break
}
// Retry same page after backoff
const delay = getBackoff(consecutiveErrors)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
if (controller.signal.aborted) return
// Cache the accumulated result before releasing loading state; an abort
// during setCache then skips the state reset instead of flickering it.
// Only cache on terminal success — caching partial results would poison the
// next mount with an incomplete list and never re-fetch the missing pages.
if (terminalSuccess && rawItems.value.length > 0) {
await setCache(config, rawItems.value)
}
if (controller.signal.aborted) return
loading.value = false
loadingMore.value = false
if (!terminalSuccess && rawItems.value.length === 0) {
error.value = t('widgets.remoteCombo.loadFailed')
}
if (terminalSuccess) {
applyAutoSelect(config)
}
}
async function fetchItems(bypassCache = false) {
const config = remoteConfig.value
if (!config) return
// Claim the active controller before any async work so the cache-hit
// path can bail out if a later fetchItems supersedes us.
abortController?.abort()
const myController = new AbortController()
abortController = myController
// Check cache first (unless manual refresh)
if (!bypassCache) {
const cached = await getCached(config)
if (myController.signal.aborted) return
if (cached) {
rawItems.value = cached
applyAutoSelect(config)
return
}
}
// Reset items for fresh fetch
rawItems.value = []
if (config.page_size) {
await fetchPaginated(config)
} else {
await fetchAll(config)
}
}
onMounted(() => {
void fetchItems()
})
onUnmounted(() => {
abortController?.abort()
})
// --- Preview type ---
const assetKind = computed(() => itemSchema.value?.preview_type ?? 'image')
provide(AssetKindKey, assetKind)
// --- Item mapping ---
const items = computed<FormDropdownItem[]>(() => {
const schema = itemSchema.value
if (schema) {
return rawItems.value.map((raw) => mapToDropdownItem(raw, schema))
}
return rawItems.value.map((raw) => {
const val = String(raw ?? '')
return { id: val, name: val }
})
})
// --- Search ---
const searchIndex = computed(() => {
const schema = itemSchema.value
const fields = schema?.search_fields
if (!schema || !fields?.length) return new Map<string, string>()
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
const text = buildSearchText(raw, fields)
if (text) index.set(id, text)
}
return index
})
const layoutMode = ref<LayoutMode>('list')
const selectedSet = ref<Set<string>>(new Set())
async function searcher(query: string, searchItems: FormDropdownItem[]) {
if (!query.trim()) return searchItems
const q = query.toLowerCase()
return searchItems.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
}
// --- Selection sync ---
watch(
[modelValue, items],
([val]) => {
selectedSet.value.clear()
if (val) {
const item = items.value.find((i) => i.id === val)
if (item) selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
function handleRefresh() {
abortController?.abort()
error.value = null
const config = remoteConfig.value
// Sequence the cache delete before the refetch: otherwise the (very fast)
// setCache from a quickly-resolved network response can land the new entry
// before the still-pending cache.delete removes it, silently dropping the
// freshly-cached data on the next mount.
void (async () => {
if (config) await clearCache(config)
await fetchItems(true)
})()
}
function handleSelection(selected: Set<string>) {
modelValue.value = selected.values().next().value
}
const placeholder = computed(() => {
if (loading.value) return t('widgets.remoteCombo.loading')
if (error.value) return error.value
if (loadingMore.value) {
return t('widgets.remoteCombo.itemsLoaded', {
count: items.value.length
})
}
return t('widgets.uploadSelect.placeholder')
})
</script>
<template>
<div
class="flex w-full min-w-0 items-center gap-1 rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted"
@pointerdown.stop
@pointermove.stop
@pointerup.stop
>
<FormDropdown
v-model:selected="selectedSet"
v-model:layout-mode="layoutMode"
:items="items"
:placeholder="placeholder"
:multiple="false"
:show-sort="false"
:show-layout-switcher="false"
:searcher="searcher"
class="min-w-0 flex-1"
@update:selected="handleSelection"
/>
<button
v-if="remoteConfig?.refresh_button !== false"
type="button"
:aria-label="t('g.refresh')"
:title="t('g.refresh')"
class="text-secondary flex size-7 shrink-0 items-center justify-center rounded-sm hover:bg-component-node-widget-background-hovered"
@click.stop="handleRefresh"
>
<i
:class="
cn(
'icon-[lucide--refresh-cw] size-3.5',
(loading || loadingMore) && 'animate-spin'
)
"
/>
</button>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<template>
<RichComboWidget v-if="hasRemoteCombo" v-model="modelValue" :widget />
<WidgetSelectDropdown
v-else-if="isDropdownUIWidget"
v-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -25,7 +24,6 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -55,8 +53,6 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasRemoteCombo = computed(() => !!comboSpec.value?.remote_combo)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -33,8 +33,6 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -61,8 +59,6 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -233,8 +229,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -20,8 +20,6 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -33,8 +31,6 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -116,8 +112,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -151,7 +145,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -16,10 +16,8 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const { showSort = true, showLayoutSwitcher = true } = defineProps<{
defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -114,7 +112,6 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
variant="textonly"
size="icon"
@@ -133,7 +130,6 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -308,7 +304,6 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -1,21 +1,17 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/utils/tailwindUtil'
import { AssetKindKey } from './types'
import type { LayoutMode } from './types'
const { t } = useI18n()
interface Props {
index: number
selected: boolean
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}
@@ -31,26 +27,11 @@ const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isAudio = computed(() => assetKind?.value === 'audio')
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
const audio = audioRef.value
if (!audio) return
if (audio.paused) {
void audio.play().catch(() => {})
} else {
audio.pause()
}
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -126,35 +107,6 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<button
v-else-if="previewUrl && isAudio"
type="button"
:aria-label="
isPlayingAudio
? t('widgets.remoteCombo.pauseAudioPreview')
: t('widgets.remoteCombo.playAudioPreview')
"
:aria-pressed="isPlayingAudio"
class="flex size-full cursor-pointer items-center justify-center bg-component-node-widget-background hover:bg-component-node-widget-background-hovered"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@play="isPlayingAudio = true"
@pause="isPlayingAudio = false"
@ended="isPlayingAudio = false"
/>
<i
:class="
cn(
'text-secondary size-5',
isPlayingAudio ? 'icon-[lucide--pause]' : 'icon-[lucide--play]'
)
"
/>
</button>
<img
v-else-if="previewUrl"
:src="previewUrl"
@@ -192,13 +144,6 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,9 +12,7 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
/** Preview image/video URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean

View File

@@ -9,7 +9,7 @@ import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/c
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
const mockCheckState = vi.hoisted(() => vi.fn())
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
@@ -20,7 +20,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
captureCanvasState: mockCaptureCanvasState
checkState: mockCheckState
}
}
})
@@ -48,7 +48,7 @@ function createItems(...names: string[]): FormDropdownItem[] {
describe('useWidgetSelectActions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockCaptureCanvasState.mockClear()
mockCheckState.mockClear()
})
describe('updateSelectedItems', () => {
@@ -71,7 +71,7 @@ describe('useWidgetSelectActions', () => {
updateSelectedItems(new Set(['input-1']))
expect(modelValue.value).toBe('photo_abc.jpg')
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('clears modelValue when empty set', () => {
@@ -93,7 +93,7 @@ describe('useWidgetSelectActions', () => {
updateSelectedItems(new Set())
expect(modelValue.value).toBeUndefined()
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
expect(mockCheckState).toHaveBeenCalledOnce()
})
})
@@ -130,7 +130,7 @@ describe('useWidgetSelectActions', () => {
await handleFilesUpdate([file])
expect(modelValue.value).toBe('uploaded.png')
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('adds uploaded path to widget values array', async () => {

View File

@@ -23,8 +23,8 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
const toastStore = useToastStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
function captureWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
function checkWorkflowState() {
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
function updateSelectedItems(selectedItems: Set<string>) {
@@ -36,7 +36,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
: dropdownItems.value.find((item) => item.id === id)?.name
modelValue.value = name
captureWorkflowState()
checkWorkflowState()
}
async function uploadFile(
@@ -109,7 +109,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
widget.callback(uploadedPaths[0])
}
captureWorkflowState()
checkWorkflowState()
}
)

View File

@@ -1,106 +0,0 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchRemoteRoute } from '@/renderer/extensions/vueNodes/widgets/utils/fetchRemoteRoute'
import type { AuthHeader } from '@/types/authTypes'
const COMFY_API_BASE = 'https://api.example.test'
const mockAuth = vi.hoisted(() => ({
authHeader: null as AuthHeader | null
}))
vi.mock('axios', async (importOriginal) => {
const actual = await importOriginal<typeof axios>()
return {
default: {
...actual,
get: vi.fn()
}
}
})
vi.mock('@/config/comfyApi', () => ({
getComfyApiBaseUrl: () => COMFY_API_BASE
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: vi.fn(() => Promise.resolve(mockAuth.authHeader))
}))
}))
describe('fetchRemoteRoute', () => {
beforeEach(() => {
vi.mocked(axios.get).mockResolvedValue({ data: [] })
})
afterEach(() => {
mockAuth.authHeader = null
vi.clearAllMocks()
})
it('uses the route as-is when useComfyApi is not set', async () => {
await fetchRemoteRoute('/voices')
const [url] = vi.mocked(axios.get).mock.calls[0]
expect(url).toBe('/voices')
})
it('passes through absolute URLs unchanged when useComfyApi is false', async () => {
await fetchRemoteRoute('https://other.example/voices', {
useComfyApi: false
})
const [url] = vi.mocked(axios.get).mock.calls[0]
expect(url).toBe('https://other.example/voices')
})
it('prepends the comfy api base URL when useComfyApi is true', async () => {
await fetchRemoteRoute('/voices', { useComfyApi: true })
const [url] = vi.mocked(axios.get).mock.calls[0]
expect(url).toBe(`${COMFY_API_BASE}/voices`)
})
it('injects the auth header when useComfyApi is true and one is available', async () => {
mockAuth.authHeader = { Authorization: 'Bearer token-123' }
await fetchRemoteRoute('/voices', { useComfyApi: true })
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toEqual({ Authorization: 'Bearer token-123' })
})
it('does not set headers when useComfyApi is true but no auth header is available', async () => {
mockAuth.authHeader = null
await fetchRemoteRoute('/voices', { useComfyApi: true })
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toBeUndefined()
})
it('does not inject auth headers when useComfyApi is false', async () => {
mockAuth.authHeader = { Authorization: 'Bearer token-123' }
await fetchRemoteRoute('/voices', { useComfyApi: false })
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.headers).toBeUndefined()
})
it('forwards params, timeout and signal to axios while stripping useComfyApi', async () => {
const controller = new AbortController()
await fetchRemoteRoute('/voices', {
useComfyApi: true,
params: { page: '2', page_size: '10' },
timeout: 5000,
signal: controller.signal
})
const [, config] = vi.mocked(axios.get).mock.calls[0]
expect(config?.params).toEqual({ page: '2', page_size: '10' })
expect(config?.timeout).toBe(5000)
expect(config?.signal).toBe(controller.signal)
expect(
(config as Record<string, unknown> | undefined)?.useComfyApi
).toBeUndefined()
})
it('returns the axios response', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: { items: [1, 2] } })
const res = await fetchRemoteRoute('/voices')
expect(res.data).toEqual({ items: [1, 2] })
})
})

View File

@@ -1,53 +0,0 @@
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useAuthStore } from '@/stores/authStore'
import type { AuthHeader } from '@/types/authTypes'
/**
* Resolve a RemoteComboOptions route to a full URL.
* - useComfyApi=true → prepend getComfyApiBaseUrl()
* - Otherwise → use as-is
*/
function resolveRoute(route: string, useComfyApi?: boolean): string {
if (useComfyApi) {
return getComfyApiBaseUrl() + route
}
return route
}
/**
* Get auth headers for a remote request.
* - useComfyApi=true → inject auth headers (comfy-api requires it)
* - Otherwise → no auth headers injected
*/
async function getRemoteAuthHeaders(
useComfyApi?: boolean
): Promise<{ headers?: AuthHeader }> {
if (useComfyApi) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
if (authHeader) {
return { headers: authHeader }
}
}
return {}
}
/**
* Convenience: make an authenticated GET request to a remote route.
*/
export async function fetchRemoteRoute(
route: string,
options: {
params?: Record<string, string>
timeout?: number
signal?: AbortSignal
useComfyApi?: boolean
} = {}
) {
const { useComfyApi, ...requestOptions } = options
const url = resolveRoute(route, useComfyApi)
const authHeaders = await getRemoteAuthHeaders(useComfyApi)
return axios.get(url, { ...requestOptions, ...authHeaders })
}

View File

@@ -1,254 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
buildSearchText,
extractItems,
getByPath,
mapToDropdownItem,
resolveLabel
} from '@/renderer/extensions/vueNodes/widgets/utils/itemSchemaUtils'
describe('getByPath', () => {
it('returns a top-level value for a plain key', () => {
expect(getByPath({ name: 'Alice' }, 'name')).toBe('Alice')
})
it('traverses nested objects via dot-path', () => {
expect(getByPath({ profile: { name: 'Alice' } }, 'profile.name')).toBe(
'Alice'
)
})
it('treats numeric segments as array indices', () => {
expect(getByPath({ items: ['a', 'b', 'c'] }, 'items.1')).toBe('b')
})
it('combines nested objects and array indices', () => {
const obj = { data: { results: [{ id: 'x' }, { id: 'y' }] } }
expect(getByPath(obj, 'data.results.1.id')).toBe('y')
})
it('returns undefined for a missing top-level key', () => {
expect(getByPath({ a: 1 }, 'b')).toBeUndefined()
})
it('returns undefined when traversing past a null segment', () => {
expect(getByPath({ a: null }, 'a.b')).toBeUndefined()
})
it('returns undefined when the root is null', () => {
expect(getByPath(null, 'a')).toBeUndefined()
})
it('returns undefined when the root is undefined', () => {
expect(getByPath(undefined, 'a')).toBeUndefined()
})
it('returns undefined for an out-of-bounds array index', () => {
expect(getByPath({ items: ['a'] }, 'items.5')).toBeUndefined()
})
})
describe('resolveLabel', () => {
it('resolves a plain dot-path to its value', () => {
expect(resolveLabel('name', { name: 'Alice' })).toBe('Alice')
})
it('resolves a nested dot-path without placeholders', () => {
expect(resolveLabel('profile.name', { profile: { name: 'Alice' } })).toBe(
'Alice'
)
})
it('substitutes a single {field} placeholder', () => {
expect(resolveLabel('Name: {name}', { name: 'Alice' })).toBe('Name: Alice')
})
it('substitutes multiple placeholders', () => {
expect(
resolveLabel('{first} {last}', { first: 'Alice', last: 'Liddell' })
).toBe('Alice Liddell')
})
it('substitutes placeholders with dot-paths', () => {
expect(
resolveLabel('{profile.name} ({profile.age})', {
profile: { name: 'Alice', age: 30 }
})
).toBe('Alice (30)')
})
it('replaces missing placeholder fields with an empty string', () => {
expect(resolveLabel('{name} - {missing}', { name: 'Alice' })).toBe(
'Alice - '
)
})
it('returns an empty string when a plain path resolves to undefined', () => {
expect(resolveLabel('missing', { a: 1 })).toBe('')
})
it('coerces numeric values to strings', () => {
expect(resolveLabel('{count}', { count: 5 })).toBe('5')
})
})
describe('mapToDropdownItem', () => {
it('maps required fields to id and name', () => {
const item = mapToDropdownItem(
{ voice_id: 'v1', label: 'Roger' },
{ value_field: 'voice_id', label_field: 'label', preview_type: 'image' }
)
expect(item).toEqual({
id: 'v1',
name: 'Roger',
description: undefined,
preview_url: undefined
})
})
it('includes description when description_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', desc: 'Laid-back American male' },
{
value_field: 'id',
label_field: 'label',
description_field: 'desc',
preview_type: 'image'
}
)
expect(item.description).toBe('Laid-back American male')
})
it('includes preview_url when preview_url_field is set', () => {
const item = mapToDropdownItem(
{ id: 'v1', label: 'Roger', sample: 'https://example.com/a.mp3' },
{
value_field: 'id',
label_field: 'label',
preview_url_field: 'sample',
preview_type: 'audio'
}
)
expect(item.preview_url).toBe('https://example.com/a.mp3')
})
it('resolves label_field templates with placeholders', () => {
const item = mapToDropdownItem(
{ id: 'v1', first: 'Alice', last: 'Liddell' },
{
value_field: 'id',
label_field: '{first} {last}',
preview_type: 'image'
}
)
expect(item.name).toBe('Alice Liddell')
})
it('resolves dot-path fields for nested data', () => {
const item = mapToDropdownItem(
{ task_result: { elements: [{ element_id: 'e1', name: 'Elem' }] } },
{
value_field: 'task_result.elements.0.element_id',
label_field: 'task_result.elements.0.name',
preview_type: 'image'
}
)
expect(item.id).toBe('e1')
expect(item.name).toBe('Elem')
})
it('stringifies non-string value_field', () => {
const item = mapToDropdownItem(
{ id: 42, label: 'Answer' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('42')
})
it('returns an empty string id when value_field is missing', () => {
const item = mapToDropdownItem(
{ label: 'Orphan' },
{ value_field: 'id', label_field: 'label', preview_type: 'image' }
)
expect(item.id).toBe('')
})
})
describe('extractItems', () => {
it('returns the full response when responseKey is undefined', () => {
expect(extractItems([1, 2, 3])).toEqual([1, 2, 3])
})
it('extracts items from a top-level key', () => {
expect(
extractItems({ voices: [{ id: 'a' }, { id: 'b' }] }, 'voices')
).toEqual([{ id: 'a' }, { id: 'b' }])
})
it('extracts items via a dot-path', () => {
expect(extractItems({ data: { items: [1, 2] } }, 'data.items')).toEqual([
1, 2
])
})
it('returns an empty array for a valid empty list', () => {
expect(extractItems([])).toEqual([])
})
it('returns null when the path does not exist', () => {
expect(extractItems({ a: 1 }, 'nonexistent')).toBeNull()
})
it('returns null when the path resolves to a non-array', () => {
expect(
extractItems({ data: { items: 'not an array' } }, 'data.items')
).toBeNull()
})
it('returns null when the full response is not an array', () => {
expect(extractItems({ not: 'array' })).toBeNull()
})
it('returns null when response is null', () => {
expect(extractItems(null)).toBeNull()
})
})
describe('buildSearchText', () => {
it('joins multiple fields with a space', () => {
expect(buildSearchText({ a: 'Hello', b: 'World' }, ['a', 'b'])).toBe(
'hello world'
)
})
it('lowercases the result', () => {
expect(buildSearchText({ name: 'ALICE' }, ['name'])).toBe('alice')
})
it('drops missing fields', () => {
expect(buildSearchText({ name: 'Alice' }, ['name', 'missing'])).toBe(
'alice'
)
})
it('supports dot-path fields', () => {
expect(
buildSearchText({ profile: { name: 'Alice', age: 30 } }, [
'profile.name',
'profile.age'
])
).toBe('alice 30')
})
it('returns an empty string when all fields are missing', () => {
expect(buildSearchText({ name: 'Alice' }, ['missing'])).toBe('')
})
})

View File

@@ -1,62 +0,0 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
/** Traverse an object by dot-path, treating numeric segments as array indices */
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
/** Resolve a label — either dot-path or template with {field.path} placeholders */
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
/** Map a raw API object to a FormDropdownItem using the item_schema */
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema
): FormDropdownItem {
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
}
}
/**
* Extract items array from a full API response using `responseKey`.
* Returns `null` when the resolved value isn't an array (path missing,
* wrong shape, etc.) so callers can distinguish a malformed response
* from a legitimate empty list.
*/
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] | null {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : null
}
/** Build search text for an item from the specified search fields */
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}

View File

@@ -1,280 +0,0 @@
import { AxiosError, AxiosHeaders } from 'axios'
import { describe, expect, it } from 'vitest'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
import {
buildCacheKey,
getBackoff,
isRetriableError,
summarizeError,
summarizePayload
} from '@/renderer/extensions/vueNodes/widgets/utils/richComboHelpers'
const baseConfig: RemoteComboConfig = {
route: '/voices',
item_schema: {
value_field: 'id',
label_field: 'name',
preview_type: 'image'
}
}
function parseKey(key: string): URLSearchParams {
return new URL(key).searchParams
}
describe('buildCacheKey', () => {
it('encodes the route, response_key and page_size', () => {
const params = parseKey(
buildCacheKey({
...baseConfig,
route: '/voices',
response_key: 'data.items',
page_size: 50
})
)
expect(params.get('route')).toBe('/voices')
expect(params.get('responseKey')).toBe('data.items')
expect(params.get('pageSize')).toBe('50')
})
it('encodes use_comfy_api as a 0/1 flag', () => {
expect(parseKey(buildCacheKey(baseConfig)).get('useComfyApi')).toBe('0')
expect(
parseKey(buildCacheKey({ ...baseConfig, use_comfy_api: true }, 'u1')).get(
'useComfyApi'
)
).toBe('1')
})
it('partitions by authScope only when use_comfy_api is true', () => {
const comfyA = buildCacheKey(
{ ...baseConfig, use_comfy_api: true },
'ws:team-a'
)
const comfyB = buildCacheKey(
{ ...baseConfig, use_comfy_api: true },
'ws:team-b'
)
expect(comfyA).not.toBe(comfyB)
expect(parseKey(comfyA).get('u')).toBe('ws:team-a')
expect(parseKey(comfyB).get('u')).toBe('ws:team-b')
})
it('shares the cache across auth scopes when use_comfy_api is false', () => {
const a = buildCacheKey(baseConfig, 'fb:user-a')
const b = buildCacheKey(baseConfig, 'fb:user-b')
expect(a).toBe(b)
expect(parseKey(a).has('u')).toBe(false)
})
it('treats workspace, firebase, and api-key scopes as distinct buckets', () => {
const ws = buildCacheKey({ ...baseConfig, use_comfy_api: true }, 'ws:abc')
const fb = buildCacheKey({ ...baseConfig, use_comfy_api: true }, 'fb:abc')
const apikey = buildCacheKey(
{ ...baseConfig, use_comfy_api: true },
'apikey'
)
expect(new Set([ws, fb, apikey]).size).toBe(3)
})
it('falls back to "anon" when use_comfy_api is true and authScope is missing', () => {
expect(
parseKey(buildCacheKey({ ...baseConfig, use_comfy_api: true }, null)).get(
'u'
)
).toBe('anon')
expect(
parseKey(buildCacheKey({ ...baseConfig, use_comfy_api: true })).get('u')
).toBe('anon')
})
it('treats missing optional fields as empty / 0 so the key stays stable', () => {
const params = parseKey(buildCacheKey(baseConfig))
expect(params.get('responseKey')).toBe('')
expect(params.get('pageSize')).toBe('0')
})
})
describe('getBackoff', () => {
it('grows exponentially from 1s', () => {
expect(getBackoff(1)).toBe(2000)
expect(getBackoff(2)).toBe(4000)
expect(getBackoff(3)).toBe(8000)
expect(getBackoff(4)).toBe(16000)
})
it('caps at 16s for higher attempt counts', () => {
expect(getBackoff(5)).toBe(16000)
expect(getBackoff(10)).toBe(16000)
expect(getBackoff(100)).toBe(16000)
})
})
describe('isRetriableError', () => {
function axiosErrorWithStatus(status: number): AxiosError {
return new AxiosError(
`HTTP ${status}`,
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
}
it('retries non-axios errors (e.g. unexpected throws)', () => {
expect(isRetriableError(new Error('boom'))).toBe(true)
expect(isRetriableError('string error')).toBe(true)
expect(isRetriableError(undefined)).toBe(true)
})
it('retries axios errors with no response (network failures)', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(isRetriableError(err)).toBe(true)
})
it('retries 5xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(500))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(502))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(503))).toBe(true)
})
it('retries 408 (request timeout) and 429 (too many requests)', () => {
expect(isRetriableError(axiosErrorWithStatus(408))).toBe(true)
expect(isRetriableError(axiosErrorWithStatus(429))).toBe(true)
})
it('does not retry other 4xx responses', () => {
expect(isRetriableError(axiosErrorWithStatus(400))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(401))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(403))).toBe(false)
expect(isRetriableError(axiosErrorWithStatus(404))).toBe(false)
})
})
describe('summarizeError', () => {
it('extracts message, code and status from an axios error', () => {
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
undefined,
undefined,
{
status: 500,
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
data: null
}
)
expect(summarizeError(err)).toEqual({
message: 'Request failed',
code: 'ERR_BAD_RESPONSE',
status: 500
})
})
it('does not include axios config, headers, request or response data', () => {
const authedConfig = {
url: '/voices',
method: 'get',
headers: new AxiosHeaders({ Authorization: 'Bearer SECRET-TOKEN-123' })
}
const err = new AxiosError(
'Request failed',
'ERR_BAD_RESPONSE',
authedConfig,
undefined,
{
status: 500,
statusText: '',
headers: { 'set-cookie': ['session=PRIVATE'] },
config: authedConfig,
data: { user_email: 'private@example.com' }
}
)
const summary = summarizeError(err)
expect(JSON.stringify(summary)).not.toContain('SECRET-TOKEN-123')
expect(JSON.stringify(summary)).not.toContain('PRIVATE')
expect(JSON.stringify(summary)).not.toContain('private@example.com')
expect(summary).not.toHaveProperty('config')
expect(summary).not.toHaveProperty('request')
expect(summary).not.toHaveProperty('response')
})
it('reports an axios network error with no response as undefined status', () => {
const err = new AxiosError('Network Error', 'ERR_NETWORK')
expect(summarizeError(err)).toEqual({
message: 'Network Error',
code: 'ERR_NETWORK',
status: undefined
})
})
it('summarizes a plain Error using its name and message', () => {
expect(summarizeError(new TypeError('boom'))).toEqual({
message: 'boom',
name: 'TypeError'
})
})
it('coerces non-Error throwables to a message string', () => {
expect(summarizeError('oops')).toEqual({ message: 'oops' })
expect(summarizeError(42)).toEqual({ message: '42' })
expect(summarizeError(null)).toEqual({ message: 'null' })
expect(summarizeError(undefined)).toEqual({ message: 'undefined' })
})
})
describe('summarizePayload', () => {
it('reports array length without exposing values', () => {
expect(
summarizePayload([{ secret: 'a' }, { secret: 'b' }, { secret: 'c' }])
).toEqual({
type: 'array',
length: 3
})
})
it('reports object keys without exposing values', () => {
expect(
summarizePayload({ user_email: 'private@example.com', voices: ['x'] })
).toEqual({
type: 'object',
keys: ['user_email', 'voices'],
keyCount: 2
})
})
it('caps the keys sample at 10 but reports the full key count', () => {
const big: Record<string, number> = {}
for (let i = 0; i < 25; i++) big[`k${i}`] = i
const summary = summarizePayload(big) as {
type: string
keys: string[]
keyCount: number
}
expect(summary.type).toBe('object')
expect(summary.keys).toHaveLength(10)
expect(summary.keyCount).toBe(25)
})
it('distinguishes null and undefined', () => {
expect(summarizePayload(null)).toEqual({ type: 'null' })
expect(summarizePayload(undefined)).toEqual({ type: 'undefined' })
})
it('reports primitive types without their value', () => {
expect(summarizePayload('hello')).toEqual({ type: 'string' })
expect(summarizePayload(123)).toEqual({ type: 'number' })
expect(summarizePayload(true)).toEqual({ type: 'boolean' })
})
})

View File

@@ -1,95 +0,0 @@
import axios from 'axios'
import type { RemoteComboConfig } from '@/schemas/nodeDefSchema'
const BACKOFF_BASE_MS = 1000
const BACKOFF_CAP_MS = 16000
/**
* Build a stable cache key for a remote combo configuration.
*
* Non-comfy-api routes intentionally share cache across users on the same
* machine; comfy-api routes are partitioned by `authScope` — an opaque,
* non-secret identifier of the active auth context (workspace id, firebase
* uid, etc.). Resolving the scope is the caller's responsibility, which
* keeps this helper pure and trivially testable.
*/
export function buildCacheKey(
config: RemoteComboConfig,
authScope?: string | null
): string {
const params = new URLSearchParams({
route: config.route,
useComfyApi: config.use_comfy_api ? '1' : '0',
responseKey: config.response_key ?? '',
pageSize: String(config.page_size ?? 0)
})
if (config.use_comfy_api) {
params.set('u', authScope ?? 'anon')
}
return `https://cache.comfy.invalid/?${params}`
}
/**
* Exponential backoff in milliseconds, capped at 16s. `count` is the
* number of failed attempts so far (1-indexed for the first retry).
*/
export function getBackoff(count: number): number {
return Math.min(BACKOFF_BASE_MS * Math.pow(2, count), BACKOFF_CAP_MS)
}
/**
* Distinguish transient errors (worth retrying) from permanent ones.
* 401/403/404 etc. won't fix themselves — retrying wastes time.
* Network-level failures (no response) are treated as retriable.
*/
export function isRetriableError(err: unknown): boolean {
if (!axios.isAxiosError(err)) return true
const status = err.response?.status
if (status == null) return true
if (status >= 500) return true
return status === 408 || status === 429
}
/**
* Build a console-safe summary of an unknown error. Authenticated remote
* routes inject auth headers via fetchRemoteRoute, and AxiosError serializes
* its `config` (including those headers) by default — so logging the raw
* error would leak bearer tokens to devtools and any attached telemetry.
* This summary keeps only the diagnostic essentials.
*/
export function summarizeError(err: unknown): Record<string, unknown> {
if (axios.isAxiosError(err)) {
return {
message: err.message,
code: err.code,
status: err.response?.status
}
}
if (err instanceof Error) {
return { message: err.message, name: err.name }
}
return { message: String(err) }
}
const PAYLOAD_KEY_SAMPLE = 10
/**
* Build a console-safe summary of a remote response payload. Logs the
* structural shape so devs can diagnose schema mismatches without the
* actual values, which for authenticated routes may contain private data.
*/
export function summarizePayload(data: unknown): Record<string, unknown> {
if (data === null) return { type: 'null' }
if (data === undefined) return { type: 'undefined' }
if (Array.isArray(data)) return { type: 'array', length: data.length }
if (typeof data === 'object') {
const keys = Object.keys(data as Record<string, unknown>)
return {
type: 'object',
keys: keys.slice(0, PAYLOAD_KEY_SAMPLE),
keyCount: keys.length
}
}
return { type: typeof data }
}

View File

@@ -5,11 +5,6 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
/**
* Plain remote combo config — feeds a standard combo dropdown from a remote endpoint.
* Handled by `useRemoteWidget` + `WidgetSelectDropdown`.
*/
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -20,42 +15,6 @@ const zRemoteWidgetConfig = z.object({
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
})
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional()
})
/**
* Rich remote combo config — feeds `RichComboWidget` with item previews, search, filtering,
* and optional progressive pagination. Requires `item_schema`. Vue-nodes only.
*/
const zRemoteComboConfig = z
.object({
route: z.string().url().or(z.string().startsWith('/')),
item_schema: zRemoteItemSchema,
refresh_button: z.boolean().optional(),
auto_select: z.enum(['first', 'last']).optional(),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
response_key: z.string().optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional(),
use_comfy_api: z.boolean().optional(),
page_size: z.number().gte(1).optional()
})
.superRefine((config, ctx) => {
if (config.use_comfy_api && !config.route.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['route'],
message: 'route must be relative when use_comfy_api is true'
})
}
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
chip: z.boolean().optional()
@@ -137,7 +96,6 @@ export const zComboInputOptions = zBaseInputOptions.extend({
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),
remote_combo: zRemoteComboConfig.optional(),
/** Whether the widget is a multi-select widget. */
multi_select: zMultiSelectOption.optional()
})
@@ -394,9 +352,7 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type RemoteComboConfig = z.infer<typeof zRemoteComboConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>
export type NumericInputOptions = z.infer<typeof zNumericInputOptions>

View File

@@ -71,67 +71,4 @@ describe('validateNodeDef', () => {
})
}
)
describe('remote_combo cross-field validation', () => {
const buildNodeDef = (remoteCombo: object): unknown => ({
...EXAMPLE_NODE_DEF,
input: {
required: {
voice: ['COMBO', { remote_combo: remoteCombo }]
}
}
})
const baseRemoteCombo = {
item_schema: { value_field: 'id', label_field: 'name' }
}
it('rejects use_comfy_api=true paired with an absolute route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices',
use_comfy_api: true
}),
() => {}
)
).toBeNull()
})
it('accepts use_comfy_api=true paired with a relative route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: '/voices',
use_comfy_api: true
})
)
).not.toBeNull()
})
it('accepts use_comfy_api=false with an absolute route', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices',
use_comfy_api: false
})
)
).not.toBeNull()
})
it('accepts an absolute route when use_comfy_api is omitted', () => {
expect(
validateComfyNodeDef(
buildNodeDef({
...baseRemoteCombo,
route: 'https://api.example.com/voices'
})
)
).not.toBeNull()
})
})
})

View File

@@ -1,302 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const mockNodeOutputStore = vi.hoisted(() => ({
snapshotOutputs: vi.fn(() => ({})),
restoreOutputs: vi.fn()
}))
const mockSubgraphNavigationStore = vi.hoisted(() => ({
exportState: vi.fn(() => []),
restoreState: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: null as { changeTracker: unknown } | null,
getWorkflowByPath: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: {
graph: {},
rootGraph: {
serialize: vi.fn(() => ({
nodes: [],
links: [],
groups: [],
extra: {},
config: {},
version: 0.4,
last_node_id: 0,
last_link_id: 0
}))
},
canvas: {
ds: { scale: 1, offset: [0, 0] }
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
dispatchCustomEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: vi.fn(() => mockNodeOutputStore)
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: vi.fn(() => mockSubgraphNavigationStore)
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
ComfyWorkflow: class {},
useWorkflowStore: vi.fn(() => mockWorkflowStore)
}))
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { ChangeTracker } from '@/scripts/changeTracker'
let nodeIdCounter = 0
function createState(nodeCount = 0): ComfyWorkflowJSON {
const nodes = Array.from({ length: nodeCount }, () => ({
id: ++nodeIdCounter,
type: 'TestNode',
pos: [0, 0],
size: [100, 50],
flags: {},
order: 0,
inputs: [],
outputs: [],
properties: {}
}))
return {
nodes,
links: [],
groups: [],
extra: {},
config: {},
version: 0.4,
last_node_id: nodeIdCounter,
last_link_id: 0
} as unknown as ComfyWorkflowJSON
}
function createTracker(initialState?: ComfyWorkflowJSON): ChangeTracker {
const state = initialState ?? createState()
const workflow = { path: '/test/workflow.json' } as never
const tracker = new ChangeTracker(workflow, state)
mockWorkflowStore.activeWorkflow = { changeTracker: tracker }
return tracker
}
function mockCanvasState(state: ComfyWorkflowJSON) {
vi.mocked(app.rootGraph.serialize).mockReturnValue(state as never)
}
describe('ChangeTracker', () => {
beforeEach(() => {
vi.clearAllMocks()
nodeIdCounter = 0
ChangeTracker.isLoadingGraph = false
mockWorkflowStore.activeWorkflow = null
mockWorkflowStore.getWorkflowByPath.mockReturnValue(null)
})
describe('captureCanvasState', () => {
describe('guards', () => {
it('is a no-op when app.graph is falsy', () => {
const tracker = createTracker()
const original = tracker.activeState
const spy = vi.spyOn(app, 'graph', 'get').mockReturnValue(null as never)
tracker.captureCanvasState()
spy.mockRestore()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(tracker.activeState).toBe(original)
})
it('is a no-op when changeCount > 0', () => {
const tracker = createTracker()
tracker.beforeChange()
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op when isLoadingGraph is true', () => {
const tracker = createTracker()
ChangeTracker.isLoadingGraph = true
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op when _restoringState is true', () => {
const tracker = createTracker()
tracker._restoringState = true
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
it('is a no-op and logs error when called on inactive tracker', () => {
const tracker = createTracker()
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.captureCanvasState()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
})
})
describe('state capture', () => {
it('pushes to undoQueue, updates activeState, and calls updateModified', () => {
const initial = createState(1)
const tracker = createTracker(initial)
const changed = createState(2)
mockCanvasState(changed)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(1)
expect(tracker.undoQueue[0]).toEqual(initial)
expect(tracker.activeState).toEqual(changed)
expect(api.dispatchCustomEvent).toHaveBeenCalledWith(
'graphChanged',
changed
)
})
it('does not push when state is identical', () => {
const state = createState()
const tracker = createTracker(state)
mockCanvasState(state)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(0)
})
it('clears redoQueue on new change', () => {
const tracker = createTracker(createState(1))
tracker.redoQueue.push(createState(3))
mockCanvasState(createState(2))
tracker.captureCanvasState()
expect(tracker.redoQueue).toHaveLength(0)
})
it('produces a single undo entry for a beforeChange/afterChange transaction', () => {
const tracker = createTracker(createState(1))
const intermediate = createState(2)
const final = createState(3)
tracker.beforeChange()
mockCanvasState(intermediate)
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(0)
mockCanvasState(final)
tracker.afterChange()
expect(tracker.undoQueue).toHaveLength(1)
expect(tracker.activeState).toEqual(final)
})
it('caps undoQueue at MAX_HISTORY', () => {
const tracker = createTracker(createState(1))
for (let i = 0; i < ChangeTracker.MAX_HISTORY; i++) {
tracker.undoQueue.push(createState(1))
}
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
mockCanvasState(createState(2))
tracker.captureCanvasState()
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
})
})
})
describe('deactivate', () => {
it('captures canvas state then stores viewport/outputs', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.deactivate()
expect(tracker.activeState).toEqual(changed)
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
expect(mockSubgraphNavigationStore.exportState).toHaveBeenCalled()
})
it('skips captureCanvasState but still calls store during undo/redo', () => {
const tracker = createTracker(createState(1))
tracker._restoringState = true
tracker.deactivate()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
})
it('is a full no-op when called on inactive tracker', () => {
const tracker = createTracker()
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.deactivate()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(mockNodeOutputStore.snapshotOutputs).not.toHaveBeenCalled()
})
})
describe('prepareForSave', () => {
it('captures canvas state when tracker is active', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.prepareForSave()
expect(tracker.activeState).toEqual(changed)
})
it('is a no-op when tracker is inactive', () => {
const tracker = createTracker()
const original = tracker.activeState
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
tracker.prepareForSave()
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
expect(tracker.activeState).toBe(original)
})
})
describe('checkState (deprecated)', () => {
it('delegates to captureCanvasState', () => {
const tracker = createTracker(createState(1))
const changed = createState(2)
mockCanvasState(changed)
tracker.checkState()
expect(tracker.activeState).toEqual(changed)
})
})
})

View File

@@ -4,8 +4,10 @@ import log from 'loglevel'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
@@ -24,18 +26,14 @@ const logger = log.getLogger('ChangeTracker')
// Change to debug for more verbose logging
logger.setLevel('info')
function isActiveTracker(tracker: ChangeTracker): boolean {
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
}
export class ChangeTracker {
static MAX_HISTORY = 50
/**
* Guard flag to prevent captureCanvasState from running during loadGraphData.
* Guard flag to prevent checkState from running during loadGraphData.
* Between rootGraph.configure() and afterLoadNewGraph(), the rootGraph
* contains the NEW workflow's data while activeWorkflow still points to
* the OLD workflow. Any captureCanvasState call in that window would
* serialize the wrong graph into the old workflow's activeState, corrupting it.
* the OLD workflow. Any checkState call in that window would serialize
* the wrong graph into the old workflow's activeState, corrupting it.
*/
static isLoadingGraph = false
/**
@@ -93,41 +91,6 @@ export class ChangeTracker {
this.subgraphState = { navigation }
}
/**
* Freeze this tracker's state before the workflow goes inactive.
* Always calls store() to preserve viewport/outputs. Calls
* captureCanvasState() only when not in undo/redo (to avoid
* corrupting undo history with intermediate graph state).
*
* PRECONDITION: must be called while this workflow is still the active one
* (before the activeWorkflow pointer is moved). If called after the pointer
* has already moved, this is a no-op to avoid freezing wrong viewport data.
*
* @internal Not part of the public extension API.
*/
deactivate() {
if (!isActiveTracker(this)) {
logger.warn(
'deactivate() called on inactive tracker for:',
this.workflow.path
)
return
}
if (!this._restoringState) this.captureCanvasState()
this.store()
}
/**
* Ensure activeState is up-to-date for persistence.
* Active workflow: flushes canvas → activeState.
* Inactive workflow: no-op (activeState was frozen by deactivate()).
*
* @internal Not part of the public extension API.
*/
prepareForSave() {
if (isActiveTracker(this)) this.captureCanvasState()
}
restore() {
if (this.ds) {
app.canvas.ds.scale = this.ds.scale
@@ -175,28 +138,8 @@ export class ChangeTracker {
}
}
/**
* Snapshot the current canvas state into activeState and push undo.
* INVARIANT: only the active workflow's tracker may read from the canvas.
* Calling this on an inactive tracker would capture the wrong graph.
*/
captureCanvasState() {
if (
!app.graph ||
this.changeCount ||
this._restoringState ||
ChangeTracker.isLoadingGraph
)
return
if (!isActiveTracker(this)) {
logger.warn(
'captureCanvasState called on inactive tracker for:',
this.workflow.path
)
return
}
checkState() {
if (!app.graph || this.changeCount || ChangeTracker.isLoadingGraph) return
const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON
if (!this.activeState) {
this.activeState = currentState
@@ -215,19 +158,6 @@ export class ChangeTracker {
}
}
/** @deprecated Use {@link captureCanvasState} instead. */
checkState() {
if (!ChangeTracker._checkStateWarned) {
ChangeTracker._checkStateWarned = true
logger.warn(
'checkState() is deprecated — use captureCanvasState() instead.'
)
}
this.captureCanvasState()
}
private static _checkStateWarned = false
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
const prevState = source.pop()
if (prevState) {
@@ -286,14 +216,14 @@ export class ChangeTracker {
afterChange() {
if (!--this.changeCount) {
this.captureCanvasState()
this.checkState()
}
}
static init() {
const getCurrentChangeTracker = () =>
useWorkflowStore().activeWorkflow?.changeTracker
const captureState = () => getCurrentChangeTracker()?.captureCanvasState()
const checkState = () => getCurrentChangeTracker()?.checkState()
let keyIgnored = false
window.addEventListener(
@@ -337,8 +267,8 @@ export class ChangeTracker {
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(bindInputEl)) return
logger.debug('captureCanvasState on keydown')
changeTracker.captureCanvasState()
logger.debug('checkState on keydown')
changeTracker.checkState()
})
},
true
@@ -347,34 +277,34 @@ export class ChangeTracker {
window.addEventListener('keyup', () => {
if (keyIgnored) {
keyIgnored = false
logger.debug('captureCanvasState on keyup')
captureState()
logger.debug('checkState on keyup')
checkState()
}
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener('mouseup', () => {
logger.debug('captureCanvasState on mouseup')
captureState()
logger.debug('checkState on mouseup')
checkState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener('promptQueued', () => {
logger.debug('captureCanvasState on promptQueued')
captureState()
logger.debug('checkState on promptQueued')
checkState()
})
api.addEventListener('graphCleared', () => {
logger.debug('captureCanvasState on graphCleared')
captureState()
logger.debug('checkState on graphCleared')
checkState()
})
// Handle litegraph clicks
const processMouseUp = LGraphCanvas.prototype.processMouseUp
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, [e])
logger.debug('captureCanvasState on processMouseUp')
captureState()
logger.debug('checkState on processMouseUp')
checkState()
return v
}
@@ -388,9 +318,9 @@ export class ChangeTracker {
) {
const extendedCallback = (v: string) => {
callback(v)
captureState()
checkState()
}
logger.debug('captureCanvasState on prompt')
logger.debug('checkState on prompt')
return prompt.apply(this, [title, value, extendedCallback, event])
}
@@ -398,8 +328,8 @@ export class ChangeTracker {
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
const v = close.apply(this, [e])
logger.debug('captureCanvasState on contextMenuClose')
captureState()
logger.debug('checkState on contextMenuClose')
checkState()
return v
}
@@ -451,7 +381,7 @@ export class ChangeTracker {
const htmlElement = activeEl as HTMLElement
if (`on${evt}` in htmlElement) {
const listener = () => {
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState?.()
useWorkflowStore().activeWorkflow?.changeTracker?.checkState?.()
htmlElement.removeEventListener(evt, listener)
}
htmlElement.addEventListener(evt, listener)

View File

@@ -364,29 +364,29 @@ describe('appModeStore', () => {
})
})
it('calls captureCanvasState when input is selected', async () => {
it('calls checkState when input is selected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
await nextTick()
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
store.selectedInputs.push([42, 'prompt'])
await nextTick()
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
})
it('calls captureCanvasState when input is deselected', async () => {
it('calls checkState when input is deselected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
store.selectedInputs.push([42, 'prompt'])
await nextTick()
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
store.selectedInputs.splice(0, 1)
await nextTick()
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
})
it('reflects input changes in linearData', async () => {

View File

@@ -93,7 +93,7 @@ export const useAppModeStore = defineStore('appMode', () => {
inputs: [...data.inputs],
outputs: [...data.outputs]
}
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
workflowStore.activeWorkflow?.changeTracker?.checkState()
},
{ deep: true }
)

View File

@@ -9,7 +9,6 @@ import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
@@ -144,12 +143,6 @@ export const useSubgraphNavigationStore = defineStore(
if (getActiveGraphId() !== graphId) return
if (!canvas.graph?.nodes?.length) return
useLitegraphService().fitView()
// fitView changes scale/offset, so re-sync slot positions for
// collapsed nodes whose DOM-relative measurement is now stale.
requestAnimationFrame(() => {
if (getActiveGraphId() !== graphId) return
requestSlotLayoutSyncForAllNodes()
})
})
}

View File

@@ -12,13 +12,10 @@ import {
VIEWPORT_CACHE_MAX_SIZE
} from '@/stores/subgraphNavigationStore'
const { mockSetDirty, mockFitView, mockRequestSlotSyncAll } = vi.hoisted(
() => ({
mockSetDirty: vi.fn(),
mockFitView: vi.fn(),
mockRequestSlotSyncAll: vi.fn()
})
)
const { mockSetDirty, mockFitView } = vi.hoisted(() => ({
mockSetDirty: vi.fn(),
mockFitView: vi.fn()
}))
vi.mock('@/scripts/app', () => {
const mockCanvas = {
@@ -69,13 +66,6 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ fitView: mockFitView })
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({
requestSlotLayoutSyncForAllNodes: mockRequestSlotSyncAll
})
)
const mockCanvas = app.canvas
let rafCallbacks: FrameRequestCallback[] = []
@@ -96,7 +86,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
mockCanvas.ds.state.offset = [0, 0]
mockSetDirty.mockClear()
mockFitView.mockClear()
mockRequestSlotSyncAll.mockClear()
})
afterEach(() => {
@@ -228,53 +217,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(mockFitView).not.toHaveBeenCalled()
})
it('re-syncs all slot layouts on the frame after fitView', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
mockGraph._nodes = mockGraph.nodes
store.restoreViewport('root')
expect(rafCallbacks).toHaveLength(1)
// Outer RAF runs fitView and schedules the inner RAF
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
expect(rafCallbacks).toHaveLength(2)
// Inner RAF re-syncs slots after fitView's transform has been applied
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).toHaveBeenCalledOnce()
mockGraph.nodes = []
mockGraph._nodes = []
})
it('skips slot re-sync if active graph changed between fitView and inner RAF', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
mockGraph._nodes = mockGraph.nodes
store.restoreViewport('root')
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
// User navigated away before the inner RAF fired
mockCanvas.subgraph = { id: 'different-graph' } as never
rafCallbacks[1](performance.now())
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
mockGraph.nodes = []
mockGraph._nodes = []
})
it('skips fitView if active graph changed before rAF fires', () => {
const store = useSubgraphNavigationStore()
store.viewportCache.delete(':root')

View File

@@ -256,10 +256,7 @@ export function createMockChangeTracker(
undoQueue: [],
redoQueue: [],
changeCount: 0,
captureCanvasState: vi.fn(),
checkState: vi.fn(),
deactivate: vi.fn(),
prepareForSave: vi.fn(),
reset: vi.fn(),
restore: vi.fn(),
store: vi.fn(),