From f2a99d3215e91cd81767868d8d3d0d4d2de4d6ce Mon Sep 17 00:00:00 2001 From: Crystian Date: Tue, 2 Jan 2024 16:18:48 +0100 Subject: [PATCH] release! --- README.md | 519 ++---------------------------------------- __init__.py | 3 +- docs/project-name.png | Bin 0 -> 8900 bytes docs/settings.png | Bin 0 -> 18779 bytes web/js/workflow.js | 464 ++++++++++++++++++------------------- 5 files changed, 257 insertions(+), 729 deletions(-) create mode 100644 docs/project-name.png create mode 100644 docs/settings.png diff --git a/README.md b/README.md index 89ab9e8..56c8a7a 100644 --- a/README.md +++ b/README.md @@ -1,511 +1,32 @@ -# ComfyUI-Crystools [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fcrystian%2FComfyUI-Crystools&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) [![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/crystian77) +# ComfyUI-Crystools-save [![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fcrystian%2FComfyUI-Crystools-save&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) [![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/crystian77) -**_A powerful set of tools for your belt when you work with ComfyUI_** +**_You can save your workflows with a particular file name (finally!), and save author info as metadata_** -You can see the metadata and compare between two images, compare between two JSONs, show any value to console/display, pipes, and more! -This provides a better nodes to load images, previews, etc, and see "hidden" data, but without load a new workflow. +With this quality of life extension, you can save your workflow with a specific name and include additional details such as the author, a description, and the version (in metadata/json). +**Important:** When you share your workflow (via png/json), others will be able to see your information! -![Show metadata](./docs/jake.gif) +It works transparently; you can load/import the information in the traditional way. -# Table of contents +## Settings +![Settings](./docs/settings.png) + +* `Use new save button?`: I've to change the default behavior of the "save" button. Now it saves the workflow with the name of the project, may you don't want this, so you can turn off this feature. +* `Show project name in menu`: Show the project name in the menu (see below). +* `Project name`: The name of the project, it will be used to save the workflow and file name. +* `Author`: The author of the project. +* `Description`: A description of the project. +* `Version`: The version of the project. -- [Metadata](#metadata) -- [Debugger](#debugger) -- [Image](#image) -- [Pipe](#pipe) -- [Utils](#utils) -- [Primitives](#primitives) -- [List](#list) -- [Switch](#switch) -- Others: [About](#about), [To do](#to-do), [Changelog](#changelog), [Installation](#installation), [Use](#use) +## Menu +![Project-name](./docs/project-name.png) ---- +You can turn on/off the project name in the menu (see settings). ## Metadata -### Node: Metadata extractor +If you want to see the metadata, you can use the "Crystools" extension with a node for this. -This node is used to extract the metadata from the image and handle it as a JSON as source for other nodes. -You can see **all information**, even metadata from others sources (like photoshop, see sample). -The input comes from the [load image with metadata](#node-load-image-with-metadata) or [preview from image](#node-preview-from-image) nodes (an others in the future). +## Important notes -![Metadata extractor](./docs/metadata-extractor.png) - -**Sample:** [metadata-extractor.json](./samples/metadata-extractor.json) - ->
-> Other metadata sample (photoshop) -> -> With metadata from Photoshop -![Metadata extractor](./docs/metadata-extractor-photoshop.png) ->
- ->
-> Parameters -> -> - input: -> - metadata_raw: The metadata raw from the image or preview node -> - Output: -> - prompt: The prompt used to produce the image. -> - workflow: The workflow used to produce the image (all information about nodes, values, etc). -> - file info: The file info of the image/metadata (resolution, size, etc) as human readable. -> - raw to JSON: The entire metadata raw but formatted/readable. -> - raw to property: The entire metadata raw as "properties" format. -> - raw to csv: The entire metadata raw as "csv" format. ->
- -
- -### Node: Metadata comparator - -This node is so useful to compare two metadata and see the differences (**the main reason why I created this extension!**) - -You can compare 3 inputs: "Prompt", "Workflow" and "Fileinfo" - -There are tree potential "outputs": `values_changed`, `dictionary_item_added`, `dictionary_item_removed` (in this order of priority). - -![Metadata extractor](./docs/metadata-comparator.png) - -**Sample:** [metadata-comparator.json](./samples/metadata-comparator.json) - -**Notes:** -- I use [DeepDiff](https://pypi.org/project/deepdiff) for that, for more info check the link. -- If you want to compare two JSONs, you can use the [JSON comparator](#node-JSON-comparator) node. - - ->
-> Parameters -> -> - options: -> - what: What to compare, you can choose between "Prompt", "Workflow" and "Fileinfo" -> - input: -> - metadata_raw_old: The metadata raw to start compare -> - metadata_raw_new: The metadata raw to compare -> - Output: -> - diff: This is the same output you can see in the display of the node, you can use it on others nodes. ->
- -
- ---- - -## Debugger - -### Node: Show Metadata - -With this node, you will be able to see the JSON produced from your entire prompt and workflow so that you can really know all the values (and more) of your prompt quickly without the need to open the file (PNG or JSON). - -![Show metadata](./docs/debugger-show-metadata.png) - -**Sample:** [debugger-metadata.json](./samples/debugger-metadata.json) - ->
-> Parameters -> -> - Options: -> - Active: Enable/disable the node -> - Parsed: Show the parsed JSON or plain text -> - What: Show the prompt or workflow (prompt are values to produce the image, and workflow is the entire workflow of ComfyUI) ->
- -
- -### Node: Show any - -You can see on the console or display any text or data from the nodes. Connect it to what you want to inspect, and you will see it. - -![Show any](./docs/debugger-show-any.png) - -**Sample:** [debugger-any.json](./samples/debugger-any.json) - ->
-> Parameters -> -> - Input: -> - any_value: Any value to show, can be a string, number, etc. -> - Options: -> - Console: Enable/disable write to console -> - Display: Enable/disable write on this node -> - Prefix: Prefix to console ->
- -
- -### Node: Show any to JSON - -As same the previous one, but it formatted the value to JSON (only display). - -![Show any](./docs/debugger-show-json.png) - -**Sample:** [debugger-json.json](./samples/debugger-json.json) - ->
-> Parameters -> -> - Input: -> - any_value: Any value to try to convert to JSON -> - Output: -> - string: The same string is shown on display ->
- -
- ---- - -## Image - -### Node: Load image with metadata -This node is the same as the default one, but it adds three features: Prompt, Metadata, and supports **subfolders** of the "input" folder. - -![Load image with metadata](./docs/image-load.png) - -**Sample:** [image-load.json](./samples/image-load.json) - ->
-> Parameters -> -> - Input: -> - image: Read the images from the input folder (and subfolder) (you can drop the image here, or even paste an image from clipboard) -> - Output: -> - Image/Mask: The same as the default node -> - Prompt: The prompt used to produce the image (not the workflow) -> - Metadata RAW: The metadata raw of the image (full workflow) as string ->
- -**Note:** The subfolders support inspired on: [comfyui-imagesubfolders](https://github.com/catscandrive/comfyui-imagesubfolders) - -
- -### Node: Save image with extra metadata -This node is the same as the default one, but it adds two features: Save the workflow or not in the png, and you can add any piece of metadata (as JSON). - -This save custom data on the image, so you can share it with others, and they can see the workflow and metadata (see [preview from metadata](#node-preview-from-metadata)), even your custom data. - -It can be any type of information supports text and JSON. - -![Save image with extra metadata](./docs/image-save.png) - -**Sample:** [image-save.json](./samples/image-save.json) - ->
-> Parameters -> -> - options: -> - with_workflow: If you want to save into the image the workflow (special to share the workflow with others) -> - Input: -> - image: The image to save (same as the default node) -> - Output: -> - Metadata RAW: The metadata raw of the image (full workflow) as string ->
- -**Note:** The data is saved as special "exif" (as ComfyUI do) in the png file, you can read it with [Load image with metadata](#node-load-image-with-metadata). - -
- -### Node: Preview from image - -This node is used to preview the image with the **current prompt** and additional features. - -![Preview from image](./docs/image-preview.png) - -**Feature:** It supports cache (shows as "CACHED") (not permanent yet!), so you can disconnect the node and still see the image and data, so you can use it to compare with others! - -![Preview from image](./docs/image-preview-diff.png) - -As you can see the seed, steps, and cfg were changed - -**Sample:** [image-preview-image.json](./samples/image-preview-image.json) - ->
-> Parameters -> -> - Input: -> - image: Any kind of image link -> - Output: -> - Metadata RAW: The metadata raw of the image and full workflow. -> - You can use it to **compare with others** (see [metadata comparator](#node-metadata-comparator)) -> - The file info like filename, resolution, datetime and size with **the current prompt, not the original one!** (see important note) ->
- -> **Important:** -> - If you want to read the metadata of the image, you need to use the [load image with metadata](#node-load-image-with-metadata) and use the output "metadata RAW" not image link. -> - To do a preview it is necessary save it first on temporal folder, and the data shown is from the temporal image, **not the original one** even **the prompt!** - -
- -### Node: Preview from metadata - -This node is used to preview the image from the metadata and shows additional data (all around this one). -It supports same features as [preview from image](#node-preview-from-image) (cache, metadata raw, etc.). But the important difference is you see **real data from the image** (not the temporal one either current prompt). - -![Preview from metadata](./docs/image-preview-metadata.png) - -**Sample:** [image-preview-metadata.json](./samples/image-preview-metadata.json) - -
- -### Node: Show resolution - -This node is used to show the resolution of an image. - -> Can be used with any image link. - -![Show resolution](./docs/image-resolution.png) - -**Sample:** [image-resolution.json](./samples/image-resolution.json) - ->
-> Parameters -> -> - Input: -> - image: Any kind of image link -> - Output: -> - Width: The width of the image -> - Height: The height of the image ->
- -
- ---- - -## Pipe - -### Nodes: Pipe to/edit any, Pipe from any - -This powerful set of nodes is used to better organize your pipes. - -The "Pipe to/edit any" node is used to encapsulate multiple links into a single one. It includes support for editing, easily adding the modified content back to the same pipe number. - -The "Pipe from any" node is used to extract the content of a pipe. - -Typical example: - -![Pipes](./docs/pipe-0.png) - -With pipes: - -![Pipes](./docs/pipe-1.png) - -**Sample:** [pipe-1.json](./samples/pipe-1.json) - -Editing pipes: - -![Pipes](./docs/pipe-2.png) - -**Sample:** [pipe-2.json](./samples/pipe-2.json) - ->
-> Parameters -> -> - Input: -> - CPipeAny: This is the type of this pipe you can use it to edit (see the sample) -> - any_*: 6 possible inputs to use -> - Output: -> - CPipeAny: You can continue the pipe with this output, you can use it to bifurcate the pipe (see the sample) ->
- ->**Important:** -> - Please note that it supports "any," meaning it does not validate (not yet!) the correspondence of input nodes with the output ones. When creating the link, it is recommended to link consciously number by number. -> - "RecursionError", It's crucial to note that the flow of links **must be in the same direction**, and they cannot be mixed with other flows that use the result of this one. Otherwise, this may lead to recursion and block the server (you need to restart it!) - - ->
-> Bad example with "RecursionError: maximum recursion depth exceeded" -> -> If you see on your console something like this, you need to check your pipes. That is bad sample of pipes, you can't mix the flows. -![Pipes](./docs/pipe-3.png) ->
- -
- ---- - -## Utils - -Some useful nodes to use in your workflow. - -### Node: JSON comparator - -This node is so useful to compare two JSONs and see the differences. - -![JSON comparator](./docs/utils-json-comparator.png) - -**Sample:** [utils-json-comparator.json](./samples/utils-json-comparator.json) - - ->
-> Parameters -> -> - input: -> - json_old: The first json to start compare -> - json_new: The json to compare -> - Output: -> - diff: A new JSON with the differences ->
- - -**Notes:** -As you can see, it is the same as the [metadata comparator](#node-metadata-comparator) but with JSONs. -The other is intentionally simple to compare two images metadata, this is more generic. -The main difference is that you can compare any JSON, not only metadata. - -
- -### Node: Stats system - -This node is used to show the stats of the system (RAM, VRAM and Space). -It **should** connect as a pipe. - -![JSON comparator](./docs/utils-stats.png) - -**Sample:** [utils-stats.json](./samples/utils-stats.json) - ->
-> Parameters -> -> - input: -> - latent: The latent to use to measure the stats -> - Output: -> - latent: Return the same latent to continue the pipe ->
- -**Notes:** The original is in [WAS](https://github.com/WASasquatch/was-node-suite-comfyui), I only show it on the display. - -
- ---- - -## Primitives - -### Nodes: Primitive boolean, Primitive integer, Primitive float, Primitive string, Primitive string multiline - -A set of nodes with primitive values to use in your prompts. - -![Primitives](./docs/primitives.png) - -
- ---- - -## List -A set of nodes with list of values (any or strings/texts) for any propose (news nodes to use it coming soon!). - -> **Important:** You can use with others nodes like "Show any" to see the values of the list - -### Node: List of strings - -**Feature:** You can concatenate them. - -![Lists](./docs/list-string.png) - -**Sample:** [list-strings.json](./samples/list-strings.json) - ->
-> Parameters -> -> - Input: -> - string_*: 8 possible inputs to use -> - delimiter: Use to concatenate the values on output -> - Output: -> - concatenated: A string with all values concatenated -> - list_string: The list of strings (only with values) ->
- -
- -### Node: List of any - -You can concatenate any value (it will try to convert to string and show the value), so util to see several values at the same time. - -![Lists](./docs/list-any.png) - -**Sample:** [list-any.json](./samples/list-any.json) - ->
-> Parameters -> -> - Input: -> - any_*: 8 possible inputs to use -> - Output: -> - list_any: The list of any elements (only with values) ->
- -
- ---- - -## Switch -A set of nodes to switch between flows. - -All switches are boolean, you can switch between flows by simply changing the value of the switch. -You have predefined switches (string, latent, image, conditioning) but you can use "Switch any" for any value/type. - -![Switches](./docs/switches.png) - -**Sample:** [switch.json](./samples/switch.json) - -
- ---- - -## About - -**Notes from the author:** -- This is my first project in python ¯\\_(ツ)_/¯ (PR are welcome!) -- I'm software engineer but in other languages (web technologies) -- My instagram is: https://www.instagram.com/crystian.ia I'll publish my works on it, consider follow me for news! :) -- I'm not a native english speaker, so sorry for my english :P - ---- - -## To do -- [✅] Review the priority of list of nodes -- [❌] Add support for others image formats (jpg, gif, etc.) - nope, I don't need it, it's complicated -- [ ] Several unit tests -- [ ] Add permanent cache for preview/metadata image (to survive to F5! or restart the server) - ---- - -## Changelog - -### 1.1.0 (29/12/2023) -- Node added: "Save image with extra metadata" -- Support to **read** Jpeg metadata added (not save) - -### 1.0.0 (26/12/2023) -- First release - ---- - -## Installation - -### Install from GitHub -1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI). -2. Clone this repo into `custom_modules`: - ``` - cd ComfyUI/custom_nodes - git clone https://github.com/crystian/ComfyUI-Crystools.git - cd ComfyUI-Crystools - pip install -r requirements.txt - ``` -3. Start up ComfyUI. - -### Install from manager - -Search for `crystools` in the [manager](https://github.com/ltdrdata/ComfyUI-Manager.git) and install it. - ---- - -## Use - -You can use it as any other node, just using the menu in the category `crystools` or double click on the canvas (I recommended to use the "oo" to fast filter), all nodes were posfixing with `[Crystools]`. - -![Menu](./docs/menu.png) -![shortcut](./docs/shortcut.png) - -If for some reason you need to see the logs, you can define the environment variable `CRYSTOOLS_LOGLEVEL` and set the [value](https://docs.python.org/es/3/howto/logging.html). - ---- - -Made with ❤️ by Crystian. \ No newline at end of file +* I used a specific field in metadata/json: "workflow.extra." I've created an "info" property to store this information. I don't know, and I haven't found any documentation about this, so I hope it's not a problem. diff --git a/__init__.py b/__init__.py index eb91cb8..6df3b6d 100644 --- a/__init__.py +++ b/__init__.py @@ -4,10 +4,9 @@ @nickname: Crystools-save @version: 1.0.0 @project: "https://github.com/crystian/ComfyUI-Crystools-save", -@description: Plugins for multiples uses, mainly for debugging, you need them! IG: https://www.instagram.com/crystian.ia +@description: With this quality of life extension, you can save your workflow with a specific name and include additional details such as the author, a description, and the version (in metadata/json) IG: https://www.instagram.com/crystian.ia """ - WEB_DIRECTORY = "web" NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} diff --git a/docs/project-name.png b/docs/project-name.png new file mode 100644 index 0000000000000000000000000000000000000000..07f4b54e16a88331f5172fb1e6924fb92143c23a GIT binary patch literal 8900 zcmaKSby!qe8!w0oBB6A5N(|lINQboaFyss|bc=MSGy)QeAl=;{NSAa;clX_#^PThE zKkj#V9+;W^tat6T*LvUg7elD3vMf3(5h?-#0=m4MlsfP_2>gzqJOTbL^=B^tFNAPu z9k>R}0`6+!2u2V$hna#Y2d<-}C}0M&V>Njk!|G;d z4@4s%2#LDco0!>v;S{D|h!s?r>Y$;8io(iVm`a;liA~8~5^QND=ivy}@KDw?^RO}F zH>VO6p%8Kt04A^l!%ZmM>};V<0&c=of6XfZynnn5qN4a~2;4@P>hDGAD5+9N!W_XA z+^jq-X6&4N6ukVb?A-i(99+y49Bk}7AU1vwI~NNZy8s)%02@2Se}1TdyE&R$2&hZR z{O4Z4Ct)f}INV+U1aftCWp(9bg*ie%?EL)vAT|yV2L}r>hzy&0yYffW?~OwXJvbQq<;n~DgFP4 z+S&a(+6k@>{_lGKpA$Q2y4!<6>R>0Bv!fYsa2C{$x3U+IbOf8gVUC(GnC(BisA>s= z!<;N(_7swmkGsZ8!JusgHHW!6G5!^!q$D5@b%L8f&A{?f!c@R4tX5X$0_?mJlH6RJ z?Be{qGVJWq(&GI5Qj#3Jd~EF85>i}j9RI{h!OWcPz)<)}cfzHkWaP*-`xU*aBAnb1fV)|8u_o#G3!_wXprqSP-xn(Bt9$Ux)qAB_M+y-~OAr zz=wa69}ERD-VsP`o$Qp?2ngg{@>1fOZohXkUclc?O!UORdh+oj%`0;hafy#8AE{U= zkfaD{Q5Zr@t-kzb)eG|(oqSh?q*tI-Tx2xyjX`g#=HrJlv_#!kRAvZZ)u-aI>@+AT za&iO`mp+ZfHB%RHpKjW{L47~(e|&E^{v~`d=XrjWC7kZGU*}&|R))xvU0xn%rmCvy zCjnj;Q?(p8druWb!jCmqX{B)z8jcSV$kP$^yuWpingG|P#@!v*n#I10r(48Qeiz=3 zAEqSzSv8X}0fHKMte7pN!7zk_x}K8bPj$J{6|O8_!Z=pt_lHi)gFkmh*-AjgW`zTUB&PB zE?39v+gL?Hv|AGy*6ENtLoI)Q|Ei6YG(kq3Uz%4@ENx^XKSRx-m3l0Ezo&m)7kwYF zj^M&{)1qz~VAgDyvE0jN5`F^GuTFE+g~Ka}o&TAE%zIWJBS-*QM7e|_%$ zmeq?GTZgFNT{WY%e-ev+&{Lz56v$gp*Snk<*ZY=Vf{cwJ`E5yi`PSTlAh&$gaiZ=y zzhlMamzIrR3weR9E5X|TgY+$(&^fxl|1Aga6|{==!KfY{nzfkKq0PlM{DGa+bSMD_ z9~XZz4g&py6v6!r&z;Bacje*XVKA9Tq1|k?kZv@Y@P5NV^VP`)Y_67_i)-9dQ$a=L zVEk+Rt&yr~sqGAhuD6kIOnUlUnVIY?`9PtYyZcoft-`N#{?)t<@;Dw-LEG zt*>DR`Ef!oQR-dxtOFra22M)C;}CXs_KsjoQTOAu{zRroJS9cNxl)sk`@5Sv{T|h^ z%S*Rx{ExZxv%gj|Uo?#cN9)E{mCIfjPPraP_wUxh(3-aJe!~|&JXFZlP8REv^E-Z# z*VIhFRzpil9w`Saggbn{(&soHQ=qY#{5}&yDTzTYlAf3drXdsc5u%}a`c19oI)B1e zsz|GNX{LwV*K4Kog^$n4Aa9e$*&8ONpF=}64sxdbYgXATdJ^S_NwFLKzusvYY`b>& z74}ZvCMG4N_X2%Mn>A&+B`4zo!>6jInm2}lMKdy!|4OS_b-%W zqsf$DoU^3AVY}01m05+71z9;F(u^*7U_*vu69D zOyQZC8AsXc(R$|{M@Pp2?d7c2fYDL)tG@USHu^+|#8y18-R)sgb`4%SD{8G*SDOT~ zw?@KZzMZDkOv=#t{_%ilMa`bK6|b3@nc95G+i)BY7XyQI;xWdj0&a&(?SU@?k#9d* zL!ro+z10?jM5zXRVO5SfM3tM1Ee~P1Ouf`KOK(oh%ms~E4=aX5I3tWLEb`l&`xN<| z{tP&HCzqC%G8Iw2eLD~u8yj1VF8TD?*+CkKtKC$2xzqF)5XQdl-AH+1NZ}2g64L+( z`kU(Tk9ddZOw}3u{;L4N6a~iR9?aKsd;BZY%ef~{LXhr{KebS}>NPb-wr1}Qn^=lS zK0B^}3}r%DU$2B2`uFSCK&7)Km<#^!UK2#52)aU@4K^fdX%#^Bt31k^dI2 zt&dYlo3sNmW)IKzF3@RX1drUDrOvCa{4;~^gLc+f4Rt$p{0%xuf>15=bY>uha|D8A zd+fB>>>Vw1jFNt0U8*Z+Y+9NePp`s5ANE5d10=_SvOl0T`sUI`QbaP5p8(5P4WO(zYT%j8B^OwlQO1v;OR!?-@ji%xKgQB~+ z*?IlWN<&R(BggLH+42v(M$sR)>Bz#_h(pNfBHl!#Py#{b5ukr}lqq~-M z-%6Z1cgOM(QGx;|!WkK*yYXyNU$sCfjYoN|>&Hfj4eZO3h1%=rMmN?r+%`qx`2b>E zJiOlwhW+HpaL#;2A*%0didI=TU&o?8}U47 z!l$M2YCnJR3@9#^5!$UR-+O+Sc%kmYcQP_T*ui`X{6S+u3c(8;MIKfqJqdUEp)-0= zQ~smJYNx}?53!@Wuh-7c>>C1Z$O-Eo^>%^E)HU9LB!2InDAMtEf+;pkVNKqrusPON zE`G{YqMfN9j}6E{c1oxOUyHY7S>=pIt{Lru}L)JuNN&S9IxO@ud_2iaka7qZROhyLComJ7-|d+sv(cia^lk zzYwvXGf=`kA3!-Ga$6r!vFyTB2v0LP#gb!+!07^7eNG9*%lrPHKW%QG26GPWdPiv$ z>**RS7hA?y_aUZU?#@(@DLKvbRR9h1=lH?aSV4Gr_)vym!SkuCR|EvpjqWEQAt9Ta zmW9ukQBhI7206J1X&UU8+sn*)wp}7K+ZDuY5(NFQLL(#d>a5`5dV`s6*jb7Ld2!#_ z{pga`y}9bsKq50koVq(7>NMlH$-a%u$&B6fLc{cf1e#JMK79B7DT}-vyPuNmfCGsw~ znVoGlnnB~_*Uw&r`MPgMJjl{JyA4r?Q}kbJl@Vi%w?%+Y`i2(?yZ@Z zTWZ=BhKTeWzBNkA!UAv$o7>AHhB^Q}9*&}YxsHXd*5dDOE_?wv!)MVm=OZU1B%FHh zO1g`qe|ypLaJM;tswhzDH$)#_KbtbbL`T2tQ}Lk!m9DL=4YSlh=ldT-AFhTD>UT=OKa+q) zSO;`^loSq=TbmzE-5qk^ylk~r&B#=bM2NF!v@!@bTg{?5T>()^BxK8{SIML)DJ#Kz zEN?V+npM5;xZ->zWP|cyU09#*orZ*`8eCx|c~AS7_1xs^3vdw-JIt)D!(&n0D(HvrH=rB_NK22JwubZSw%MF>|uCuxhf z+uAKuoQ%PklV>=OI}Q>E*F5+!nfxk{yeJCB8&1Tw;SXe=4pPKOZS zr1~#Cb7**B$EW8dizpsV$oc#ZIG@|qri+F6PN4i2f!urCEZ>KlMg0h`5rNHsPewkv zSZbJ-QGN0BzSmm?c*yi7eZYB93JW-2?9ZR#QQQ8mw8G|!%c_0KVD{}{Ttg*W?+a>r z;5v|oHyff4j58<}Cd*k5w-6jGJ-qH=(dAd%RDlKFM_u@@Ukmpi3adsM+#mQpq&nx|NoWDce@a!cXK`6P` zp*WDN1_mT7=HQqooZi=`TMxGzqB1K0iL(+VuwRYj)pDiOq=?eJBQ?JAzL*bodcj>* zXR+}VY;jBTIp|dahz4m+jw?pBE#x5NBTbwbW%e`!vLuMnlCDG|?%0kNv0q(|?!io* zKU`J~`Cd2UBqHkFszQPCY+fyL6Uk6O66Xy`;j2oVZhTZS0eE5eY__R8+i4DehE`{; z1fo94)Mvx#{y_{rFelHOj_GCCGS548cqT7qGscC7l+*eA6Vz^UK7Jbrs6`@a=+k9Xl_Nax8)N)Jf{Xi(dg$ZV zX^MfQ9bK~;M5a}uiD%i*&6Ze6#SG!a?3sMd+X-X6AJU2nZ4+nr`S<)sb==OcHnJqS z=O#Kkr9uH&?!UgqJbnO(xdL0XGSzW=`K#`<0_Z@e1b*tXFmjny zaXP1-E|xS=-+Q2YI+UPGo3cLL16OhhbyP~r%BB(ayM~CkmY#qv8j0?K%E24&>ixMfn+K&Wu zT|7H%K`8Y2^J^MQ0I!$nW80=bKYP``+^$grEN^zR=drHL1H}qee!pX`GSOMCnQ5R8 z>G<&hvf2~P+xyl^h*;%=1`Kv~v|xD&v}~ezG&MA09_H4vww{TE2!u%gWD)Oc8d_S) z4nU9)5~iPHpJQ#lX?wTdU`1Adet3U_SXc?PD97#y!p^lH&DYz2cmVrgix4u2n}jF`xG!y4?X;mhzBc|}vPaL2!at^?XGF`6v8kXFG281A+I} z-NoV~k_Z{MBJ47Xg!03yNY8gysmz9xX+W_Yw6xDcjoC-s@40aIgnJvY^{fGWUC2>3 z{yZ>TYg9#hQ#*|f%h?VH2uS0F?dLg1>656Qk24L=jh;4#^LFLMtFcN``*YQFsbP7# z(OE|z46eK}l$JzBS8_tuS-_vG_C8-5RPtW@8XFTaOi4r2*{`4Oat*cc7mbfLHJ z7|6uOSA#kKOgnHd-IdA_uNZI7k0*jWmvvDVzc^@Q{Eqt;w`$R-GyJ^$euXh7XV#yxKW4FjWiZH6PuF9hb453t|rQ5Y#4d zFYwldqHpa2#muau9&NHVT*S zjk?+=i0kX?2YfNHvDt%54?;z=SAMMT>n({3=TE78?skk?j=G5ho}+sJy}m2xxleGN z)5hD(+uPgIQ@A`$0F9|Rd)1d@^pa#_>e!dQr@7>Px>3}!QMW7MptT18q54XS=d|Dc z!CGbJhYY6DN)twBTeb?_%r4&ZIB$;wLQUv=+KlU#=|`i4SuP(hbS{LQ_F0F3S$6~g zF!8-#3o<>zsJB{f*Fi?!XYa81kA0hw44PdJ4CTM94G#~K348SVbdu!e*xlW$*FdY3k@O7!dN=FYWwjS;&zNFL~eKb9Xj{ z7-bhbVnRZ>4?uMg2C4NgnVhkn7sfnX>rsb8$mFPMnO7KG=OCY>mu033NzAcQR}qC{ z_b*Cc*}TN;?TdYo_s^fM9Zcg@cwrhs$w0K<>W`!&2`84l+Ku!oXlH9?WMuqfw=i2} z1DPAK3MQ5g*dWYDtruhdJ{^E5!Vo&T(9YvD=U0X6Ngrd6`>j}~OoDm$!?TyAt{H-^ zQIV1JH8AqjuK;wH|9QVp~?>G3~9NT5AHn&hR@Zk_`ZGF8D^26a5 z&9j`&uCCB%T13PW;@>DT{jB3T@t+fB#)2~}K{KH&LP8`?%!1YqohV@=IL54Xq*DTW z+|1SLqk~tt=$Q~Vy}rIadlV)f>v4v0wDbUmA)ynr%8vRr;yF4T%-@@NpA0>g{E_8o z9y%~ZBMJgbJZ_JA91dwWlG)8VCjw{>nr}+bGVGb(yx|bp=W zZqQX%H)S#_VH_{7t(l5 z5ThoL7$Dk{h8l@v8;mHw@~ zux@>N^EdV932C@Jm*WAPk`1a1ttrm0xudo;t0Fn{3omWucWKidr#|!;PCrJn85NqoS z$1j>cC3yDi+0P^vLErnvuG<(}fbSjGCk)B)c${tm92{k+v9mO2J8KJAE54A;<_V%( z=ifHfm(A#pA3s)BRsuBJ45D9Y+4kVz-~e>E?l7Rjoevh88XLU;h?v$W;js1q2>F|d zXdKp=2q78_LXI_n!X?mQ=>w|+c%Pa@F=;D2#{(=~;1FuVpW^@+KU2A~m}H3w0P8k~ zK!^)y)X1iDqZe!%twilxEQv3lb~rjY-JI>n+UzYf5wFs^U?WFac(7IJq?BqQDn9^GC=X!70omGLeK0jaYoS8dQ! zxlP$!e)>g1=>F?thZhNfov);S9c#eRY9#&70OYE_sRtp8$ny)y0CZbNM@L>h)RM#B z_tM=53)z@(1RJo(l1sObaq#f!>-UCh1PVmBxNxjfw?aZYmyRFu6h6IGP@v72O-xLb zmXYz$!|-zSezQ!qrT}jhjmUq9$QrSYL0b(GDJ2NY;Z)^(iO-slr3HPBg=P9PcTz{W z8tK>=IK)$=>MNw7VM2L%`9fqNVnr;}4)1tCBvQ7Pfn&_EqXgC+Ce+!I-9j2-$R7qM zDvP?yb(AB|ux?mwS5t_&hHg!KDgcGZK+pUKkjh)vZiAz(x8wl%bG|^uzSeROd4QtpXCOKoDJO38P*Zmh1SJ}ZZ?JDHvnr1SVOIlM zHiJ>y`;RUO1rp*V?Ckr# z;VN)IW{|efG2O-6Y);=E*U{3#y8@$1bt|R2)!EJRL_uY4 z!9ErDTv--j8@S(xk96|BeW{Fz;Q(e+w!QlSr7>H8Wvk8QFS1;7lO zt$`ipdFvm1wT$U&iPpI!4;XkogG`pnArTR~3r(Jp@jT-R*ZslTG+m}=Uj=(GMQ>Go zy1Tm2N%#%{hUf0?UKJCdO>0$lt!4Ve^AA!dNIXLi2fbtfpj#kyGF~Xj!!*<9(a}-q za6H|OB*G~}&tk>0DU|K-&w2ZkMGt-SSrG*65+aHCOP6{xkSuSlhV2`%xXB_Tq#@fB z%oqZ)p{cOy&AGaYBg18Y)J~SX55MPgTtgWJ40y&-Swobf($d3B&;8ip=Ydid1!xQi zBl>RAz_%kjzAnGc@&RhR;RZC1i6U)h|PDK2aK(%gL+ zn7ED1XBfIFm&iz7Esm3nY*xGKkAytH-F`zk3y@q_K=4=DOkq$CBhz$6(lDA2B(rPA z6Tj1Cc67>Wi=>&?Ty#Ck?>a;Un;9O5QS0Wd1imBYvHi@x`L!23n98k8sIH*_dvR#@CgYF4WT2unv95ALlJ58qtjgB zWL18%x+^=m);Ad6h)^9fi1TEiN+$UifJ^{gOHWS+vM-H0l31RKb{RD^S-QY4@w)eK9K;L1+8)$f0@&pL)*FVfvq;tb+uTlRtxQ zxav`7pVh zU1t~|ID&ae4~XB}{cLiDl+?EeEt_IyCC!NnseDae)pT`3Z?;l@bZ?oQ(u;6sBHp5- zqSmkZW%c0?;lIwU7jWMGm{3@5VnpPAHXo0*V+&~vC8m6dmqU7ZAhK6)oE@v(62#ZK zaDi0Ii}5!1z28J{(LofXx`mwL{;imqH1!W3=V9krRHIf*=Z2DWtxR{)dPhC?KhB%| zR_iGsu9TAWNxs${5#Y6CsMJp6GxRkc9v&9fhq~_$A3df^P09@=efn3bF-Qee+W|r= zT)$Q8uo}UB1TYAI9FvN8xh=K&ZxR&bgeb(t#{=nX4HJ8XgM&jv6c$&}Jl}L+^57*# z+J4>cK>SHbJM@jVJ75hi5OY2yD^4#ldl;xEjvK;xCV)@0D>XrllCPJki#L zC9Ru1?w!FC-EvAXC4l8ls>YXoQ^^v+*{rt~T55QR0#m0!VVtOJ?9K^e-nwJbg+XiE#Pg zo2udDj^DfbttKEYE*agNEsS^-_d_tBhQ(@8?ts4zP?{Fte{qNKp8q|$d;UKp z@^3TyKSB&}xB(pns53R;Q_ptPtV6{OB%J1@ls($(3!XQTB-eLJfhyy1fbv_m+q~e# zZT5S7t1)hhPw46PXA8LEBgTCt7Ti_ivs;!%=IvYILj~B7)YR0htgJLNU&_nXn_17! pXJNo$y(#+kQMRG?wgmwRq0Ad8>$W4_te6jtM^)at^0T1zZI;cAc^*Z@C672LX(yfhk!tj@qzH^b0pw@33Y}q za3g?$KfqM%Oku8uj!@8h6FVa)xwMU;859CFG;w$6g$e?>s4Y}Kz&^;!@f+LOFdIIk zVRo~z2Xcczf+BAAhQ?M<7`YMD%)(ZPV!x@4g51JHh(d!)o>ksn3~Fv6<>3fb@la4T z_OLSMGocU>CKq($2MVx(!VJmXY^-ga_}zpk{+7!RgdZNWP>}yk0<#jL_@_}H>Q!wT+G}|#%vtChq6GRPIk_Y#=yXtzI~|5o?pxnY6!D)RJF6S z{`Xr{GPi@-IhotplZ%Nxyfq$jS`7q+1S{?U{O9Wn<$SsH!qi{#CyK?@Bf`w+|Jn925JlYcV3hK%KQF* z&HFGNZ0v!W#i5QCE>IH*M>`wxzuo4y_}|;Y^S_q&ue>Jzds}$_*Ssu1V^|&r_x~8| ze?J0d&_nP)Qy2L0pUDrk1!lY>Ftu?W!)rhwaKE(pdsVlYoisEhRgL6x#kagRX{ce*MDYGpFUiJ2N=6fXPHmTGnWp?qj9u-B z>&F*e)In7bYt!(cOm%@^vi;I%3nSGJRT6JmIfzDlhPzwp?0iSxWqaNDLix`Zoc0>l zQ$3sZGuGYroVXS*hg^FSN6b05kkKR={R09<0$7s>h#Oy#k}BZ0^KqCBfBK&Da#z#O7FlIW5sY$6SshCVmK~v} z_{KUy{rq9!__5;Moez2M1wjGh0d~XC<;%+88tnuUZBK3{-{e>-;r?S;EK4piEtz_FU|6Y2oUwh5Q?9Cgn>+_ zW}z<_a|B&JetpRCof2t}<8Oij+W+I`7c5sVY5O!ItW>>T@IFk;XhXCl^-^l;# zCdWp7#o-|>EzK(p0^M*v0)5Mpkh8O6QNRKjh+<-6f6kx=jV}gS2)OV>F0==rl6me_ zC|4hSe~fW6xOfxpI&;|Iw9c&8?6x+LRNwPZ4QuDSN%@C>f7oVRlUHvvl}4G~?Q$p! z9!ebX@vxQCN1$m zdY7BU`&<1CNsp5eNek4dsHkdrt;@bR1`Oc~0~oAYBAej#>w1TkrzOX`cD~vt58VgF zL|Mc&38u;vJR^1cvt8DfCn~d%h>Fkb9~h{DGO@VmyEn(zcv#U*I<^Ue3Ft-y1%2b+ zT6e0C-qE`3bW+ZhrxN*MepjPjJp$>8A3JVlW(fdgGn6gnSrEP zq9LRndvgt<3V&;rgY`<~hRq~WzsjSg@;X}X3{~YAOy&x;m!0>$KldF&F3c?;%^Ib- z_>6{*4yh5xw_RwKKAk+>okyowuVT zNimBs1P{JrD+(T#*m+ycdH(#N`OwSnluF78Ec&fZ>qFk>^O72^cURj51+?^urUQwG z(`Fgo=S_>c?MWiOcfNP)zE`q_?>6`MBZGo~ubnWo-_F3mPo^%A)Z@7s@ErXgxx45V8C6`O zY6eCeR}aP~D40s0ABxX13N8HEdUMoIhp(HirK6H_Uud%{>uj5XTA0kfHtkwgBped@ z3!^`DaiQS;=+{j2?c3BtrkW)0?bVi}qO(Ce&x%H4Ya#f2L1gTmQTe`>{`}I3Hcwk2 z+isD5sb}^IvyR3gzDxN1M`}uBwQvG|JwoTqj_a>B~NiUcL=+%Nh<7V^`E?G zn#Y^K!S<~z{zkv@G16Dl5kFR5^7INlb=TqB9^xm8JaQ*ovZ!0Q4d0pQu@vnKrd)Og zQu-HiOyj+McKt!mj={_Fz774Gm2P=V@Ae!c0~bCq61G8}ym|C0=!R@7`)YSziaH}= z>-OxUxwq-Z{YEu{#`J7~Oj~N6YmDjOHWfj3B&mY4jIZl=E{XggIf(ybdPYXMTRJ?P zb}!6tQo%{2oe@8XVdJ^wVEHiZ({1^riqWG#(lsg6ZH06yGU7{92_0Ks#cU|WN~c%W z-l=j4(_X$x5;Ixo4iOpgj+>y-W9|W^sR}edcOdSgo8HGEDl5Eou$H~KoEIq--Z~-H z07a(M*4Ii~>R3*5nLyRfdQ#=B`wU99*prj7cF!6-yg7Bp@VO?05#Bn><|;a($Yoqm z^BMH0ftP85FGh~XFP%^tT&5>lvJ=Yiw#Y{FAYcSENM?M5ds|00*?U3Q4rRM}oRG%~ ze9134_p>O*qvl$IFoo8G|HIqEbzPM~g&#U>y7ZBx^RAa@*IX^alUzxcYT-%z;j_}` zf?pSt%&sjrPGjlPY|kwVE-7k+SQb!-nK&0^G7ZtbEu=5+ZO%q z!3t2Nso+M3^RmcU@b&^yn`u^OLwQpQgFr-@Z>G7s!`9uF16GHs=FeKF)L;Cw&Qoeq z=Upmu3S_PXc<;G9erDBaE6j3vD;uk|W=OyYEO=bj%RK^#DY;c_-_L=GA=rVXTRhwq zS+`~+Lp{@>tNg(yr3J<8^Q>-znAS86?B6V<%ePm*F|X_{BUEjO1r`-y!Iv`Mbhcs+ zqi)wDy=i3$PV19zBCSv+u(OUMeus4xe#iTCOI2<6`9`?qEO%=Qf2#KMz;k`ZSx!`$ zAYa%vl6h>mzHtD-(*c?#R?z)IyY(%+zBdhVyl&AmAxu`M!5^NpZrw7LeY}(N90d%` zUCZHZXTF{r$n)-m6DCwT1o7p_C{Rg%rqU{-@zN0$Z7C(phmCR_eFQ#h+4Vk!Q^d9p zwJ(QLlHy;us`f|dpnpw0)v@M!-L^0t7gO}dY3UusS*~gzp4i%Zm z685hdB&aiKS(*dML_pMSz|J~nHCI9+Su|A=cL!0Nk3K8t8lhfn-`2Cd&Q?iSTR-D) zJ4#EFyhQWzP0@{F=?@($Xp`o4z{)`fx-Q%Tg<2AB# zZJ&9~XTj+u+0R;7$rta}hPOo)eSBEi@8GIDp~|djlq7b=*k_UBOf~X2oyJsm-!zJq zk&`^Ahs7Tt3+Tip^Lyjw0 zCJYUgx)dI?c0R7|e?Br8kw?=zVIsLW5f!<&1-*=wrfC_13vVh2+bxHcSAF6(2pw_d%V>GAZ8EYW#qPG^ zEQ#9xEHEd&-DW+c1u0v*4wXQCT6uds+|9R1xxw+i`nlU|$W_=NUAC$wi zgaozCnH|*2jrVee;BNn1))ih?tfq5aL~0Rci(D8^tSegFYqSe6(8>NWtHOt*GPx}( z8h%G`(L)6emj~P4Dz7GZY_Iba^t_&Z=bRKStiaDfL5i@g4C~5!xlF*0J5I?7|C|dO zfZ~n+IwgX)pAlajJaJCDh-ysc2BD>&l{cu(|^$#9O>8K z*)n6HXl#hm7NY#P<>*7&^osE#3Y zmQD=bFrQb}2Myh7P#Edep4ByeLmwVGNaCd=ELbhLq8-t#`?_DVV|3cEN~e*(S8QasX%!PS2IA@$ zAiasDuW=3Y3b)E=b7<(yW5xc|zw&m@aIfytaJ9JG!5hqu++n8FARAZ7oO|UESF!1Q zF2R=n`nhb<{YHif3azNq_E3if?bem zex9cTo~vFXHC9!cSiN^gnz(dC_f2!-V!p%^fyuPY?`O+ehs=HIZYd+HrTVo=+VC~& zD)`lhOi-dDz2ydT*6KV~1}QCHeO@x#R=7w3DSGstWm3iGv!Bj=Et$P)ePRv`%!W&= z%B3VKbpDPyQhr3@E@ zr+AJ^_BJX|d4ieM^U=i&GrLFjRzl7Mm~|y=4jnfqQ!>YT($=%Y(JhwYT!Hy}tfA5B z5&h>ce1zl3iP~^)BwaD-QU$q>Z8V>hXP?iyYdwKWxvw1tP(|7HDdKU$wbQ30pZSjJ zJBe%xAbxWtcr88J-#Q?#3m#S_HS3>}JgerA#fo8UtR5_U^XooXswR74_v9P$s7fGY zq^5s>Yv66G?s}Bj1fk%xI?qiQnPZzm`nf||`ASHrc=pS9?xFNpoR<0JqB;PiUAx=G zp}P~Oph`N|t4aCe#3}i{6Xye|gdPM11y$5#HD7cZzo!f#cT8qHeEPbP^=I0_f&J+j z9Z&Dc(;eCcjhv7JCC_R8LVZfxTp=5)Ty5}tKVMFvGz!|1ZE_Upb#Be7vl{l6s?ZL% zSdg`JmhU%w%(S2RK}UyD|;#7zgyKGLx>dYv>K-a0<| z2sU#On6TapcxL#zYEwGPSj%Lhw18@d5Y#4zR;8ijuAGh|8*agO%mG2f)+!HQ4c;h0 zRKLP>;vc=a@P9`krbhk-tY%^Q)RTIoj$zmQlpk_)CNTm%-{8?xl?|jbic{b}kzMP} zx0|~0J~}z8-sMkH$03`OF0(ul-UQb5x7wC!W>G`U83Y@3v%zzI%j(&;I;s9bZcQjPbbPC`cD)pta@(Sm9erp z8N;jDN{)pr_9);gdI=cP!3K` zaghmI+D3Zu;8%(vAy}F`7RZ?dUWK%Wgm}Izvm|ZJ$sc7gb%i5T)zsXr>FDS%Q2}fD zPo8|rIQYR7?_}|L6OWCB_2R``C2pF4tL?qdrsVDZ-QNBNw2=As9I#dwZ}(h{rmeYY zXlROLQ&LhC)A*_4QBhIJ+*dK3H`9eY0gInD9-wCK()PV1b+5LH%I**>zL}^aBO@$r zH?yt&>_};!< zf#;_~5^8H|oMBR~2kYscdu4Z9W#hT~t=D^($LpO(lO@{g14-G;^8nodn9Q9#Hr){< zTNGEjxsO#_kMA!c?+ch)91(q`x=kHNE0H4UF)`W9x2rLV$;rtbMX&*$K#$0R0($aF^ zXY?3!bzSj5>1Pb_D=RA*zIX1UZBgRyEiFy+W@BuD774(lJf_HnxK5t9 z>t}t!*Y`Rckt9>IgTvttyg3H!9@4*1Kkrh|pvT2NQ?xcT3ue+;{3LRJIgCIcjFR;H zPavv1vMiz=bRkfWM`QgTbY)l!JO~)tMot*MG3Vlo+`f>)s{ZEZ<2{~x5-QdvY;>7% zx0$gv=!7cZxm{8Rn830K)c)@1A6Sli7(ZA*aguJ5q#zX{fR7UGzKa$^31_jEZsEUu z+A6E99s4FV+T;WSG4FY#f4wAjCd9G=4FdTY_(@`bKoSyPNrgap!#qYa9w%QnAFOqd z0q6fnA^q#29M`{+y)o88Fb9S_Fk+wjC% zEFH7GI#v~O~7uCZ?c{_YzpCUK)*6;fBn7a?_(RI8e%=?@>W=_v|NgZr<9 z)Q_7>=~du1uxla)ttu8%`SFi=33c(5=|x&9mQD%?2IZ=YfP3k=qtlY4JNh~N!@la`AmnGFKAnqX%r>A-5uH^ zhH8cSxzS3yfTD=si7J1vSdMQ{QU=Lsz0yU`iw)9>dd>B#K{j4#$49rZ&dNYN0l#MR) zR^?Z^N`yO6Hd^~$>Y|txRn|5Us3&qnaPnYR@eP&GnNsj8v7hTCOn#3L#b|M0aX8wl zd_0QqJ~`Nc)1$$52Oz!T=eN`Lt7zv*CF(5+*U{%gqjGXb3iE>uJd;vljc@&YT5z;+xe>_{xqUTHmdM3KIuYbpVB6o<-lBjI zthJU8KC>KLr|poFxmA|6blMFmSM{=q{a9Dbn=U5>rg)uStNlepCt$quL*5S<;OehEA!8YS>6qMLJ#diHtFE97&~` z^x5N4?~;sjzHF@YHCOGG5P!|bk9o59?$clCc{E1eP6-Q)&NHICR#SoWUWWu2d#lTg z50=tvZjD)mDfsgC87!oA017&hmN{yXb9zH>c(|d)g63C zrrEW;3c9XZa20ajs;_q5?bOJPsT59;-z?Rb8_w>}=x-dP8CAF3rLMwKf4#qTDt$U8 za{N~Kp48(0<)Q{Fc#uP{p+8QsJtorAL;zA71n~{(dK~+7!8KJTGwVAJ4y3YI{55N0 ztk!0fki=hh5mWpy&+N#y2!t(`TZs~n@W+%A$yayIv@+kg3y ze>(tt;k2oVWworAaK~Nu0qygI$E#}^^Lk9b?!=Ev8@rC-8(ZbKDD;zaT+VVcS94ZE zxV4YXBC7?2s72(pPA7H`*BaWWBCxOo0&lJEQD%Y9!H)&Qj}B=W=#ILIPVWJ2ftj9s z(X=hmd`qg#qbliP*ci6MdXo_KYt-yaZgh(G8Kpv0FuHhb*x zIjqK`tn?u%q1M8bLuUGDUpv5EFm}5?MZ*P_SC-#n>x~{uCvw^ERT+rKmL<9(;`is- zY||X;7B3>|O{p1dH&LV~K})Dk;17|b6BFLUV-9FAb*Np&J9j3S8nd?Quygd8l^4yU zME>vD&+=XooqZ@N^fR{;TD@PmpOB|$*ri0PpfHTLPOF?5nh%rd;imcOBv}kQH6ja^ zVWk$IUoC14h*`j$G1keUdb}=t63<^7s*wf}k194v6zgj#s%7vlbaP=&@6!f%RJ)G6 zHpr{_SS1;&=jgiB^X=Bb19m!rSA?*Yu^qWQCt-Jzc5ct!Ngo;=#kf{1DWAYATzq`0 zPS(SXTuo)m!6l~b0SJqmN@0*A7gq`6ndYhAY%=fYZEN6qR6xw}dy5>J&5)PM^OF#~ ztG8X-9>2oqzDypC1mn%FYe>ibU~~>33wWi$t6_hv@AgMD$LcOTPDC3|zR3e0+xp1< zh}WsVX!9s6ab?5(rpD>Rt>lH%)8<)R@Rx$G=V34CfMwM$$Ptu>cd+ph?q4!)V%*?6 zp2@e^bs*gJyqa zF=OOM)nikQxRJoM0y@sh0Vy&s@~63d4xL@H=LrIXshZ1dKPwl@Dw01i`~8U_oxo9N zs*LT?CD@pm_PSShBCgsFdxtz>ts||of@zVuw?}2bG)NK0)aTwzFsBVW!&4WU(=w{m zlE3la-SIHBYX5!hsNcb(zjzg2xy6x{I{D)q*c3udhf?|8m{aBKj|g~Ol>b}9{2`4L z6I#)V!~zTVbD#h~c47u~vm}OGhF!;x&(AuK3e}1LX~^jxv~IW9Dv#emqe~@ti>3CQ zA|>|2t4|-*UroPCg-{qwAqB*bDgwYg*M@%j)t)JxHLyLu`w$!LiS>l_^4pO)^DTZo zdLD{j?@Lu25cbPWWUj}$p67<_fY23T;F!ShTsyY1tOs{*3@^9zPYejWqVWP3`CU*H zwpw_6OF&mkA{g}Dq*K6^4_9niy zl}|tASENT~C;qw$x8>>Y+$9v*4(Y_j#e>#QpYSp~q)dE5A3~pasG{tDEjd9ivrBt= z>#@q3*!>X4N}#Ih>+5T-7VGi8hnk{0nXZ-=1HtLECTAFmb(!^VLF(huUwP7i(_d0j zGO{I(&Eq6iaLwq^Kgv+dJ8&>;XC)^KNjyP7%}urIKk5@81YwkfP#F6}lKz#+Sf$_Z z9ifOrAXueRAdpWoD3S3&{lwMcSNXr* z0J8BR?q9chD{^ICK+TMYs+%r@hcpBd_}Q0TqTYP zKePXKiQCZ8LLz%9)6@#W;K3sjr+6%MN5>^ZdeK5E8!3X`7I=H6VhNhCsdH<`zW z1Q4>PihEk;BPvG@&vu*CO2y7g81;>F*AR51!nXp4ECW#tiSgF7S6eIIXe5;DZs!t0C*5*{6vs z>@CN+Os|`A?=dLgf|1qaiB9!N`!La}0m*2Wf!gjak-M{VaK_mdONPQxdTl4$D{vlA z!fsKubGj1KJ^vUxyq-yXtEI0RSGJQgoH#!eIW{S#O=~&BdyO$_nd6hK6DdpUtdSC1 z8u9UquN!Y!QY-;yGD({Ct$6MctPWnGNMNvz?p{<-`@LD zci56%QOf2Q`;=p1bwUv|mt?XMo>?*4LEQ_NnlpN^<*$4YItpV!T)xUAkQ0m^{yI+`KtR>8t>jv_U__GK{t z=-Anj4}7;Vp`MrM%x7VYI;lQF{r<;NSeP%p8YiACv7~FC3%s#PXuv-YW_n8T+qVFBzdXR;<<_bBgaCQbPqM!=z*|{{P-C3xS*_M z(*uCcjnD38Ufo`)QC-+@i!#n3A#PrCN@$F(5gkHka6z{^xF^bHCWJN9y?uxM+No|Tbz4yCPpM*b{HxyRP$Xj)$WwXL!r8cXv|E`tu$l@ zoNq#Pb>Y8b`{@~b=5^8&uC9k5KiPHe$oWNjTMm$?e_PKo`kIN*d&bhwqecR=ORV3WDIc&brUs@8)zj?wG;Jl7e#)} zjS-u7c9_l~8piN@#R05ifxxO9IEhEKCOVBp?S9T>^j@gsMLRs-?c5#(iFE`JS&li! z?YGArUAISib+;D|xYt=fj|kcne(@KTVq#GUrHT;aqpI{(;wdz}fpNi73kK~L9Og7F z$pzctnp?#~a*PqemE%-7)r}@b+`5-Gwqg0rnWBA~eTb1{$@D@U_k0-qO-pm9iv@>+ zX_@vnD$SfTu~|B!D+E%l*?N;Z6Qa9;BMMonrhdl4S%<DjX2o$ErzCfs0 z;CQmIJZOV!*-r{GKpGu!RSTw#E`A@(&$1o6A|Vo-S`^QDS;iGY4%^RSD>rw^;r9w{ zT9*ilELG>a3`rnk$WQcI+Qh)=jZKf6AGO?GVPJgiB}r4qifD0ar<;xu|IMrY^=$|K z#!01+G=75a*#}yR@`gkyKMk zv&l|IQeL;a4wfXLO4%bfvDSsd)cGFFY-u@z6~y|f_&?Fk7+k*E0;$-mJ+ku zgy8)&F^pPkuWZ{F*owb#OoiIWX0>-#@aVa_nJ|ys)I~2@&ef8X|<-+o&$-Q9~*#5f&d$ zV`G&sCTN!0{TUb8SJv)Pz~(ZBF&`s~{Kx#X5ezt494ULuxTMK3Xf2!4qZVRDmbQK% zd2yaj%*q_^>q&E7H!4$FnbenNqp;3>ev7wCS|78W`;#&-T~a=DG}dFcH z?of~dWT0vekg?dG)SF2MRgiMVmz0(Xgbowau~5Mx6MUE(?Mpuc!B#~YWqZ53ozh4K zqpWM^8}csz;Ba%xnVXxdrlFxYS6Y=wFM9@Fg|x{6`+XgN11=3m{~O%Pj@WAOzOH>f zk6Gm3Z51kc9BXzGyWx1K70KXYpX66Xym+0u}d`=Seq<}+C zSnL^qa+Zw-9xIgwTJUZ?cwC+BOo!qz@q3=_eE-vXbLHa~j$5HjGDJ9$-*nD{!C@-Kn|DJj1NhKmDmN%KPi1IP|QbxOW( z$BVX15OvTmU%qVQOEm+&Z+yCVQPYnc6P`VvM<<>TQpj~9{@b`WVw6-EsHoXTQkOWW zGJTGXTO~36saWuV+cQ;Y@$@vaEj?XesP7hL~nKp&G}nCH8{ z3NnWey^K(b(feB?l8h&be;G}`U@>5C`v(kH%%C80erE~BjsblOU<#4p|DE;FeO5ZZ zf3WcXMPpKYCI{Zfb1J|u?gu64$H&LdSWy7#4g~rXr!+A!k@yVs1{s(l4<)eg)n_X} z6;=nt10*Np-8lLQ9Px$?UOO)B{*!mt_ni zJz(0)=eQ%f2r*d=i>>FAN6j=frChY-9bjUqy;nc52fNyB^#n>sA<^uiEDJk@>~S-( zk9R7PS8@=xp%wOJ)$ZrdhIK?n5{$kBTY*%K$=+9T4Og_K`5LTTewyD%HJdNd7}5m+3wazFjB3F)QiydIaeN+y)v z%L|F0n^BN%tYi*8>LZ~ZG1FqQmTYNmsf;<(ZzNG|yn=}m2w7=v)EW}IJ1TM(S`OgQ zIP0Y|PdY+B3$FHImp)R5@gE{1xhjG$y@bk>f5jILDu;|h7SxKltsa5$%BZ%2c?Sob zB$BGiC{lDgQ>J!RS3TsZhOS=-oX3D9dlkcV!G%a`MfA5fbbN!mu!A^Ei zI;_}H6)Urk@lI`&h(;cSnqbeH^y;SRjaXL%htpU?O;xUt3Savq*IiuL)GFHDZbBKw zEi5+UJh0iO&RY|T5SDbOFcP~Hx6U(YUY9V91}3n$lpT#I#)jcDI~WSln<&l&w|L zs`u>doO4Wv(BU$z7U4zZtf#TZ%NUA`q8&M|y%7y>4eLyT9;%dFzS)!NcqWz}A^Yy= zrgu21IkKdjVjk|H2r-wj{c79(A(<(etyOtatWp5rYBr9mBu#5+S5}U?$^UHpvJPVBoBbGoF$DcyD#h8mAdm* z4m!~hwCCX@BNL{V!0?Wl*t(6Z^*P>aQ6{Ub5YIX-3$`3p&BB;v6i!L zh}a|4QuoVzWN)Vy+e6^dEt1WQ+gsVJ66>)_kK5muKZcUP4I0xDFOdqP0Hg{HLfxI6 zt$cP%u?=+g?K|Ewfs6Gw7?k?o`l<7YKmII8aLivoN$E^VW8P+>&*>OmdN7LQN$==P zclah&ma2>SvkBTYqj`e=r2!r7(w-7^9 zO-&lljAlx|w+*4`Sageg#P0R_plp+ zSwlF0{s5mtsCePIl3{A>KzZ(0MgTT+T;Qhr*W>N}%~$%$~@64g_E|q&EU# z(K~F`#_tgv7s8ANUPQB{-uwbhd@Nr??(zmJV2nfS;miySBDR>l&*S6IxKp*dT>G5% z_UBFQwE6H*a|OJ`%gqE9=0{^()G4 z)mMTPNbtGBD77@~Y0hpXnHK4mXl!->lZJ&+WLd|LSO2CLfL9 znGwb1v6QKZ)406#28aaw2n;_KEm^$%s5zFE6L};Lfkd}u-eLmp~1-7htjWH zY=rVIVr3_P(kbE`wdq%_Q_}=E?Ye?r%mw~FV9LP`djliO1CJhXJLnuQ9+Uoj!S&~N2nRB)hTbd^ z$o|;~;sLFJvLDp~=R=+ojJ=6{{m0`kKJp`?0U21+%k=gWd>bv#73Lr*kOdFvI7`SQ zM&4&!{(A7eXC*+Pc5UdEl@~nuGA#G(=Zk1I`lOM#FDi5#rI%UaaMxDa)xT#x@<3%LyJU^*b$!p}?j?n!S;A3k#LUwZSuD9ZoNcmC^FqLJK*m-7G1!H@?7 z2Dl_1J0?7!d~y?V*M{|>f24Om1Hc{qZ!~M_|9lQb&Y=B4{C_1nIy%aV^6uBdLWK0+ zCcG;4FDxzX5(K?>aA5NvAAbt?BRpLHoUQR|LVl=D;@w}~aiw)|q_#F^Z;!Z{E`}J` z!+d{x*bV#McTY&h={m}s2{`P5>IEKnVF>ym!5x7!{FfZ5HsBQCC#~4#J*9=;q%hQP zUC5;8gO9GGQR(7_0)A1*uEVdm<5)(iYL?rTW=3>{G_h~xrfzI`Qs|h)&RAR-!(z_q z_2$C+^S81QPMvNEt_u=FfD~L2?oJ?_y!kwSIYg4&hrQwpaW&uTs;A}v1|?20^92su zpEnp^CWs!zBXlA`b)Pa+l1!q34W*x9OwJuP?y^k$Kpm|8ToKT*vx$ zePMC&&mLIVFGZ_0T?3L-*wH%_j0KijK0kfr%OY`ib%tVvJO(NX_{JsJb0=4`b8O*E zT3FIN8$8?-!qT%-4elp@>1s(2=dfK|-_~6(uQe=XUh~D42!SVJq1>)2;PfAp4TA=+ zbsx*hrX9$61hHF9qrW|Wrdz2Qw5hRDmi2Ph9?K8)!Q3%k+dvfg4~*qm;-E~7QQvsm zOr3z`)h?rJEeBE=JRiHxrpbmBz6heUO=c(KuaQMi6!+nW&;#Dz&2(`%F+qa^IAAN+ z>9ngX?}68ohh2>|fy4^bpC=0`2?H%F!#x}wH6nfoNHs}`Z*#2I%9fl)wpr{#ekQYa zzWh1Jk=9o-t-d*YyMDC6cAwS}p)et@yP&aqVN6Z-apoYq+Msav?5&cEdHbqZfq+tj zm_@0EMPDStU?y?y9V$&(Lvh$w_Ro1)$8L!BX1vkpy02%2WlLo3a^Yxw-#ybv(_q_T ziB_ikHnR+)875OQi-CC2aiwZ;FBEglLzwnAaxx4Z1=`uxV-w`VZQ@|>^m!2kG3y~r zP43FjNYee|2pM-6@~QdhNv#QE#SMZPH6=ET&2Isu&`hINo-Z_P&Q>#p0Wsf()S&TcA}rA=sA zntk6j|7fR#(=@xq;!vODQA~hF1qYv9Cah}T>c)Z;4lu$zF}qBy=!RM@jd$#!w?cXq z-bwV!g*&Rmi)B?=T=!-=mgY{2PVvmA6C=BLM|ee^&CR)g)>Y?zC3iE_CsYlT7oUV) zSx4*M2T-|wT>rkVL7c!3C(NT9kB?t)XVG9l5$~p}VpdOW+lx0zi)WNybog#$+_F_w z6K!o|(YCtU&x9-S`OHG)jG?sw&9hDW$}2lHlHz?W8C}>jB(tv7 zQC|9HR$E*emlF~Yub#q~P0zi4aErH58A#I1iI)|c?acM|^##Ljn(&v+30Xtm(H;pp z_4oN4b^O1X(Vwx|3{gb%&rMGa$O7EtDh@;Lk=5baAR?bQAq^JNH;{LNTpUv}FQoGo zbBr2$(}nh^Uq8WHj(4BK=Q*IkgUH?Ea20Mi=Buz}UC;<9gwUdrt5own^B|I15Ur^ih`j7~2qEqXRW{M#;5+RW&SOoM zu2k3xRZrNd$xvOgZOm(aRytTw5V%Pa$h@OSI8EDmK9zS*+cYrKTRmcw_CBF`(Bl*M zo9r2-*i}(UJjQk6O~}~hvJ|SWODfhUNUf^!`}kt*LRr&zdVUPiROHYNjznSdMq&`F zY5Z`8s~$*6PIb_bu`n2U zeYA-tM=NnxvgqZ_y53KnTKJoJ8}hlk>hMsoY3x-T?Wk~C+Wgi=n1GrgBb97#gg!5! zlxBc3R`S!82)v@tLxr2}CGm+pO}n*Y9VhYSrMYG)zH7Xh0&3}cGB0y(KX=RQjxp@0lh4ax{O!+?Uj($_IE}Gn< zv(DB=tqEbr`YB*rhmyRVBULZXDuwx!^^!!ZU;^;(BNT0dH!?h0j#jxB4)^a>w^Zc@ zG;1%E+o+lGXkzuZ`xwdFf7fhkPWOGZE?IC;ZWb*ni3~)WSfk%-I}~@+Kl<2~Y-&F| z&=ClC+J2nIW8w4RdF-Y|342}dU_6gD%CN_`u_(NLi#Q>RkIbIeDR$`!rnx7KsTCid zJFQD6bHF=~@HkWs-$utzsyv!=?>L9Q=Z0J4WyQ`R?p`~?qjObTz%Aq+esc(roF$}g z+_VdQ=3I98SB3qNxv5FCto|=aftl$QM|dZfynGo$AC1UcKFfof3iNbZLsqT(c2i9Y zTve_L&h(#!^tyTDT9yv2!j<_#2DV6~9Hc4Ir>PPF({kfn-MVw^_B0+0JK4L0yEF$2TebLmdq2Qc{vO;Q_r<*2vK5uNm#e>Abxt8}^(uMeN`avFd6m?L<~f&0?(Vn{Q0w_4gZQ49Y0t zrf^GJ%vOaG<_5&OO2#&RUBv@rQed~>yq5>8yoNf1=Mr{L3Ro3nOn*ZLQ~8`?6Dlq! z6CoLPewY^X09Ce8DPAQISJh*wOW__aa&FomtUfxjVKmkZ+yDQDH7#-9`Fq~`sM=(c*hWQvnIR<5|a(EeqEzZ5D5Wx z#_y{*PJr8Vzd-Q#e52Vr@cX}im>vHda1KKkd=TOPEMyJd?xyL;J$`_beogOmr4>j_ z0U-SV&sDzs7jOPd4(YEqz#Q)*Q2N74@^Jm*So+5nvvd{lQpw^sB;zt`Ja-wKN`c{D zsZ04USZmPtN^Z{)8R)#29bk8H6_XQWM-%8;@W!#PBs&8ff!Vz3%*$h>1ahEAR_Vj~ z{IOY~iO0(-$~c4sQFbc5jdU`PP3BK%q`o?1KU8Y~k(Q8q0Q>*;Q24HEl(ilj5*!RT zoPhr1qX5n4G6KkZz<2{CDp68VEdkF?0WaMMlK+N}{|%FaU;aO$@~}_#4;A}=1#CA1 zEWba1DVF#bg@Ati%ge#D|;&@mKTuRtXh3TUoF> zxZfq!hE?~kO;ay0{<{e!c&sSRV>=@#8!P{EN=aXG+tuj3y+9VxxuheA>}Gi?vmx%qC>?@^Bm`Rc5ja zQLc>H>;e2(IPVBZPWdnMJ|{~sG0OEWQAIHVWVY7~IF-$$QMTM9@s7^x&?dnxU!*ii z&vBG~J>EO7ll`c&$)+WGC7e(!SzE~^r`144(=ysa*gs>t;yXJdbMAtsgjnS-btKn@ z*Q5-{e87CMnXPO2Ynk}*-w$Nv(`ipb%KtZ&a}hI-J>?1a(^)sCYlUcM;F8IvEkoZE410+! zA_SZ?bK?fChH0qw^DTbv5b~(&tG~L--8Wg&Hp$(v+wXN^kLxOp_g}oc4OUuYo9+8} zk>R3V5qA!7EAFyW(jJCJN7-J3x0^8-?A%yev93O>>!yHd!MBb@*%1~$b*^77d->E= zb90Q~)2vJJxf@r>-H)C>RjBmy9YOPcxqiuh<6uFxN9(oBmv9_q3kJ@EUIAs784MBL z_0zvL{@i%}j%$$b!ijG-YAp<`yIZ|2e3NHocBghr*EOB5A<@8~%B5GAx> zV_`z(O5r5lhLs&^R#sLlYk!=N)ti)CrSd?irTE1m;7HF=_1NByjvsg5*QtMt-COl_ zrg1uG)x2=mk|iqj_P)SLqm{tkvj`)w_xuq!b+qiT`J)F1o0BT9t_t0rcUMYE>esW` z`FZ#E>DskhY)}33R8M@pr6ObTbbpgWwk#9aPcbk}*Wx^G8#3jD+o~7pr9#E2;;Er0 zQ@Zr!fCYeor{MOYs_5n58MafsaTgh~=BhlI;*t7p{gvn3elDih6(E&Ie1v*ATfQ7lh7F?no=oJR`mLFUME5JT(k=1g4U+wG#561z%GNa1KrBHE33s z!QsWs7e5zGnluS{Cj)Tv4{(O_K#P)^8k@Z@!;1Zam&@M#k8faP;Q7$ED`r9JI*?XR LS3j3^P6 { - return app.graphToPrompt(); -}; - -// just persist the data on workflow reference -const setInfoOnGraph = (values) => { - // create the info object if not exists on workflow - - let name = values?.name || defaultProjectNameText; - app.ui.settings.setSettingValue(idProjectNameText, name); - values.name = name; - - let author = values?.author || defaultAuthor; - app.ui.settings.setSettingValue(idAuthor, author); - values.author = author; - - let description = values?.description || defaultDescription; - app.ui.settings.setSettingValue(idDescription, description); - values.description = description; - - let version = values?.version || defaultVersion; - app.ui.settings.setSettingValue(idVersion, version); - values.version = version; - - if (!(values?.created)) { - values.created = new Date().toISOString(); + constructor() { + this.createSettings(); } - inputRefProjectNameText.value = values.name; - values.modified = new Date().toISOString(); - values.software = 'ComfyUI'; -}; + // not on setup because this affect the order on settings, I prefer to options at first + createSettings = () => { + /** Form on settings: (the order is important) */ -// just the name of project -const updateProjectName = (value) => { - const ctools = document.getElementById(htmlIdCrystoolsMenu); - // validation because this run before setup - if (ctools && inputRefProjectNameText) { - getInfoOnGraph().then((p) => { - if (!p.workflow.extra.info) { - p.workflow.extra.info = {}; - } - setInfoOnGraph(Object.assign(p.workflow.extra.info, {name: value})); + // use new save button + app.ui.settings.addSetting({ + id: this.idNewSave, + name: this.menuPrefix + 'Use new save button? (requires page reload)', + type: 'boolean', + tooltip: 'This will replace the save button function and propose the name of project as filename!', + defaultValue: this.defaultNewSave, + onChange: this.saveFunctionSwitch, }); - } -}; -// remember to send `{ property: value }` -const updateInfoOnGraph = (value) => { - if (typeof value !== 'object') { - console.warn('updateInfoOnGraph: value must be an object'); - return; - } - - if (app.graph) { - getInfoOnGraph().then((p) => { - setInfoOnGraph(Object.assign(p.workflow.extra.info, value)); + // show the input on menu + app.ui.settings.addSetting({ + id: this.idProjectNameShow, + name: this.menuPrefix + 'Show project name in menu', + type: 'boolean', + defaultValue: this.defaultProjectNameShow, + onChange: this.showProjectName, }); - } -}; -const showProjectName = (value) => { - const ctools = document.getElementById(htmlIdCrystoolsMenu); + // project name + app.ui.settings.addSetting({ + id: this.idProjectNameText, + name: this.menuPrefix + 'Project name', + type: 'text', + defaultValue: this.defaultProjectNameText, + onChange: this.updateProjectName, + }); - // validation because this run before setup - if (ctools) { - ctools.style.display = value ? 'block' : 'none'; - } -}; + // author + app.ui.settings.addSetting({ + id: this.idAuthor, + name: this.menuPrefix + 'Author', + type: 'text', + defaultValue: this.defaultAuthor, + onChange: (value) => { + this.updateInfoOnGraph({author: value}); + }, + }); -const saveFunctionOldHandler = (event) => { - originalSaveEventsRef && originalSaveEventsRef(event); -}; + // description + app.ui.settings.addSetting({ + id: this.idDescription, + name: this.menuPrefix + 'Description', + type: 'text', + defaultValue: this.defaultDescription, + onChange: (value) => { + this.updateInfoOnGraph({description: value}); + }, + }); -const saveFunctionNewHandler = async() => { - let filename = inputRefProjectNameText.value + '.json'; - const promptFilename = app.ui.settings.getSettingValue('Comfy.PromptFilename', true); - - - // literally copied and pasted from comfy ui.js ... https://github.com/comfyanonymous/ComfyUI/blob/master/web/scripts/ui.js#L710C10-L710C10 - - // ¯\_(ツ)_/¯ - if (promptFilename) { - filename = prompt('Save workflow as:', filename); - if (!filename) { - return; - } - if (!filename.toLowerCase().endsWith('.json')) { - filename += '.json'; - } - } - - const p = await app.graphToPrompt(); - const json = JSON.stringify(p.workflow, null, 2); // convert the data to a JSON string - const blob = new Blob([json], {type: 'application/json'}); - const url = URL.createObjectURL(blob); - const a = $el('a', { - href: url, - download: filename, - style: {display: 'none'}, - parent: document.body, - }); - a.click(); - setTimeout(function() { - a.remove(); - window.URL.revokeObjectURL(url); - }, 0); -}; - -const saveFunctionSwitch = (newOne) => { - // for ignore the first call - if (originalSaveEventsRef === null) { - return; - } - - let handler = null; - - // remove all event listeners - const clone = saveButtonRef.cloneNode(true); - - if (newOne) { - handler = saveFunctionNewHandler; - } else { - handler = saveFunctionOldHandler; - } - - handler && clone.addEventListener('click', handler, true); - saveButtonRef.replaceWith(clone); -}; - -/** Form on settings: (the order is important) */ - -// use new save button -app.ui.settings.addSetting({ - id: idNewSave, - name: menuPrefix + 'Use new save button? (requires page reload)', - type: 'boolean', - tooltip: 'This will replace the save button function and propose the name of project as filename!', - defaultValue: defaultNewSave, - onChange: saveFunctionSwitch, -}); - -// show the input on menu -app.ui.settings.addSetting({ - id: idProjectNameShow, - name: menuPrefix + 'Show project name in menu', - type: 'boolean', - defaultValue: defaultProjectNameShow, - onChange: showProjectName, -}); - - -// project name -app.ui.settings.addSetting({ - id: idProjectNameText, - name: menuPrefix + 'Project name', - type: 'text', - defaultValue: defaultProjectNameText, - onChange: updateProjectName, -}); - -// author -app.ui.settings.addSetting({ - id: idAuthor, - name: menuPrefix + 'Author', - type: 'text', - defaultValue: defaultAuthor, - onChange: (value) => { - updateInfoOnGraph({author: value}); - }, -}); - -// description -app.ui.settings.addSetting({ - id: idDescription, - name: menuPrefix + 'Description', - type: 'text', - defaultValue: defaultDescription, - onChange: (value) => { - updateInfoOnGraph({description: value}); - }, -}); - - -// version -app.ui.settings.addSetting({ - id: idVersion, - name: menuPrefix + 'Version', - type: 'text', - defaultValue: defaultVersion, - onChange: (value) => { - updateInfoOnGraph({version: value}); - }, -}); - - -app.registerExtension({ - name: idExtensionName, - async setup() { + // version + app.ui.settings.addSetting({ + id: this.idVersion, + name: this.menuPrefix + 'Version', + type: 'text', + defaultValue: this.defaultVersion, + onChange: (value) => { + this.updateInfoOnGraph({version: value}); + }, + }); + }; + setup() { app.graph.onConfigure = (a) => { // on drops a file on the canvas - getInfoOnGraph().then((p) => { + this.getInfoOnGraph().then((p) => { if (!p.workflow.extra.info) { p.workflow.extra.info = {}; } - setInfoOnGraph(p.workflow.extra.info); + this.setInfoOnGraph(p.workflow.extra.info); }); }; const queueBtn = document.getElementById('queue-button'); const ctools = document.createElement('div'); - ctools.setAttribute('id', htmlIdCrystoolsMenu); - ctools.classList.add(htmlIdCrystoolsMenu); + ctools.setAttribute('id', this.htmlIdCrystoolsMenu); + ctools.classList.add(this.htmlIdCrystoolsMenu); ctools.classList.add('comfy-menu-btns'); ctools.style.margin = '8px 0'; ctools.style.width = '100%'; @@ -253,23 +123,161 @@ app.registerExtension({ const projectNameInput = document.createElement('input'); projectNameInput.setAttribute('placeholder', 'Project name'); projectNameInput.style.width = '80%'; - inputRefProjectNameText = projectNameInput; ctools.append(projectNameInput); - - // this boolean does not save the value on workflow - showProjectName(app.ui.settings.getSettingValue(idProjectNameShow, defaultProjectNameShow)); - - // this text save the value on workflow and localstorage - updateProjectName(app.ui.settings.getSettingValue(idProjectNameText, defaultProjectNameText)); + this.inputRefProjectNameText = projectNameInput; // events for the input to persist the data on workflow projectNameInput.addEventListener('keyup', (event) => { - updateProjectName(event.target.value); + this.updateProjectName(event.target.value); }); + // this boolean does not save the value on workflow + this.showProjectName(app.ui.settings.getSettingValue(this.idProjectNameShow, this.defaultProjectNameShow)); + + // this text save the value on workflow and localstorage + this.updateProjectName(app.ui.settings.getSettingValue(this.idProjectNameText, this.defaultProjectNameText)); + // save the original save button event - saveButtonRef = document.getElementById('comfy-save-button'); - originalSaveEventsRef = saveButtonRef.onclick; - saveFunctionSwitch(app.ui.settings.getSettingValue(idNewSave, defaultNewSave)); - }, -}); + this.saveButtonRef = document.getElementById('comfy-save-button'); + this.originalSaveEventsRef = this.saveButtonRef.onclick; + this.saveFunctionSwitch(app.ui.settings.getSettingValue(this.idNewSave, this.defaultNewSave)); + } + + showProjectName = (value) => { + const ctools = document.getElementById(this.htmlIdCrystoolsMenu); + + // validation because this run before setup + if (ctools) { + ctools.style.display = value ? 'block' : 'none'; + } + }; + + // just persist the data on workflow reference + setInfoOnGraph = (values) => { + // create the info object if not exists on workflow + + let name = values?.name || this.defaultProjectNameText; + app.ui.settings.setSettingValue(this.idProjectNameText, name); + values.name = name; + + let author = values?.author || this.defaultAuthor; + app.ui.settings.setSettingValue(this.idAuthor, author); + values.author = author; + + let description = values?.description || this.defaultDescription; + app.ui.settings.setSettingValue(this.idDescription, description); + values.description = description; + + let version = values?.version || this.defaultVersion; + app.ui.settings.setSettingValue(this.idVersion, version); + values.version = version; + + if (!(values?.created)) { + values.created = new Date().toISOString(); + } + + this.inputRefProjectNameText.value = values.name; + values.modified = new Date().toISOString(); + values.software = 'ComfyUI'; + }; + + // remember to send `{ property: value }` + updateInfoOnGraph = (value) => { + if (typeof value !== 'object') { + console.warn('updateInfoOnGraph: value must be an object'); + return; + } + + if (app.graph) { + this.getInfoOnGraph().then((p) => { + this.setInfoOnGraph(Object.assign(p.workflow.extra.info, value)); + }); + } + }; + + getInfoOnGraph = () => { + return app.graphToPrompt(); + }; + + // just the name of project + updateProjectName = (value) => { + const ctools = document.getElementById(this.htmlIdCrystoolsMenu); + // validation because this run before setup + if (ctools && this.inputRefProjectNameText) { + this.getInfoOnGraph().then((p) => { + if (!p.workflow.extra.info) { + p.workflow.extra.info = {}; + } + this.setInfoOnGraph(Object.assign(p.workflow.extra.info, {name: value})); + }); + } + }; + + saveFunctionOldHandler = (event) => { + this.updateInfoOnGraph({modified: new Date().toISOString()}); + this.originalSaveEventsRef && this.originalSaveEventsRef(event); + }; + + saveFunctionNewHandler = async() => { + this.updateInfoOnGraph({modified: new Date().toISOString()}); + let filename = this.inputRefProjectNameText.value + '.json'; + const promptFilename = app.ui.settings.getSettingValue('Comfy.PromptFilename', true); + + + // literally copied and pasted from comfy ui.js ... https://github.com/comfyanonymous/ComfyUI/blob/master/web/scripts/ui.js#L710C10-L710C10 + + // ¯\_(ツ)_/¯ + if (promptFilename) { + filename = prompt('Save workflow as:', filename); + if (!filename) { + return; + } + if (!filename.toLowerCase().endsWith('.json')) { + filename += '.json'; + } + } + + const p = await app.graphToPrompt(); + const json = JSON.stringify(p.workflow, null, 2); // convert the data to a JSON string + const blob = new Blob([json], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + const a = $el('a', { + href: url, + download: filename, + style: {display: 'none'}, + parent: document.body, + }); + a.click(); + setTimeout(function() { + a.remove(); + window.URL.revokeObjectURL(url); + }, 0); + }; + + saveFunctionSwitch = (newOne) => { + // for ignore the first call + if (this.originalSaveEventsRef === null) { + return; + } + + let handler = null; + + // remove all event listeners + const clone = this.saveButtonRef.cloneNode(true); + + if (newOne) { + handler = this.saveFunctionNewHandler; + } else { + handler = this.saveFunctionOldHandler; + } + + handler && clone.addEventListener('click', handler); + this.saveButtonRef.replaceWith(clone); + }; +} + +const crystoolsSave = new CrystoolsSave(); +app.registerExtension({ + name: crystoolsSave.idExtensionName, + setup: crystoolsSave.setup.bind(crystoolsSave), +})