commit c8a8f6da362864f9d072a9ec928aeb9c121b8428 Author: Enrique Calot Date: Wed Apr 16 13:44:06 2025 +1000 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..248ccf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python cache and bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Build and packaging artifacts +build/ +dist/ +*.egg-info/ +.eggs/ +*.egg +pip-wheel-metadata/ + +# Test and type-check caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyre/ +.hypothesis/ +.coverage +.coverage.* +htmlcov/ + +# Editor and IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# macOS +.DS_Store + +# Local environment files +.env +.env.* + +# Logs +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..121f7e3 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# ComfyUI-GameDev + +A custom ComfyUI node pack for game development workflows. +Currently includes depth-to-layers color mapping and grid-based sprite/frame slicing. + +## Features + +- Adds a node named **Depth to Layers** (`depth to layers` internally) +- Maps grayscale/depth ranges to color layers using editable intervals +- Interactive in-node editor with histogram, preview, add/drag/remove handles, and reset button +- Adds a node named **Grid Slice** (`grid slice` internally) +- Splits a sprite sheet or atlas into a batch of equally sized frames +- Supports optional skip of the last grid column for irregular sheets + +## Requirements + +- ComfyUI +- Python dependencies: + - `torch>=2.6.0` + - `numpy` + - `pillow` + +## Installation + +1. Clone or copy this folder into your ComfyUI custom nodes directory: + +```bash +ComfyUI/custom_nodes/ComfyUI-GameDev +``` + +1. Install this package (dependencies are defined in `pyproject.toml`): + +```bash +pip install . +``` + +1. Restart ComfyUI. + +## Node Info + +### Depth to Layers Node + +- **Display name:** `Depth to Layers` +- **Class key:** `depth to layers` +- **Category:** `Game Development/Image Adjustments` +- **Function:** `transform` +- **Inputs:** + - `all_intervals_json` (required `STRING`, multiline JSON) + - `image` (optional `IMAGE`) + - `node_id` (hidden `UNIQUE_ID`, used for UI preview messaging) +- **Output:** + - `IMAGE` (`image`) + +### Grid Slice Node + +- **Display name:** `Grid Slice` +- **Class key:** `grid slice` +- **Category:** `Game Development/Image Adjustments` +- **Function:** `slice_image` +- **Inputs:** + - `image` (`IMAGE`) + - `columns` (`INT`) + - `rows` (`INT`) + - `ignore_last_column` (`BOOLEAN`) +- **Output:** + - `IMAGE` batch (all sliced frames) + +## Usage + +### Using Depth to Layers + +1. Add **Depth to Layers** to your workflow. +2. Connect an image output to the `image` input. +3. Run once to populate preview and histogram. +4. Click in the histogram to add intervals. +5. Drag start/end handles to adjust intervals. +6. Use `Shift + click` on an interval/handle to remove it. +7. Click **Reset** to restore defaults. +8. Run again to bake the updated output image. + +### Using Grid Slice + +1. Add **Grid Slice** to your workflow. +2. Connect a sprite sheet image to `image`. +3. Set `columns` and `rows` to match the sheet layout. +4. Optionally set `ignore_last_column` to true if the last column should be skipped. +5. Run to produce a batch of tiles/frames. + +## How It Works + +- Backend processing lives in `depth_to_layers.py` and `grid_slice.py`. +- Frontend UI logic for Depth to Layers lives in `js/depth_to_layers.js`. +- The Depth to Layers UI previews interval changes locally in the node editor. + +## Notes + +- Depth to Layers stores intervals in `all_intervals_json`. +- Depth to Layers outputs an RGB colorized image based on interval mapping. +- Grid Slice divides image dimensions using integer grid steps (`width // columns`, `height // rows`). +- If no image is provided to Depth to Layers, the node returns the input unchanged. + +## License + +See `LICENSE`. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b1821c4 --- /dev/null +++ b/__init__.py @@ -0,0 +1,14 @@ +from .depth_to_layers import DepthToLayers +from .grid_slice import GridSlice + +NODE_CLASS_MAPPINGS = { + "depth to layers": DepthToLayers, + "grid slice": GridSlice, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "depth to layers": "Depth to Layers", + "grid slice": "Grid Slice", +} + +WEB_DIRECTORY = "./js" diff --git a/assets/comfyui-gamedev-icon.png b/assets/comfyui-gamedev-icon.png new file mode 100644 index 0000000..07738b5 Binary files /dev/null and b/assets/comfyui-gamedev-icon.png differ diff --git a/depth_to_layers.py b/depth_to_layers.py new file mode 100644 index 0000000..fec2f48 --- /dev/null +++ b/depth_to_layers.py @@ -0,0 +1,217 @@ +"""ComfyUI node backend for Depth to Layers. + +This module provides: +- Node runtime logic for the `depth to layers` node. +- Interval parsing/validation and grayscale-to-layer color mapping. +- PromptServer event payload used by the frontend editor to initialize previews. + +The node converts image intensity to discrete color layers using ordered +intervals in the normalized range [0, 1]. +""" + +import base64 +import json +from io import BytesIO + +import numpy as np +import torch +from PIL import Image +from server import PromptServer # type: ignore + + +def tensor_to_gray_uint8(image_tensor): + """Convert a ComfyUI image tensor to uint8 grayscale using torch only.""" + source = image_tensor.mul(255.0).clamp(0, 255).to(torch.uint8) + + if source.ndim == 2: + return source + + if source.ndim in (3, 4) and source.shape[-1] == 1: + return source[..., 0] + + if source.ndim in (3, 4) and source.shape[-1] >= 3: + rgb = source[..., :3].to(torch.float32) + return rgb.mean(dim=-1).round().clamp(0, 255).to(torch.uint8) + + raise ValueError( + f"Unsupported image shape for intensity conversion: {source.shape}" + ) + + +def tensor_to_png_base64(gray_tensor: torch.Tensor): + """Helper to encode a grayscale tensor as a PNG base64 string.""" + img = Image.fromarray(gray_tensor.cpu().numpy(), mode="L") + buffered = BytesIO() + img.save(buffered, format="PNG") + return base64.b64encode(buffered.getvalue()).decode("utf-8") + + +class DepthToLayers: + """Backend implementation of the `depth to layers` ComfyUI node. + + ComfyUI contract: + - INPUT_TYPES: JSON interval definition plus optional IMAGE input. + - RETURN_TYPES: one IMAGE tensor with VGA-style colorized layers. + - Hidden `node_id`: used to push initial UI payload to the frontend. + """ + + DEFAULT_INTERVALS = [ + {"start": 0.0, "end": 0.3}, + ] + + VGA_COLORS = [ + (0, 0, 0), + (0, 0, 170), + (0, 170, 0), + (0, 170, 170), + (170, 0, 0), + (170, 0, 170), + (170, 85, 0), + (170, 170, 170), + (85, 85, 85), + (85, 85, 255), + (85, 255, 85), + (85, 255, 255), + (255, 85, 85), + (255, 85, 255), + (255, 255, 85), + (255, 255, 255), + ] + VGA_PALETTE_FLOAT_CPU = torch.tensor(VGA_COLORS, dtype=torch.float32).div(255.0) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "intervals": ( + "STRING", + {"default": json.dumps(cls.DEFAULT_INTERVALS), "multiline": True}, + ), + }, + "optional": { + "images": ("IMAGE",), + }, + "hidden": { + "node_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("images",) + FUNCTION = "transform" + CATEGORY = "Game Development/Image Adjustments" + DESCRIPTION = """ +Generate layered images based on depth information. +Define intervals to group depth values and visualize +them with distinct colors. Useful for game development +workflows that require depth-based layer separation. + +Click on the histogram to add intervals, and drag the +handles to adjust them. Use shift + click to remove them. +""" + + @classmethod + def parse_intervals(cls, raw_intervals): + """Parse and normalize interval JSON. + + Returns a list of tuples: (start, end, layer_index), where + `layer_index` starts at 1 because index 0 is reserved as background. + """ + try: + intervals = json.loads(raw_intervals) + except (TypeError, json.JSONDecodeError): + intervals = cls.DEFAULT_INTERVALS + + if not isinstance(intervals, list): + intervals = cls.DEFAULT_INTERVALS + + valid_intervals = [] + for p in intervals: + if not isinstance(p, dict): + continue + if not isinstance(p.get("start"), (int, float)): + continue + if not isinstance(p.get("end"), (int, float)): + continue + valid_intervals.append(p) + + if not valid_intervals: + valid_intervals = cls.DEFAULT_INTERVALS + + extended_intervals = [] + for i, p in enumerate(valid_intervals): + start = float(np.clip(p["start"], 0.0, 1.0)) + next_start = ( + float(np.clip(valid_intervals[i + 1]["start"], 0.0, 1.0)) + if i + 1 < len(valid_intervals) + else 1.0 + ) + end = float(np.clip(p["end"], start, next_start)) + if end > start and start < 1.0: + extended_intervals.append((start, end, len(extended_intervals) + 1)) + + return extended_intervals + + @classmethod + def IS_CHANGED(cls, **kwargs): + """ + Indicates that the node should reprocess whenever any input changes. + This is a common pattern hack as NaN never equals itself, forcing the node to run on every input change. + This is more efficient than using timestamps or hashing for change detection in this case, since the node needs + to update whenever intervals or images change. + """ + return float("NaN") + + @classmethod + def process_images(cls, gray_tensors, intervals): + """Map batched uint8 grayscale tensors to normalized RGB layer colors.""" + norm = gray_tensors.to(torch.float32).div(255.0) + output = torch.zeros( + (*gray_tensors.shape, 3), dtype=torch.float32, device=gray_tensors.device + ) + palette = cls.VGA_PALETTE_FLOAT_CPU.to(device=gray_tensors.device) + + for start, end, layer_index in intervals: + if layer_index >= len(cls.VGA_COLORS): + raise RuntimeError( + f"Too many intervals defined. Max supported is {len(cls.VGA_COLORS) - 1}." + ) + output[(start <= norm) & (norm <= end)] = palette[layer_index] + + return output + + def _send_initial_data(self, node_id, gray_tensor): + """Send initial preview/histogram data to the frontend editor.""" + + histogram = torch.bincount( + gray_tensor.reshape(-1).to(torch.int64), minlength=256 + ).to(torch.float32) + if histogram.numel() and (max_val := float(histogram.max().item())): + histogram /= max_val + + PromptServer.instance.send_sync( + "game_dev.depth_to_layers.initialise", + { + "node_id": node_id, + "preview_image": tensor_to_png_base64(gray_tensor), + "histogram_data": histogram.cpu().tolist(), + "default_intervals": self.DEFAULT_INTERVALS, + "vga_colors": self.VGA_COLORS, + }, + ) + + def transform(self, intervals, images=None, node_id=None): + """ComfyUI entry point. + + Applies interval-based layer coloring to each image in the batch. + If `node_id` is available, sends UI bootstrap data for the JS editor. + """ + if images is None: + return (None,) + + parsed_intervals = self.parse_intervals(intervals) + + if node_id: + self._send_initial_data(node_id, tensor_to_gray_uint8(images[0])) + + return (self.process_images(tensor_to_gray_uint8(images), parsed_intervals),) diff --git a/grid_slice.py b/grid_slice.py new file mode 100644 index 0000000..1981623 --- /dev/null +++ b/grid_slice.py @@ -0,0 +1,41 @@ +import torch + + +class GridSlice: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "columns": ("INT", {"default": 8, "min": 1, "max": 64}), + "rows": ("INT", {"default": 4, "min": 1, "max": 64}), + "ignore_last_column": ("BOOLEAN", {"default": False}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "slice_image" + CATEGORY = "Game Development/Image Adjustments" + + def slice_image(self, image, columns, rows, ignore_last_column): + _, h, w, _ = image.shape + + frame_w = w // columns + frame_h = h // rows + columns_to_slice = ( + columns - 1 if ignore_last_column and columns > 1 else columns + ) + + frames = [] + for r in range(rows): + for c in range(columns_to_slice): + y1 = r * frame_h + y2 = (r + 1) * frame_h + x1 = c * frame_w + x2 = (c + 1) * frame_w + + frame = image[:, y1:y2, x1:x2, :] + frames.append(frame) + + batch_frames = torch.cat(frames, dim=0) + return (batch_frames,) diff --git a/js/depth_to_layers.js b/js/depth_to_layers.js new file mode 100644 index 0000000..1a46e30 --- /dev/null +++ b/js/depth_to_layers.js @@ -0,0 +1,814 @@ +/** + * ComfyUI frontend extension for the `depth to layers` node. + * + * Responsibilities: + * - Render source/preview panels and histogram overlays inside the node. + * - Provide interactive interval editing (add, drag, remove, reset). + * - Keep interval widget state synchronized for backend execution. + * - Render preview updates locally from cached source pixels. + * + * Event contract: + * - Receives initial state from `game_dev.depth_to_layers.initialise`. + */ +import { app } from "/scripts/app.js"; +import { api } from "/scripts/api.js"; + +const NODE_BASE_WIDTH = 256; +const NODE_HEADER_HEIGHT = 28; +const NODE_PANEL_HEIGHT = 167; +const NODE_HISTOGRAM_HEIGHT = 90; +const NODE_RESET_BUTTON_HEIGHT = 24; +const NODE_PADDING = 5; +const MIN_HANDLE_GAP = 0.001; +const INTERVAL_LEVEL = 0.5; +const HANDLE_HEIGHT = 26; +const HANDLE_WIDTH = 10; +const HANDLE_HIT_PAD = 3; +const INTERVAL_OVERLAY_ALPHA = 0.38; +const TEXT_COLOR_DEFAULT = "#e6e6e6"; +const PREVIEW_PLACEHOLDER_TEXT_COLOR = "#ebebeb"; +const HISTOGRAM_BG_COLOR = "#3a3a3a"; +const HISTOGRAM_BAR_COLOR = "rgba(255,255,255,0.95)"; +const RESET_BUTTON_BG_COLOR = "#2a2a2a"; +const RESET_BUTTON_BORDER_COLOR = "#8a8a8a"; +const HOVER_MARKER_SHADOW_COLOR = "rgba(0, 0, 0, 0.88)"; +const INTERVAL_LABEL_BADGE_BG_COLOR = "rgba(245, 218, 154, 0.95)"; +const INTERVAL_LABEL_BADGE_BORDER_COLOR = "rgba(255,255,255,1)"; +const INTERVAL_HANDLE_SELECTED_COLOR = "rgb(233, 62, 15)"; +const FALLBACK_PALETTE = [ + [0, 0, 0], + [255, 255, 255], +]; +const FALLBACK_DEFAULT_INTERVALS = [{ start: 0.0, end: 0.3 }]; + +const cloneIntervals = (intervals) => + (Array.isArray(intervals) && intervals.length > 0 ? intervals : FALLBACK_DEFAULT_INTERVALS).map((p) => ({ + start: p.start, + end: p.end, + })); +const colorToCss = (color) => + Array.isArray(color) && color.length === 3 ? `rgb(${color[0]},${color[1]},${color[2]})` : String(color); +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const getNextStart = (intervals, index) => (index < intervals.length - 1 ? intervals[index + 1].start : 1); +const getHandleY = (area) => area.y + (1.0 - INTERVAL_LEVEL) * area.height; +const isPointInRect = (x, y, rect) => + !!rect && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height; +const drawStyledText = (ctx, text, x, y, style) => { + Object.assign(ctx, { + fillStyle: TEXT_COLOR_DEFAULT, + font: "13px Arial", + textAlign: "center", + textBaseline: "middle", + ...style, + }); + ctx.fillText(text, x, y); +}; + +app.registerExtension({ + name: "depth to layers", + + nodeCreated(node) { + if (node.comfyClass !== "depth to layers") { + return; + } + + const defaultIntervals = cloneIntervals(FALLBACK_DEFAULT_INTERVALS); + Object.assign(node, { + size: [NODE_BASE_WIDTH, NODE_PANEL_HEIGHT], + _configure: node.configure, + _serialize: node.serialize, + imagePreview: null, + originalImage: null, + hasUserResizedNode: false, + selectedHandleIndex: -1, + selectedHandleType: null, + isDragging: false, + histogram: null, + vgaColors: [...FALLBACK_PALETTE], + defaultIntervals, + allIntervals: cloneIntervals(defaultIntervals), + sourcePixelData: null, + sourcePixelWidth: 0, + sourcePixelHeight: 0, + localDragPreviewCanvas: null, + localDragPreviewCtx: null, + hoverHistogramValue: null, + + getPreviewPanelArea: function () { + if (!this.size) return null; + const mainAreaHeight = this.size[1] - NODE_PANEL_HEIGHT; + if (mainAreaHeight < 10) return null; + const area = { + x: NODE_PADDING, + y: NODE_HEADER_HEIGHT + NODE_PADDING, + width: this.size[0] - NODE_PADDING * 2, + height: mainAreaHeight, + }; + return area.width < 1 || area.height < 1 ? null : area; + }, + + getHistogramPanelArea: function () { + const imageArea = this.getPreviewPanelArea(); + if (!imageArea) return null; + return { + x: NODE_PADDING, + y: imageArea.y + imageArea.height + NODE_PADDING, + width: this.size[0] - NODE_PADDING * 2, + height: NODE_HISTOGRAM_HEIGHT, + }; + }, + + getTopPreviewArea: function () { + const imageArea = this.getPreviewPanelArea(); + if (!imageArea) return null; + const halfHeight = Math.max(1, Math.floor((imageArea.height - NODE_PADDING) / 2)); + return { + x: imageArea.x, + y: imageArea.y, + width: imageArea.width, + height: halfHeight, + }; + }, + + getBottomPreviewArea: function () { + const imageArea = this.getPreviewPanelArea(); + const topArea = this.getTopPreviewArea(); + if (!imageArea || !topArea) return null; + const bottomY = topArea.y + topArea.height + NODE_PADDING; + return { + x: imageArea.x, + y: bottomY, + width: imageArea.width, + height: Math.max(1, imageArea.y + imageArea.height - bottomY), + }; + }, + + getResetButtonArea: function () { + const histogramArea = this.getHistogramPanelArea(); + if (!histogramArea) return null; + return { + x: NODE_PADDING, + y: histogramArea.y + histogramArea.height + NODE_PADDING, + width: this.size[0] - NODE_PADDING * 2, + height: NODE_RESET_BUTTON_HEIGHT, + }; + }, + + getVgaColorCount: function () { + return Math.max(2, this.vgaColors.length); + }, + + getVgaColor: function (index) { + const fallback = [255, 255, 255]; + const color = this.vgaColors[index] || this.vgaColors[this.vgaColors.length - 1] || fallback; + return colorToCss(color); + }, + + updateBackend: function () { + // Persist current interval state and refresh preview from local source cache. + const intervalsWidget = this.widgets.find((w) => w.name === "intervals"); + if (!intervalsWidget) return; + + if (!this.originalImage) { + this.imagePreview = null; + this.setDirtyCanvas(true, true); + return; + } + + const serializableIntervals = []; + for (let i = 0; i < this.allIntervals.length; i++) { + const p = this.allIntervals[i]; + const nextStart = getNextStart(this.allIntervals, i); + const start = clamp(p.start, 0, 1); + const end = clamp(p.end, start, nextStart); + serializableIntervals.push({ start, end }); + } + intervalsWidget.value = JSON.stringify(serializableIntervals); + + this.renderLocalDragPreview(); + this.imagePreview = this.localDragPreviewCanvas; + this.setDirtyCanvas(true, true); + }, + + cacheSourceAssets: function (histogramData) { + // Cache source assets used by both UI rendering and hover sampling. + if (!this.originalImage) return; + this.histogram = Array.isArray(histogramData) ? histogramData : null; + this.buildSourcePixelCache(); + this.setDirtyCanvas(true, true); + }, + + clearSourcePixelCache: function () { + this.sourcePixelData = null; + this.sourcePixelWidth = 0; + this.sourcePixelHeight = 0; + this.localDragPreviewCanvas = null; + this.localDragPreviewCtx = null; + }, + + resetHoverHistogramMarker: function () { + this.hoverHistogramValue = null; + }, + + buildSourcePixelCache: function () { + // Create a grayscale sampling buffer for hover-to-histogram mapping. + if (!this.originalImage) { + this.clearSourcePixelCache(); + return; + } + + const width = this.originalImage.naturalWidth || this.originalImage.width || 0; + const height = this.originalImage.naturalHeight || this.originalImage.height || 0; + if (width <= 0 || height <= 0) { + this.clearSourcePixelCache(); + return; + } + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + this.clearSourcePixelCache(); + return; + } + + ctx.drawImage(this.originalImage, 0, 0, width, height); + this.sourcePixelData = ctx.getImageData(0, 0, width, height).data; + this.sourcePixelWidth = width; + this.sourcePixelHeight = height; + }, + + ensureLocalDragPreviewCanvas: function () { + if (this.sourcePixelWidth <= 0 || this.sourcePixelHeight <= 0) return null; + + if ( + !this.localDragPreviewCanvas || + this.localDragPreviewCanvas.width !== this.sourcePixelWidth || + this.localDragPreviewCanvas.height !== this.sourcePixelHeight + ) { + this.localDragPreviewCanvas = document.createElement("canvas"); + this.localDragPreviewCanvas.width = this.sourcePixelWidth; + this.localDragPreviewCanvas.height = this.sourcePixelHeight; + this.localDragPreviewCtx = this.localDragPreviewCanvas.getContext("2d"); + } + + return this.localDragPreviewCtx ? this.localDragPreviewCanvas : null; + }, + + renderLocalDragPreview: function () { + // UI-only preview while dragging: mirrors backend interval coloring using cached grayscale pixels. + if (!this.sourcePixelData || this.sourcePixelWidth <= 0 || this.sourcePixelHeight <= 0) { + return; + } + + const canvas = this.ensureLocalDragPreviewCanvas(); + if (!canvas || !this.localDragPreviewCtx) { + return; + } + + const activeHandles = this.allIntervals; + const colorCount = this.getVgaColorCount(); + + const lutR = new Uint8Array(256); + const lutG = new Uint8Array(256); + const lutB = new Uint8Array(256); + + for (let i = 0; i < activeHandles.length; i++) { + const current = activeHandles[i]; + const nextStart = getNextStart(activeHandles, i); + const startNorm = clamp(current.start, 0, 1); + const endNorm = clamp(current.end, startNorm, nextStart); + const start = clamp(Math.ceil(startNorm * 255), 0, 255); + const end = clamp(Math.floor(endNorm * 255), start, 255); + const colorIndex = (i % (colorCount - 1)) + 1; + const color = this.vgaColors[colorIndex] || this.vgaColors[this.vgaColors.length - 1] || [255, 255, 255]; + const r = color[0] ?? 255; + const g = color[1] ?? 255; + const b = color[2] ?? 255; + + for (let value = start; value <= end; value++) { + lutR[value] = r; + lutG[value] = g; + lutB[value] = b; + } + } + + const imageData = this.localDragPreviewCtx.createImageData(this.sourcePixelWidth, this.sourcePixelHeight); + const output = imageData.data; + const source = this.sourcePixelData; + + for (let i = 0; i < source.length; i += 4) { + const intensity = source[i]; + output[i] = lutR[intensity]; + output[i + 1] = lutG[intensity]; + output[i + 2] = lutB[intensity]; + output[i + 3] = 255; + } + + this.localDragPreviewCtx.putImageData(imageData, 0, 0); + }, + + clearHoverHistogramMarker: function () { + if (this.hoverHistogramValue !== null) { + this.resetHoverHistogramMarker(); + this.setDirtyCanvas(true, true); + } + }, + + updateHoverHistogramMarkerFromMouse: function (mouseX, mouseY) { + // Convert mouse position in the top preview to source pixel intensity. + const topPreviewArea = this.getTopPreviewArea(); + if (!topPreviewArea || !this.sourcePixelData || this.sourcePixelWidth <= 0 || this.sourcePixelHeight <= 0) { + this.clearHoverHistogramMarker(); + return; + } + + if (!isPointInRect(mouseX, mouseY, topPreviewArea)) { + this.clearHoverHistogramMarker(); + return; + } + + const u = clamp((mouseX - topPreviewArea.x) / Math.max(1, topPreviewArea.width), 0, 1); + const v = clamp((mouseY - topPreviewArea.y) / Math.max(1, topPreviewArea.height), 0, 1); + const pixelX = Math.min(this.sourcePixelWidth - 1, Math.floor(u * this.sourcePixelWidth)); + const pixelY = Math.min(this.sourcePixelHeight - 1, Math.floor(v * this.sourcePixelHeight)); + const index = (pixelY * this.sourcePixelWidth + pixelX) * 4; + const data = this.sourcePixelData; + + const intensity = data[index]; // Assuming grayscale, so R=G=B. If not, this will still give a valid intensity based on the red channel. + if (this.hoverHistogramValue !== intensity) { + this.hoverHistogramValue = intensity; + this.setDirtyCanvas(true, true); + } + }, + + drawHoverHistogramMarker: function (ctx, histogramArea) { + if (this.hoverHistogramValue === null) return; + + const x = histogramArea.x + (this.hoverHistogramValue / 255) * histogramArea.width; + + ctx.save(); + ctx.lineCap = "round"; + + ctx.beginPath(); + ctx.moveTo(x, histogramArea.y); + ctx.lineTo(x, histogramArea.y + histogramArea.height); + ctx.strokeStyle = HOVER_MARKER_SHADOW_COLOR; + ctx.lineWidth = 4; + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(x, histogramArea.y); + ctx.lineTo(x, histogramArea.y + histogramArea.height); + const gray = this.hoverHistogramValue; + ctx.strokeStyle = `rgb(${gray},${gray},${gray})`; + ctx.lineWidth = 2; + ctx.stroke(); + + ctx.restore(); + }, + + drawHistogram: function (ctx, area, data) { + if (!data || data.length === 0) return; + + ctx.fillStyle = HISTOGRAM_BAR_COLOR; + const barWidth = area.width / NODE_BASE_WIDTH; + + for (let i = 0; i < NODE_BASE_WIDTH; i++) { + const value = data[i] ?? 0; + const barHeight = value * area.height; + const x = area.x + i * barWidth; + ctx.fillRect(x, area.y + area.height - barHeight, barWidth, barHeight); + } + }, + + drawIntervalHandle: function (ctx, x, y, isStartHandle, fillColor, isSelected) { + const top = y - HANDLE_HEIGHT / 2; + const bottom = y + HANDLE_HEIGHT / 2; + const left = isStartHandle ? x : x - HANDLE_WIDTH; + const right = isStartHandle ? x + HANDLE_WIDTH : x; + + ctx.beginPath(); + if (isStartHandle) { + // D shape: flat side aligned at x, rounded side to the right. + ctx.moveTo(left, top); + ctx.lineTo(right - 2, top); + ctx.quadraticCurveTo(right + 3, y, right - 2, bottom); + ctx.lineTo(left, bottom); + } else { + // Inverted D shape: rounded side to the left, flat side aligned at x. + ctx.moveTo(right, top); + ctx.lineTo(left + 2, top); + ctx.quadraticCurveTo(left - 3, y, left + 2, bottom); + ctx.lineTo(right, bottom); + } + ctx.closePath(); + ctx.fillStyle = isSelected ? INTERVAL_HANDLE_SELECTED_COLOR : fillColor; + ctx.fill(); + }, + + onDrawForeground: function (ctx) { + // Draw order: histogram -> source/preview panels -> interval overlays -> controls. + if (this.flags?.collapsed) return; + + const topPreviewArea = this.getTopPreviewArea(); + const bottomPreviewArea = this.getBottomPreviewArea(); + const histogramArea = this.getHistogramPanelArea(); + const resetArea = this.getResetButtonArea(); + if (!topPreviewArea || !bottomPreviewArea) return; + + if (histogramArea) { + ctx.fillStyle = HISTOGRAM_BG_COLOR; + ctx.fillRect(histogramArea.x, histogramArea.y, histogramArea.width, histogramArea.height); + if (this.histogram) { + this.drawHistogram(ctx, histogramArea, this.histogram); + } + } + + if (this.originalImage) { + ctx.save(); + ctx.beginPath(); + ctx.rect(topPreviewArea.x, topPreviewArea.y, topPreviewArea.width, topPreviewArea.height); + ctx.clip(); + ctx.drawImage( + this.originalImage, + topPreviewArea.x, + topPreviewArea.y, + topPreviewArea.width, + topPreviewArea.height, + ); + ctx.restore(); + } + + const useLocalPreview = this.isDragging && this.localDragPreviewCanvas; + const previewImage = useLocalPreview ? this.localDragPreviewCanvas : this.imagePreview; + + if (previewImage) { + ctx.save(); + ctx.beginPath(); + ctx.rect(bottomPreviewArea.x, bottomPreviewArea.y, bottomPreviewArea.width, bottomPreviewArea.height); + ctx.clip(); + ctx.drawImage( + previewImage, + bottomPreviewArea.x, + bottomPreviewArea.y, + bottomPreviewArea.width, + bottomPreviewArea.height, + ); + ctx.restore(); + } else { + ctx.save(); + drawStyledText( + ctx, + "Run the workflow first to generate the preview", + bottomPreviewArea.x + bottomPreviewArea.width / 2, + bottomPreviewArea.y + bottomPreviewArea.height / 2, + { + fillStyle: PREVIEW_PLACEHOLDER_TEXT_COLOR, + }, + ); + ctx.restore(); + } + + if (histogramArea) { + const activeHandles = this.allIntervals; + const colorCount = this.getVgaColorCount(); + + // Color 0 (black): regions that are not part of any active interval. + ctx.save(); + ctx.globalAlpha = 0.1; + ctx.fillStyle = this.getVgaColor(0); + ctx.fillRect(histogramArea.x, histogramArea.y, histogramArea.width, histogramArea.height); + ctx.restore(); + + ctx.save(); + ctx.globalAlpha = INTERVAL_OVERLAY_ALPHA; + + for (let i = 0; i < activeHandles.length; i++) { + const current = activeHandles[i]; + const nextStart = getNextStart(activeHandles, i); + const startXNorm = clamp(current.start, 0, 1); + const endXNorm = clamp(current.end, startXNorm, nextStart); + const startX = histogramArea.x + startXNorm * histogramArea.width; + const endX = histogramArea.x + endXNorm * histogramArea.width; + const colorIndex = (i % (colorCount - 1)) + 1; + + ctx.fillStyle = this.getVgaColor(colorIndex); + ctx.fillRect(startX, histogramArea.y, Math.max(0, endX - startX), histogramArea.height); + + const intervalWidth = endX - startX; + if (intervalWidth > 18) { + ctx.globalAlpha = 1; + + const labelX = startX + intervalWidth / 2; + const labelY = histogramArea.y + 10; + + ctx.beginPath(); + ctx.arc(labelX, labelY, 8, 0, Math.PI * 2); + ctx.fillStyle = INTERVAL_LABEL_BADGE_BG_COLOR; + ctx.fill(); + ctx.strokeStyle = INTERVAL_LABEL_BADGE_BORDER_COLOR; + ctx.lineWidth = 1.1; + ctx.stroke(); + + drawStyledText(ctx, String(i + 1), labelX, labelY, { + fillStyle: "#000000", + font: "bold 10px Arial", + }); + + ctx.globalAlpha = INTERVAL_OVERLAY_ALPHA; + } + } + ctx.restore(); + + const handleY = getHandleY(histogramArea); + for (let i = 0; i < activeHandles.length; i++) { + const current = activeHandles[i]; + const nextStart = getNextStart(activeHandles, i); + const startXNorm = clamp(current.start, 0, 1); + const endXNorm = clamp(current.end, startXNorm, nextStart); + const colorIndex = (i % (colorCount - 1)) + 1; + const intervalColor = this.getVgaColor(colorIndex); + + const startX = histogramArea.x + startXNorm * histogramArea.width; + const endX = histogramArea.x + endXNorm * histogramArea.width; + + this.drawIntervalHandle( + ctx, + startX, + handleY, + true, + intervalColor, + this.selectedHandleIndex === i && this.selectedHandleType === "start", + ); + + this.drawIntervalHandle( + ctx, + endX, + handleY, + false, + intervalColor, + this.selectedHandleIndex === i && this.selectedHandleType === "end", + ); + } + + this.drawHoverHistogramMarker(ctx, histogramArea); + } + + if (resetArea) { + ctx.save(); + ctx.fillStyle = RESET_BUTTON_BG_COLOR; + ctx.fillRect(resetArea.x, resetArea.y, resetArea.width, resetArea.height); + ctx.strokeStyle = RESET_BUTTON_BORDER_COLOR; + ctx.lineWidth = 1; + ctx.strokeRect(resetArea.x + 0.5, resetArea.y + 0.5, resetArea.width - 1, resetArea.height - 1); + drawStyledText(ctx, "Reset", resetArea.x + resetArea.width / 2, resetArea.y + resetArea.height / 2); + ctx.restore(); + } + }, + + onMouseDown: function (event) { + // Primary interactions: reset, handle selection/removal, or interval insertion. + const intervalArea = this.getHistogramPanelArea(); + const resetArea = this.getResetButtonArea(); + if (event.button !== 0) return false; + + const localPos = app.canvas.convertEventToCanvasOffset(event); + const mouseX = localPos[0] - this.pos[0]; + const mouseY = localPos[1] - this.pos[1]; + + if (isPointInRect(mouseX, mouseY, resetArea)) { + this.allIntervals = cloneIntervals(this.defaultIntervals); + this.updateBackend(); + return true; + } + + if (!this.originalImage || !intervalArea) return false; + + if (!isPointInRect(mouseX, mouseY, intervalArea)) return false; + + const activeHandles = this.allIntervals; + const handleY = getHandleY(intervalArea); + const removeInterval = (index) => { + activeHandles.splice(index, 1); + this.selectedHandleIndex = -1; + this.selectedHandleType = null; + this.updateBackend(); + return true; + }; + + for (let i = 0; i < activeHandles.length; i++) { + const handle = activeHandles[i]; + const startX = intervalArea.x + handle.start * intervalArea.width; + const nextStart = getNextStart(activeHandles, i); + const endXNorm = clamp(handle.end, handle.start, nextStart); + const endX = intervalArea.x + endXNorm * intervalArea.width; + + const handleTop = handleY - HANDLE_HEIGHT / 2 - HANDLE_HIT_PAD; + const handleBottom = handleY + HANDLE_HEIGHT / 2 + HANDLE_HIT_PAD; + const inY = mouseY >= handleTop && mouseY <= handleBottom; + const startHandleHit = + inY && mouseX >= startX - HANDLE_HIT_PAD && mouseX <= startX + HANDLE_WIDTH + HANDLE_HIT_PAD; + const endHandleHit = inY && mouseX >= endX - HANDLE_WIDTH - HANDLE_HIT_PAD && mouseX <= endX + HANDLE_HIT_PAD; + + if (event.shiftKey && mouseX >= startX && mouseX <= endX) { + return removeInterval(i); + } + + // Prioritize start handle if both are hit around adjacent boundaries. + if (startHandleHit || endHandleHit) { + if (event.shiftKey) { + return removeInterval(i); + } else { + this.selectedHandleIndex = i; + this.selectedHandleType = startHandleHit ? "start" : "end"; + this.setDirtyCanvas(true, true); + return true; + } + } + } + + if (event.shiftKey) return false; + + const newX = clamp((mouseX - intervalArea.x) / intervalArea.width, 0, 1); + const insertIndex = activeHandles.findIndex((p) => p.start > newX); + const targetIndex = insertIndex === -1 ? activeHandles.length : insertIndex; + const nextStart = getNextStart(activeHandles, targetIndex - 1); + + activeHandles.splice(targetIndex, 0, { + start: newX, + end: clamp(newX + 0.1, newX + MIN_HANDLE_GAP, nextStart - MIN_HANDLE_GAP), + }); + this.selectedHandleIndex = targetIndex; + this.selectedHandleType = "start"; + this.isDragging = true; + this.updateBackend(); + + return true; + }, + + onMouseMove: function (event) { + // Hover updates histogram marker; drag updates selected interval boundary. + const localPos = app.canvas.convertEventToCanvasOffset(event); + const mouseX = localPos[0] - this.pos[0]; + const mouseY = localPos[1] - this.pos[1]; + + this.updateHoverHistogramMarkerFromMouse(mouseX, mouseY); + + if (event.buttons !== 1) { + if (this.isDragging || this.selectedHandleIndex !== -1) { + if (this.isDragging) { + this.updateBackend(); + } + this.isDragging = false; + this.selectedHandleIndex = -1; + this.selectedHandleType = null; + this.setDirtyCanvas(true, true); + } + return; + } + + if (this.selectedHandleIndex === -1) return; + + this.isDragging = true; + const intervalArea = this.getHistogramPanelArea(); + if (!intervalArea) return; + + const activeHandles = this.allIntervals; + const handle = activeHandles[this.selectedHandleIndex]; + + if (this.selectedHandleType === "start") { + const newX = (mouseX - intervalArea.x) / intervalArea.width; + const minX = + this.selectedHandleIndex > 0 ? activeHandles[this.selectedHandleIndex - 1].end + MIN_HANDLE_GAP : 0; + const maxX = handle.end - MIN_HANDLE_GAP; + handle.start = clamp(newX, minX, maxX); + } else if (this.selectedHandleType === "end") { + const newEndX = (mouseX - intervalArea.x) / intervalArea.width; + const minEndX = handle.start + MIN_HANDLE_GAP; + const maxEndX = + this.selectedHandleIndex < activeHandles.length - 1 + ? activeHandles[this.selectedHandleIndex + 1].start - MIN_HANDLE_GAP + : 1; + handle.end = clamp(newEndX, minEndX, maxEndX); + } + + this.updateBackend(); + }, + onMouseUp: function () { + if (this.isDragging) { + this.isDragging = false; + if (this.selectedHandleIndex !== -1) { + this.updateBackend(); + } + } + }, + onMouseLeave: function () { + this.clearHoverHistogramMarker(); + }, + onResize: function () { + this.hasUserResizedNode = true; + }, + hideIntervalTextField: function () { + const intervalsWidget = this.widgets.find((w) => w.name === "intervals"); + if (!intervalsWidget) return; + + intervalsWidget.type = "hidden"; + }, + configure: function (data) { + this._configure(data); + this.hideIntervalTextField(); + + if (data.hasUserResizedNode) { + this.hasUserResizedNode = data.hasUserResizedNode; + } + + const savedIntervals = Array.isArray(data.storedIntervals) ? data.storedIntervals : null; + + if (Array.isArray(savedIntervals)) { + const mappedIntervals = savedIntervals + .filter((p) => typeof p?.start === "number" && typeof p?.end === "number") + .map((p, idx, arr) => { + const nextStart = getNextStart(arr, idx); + const start = clamp(p.start, 0, 1); + const end = clamp(p.end, start + MIN_HANDLE_GAP, nextStart - MIN_HANDLE_GAP); + return { start, end }; + }) + .filter((p) => p.start < 1 - MIN_HANDLE_GAP && p.end > p.start); + + this.allIntervals = mappedIntervals; + if (this.allIntervals.length === 0) { + this.allIntervals = cloneIntervals(this.defaultIntervals); + } + + const intervalsWidget = this.widgets.find((w) => w.name === "intervals"); + if (intervalsWidget) { + const serialized = []; + for (let i = 0; i < this.allIntervals.length; i++) { + const p = this.allIntervals[i]; + const nextStart = getNextStart(this.allIntervals, i); + const end = clamp(p.end, p.start, nextStart); + serialized.push({ start: p.start, end }); + } + intervalsWidget.value = JSON.stringify(serialized); + } + } + }, + serialize: function () { + const data = this._serialize(); + data.storedIntervals = this.allIntervals; + data.hasUserResizedNode = this.hasUserResizedNode; + return data; + }, + }); + + node.hideIntervalTextField(); + }, +}); + +api.addEventListener("game_dev.depth_to_layers.initialise", ({ detail }) => { + // Bootstrap node state and assets after backend execution. + const node = app.graph.getNodeById(detail.node_id); + if (!node) { + return; + } + + if (Array.isArray(detail.default_intervals) && detail.default_intervals.length > 0) { + node.defaultIntervals = cloneIntervals(detail.default_intervals); + if (!Array.isArray(node.allIntervals) || node.allIntervals.length === 0) { + node.allIntervals = cloneIntervals(node.defaultIntervals); + } + } + if (Array.isArray(detail.vga_colors) && detail.vga_colors.length > 0) { + node.vgaColors = detail.vga_colors; + } + + const onAssetLoad = () => { + if (!node.hasUserResizedNode && node.originalImage) { + const aspectRatio = node.originalImage.naturalWidth / node.originalImage.naturalHeight; + node.size[0] = NODE_BASE_WIDTH; + node.size[1] = NODE_PANEL_HEIGHT + (NODE_BASE_WIDTH - NODE_PADDING * 2) / (aspectRatio || 1); + node.hasUserResizedNode = true; + } + node.updateBackend(); + }; + + if (!detail.preview_image) { + node.originalImage = null; + node.histogram = null; + node.clearSourcePixelCache(); + node.resetHoverHistogramMarker(); + onAssetLoad(); + return; + } + + const img = new Image(); + img.src = "data:image/png;base64," + detail.preview_image; + img.onload = () => { + node.originalImage = img; + node.cacheSourceAssets(detail.histogram_data); + onAssetLoad(); + }; + node.originalImage = img; +}); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8497f26 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "comfyui-gamedev" +description = "ComfyUI custom nodes for depth-to-layers processing and grid slicing. For Game Development use cases." +version = "0.1.0" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10" +dependencies = [ + "torch>=2.6.0", + "numpy", + "pillow", +] + +[project.urls] +Homepage = "https://github.com/ecalot/ComfyUI-GameDev" +Repository = "https://github.com/ecalot/ComfyUI-GameDev" +# Used by Comfy Registry: https://comfyregistry.org + +[tool.comfy] +PublisherId = "ecalot" +DisplayName = "ComfyUI-GameDev" +Icon = "https://raw.githubusercontent.com/ecalot/ComfyUI-GameDev/main/assets/comfyui-gamedev-icon.png"